From e197521774a40772c629fd3d37d40d532be6c965 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:54:59 +0000 Subject: [PATCH 01/76] Replace direct ryu usage with zmij Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- Cargo.lock | 4 ++-- lib/executor/Cargo.toml | 2 +- lib/executor/src/json_writer.rs | 20 +++++++++++++++++++- lib/graphql-tools/Cargo.toml | 2 +- lib/graphql-tools/src/parser/query/minify.rs | 12 ++++++------ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 617ca5dac..7e9941ef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,12 +2104,12 @@ dependencies = [ "itoa", "lazy_static", "pretty_assertions", - "ryu", "serde", "serde_json", "serde_with", "thiserror 2.0.18", "xxhash-rust", + "zmij", ] [[package]] @@ -2440,7 +2440,6 @@ dependencies = [ "ntex", "regex-automata", "rustls", - "ryu", "serde", "serde_json", "sonic-rs", @@ -2451,6 +2450,7 @@ dependencies = [ "tokio", "tracing", "xxhash-rust", + "zmij", ] [[package]] diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 4f40d159f..8287a02bd 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -49,7 +49,7 @@ hyper-util = { version = "0.1.16", features = [ "tokio", ] } itoa = "1.0.15" -ryu = "1.0.20" +zmij = "1.0.21" indexmap = "2.10.0" bumpalo = "3.19.0" sonic-simd = "0.1.2" diff --git a/lib/executor/src/json_writer.rs b/lib/executor/src/json_writer.rs index 141fe70aa..9605c313a 100644 --- a/lib/executor/src/json_writer.rs +++ b/lib/executor/src/json_writer.rs @@ -16,7 +16,7 @@ pub fn write_f64(writer: &mut Vec, value: f64) { return; } - let mut buffer = ryu::Buffer::new(); + let mut buffer = zmij::Buffer::new(); let s = buffer.format_finite(value); writer.put(s.as_bytes()) } @@ -518,6 +518,24 @@ fn format_string(input_str: &str, writer: &mut Vec, need_quote: bool) { mod test { use super::*; + #[test] + fn test_write_f64_finite() { + let mut dst: Vec = Vec::new(); + + write_f64(&mut dst, 123.5); + + assert_eq!(dst.as_slice(), b"123.5"); + } + + #[test] + fn test_write_f64_non_finite_is_null() { + let mut dst: Vec = Vec::new(); + + write_f64(&mut dst, f64::NAN); + + assert_eq!(dst.as_slice(), b"null"); + } + #[test] fn test_quote() { let mut dst: Vec = Vec::with_capacity(1000); diff --git a/lib/graphql-tools/Cargo.toml b/lib/graphql-tools/Cargo.toml index f429840b4..1e5362995 100644 --- a/lib/graphql-tools/Cargo.toml +++ b/lib/graphql-tools/Cargo.toml @@ -21,7 +21,7 @@ serde_with = "3.0.0" combine = { workspace = true } thiserror = { workspace = true } itoa = "1.0.17" -ryu = "1.0.22" +zmij = "1.0.21" xxhash-rust = { workspace = true } [dev-dependencies] diff --git a/lib/graphql-tools/src/parser/query/minify.rs b/lib/graphql-tools/src/parser/query/minify.rs index 0e3e4f04c..f104f4e43 100644 --- a/lib/graphql-tools/src/parser/query/minify.rs +++ b/lib/graphql-tools/src/parser/query/minify.rs @@ -54,7 +54,7 @@ pub fn minify_query_document<'a, T: Text<'a>>(doc: &Document<'a, T>) -> String { /// /// Key optimizations: /// - Direct buffer writing avoids intermediate allocations -/// - Reusable buffers for number formatting (itoa, ryu) reduce allocation overhead +/// - Reusable buffers for number formatting (itoa, zmij) reduce allocation overhead /// - Tracking `last_was_non_punctuator` allows spacing without post-processing struct Minifier { /// Accumulates the minified output as we traverse the AST @@ -68,8 +68,8 @@ struct Minifier { /// Reusable buffer for converting integers to strings using the `itoa` crate, /// that's optimized for fast, allocation-free integer formatting. int_buffer: itoa::Buffer, - /// Reusable buffer for converting floats to strings using the `ryu` crate. - floats_buffer: ryu::Buffer, + /// Reusable buffer for converting floats to strings using the `zmij` crate. + floats_buffer: zmij::Buffer, } impl Minifier { @@ -81,7 +81,7 @@ impl Minifier { last_was_non_punctuator: false, block_indent: 2, int_buffer: itoa::Buffer::new(), - floats_buffer: ryu::Buffer::new(), + floats_buffer: zmij::Buffer::new(), } } @@ -422,8 +422,8 @@ impl Minifier { self.last_was_non_punctuator = true; } Value::Float(f) => { - // Use ryu's format method for fast, accurate float-to-string conversion. - // ryu produces the shortest decimal representation that correctly round-trips, + // Use zmij's format method for fast, accurate float-to-string conversion. + // zmij produces the shortest decimal representation that correctly round-trips, // which is more efficient than using Display or other methods. let s = self.floats_buffer.format(*f); if self.last_was_non_punctuator { From 01d5be7ad7f409611212bc62044f31db7533ce89 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 24 Mar 2026 12:14:24 +0100 Subject: [PATCH 02/76] Add router-level in-flight GraphQL request deduplication (#849) - Add inbound router request deduplication for concurrent identical GraphQL requests, with atomic in-flight key claiming and leader/joiner response sharing. - Feature behind `traffic_shaping.router.dedupe.enabled` (default: false - but I'm open to change it. I made it 'all' and disabled by default as anything that makes a request "unique" affects the request fingerprint and therefore deduplication), and include current schema checksum in the dedupe fingerprint to prevent cross-schema sharing. - Restrict response sharing to `query` operations only ```yaml traffic_shaping: router: dedupe: enabled: true # default headers: all # default ``` --- The deduplication key is built out of: - request method and path - selected request headers (based on dedupe header policy - see examples below) - normalized operation hash - GraphQL variables hash - GraphQL extensions hash - schema checksum Header policy: include all headers in the req fingerprint ```yaml headers: all ``` Header policy: do not include headers in the req fingerprint ```yaml headers: none ``` Header policy: include only some headers in the req fingerprint ```yaml headers: include: - authorization - cookie ``` Docs: https://github.com/graphql-hive/docs/pull/62 --- .changeset/router-inflight-dedupe.md | 48 ++ Cargo.lock | 1 + bench/configs/default.config.yaml | 4 + bench/configs/no-deduplication.config.yaml | 3 + bin/router/benches/router_benches.rs | 8 + bin/router/src/pipeline/authorization/mod.rs | 10 +- .../src/pipeline/authorization/tests.rs | 17 +- bin/router/src/pipeline/mod.rs | 385 +++++++++--- bin/router/src/pipeline/normalize.rs | 49 +- bin/router/src/pipeline/query_plan.rs | 17 +- bin/router/src/pipeline/usage_reporting.rs | 17 +- bin/router/src/shared_state.rs | 120 +++- docs/README.md | 33 +- e2e/src/http.rs | 573 ++++++++++++++++++ lib/executor/src/executors/dedupe.rs | 5 +- lib/executor/src/executors/http.rs | 36 +- lib/executor/src/executors/map.rs | 11 +- .../src/plugins/hooks/on_supergraph_load.rs | 7 + lib/internal/Cargo.toml | 1 + lib/internal/src/inflight.rs | 147 +++++ lib/internal/src/lib.rs | 1 + lib/router-config/src/traffic_shaping.rs | 62 ++ 22 files changed, 1400 insertions(+), 155 deletions(-) create mode 100644 .changeset/router-inflight-dedupe.md create mode 100644 lib/internal/src/inflight.rs diff --git a/.changeset/router-inflight-dedupe.md b/.changeset/router-inflight-dedupe.md new file mode 100644 index 000000000..d2d3acf3b --- /dev/null +++ b/.changeset/router-inflight-dedupe.md @@ -0,0 +1,48 @@ +--- +hive-router: minor +hive-router-config: minor +hive-router-internal: minor +hive-router-plan-executor: minor +--- + +# Add router-level in-flight request deduplication for GraphQL queries + +The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. + +## Configuration + +A new router traffic-shaping section is available: + +- `traffic_shaping.router.dedupe.enabled` (default: `false`) +- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) + +Supported header config shapes: + +```yaml +headers: all +``` + +```yaml +headers: none +``` + +```yaml +headers: + include: + - authorization + - cookie +``` + +Header names are validated and normalized as standard HTTP header names. + +## Deduplication key behavior + +The router dedupe fingerprint includes: + +- request method and path +- selected request headers (based on dedupe header policy) +- normalized operation hash +- GraphQL variables hash +- schema checksum +- GraphQL extensions + diff --git a/Cargo.lock b/Cargo.lock index 7e9941ef2..937f2c062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,6 +2382,7 @@ dependencies = [ "async-trait", "bytes", "criterion", + "dashmap", "futures", "hive-router-config", "http", diff --git a/bench/configs/default.config.yaml b/bench/configs/default.config.yaml index 26a68560a..1c4bba3e1 100644 --- a/bench/configs/default.config.yaml +++ b/bench/configs/default.config.yaml @@ -4,3 +4,7 @@ supergraph: path: ../supergraph.graphql log: level: info +traffic_shaping: + router: + dedupe: + enabled: true # it's opt-in diff --git a/bench/configs/no-deduplication.config.yaml b/bench/configs/no-deduplication.config.yaml index 28d80615c..9b33e32d4 100644 --- a/bench/configs/no-deduplication.config.yaml +++ b/bench/configs/no-deduplication.config.yaml @@ -7,3 +7,6 @@ log: traffic_shaping: all: dedupe_enabled: false + router: + dedupe: + enabled: false diff --git a/bin/router/benches/router_benches.rs b/bin/router/benches/router_benches.rs index b29ff053d..55fd0b5a8 100644 --- a/bin/router/benches/router_benches.rs +++ b/bin/router/benches/router_benches.rs @@ -1,5 +1,6 @@ use criterion::{criterion_group, criterion_main, Criterion}; use hive_router::pipeline::authorization::metadata::AuthorizationMetadataExt; +use hive_router::pipeline::normalize::hash_normalized_operation; use hive_router::pipeline::{ authorization::apply_authorization_to_operation, coerce_variables::CoerceVariablesPayload, @@ -48,6 +49,10 @@ fn authorization_benchmark(c: &mut Criterion) { let (root_type_name, projection_plan) = FieldProjectionPlan::from_operation(&normalized.operation, &metadata); let partitioned_operation = partition_operation(normalized.operation); + let hashes = hash_normalized_operation( + &partitioned_operation.downstream_operation, + partitioned_operation.introspection_operation.as_ref(), + ); GraphQLNormalizationPayload { root_type_name, @@ -61,6 +66,9 @@ fn authorization_benchmark(c: &mut Criterion) { operation_type: "query", client_document_hash: "".to_string(), }, + operation_for_plan_hash: hashes.operation_for_plan_hash, + operation_for_introspection_hash: hashes.operation_for_introspection_hash, + normalized_operation_hash: hashes.combined_operation_hash, } } diff --git a/bin/router/src/pipeline/authorization/mod.rs b/bin/router/src/pipeline/authorization/mod.rs index 62d7bdc2e..d41f073d5 100644 --- a/bin/router/src/pipeline/authorization/mod.rs +++ b/bin/router/src/pipeline/authorization/mod.rs @@ -25,7 +25,7 @@ use crate::pipeline::authorization::rebuilder::{ use crate::pipeline::authorization::tree::UnauthorizedPathTrie; use crate::pipeline::coerce_variables::CoerceVariablesPayload; use crate::pipeline::error::PipelineError; -use crate::pipeline::normalize::GraphQLNormalizationPayload; +use crate::pipeline::normalize::{hash_normalized_operation, GraphQLNormalizationPayload}; use hive_router_config::authorization::UnauthorizedMode; use hive_router_config::HiveRouterConfig; @@ -117,13 +117,21 @@ pub fn enforce_operation_authorization( new_projection_plan, errors, } => { + let hashes = hash_normalized_operation( + &new_operation_definition, + normalized_payload.operation_for_introspection.as_deref(), + ); + ( Arc::new(GraphQLNormalizationPayload { operation_for_plan: Arc::new(new_operation_definition), + operation_for_plan_hash: hashes.operation_for_plan_hash, // These are cheap Arc clones operation_for_introspection: normalized_payload .operation_for_introspection .clone(), + operation_for_introspection_hash: hashes.operation_for_introspection_hash, + normalized_operation_hash: hashes.combined_operation_hash, root_type_name: normalized_payload.root_type_name, projection_plan: Arc::new(new_projection_plan), operation_indentity: normalized_payload.operation_indentity.clone(), diff --git a/bin/router/src/pipeline/authorization/tests.rs b/bin/router/src/pipeline/authorization/tests.rs index 4e769ca00..0c6bb4423 100644 --- a/bin/router/src/pipeline/authorization/tests.rs +++ b/bin/router/src/pipeline/authorization/tests.rs @@ -19,7 +19,7 @@ use crate::pipeline::{ authorization::{ apply_authorization_to_operation, metadata::AuthorizationMetadataExt, AuthorizationDecision, }, - normalize::{GraphQLNormalizationPayload, OperationIdentity}, + normalize::{hash_normalized_operation, GraphQLNormalizationPayload, OperationIdentity}, }; struct SupergraphTestData { @@ -67,14 +67,21 @@ impl SupergraphTestData { let (root_type_name, projection_plan) = FieldProjectionPlan::from_operation(&operation, &self.schema_metadata); let partitioned_operation = partition_operation(operation); + let operation_for_plan = Arc::new(partitioned_operation.downstream_operation); + let operation_for_introspection = + partitioned_operation.introspection_operation.map(Arc::new); + + let hashes = + hash_normalized_operation(&operation_for_plan, operation_for_introspection.as_deref()); let payload = GraphQLNormalizationPayload { root_type_name, projection_plan: Arc::new(projection_plan), - operation_for_plan: Arc::new(partitioned_operation.downstream_operation), - operation_for_introspection: partitioned_operation - .introspection_operation - .map(Arc::new), + operation_for_plan, + operation_for_plan_hash: hashes.operation_for_plan_hash, + operation_for_introspection, + operation_for_introspection_hash: hashes.operation_for_introspection_hash, + normalized_operation_hash: hashes.combined_operation_hash, operation_indentity: OperationIdentity { name: doc.operation_name.clone(), operation_type: "query", diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index fcef3a1fd..ab00898ce 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -1,5 +1,11 @@ -use std::{sync::Arc, time::Instant}; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + sync::Arc, + time::Instant, +}; use tracing::{error, Instrument}; +use xxhash_rust::xxh3::Xxh3; use hive_router_internal::telemetry::traces::spans::{ graphql::GraphQLOperationSpan, http_request::HttpServerRequestSpan, @@ -9,6 +15,7 @@ use hive_router_plan_executor::{ client_request_details::{ClientRequestDetails, JwtRequestDetails, OperationDetails}, plan::PlanExecutionOutput, }, + hooks::on_graphql_params::GraphQLParams, hooks::on_supergraph_load::SupergraphData, plugin_context::{PluginContext, PluginRequestState}, }; @@ -17,6 +24,7 @@ use hive_router_query_planner::{ }; use http::{header::CONTENT_TYPE, Method}; use ntex::web::{self, HttpRequest}; +use sonic_rs::{JsonContainerTrait, JsonType, JsonValueTrait, Value}; use crate::{ pipeline::{ @@ -27,7 +35,7 @@ use crate::{ error::PipelineError, execution::{execute_plan, PlannedRequest}, execution_request::{deserialize_graphql_params, DeserializationResult, GetQueryStr}, - header::{RequestAccepts, ResponseMode, TEXT_HTML_MIME}, + header::{RequestAccepts, ResponseMode, SingleContentType, TEXT_HTML_MIME}, introspection_policy::handle_introspection_policy, normalize::{normalize_request_with_cache, GraphQLNormalizationPayload}, parser::{parse_operation_with_cache, ParseResult}, @@ -40,7 +48,7 @@ use crate::{ validation::validate_operation_with_cache, }, schema_state::SchemaState, - shared_state::RouterSharedState, + shared_state::{RouterRequestDedupeHeaderPolicy, RouterSharedState, SharedRouterResponse}, GRAPHIQL_HTML, }; @@ -132,7 +140,7 @@ pub async fn graphql_request_handler( let deserialization_result = deserialize_graphql_params(req, body_bytes, &plugin_req_state).await?; - let mut graphql_params = match deserialization_result { + let graphql_params = match deserialization_result { DeserializationResult::GraphQLParams(params) => params, DeserializationResult::EarlyResponse(response) => { return Ok(response); @@ -205,10 +213,10 @@ pub async fn graphql_request_handler( .await?; write_graphql_operation_metric_identity( - req, - normalize_payload.operation_indentity.name.clone(), - Some(normalize_payload.operation_indentity.operation_type), - ); + req, + normalize_payload.operation_indentity.name.clone(), + Some(normalize_payload.operation_indentity.operation_type), + ); if req.method() == Method::GET { if let Some(OperationKind::Mutation) = @@ -236,103 +244,190 @@ pub async fn graphql_request_handler( return Err(PipelineError::UnsupportedContentType); }; - let jwt_request_details = match &shared_state.jwt_auth_runtime { - Some(jwt_auth_runtime) => match jwt_auth_runtime - .validate_headers(req.headers(), &shared_state.jwt_claims_cache) - .await? - { - Some(jwt_context) => JwtRequestDetails::Authenticated { - scopes: jwt_context.extract_scopes(), - claims: jwt_context.get_claims_value()?, - token: jwt_context.token_raw, - prefix: jwt_context.token_prefix, - }, - None => JwtRequestDetails::Unauthenticated, - }, - None => JwtRequestDetails::Unauthenticated, - }; + let request_dedupe_enabled = + shared_state.router_config.traffic_shaping.router.dedupe.enabled; + + let shared_response = if request_dedupe_enabled + && matches!( + normalize_payload.operation_for_plan.operation_kind, + Some(OperationKind::Query) | None + ) { + let variables_hash = hash_graphql_variables(&graphql_params.variables); + let extensions_hash = graphql_params + .extensions + .as_ref() + .map_or(0, hash_graphql_extensions); + + let schema_checksum = supergraph.schema_checksum(); + let fingerprint = inbound_request_fingerprint( + req, + &shared_state.in_flight_requests_header_policy, + schema_checksum, + normalize_payload.normalized_operation_hash, + variables_hash, + extensions_hash, + ); + let (shared_response, _role) = shared_state + .in_flight_requests + .claim(fingerprint) + .get_or_try_init(|| async { + execute_planned_request( + req, + graphql_params, + &normalize_payload, + supergraph, + shared_state, + schema_state, + &operation_span, + &plugin_req_state, + single_content_type, + ) + .await + }) + .await?; - let variable_payload = coerce_request_variables( - supergraph, - &mut graphql_params.variables, - &normalize_payload, - )?; - - let client_request_details = ClientRequestDetails { - method: req.method(), - url: req.uri(), - headers: req.headers(), - operation: OperationDetails { - name: normalize_payload.operation_for_plan.name.as_deref(), - kind: match normalize_payload.operation_for_plan.operation_kind { - Some(OperationKind::Query) => "query", - Some(OperationKind::Mutation) => "mutation", - Some(OperationKind::Subscription) => "subscription", - None => "query", - }, - query: graphql_params.get_query()?, - }, - jwt: jwt_request_details, + Arc::unwrap_or_clone(shared_response) + } else { + execute_planned_request( + req, + graphql_params, + &normalize_payload, + supergraph, + shared_state, + schema_state, + &operation_span, + &plugin_req_state, + single_content_type, + ) + .await? }; - let pipeline_result = execute_pipeline( - &client_request_details, - &normalize_payload, - &variable_payload, - supergraph, - shared_state, - schema_state, - &operation_span, - &plugin_req_state, - ) - .await?; - - write_graphql_response_metric_status(req, if pipeline_result.error_count > 0 { - GraphQLResponseStatus::Error - } else { - GraphQLResponseStatus::Ok - }); - if let Some(hive_usage_agent) = &shared_state.hive_usage_agent { - usage_reporting::collect_usage_report( - supergraph.supergraph_schema.clone(), - started_at.elapsed(), - client_name, - client_version, - &client_request_details, - hive_usage_agent, - shared_state - .router_config - .telemetry - .hive - .as_ref() - .map(|c| &c.usage_reporting) - .expect( - // SAFETY: According to `configure_app_from_config` in `bin/router/src/lib.rs`, - // the UsageAgent is only created when usage reporting is enabled. - // Thus, this expect should never panic. - "Expected Usage Reporting options to be present when Hive Usage Agent is initialized", - ), - pipeline_result.error_count, - ) - .await; - } + usage_reporting::collect_usage_report( + supergraph.supergraph_schema.clone(), + started_at.elapsed(), + client_name, + client_version, + normalize_payload.operation_for_plan.name.as_deref(), + &parser_payload.minified_document, + hive_usage_agent, + shared_state + .router_config + .telemetry + .hive + .as_ref() + .map(|c| &c.usage_reporting) + .expect( + // SAFETY: According to `configure_app_from_config` in `bin/router/src/lib.rs`, + // the UsageAgent is only created when usage reporting is enabled. + // Thus, this expect should never panic. + "Expected Usage Reporting options to be present when Hive Usage Agent is initialized", + ), + shared_response.error_count, + ) + .await; + } - let mut response_builder = web::HttpResponse::Ok(); + let pipeline_error_count = shared_response.error_count; + let response = shared_response.into(); - if let Some(response_headers_aggregator) = pipeline_result.response_headers_aggregator { - response_headers_aggregator.modify_client_response_headers(&mut response_builder)?; - } + write_graphql_response_metric_status( + req, + if pipeline_error_count > 0 { + GraphQLResponseStatus::Error + } else { + GraphQLResponseStatus::Ok + }, + ); - Ok(response_builder - .content_type(single_content_type.as_ref()) - .status(pipeline_result.status_code) - .body(pipeline_result.body)) + Ok(response) } .instrument(operation_span.clone()) .await .inspect_err(|_| { - write_graphql_response_metric_status(req, GraphQLResponseStatus::Error); + write_graphql_response_metric_status(req, GraphQLResponseStatus::Error); + }) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_planned_request<'exec>( + req: &'exec HttpRequest, + mut graphql_params: GraphQLParams, + normalize_payload: &Arc, + supergraph: &'exec SupergraphData, + shared_state: &'exec Arc, + schema_state: &'exec Arc, + operation_span: &'exec GraphQLOperationSpan, + plugin_req_state: &'exec Option>, + single_content_type: &'exec SingleContentType, +) -> Result { + let jwt_request_details = match &shared_state.jwt_auth_runtime { + Some(jwt_auth_runtime) => match jwt_auth_runtime + .validate_headers(req.headers(), &shared_state.jwt_claims_cache) + .await? + { + Some(jwt_context) => JwtRequestDetails::Authenticated { + scopes: jwt_context.extract_scopes(), + claims: jwt_context.get_claims_value()?, + token: jwt_context.token_raw, + prefix: jwt_context.token_prefix, + }, + None => JwtRequestDetails::Unauthenticated, + }, + None => JwtRequestDetails::Unauthenticated, + }; + + let variable_payload = + coerce_request_variables(supergraph, &mut graphql_params.variables, normalize_payload)?; + + let client_request_details = ClientRequestDetails { + method: req.method(), + url: req.uri(), + headers: req.headers(), + operation: OperationDetails { + name: normalize_payload.operation_for_plan.name.as_deref(), + kind: match normalize_payload.operation_for_plan.operation_kind { + Some(OperationKind::Query) => "query", + Some(OperationKind::Mutation) => "mutation", + Some(OperationKind::Subscription) => "subscription", + None => "query", + }, + query: graphql_params.get_query()?, + }, + jwt: jwt_request_details, + }; + + let pipeline_result = execute_pipeline( + &client_request_details, + normalize_payload, + &variable_payload, + supergraph, + shared_state, + schema_state, + operation_span, + plugin_req_state, + ) + .await?; + + let error_count = pipeline_result.error_count; + let mut response_builder = web::HttpResponse::Ok(); + + if let Some(response_headers_aggregator) = pipeline_result.response_headers_aggregator { + response_headers_aggregator.modify_client_response_headers(&mut response_builder)?; + } + + let body = ntex::util::Bytes::from(pipeline_result.body); + + let response = response_builder + .content_type(single_content_type.as_ref()) + .status(pipeline_result.status_code) + .body(body.clone()); + + Ok(SharedRouterResponse { + body, + headers: Arc::new(response.headers().clone()), + status: response.status(), + error_count, }) } @@ -397,3 +492,105 @@ pub async fn execute_pipeline<'exec>( execute_plan(supergraph, shared_state, planned_request, operation_span).await } + +fn inbound_request_fingerprint( + req: &HttpRequest, + dedupe_header_policy: &RouterRequestDedupeHeaderPolicy, + schema_checksum: u64, + normalized_operation_hash: u64, + variables_hash: u64, + extensions_hash: u64, +) -> u64 { + let mut hasher = Xxh3::new(); + + let mut headers: Vec<(&str, &str)> = req + .headers() + .iter() + .filter(|(name, _)| dedupe_header_policy.should_include(name.as_str())) + .filter_map(|(name, value)| value.to_str().ok().map(|v_str| (name.as_str(), v_str))) + .collect(); + headers.sort_unstable_by(|(left_name, left_value), (right_name, right_value)| { + left_name + .cmp(right_name) + .then_with(|| left_value.cmp(right_value)) + }); + + req.method().hash(&mut hasher); + req.path().hash(&mut hasher); + headers.hash(&mut hasher); + schema_checksum.hash(&mut hasher); + normalized_operation_hash.hash(&mut hasher); + variables_hash.hash(&mut hasher); + extensions_hash.hash(&mut hasher); + + hasher.finish() +} + +fn hash_graphql_variables(variables: &HashMap) -> u64 { + let mut hasher = Xxh3::new(); + + let mut keys: Vec<&str> = variables.keys().map(String::as_str).collect(); + keys.sort_unstable(); + + keys.len().hash(&mut hasher); + for key in keys { + key.hash(&mut hasher); + if let Some(value) = variables.get(key) { + hash_graphql_value(value, &mut hasher); + } + } + + hasher.finish() +} + +fn hash_graphql_extensions(extensions: &HashMap) -> u64 { + // reused as hash_graphql_variables has the same function signature + hash_graphql_variables(extensions) +} + +fn hash_graphql_value(value: &Value, hasher: &mut Xxh3) { + match value.get_type() { + JsonType::Null => 0u8.hash(hasher), + JsonType::Boolean => { + 1u8.hash(hasher); + value.as_bool().unwrap_or(false).hash(hasher); + } + JsonType::Number => { + 2u8.hash(hasher); + if let Some(number) = value.as_i64() { + 0u8.hash(hasher); + number.hash(hasher); + } else if let Some(number) = value.as_u64() { + 1u8.hash(hasher); + number.hash(hasher); + } else if let Some(number) = value.as_f64() { + 2u8.hash(hasher); + number.to_bits().hash(hasher); + } + } + JsonType::String => { + 3u8.hash(hasher); + value.as_str().unwrap_or_default().hash(hasher); + } + JsonType::Object => { + 4u8.hash(hasher); + if let Some(object) = value.as_object() { + object.len().hash(hasher); + for (key, nested_value) in object.iter() { + key.hash(hasher); + hash_graphql_value(nested_value, hasher); + } + } + } + JsonType::Array => { + 5u8.hash(hasher); + if let Some(array) = value.as_array() { + let slice = array.as_slice(); + slice.len().hash(hasher); + for item in slice { + hash_graphql_value(item, hasher); + } + } + } + } +} diff --git a/bin/router/src/pipeline/normalize.rs b/bin/router/src/pipeline/normalize.rs index 3a9a1233c..eb40060ae 100644 --- a/bin/router/src/pipeline/normalize.rs +++ b/bin/router/src/pipeline/normalize.rs @@ -23,7 +23,10 @@ use tracing::{trace, Instrument}; pub struct GraphQLNormalizationPayload { /// The operation to execute, without introspection fields. pub operation_for_plan: Arc, + pub operation_for_plan_hash: u64, pub operation_for_introspection: Option>, + pub operation_for_introspection_hash: Option, + pub normalized_operation_hash: u64, pub root_type_name: &'static str, pub projection_plan: Arc>, pub operation_indentity: OperationIdentity, @@ -47,6 +50,34 @@ impl<'a> From<&'a OperationIdentity> for GraphQLSpanOperationIdentity<'a> { } } +pub fn hash_normalized_operation( + operation_for_plan: &OperationDefinition, + operation_for_introspection: Option<&OperationDefinition>, +) -> NormalizedOperationHashes { + let operation_for_plan_hash = operation_for_plan.hash(); + let operation_for_introspection_hash = + operation_for_introspection.map(OperationDefinition::hash); + + let mut hasher = Xxh3::new(); + operation_for_plan_hash.hash(&mut hasher); + operation_for_introspection_hash.is_some().hash(&mut hasher); + if let Some(hash) = operation_for_introspection_hash { + hash.hash(&mut hasher); + } + + NormalizedOperationHashes { + operation_for_plan_hash, + operation_for_introspection_hash, + combined_operation_hash: hasher.finish(), + } +} + +pub struct NormalizedOperationHashes { + pub operation_for_plan_hash: u64, + pub operation_for_introspection_hash: Option, + pub combined_operation_hash: u64, +} + #[inline] pub async fn normalize_request_with_cache( supergraph: &SupergraphData, @@ -89,13 +120,23 @@ pub async fn normalize_request_with_cache( FieldProjectionPlan::from_operation(&operation, &supergraph.metadata); let partitioned_operation = partition_operation(operation); + let operation_for_plan = Arc::new(partitioned_operation.downstream_operation); + let operation_for_introspection = + partitioned_operation.introspection_operation.map(Arc::new); + + let hashes = hash_normalized_operation( + &operation_for_plan, + operation_for_introspection.as_deref(), + ); + let payload = GraphQLNormalizationPayload { root_type_name, projection_plan: Arc::new(projection_plan), - operation_for_plan: Arc::new(partitioned_operation.downstream_operation), - operation_for_introspection: partitioned_operation - .introspection_operation - .map(Arc::new), + operation_for_plan, + operation_for_plan_hash: hashes.operation_for_plan_hash, + operation_for_introspection, + operation_for_introspection_hash: hashes.operation_for_introspection_hash, + normalized_operation_hash: hashes.combined_operation_hash, operation_indentity: OperationIdentity { name: doc.operation_name.clone(), operation_type: parser_payload.operation_type, diff --git a/bin/router/src/pipeline/query_plan.rs b/bin/router/src/pipeline/query_plan.rs index 92ad37a3e..2be40683c 100644 --- a/bin/router/src/pipeline/query_plan.rs +++ b/bin/router/src/pipeline/query_plan.rs @@ -44,12 +44,12 @@ pub async fn plan_operation_with_cache( async { let mut on_end_callbacks = vec![]; - let filtered_operation_for_plan = &normalized_operation.operation_for_plan; + let mut filtered_operation_for_plan = normalized_operation.operation_for_plan.as_ref(); if let Some(plugin_req_state) = plugin_req_state { let mut start_payload = OnQueryPlanStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, - filtered_operation_for_plan: &normalized_operation.operation_for_plan, + filtered_operation_for_plan, cancellation_token, planner: &supergraph.planner, }; @@ -69,6 +69,8 @@ pub async fn plan_operation_with_cache( } } } + + filtered_operation_for_plan = start_payload.filtered_operation_for_plan; }; let metrics = &schema_state.telemetry_context.metrics; @@ -76,8 +78,15 @@ pub async fn plan_operation_with_cache( let stable_override_context = StableOverrideContext::new(&supergraph.planner.supergraph, request_override_context); - let plan_cache_key = - calculate_cache_key(filtered_operation_for_plan.hash(), &stable_override_context); + let operation_for_plan_hash = if std::ptr::eq( + filtered_operation_for_plan, + normalized_operation.operation_for_plan.as_ref(), + ) { + normalized_operation.operation_for_plan_hash + } else { + filtered_operation_for_plan.hash() + }; + let plan_cache_key = calculate_cache_key(operation_for_plan_hash, &stable_override_context); let is_plan_operation_empty = filtered_operation_for_plan.selection_set.is_empty(); let is_projection_plan_empty = normalized_operation.projection_plan.is_empty(); let contains_introspection = normalized_operation.operation_for_introspection.is_some(); diff --git a/bin/router/src/pipeline/usage_reporting.rs b/bin/router/src/pipeline/usage_reporting.rs index 941c72c64..10ce60876 100644 --- a/bin/router/src/pipeline/usage_reporting.rs +++ b/bin/router/src/pipeline/usage_reporting.rs @@ -13,7 +13,6 @@ use hive_router_config::{ }; use hive_router_internal::background_tasks::{BackgroundTask, BackgroundTasksManager}; use hive_router_internal::telemetry::utils::resolve_value_or_expression; -use hive_router_plan_executor::execution::client_request_details::ClientRequestDetails; use rand::prelude::*; use tokio_util::sync::CancellationToken; @@ -86,7 +85,8 @@ pub async fn collect_usage_report<'a>( duration: Duration, client_name: Option<&str>, client_version: Option<&str>, - client_request_details: &ClientRequestDetails<'a>, + operation_name: Option<&'a str>, + operation_body: &'a str, hive_usage_agent: &UsageAgent, usage_config: &UsageReportingConfig, error_count: usize, @@ -95,11 +95,7 @@ pub async fn collect_usage_report<'a>( if sample_rate < 1.0 && !rand::rng().random_bool(sample_rate) { return; } - if client_request_details - .operation - .name - .is_some_and(|op_name| usage_config.exclude.iter().any(|s| s == op_name)) - { + if operation_name.is_some_and(|op_name| usage_config.exclude.iter().any(|s| s == op_name)) { return; } let timestamp = SystemTime::now() @@ -114,11 +110,8 @@ pub async fn collect_usage_report<'a>( duration, ok: error_count == 0, errors: error_count, - operation_body: client_request_details.operation.query.to_owned(), - operation_name: client_request_details - .operation - .name - .map(|op_name| op_name.to_owned()), + operation_body: operation_body.to_owned(), + operation_name: operation_name.map(|s| s.to_owned()), persisted_document_hash: None, }; diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index 6b54e6394..22823db44 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -1,17 +1,24 @@ use graphql_tools::validation::validate::ValidationPlan; use hive_console_sdk::agent::usage_agent::{AgentError, UsageAgent}; +use hive_router_config::traffic_shaping::{ + TrafficShapingRouterDedupeHeadersConfig, TrafficShapingRouterDedupeHeadersKeyword, +}; use hive_router_config::HiveRouterConfig; use hive_router_internal::expressions::values::boolean::BooleanOrProgram; use hive_router_internal::expressions::ExpressionCompileError; +use hive_router_internal::inflight::InFlightMap; use hive_router_internal::telemetry::TelemetryContext; use hive_router_plan_executor::headers::{ compile::compile_headers_plan, errors::HeaderRuleCompileError, plan::HeaderRulesPlan, }; use hive_router_plan_executor::plugin_trait::RouterPluginBoxed; +use http::StatusCode; use moka::future::Cache; use moka::Expiry; -use std::sync::Arc; +use ntex::web; +use ntex::{http::HeaderMap, util::Bytes}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{collections::HashSet, sync::Arc}; use crate::cache_state::CacheState; use crate::jwt::context::JwtTokenPayload; @@ -22,6 +29,71 @@ use crate::pipeline::parser::ParseCacheEntry; use crate::pipeline::progressive_override::{OverrideLabelsCompileError, OverrideLabelsEvaluator}; pub type JwtClaimsCache = Cache>; +pub type RouterInflightRequestsMap = InFlightMap; + +#[derive(Clone)] +pub enum RouterRequestDedupeHeaderPolicy { + All, + None, + Include(HashSet), +} + +impl RouterRequestDedupeHeaderPolicy { + #[inline] + pub fn should_include(&self, header_name: &str) -> bool { + match self { + Self::All => true, + Self::None => false, + Self::Include(allowed_headers) => allowed_headers.contains(header_name), + } + } +} + +impl From<&TrafficShapingRouterDedupeHeadersConfig> for RouterRequestDedupeHeaderPolicy { + fn from(headers: &TrafficShapingRouterDedupeHeadersConfig) -> Self { + match headers { + TrafficShapingRouterDedupeHeadersConfig::Keyword( + TrafficShapingRouterDedupeHeadersKeyword::All, + ) => Self::All, + TrafficShapingRouterDedupeHeadersConfig::Keyword( + TrafficShapingRouterDedupeHeadersKeyword::None, + ) => Self::None, + TrafficShapingRouterDedupeHeadersConfig::Include { include } => { + if include.is_empty() { + return Self::None; + } + + let mut dedupe_headers = HashSet::with_capacity(include.len()); + for header in include { + dedupe_headers.insert(header.get_header_ref().as_str().to_owned()); + } + + Self::Include(dedupe_headers) + } + } + } +} + +#[derive(Clone)] +pub struct SharedRouterResponse { + pub body: Bytes, + pub headers: Arc, + pub status: StatusCode, + pub error_count: usize, +} + +impl From for web::HttpResponse { + fn from(shared_response: SharedRouterResponse) -> Self { + let mut response = web::HttpResponse::Ok(); + response.status(shared_response.status); + + for (header_name, header_value) in shared_response.headers.iter() { + response.set_header(header_name, header_value); + } + + response.body(shared_response.body) + } +} /// Default TTL for JWT claims cache entries (5 seconds) const DEFAULT_JWT_CACHE_TTL_SECS: u64 = 5; @@ -79,6 +151,8 @@ pub struct RouterSharedState { pub introspection_policy: BooleanOrProgram, pub telemetry_context: Arc, pub plugins: Option>>, + pub in_flight_requests: RouterInflightRequestsMap, + pub in_flight_requests_header_policy: RouterRequestDedupeHeaderPolicy, } impl RouterSharedState { @@ -114,6 +188,13 @@ impl RouterSharedState { .map_err(Box::new)?, telemetry_context, plugins, + in_flight_requests: InFlightMap::default(), + in_flight_requests_header_policy: (&router_config + .traffic_shaping + .router + .dedupe + .headers) + .into(), }) } } @@ -131,3 +212,40 @@ pub enum SharedStateError { #[error("invalid introspection config: {0}")] IntrospectionPolicyCompile(#[from] Box), } + +#[cfg(test)] +mod tests { + use super::RouterRequestDedupeHeaderPolicy; + use hive_router_config::traffic_shaping::{ + TrafficShapingRouterDedupeHeadersConfig, TrafficShapingRouterDedupeHeadersKeyword, + }; + + #[test] + fn should_map_header_variants_to_policy() { + let all = TrafficShapingRouterDedupeHeadersConfig::Keyword( + TrafficShapingRouterDedupeHeadersKeyword::All, + ); + assert!(matches!( + RouterRequestDedupeHeaderPolicy::from(&all), + RouterRequestDedupeHeaderPolicy::All + )); + + let none = TrafficShapingRouterDedupeHeadersConfig::Keyword( + TrafficShapingRouterDedupeHeadersKeyword::None, + ); + assert!(matches!( + RouterRequestDedupeHeaderPolicy::from(&none), + RouterRequestDedupeHeaderPolicy::None + )); + + let include = TrafficShapingRouterDedupeHeadersConfig::Include { + include: vec!["Authorization".into()], + }; + let include_policy = RouterRequestDedupeHeaderPolicy::from(&include); + assert!(matches!( + include_policy, + RouterRequestDedupeHeaderPolicy::Include(_) + )); + assert!(include_policy.should_include("authorization")); + } +} diff --git a/docs/README.md b/docs/README.md index a38bb7145..3b53337ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| |[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
|| |[**telemetry**](#telemetry)|`object`|Default: `{"client_identification":{"name_header":"graphql-client-name","version_header":"graphql-client-version"},"hive":null,"metrics":{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}},"resource":{"attributes":{}},"tracing":{"collect":{"max_attributes_per_event":16,"max_attributes_per_link":32,"max_attributes_per_span":128,"max_events_per_span":128,"parent_based_sampler":false,"sampling":1},"exporters":[],"instrumentation":{"spans":{"mode":"spec_compliant"}},"propagation":{"b3":false,"baggage":false,"jaeger":false,"trace_context":true}}}`
|| -|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"request_timeout":"1m"}}`
|| +|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"request_timeout":"1m"}}`
|| **Additional Properties:** not allowed **Example** @@ -195,6 +195,9 @@ traffic_shaping: request_timeout: 30s max_connections_per_host: 100 router: + dedupe: + enabled: false + headers: all request_timeout: 1m ``` @@ -2914,7 +2917,7 @@ Configuration for the traffic-shaping of the executor. Use these configurations |----|----|-----------|--------| |[**all**](#traffic_shapingall)|`object`|The default configuration that will be applied to all subgraphs, unless overridden by a specific subgraph configuration.
Default: `{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"}`
|| |**max\_connections\_per\_host**|`integer`|Limits the concurrent amount of requests/connections per host/subgraph.
Default: `100`
Format: `"uint"`
Minimum: `0`
|| -|[**router**](#traffic_shapingrouter)|`object`|Configuration for the router itself, e.g., for handling incoming requests, or other router-level traffic shaping configurations.
Default: `{"request_timeout":"1m"}`
|| +|[**router**](#traffic_shapingrouter)|`object`|Configuration for the router itself, e.g., for handling incoming requests, or other router-level traffic shaping configurations.
Default: `{"dedupe":{"enabled":false,"headers":"all"},"request_timeout":"1m"}`
|| |[**subgraphs**](#traffic_shapingsubgraphs)|`object`|Optional per-subgraph configurations that will override the default configuration for specific subgraphs.
|| **Additional Properties:** not allowed @@ -2927,6 +2930,9 @@ all: request_timeout: 30s max_connections_per_host: 100 router: + dedupe: + enabled: false + headers: all request_timeout: 1m ``` @@ -2965,16 +2971,39 @@ Configuration for the router itself, e.g., for handling incoming requests, or ot |Name|Type|Description|Required| |----|----|-----------|--------| +|[**dedupe**](#traffic_shapingrouterdedupe)|`object`|Default: `{"enabled":false,"headers":"all"}`
|| |**request\_timeout**|`string`|Optional timeout configuration for incoming requests to the router.
It starts from the moment the request is received by the router,
and includes the entire processing of the request (validation, execution, etc.) until a response is sent back to the client.
If a request takes longer than the specified duration, it will be aborted and a timeout error will be returned to the client.
Default: `"1m"`
|| **Additional Properties:** not allowed **Example** ```yaml +dedupe: + enabled: false + headers: all request_timeout: 1m ``` + +#### traffic\_shaping\.router\.dedupe: object + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`|Enables/disables in-flight request deduplication at the router endpoint level.

When enabled, identical incoming GraphQL query requests that are processed at the same time
share the same in-flight execution result.
Default: `false`
|| +|**headers**||Header configuration participating in the dedupe key.

Accepted forms:
- `all`
- `none`
- `{ include: ["authorization", "cookie"] }`

Header names are case-insensitive and validated as standard HTTP header names.
Default: `"all"`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +enabled: false +headers: all + +``` + ### traffic\_shaping\.subgraphs: object diff --git a/e2e/src/http.rs b/e2e/src/http.rs index 7225ab9c6..069abc9a7 100644 --- a/e2e/src/http.rs +++ b/e2e/src/http.rs @@ -1,10 +1,33 @@ #[cfg(test)] mod http_tests { + use std::{ + thread::sleep, + time::{Duration, Instant}, + }; + + use futures::{stream::FuturesUnordered, StreamExt}; use hive_router::pipeline::execution::EXPOSE_QUERY_PLAN_HEADER; + use mockito::Mock; + use ntex::time; use sonic_rs::JsonValueTrait; use crate::testkit::{some_header_map, ClientResponseExt, TestRouter, TestSubgraphs}; + async fn wait_until_mock_matched(mock: &Mock, timeout: Duration) -> Result<(), String> { + let started = Instant::now(); + loop { + if mock.matched_async().await { + return Ok(()); + } + + time::sleep(Duration::from_millis(10)).await; + + if started.elapsed() > timeout { + return Err(format!("timeout after {:?}", started.elapsed())); + } + } + } + #[ntex::test] async fn should_allow_to_customize_graphql_endpoint() { let subgraphs = TestSubgraphs::builder().build().start().await; @@ -205,4 +228,554 @@ mod http_tests { "expected no requests to products subgraph" ); } + + #[ntex::test] + async fn should_not_dedupe_inflight_router_requests_by_default() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + "#, + ) + .build() + .start() + .await; + + let request_count = 12; + let mut requests = FuturesUnordered::new(); + + for _ in 0..request_count { + requests.push(router.send_graphql_request( + r#" + { + topProducts { + name + price + } + } + "#, + None, + None, + )); + } + + while let Some(response) = requests.next().await { + assert!(response.status().is_success(), "Expected 200 OK"); + let json_body = response.json_body().await; + assert!(json_body["data"]["topProducts"].is_array()); + assert!(json_body["errors"].is_null()); + } + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert!( + products_requests >= request_count, + "expected at least one products subgraph request per incoming request when router inflight dedupe is disabled by default; got {products_requests} for {request_count} incoming requests" + ); + } + + #[ntex::test] + async fn should_dedupe_inflight_router_requests_when_enabled() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + "#, + ) + .build() + .start() + .await; + + let request_count = 12; + let mut requests = FuturesUnordered::new(); + + for _ in 0..request_count { + requests.push(router.send_graphql_request( + r#" + { + topProducts { + name + price + } + } + "#, + None, + None, + )); + } + + while let Some(response) = requests.next().await { + assert!(response.status().is_success(), "Expected 200 OK"); + let json_body = response.json_body().await; + assert!(json_body["data"]["topProducts"].is_array()); + assert!(json_body["errors"].is_null()); + } + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert!( + products_requests < request_count, + "expected fewer products subgraph requests than incoming requests when router inflight dedupe is enabled; got {products_requests} for {request_count} incoming requests" + ); + } + + #[ntex::test] + async fn should_not_dedupe_inflight_router_mutation_requests() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + "#, + ) + .build() + .start() + .await; + + let request_count = 8; + let mut requests = FuturesUnordered::new(); + + for _ in 0..request_count { + requests.push(router.send_graphql_request( + r#" + mutation { + oneofTest(input: { string: "router-dedupe" }) { + string + } + } + "#, + None, + None, + )); + } + + while let Some(response) = requests.next().await { + assert!(response.status().is_success(), "Expected 200 OK"); + } + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert!( + products_requests >= request_count, + "expected no mutation dedupe at router level; got {products_requests} products requests for {request_count} incoming mutation requests" + ); + } + + #[ntex::test] + async fn should_not_share_inflight_dedupe_entry_across_schema_reload() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(1500)); + } + None + }) + .build() + .start() + .await; + + let initial_supergraph = subgraphs.supergraph(include_str!("../supergraph.graphql")); + let changed_supergraph = initial_supergraph.replacen("first: Int = 5", "first: Int = 6", 1); + assert_ne!(initial_supergraph, changed_supergraph); + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + + let mock_initial = server + .mock("GET", "/supergraph") + .expect_at_least(1) + .with_status(200) + .with_header("content-type", "text/plain") + .with_header("etag", "v1") + .with_body(initial_supergraph) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: hive + endpoint: http://{host}/supergraph + key: dummy_key + poll_interval: 100ms + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + "#, + )) + .build() + .start() + .await; + + let query = r#" + { + topProducts { + name + price + } + } + "#; + + let first_request = async { router.send_graphql_request(query, None, None).await }; + let second_request = async { + let started = Instant::now(); + while subgraphs + .get_requests_log("products") + .unwrap_or_default() + .is_empty() + { + assert!( + started.elapsed() < Duration::from_secs(3), + "first request did not reach products subgraph in time" + ); + time::sleep(Duration::from_millis(25)).await; + } + + mock_initial.remove(); + + let mock_changed = server + .mock("GET", "/supergraph") + .expect_at_least(1) + .with_status(200) + .with_header("content-type", "text/plain") + .with_header("etag", "v2") + .with_body(changed_supergraph) + .create(); + + wait_until_mock_matched(&mock_changed, Duration::from_secs(2)) + .await + .expect("expected schema reload poll to fetch changed supergraph"); + + router.send_graphql_request(query, None, None).await + }; + + let (first_response, second_response) = futures::join!(first_request, second_request); + + assert!( + first_response.status().is_success(), + "expected first request 200 OK" + ); + assert!( + second_response.status().is_success(), + "expected second request 200 OK" + ); + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert!( + products_requests >= 2, + "expected at least two products subgraph requests across schema reload to avoid sharing old in-flight dedupe entry; got {products_requests}" + ); + } + + #[ntex::test] + async fn should_use_all_headers_in_router_dedupe_key_by_default() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + "#, + ) + .build() + .start() + .await; + + let query = r#" + { + topProducts { + name + price + } + } + "#; + + let (response_a, response_b) = futures::join!( + router.send_graphql_request( + query, + None, + some_header_map! { + "x-user" => "a" + }, + ), + router.send_graphql_request( + query, + None, + some_header_map! { + "x-user" => "b" + }, + ) + ); + + assert!( + response_a.status().is_success(), + "Expected first request 200 OK" + ); + assert!( + response_b.status().is_success(), + "Expected second request 200 OK" + ); + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert!( + products_requests >= 2, + "expected at least two products subgraph requests when all headers are part of dedupe key; got {products_requests}" + ); + } + + #[ntex::test] + async fn should_ignore_headers_in_router_dedupe_key_when_headers_is_empty() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + headers: none + "#, + ) + .build() + .start() + .await; + + let query = r#" + { + topProducts { + name + price + } + } + "#; + + let (response_a, response_b) = futures::join!( + router.send_graphql_request( + query, + None, + some_header_map! { + "x-user" => "a" + }, + ), + router.send_graphql_request( + query, + None, + some_header_map! { + "x-user" => "b" + }, + ) + ); + + assert!( + response_a.status().is_success(), + "Expected first request 200 OK" + ); + assert!( + response_b.status().is_success(), + "Expected second request 200 OK" + ); + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert_eq!( + products_requests, 1, + "expected exactly one products subgraph request when router dedupe ignores headers" + ); + } + + #[ntex::test] + async fn should_use_case_insensitive_header_allowlist_in_router_dedupe_key() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/products" { + sleep(Duration::from_millis(50)); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + dedupe_enabled: false + router: + dedupe: + enabled: true + headers: + include: ["X-Tenant"] + "#, + ) + .build() + .start() + .await; + + let query = r#" + { + topProducts { + name + price + } + } + "#; + + let (response_a, response_b) = futures::join!( + router.send_graphql_request( + query, + None, + some_header_map! { + "x-tenant" => "acme", + "authorization" => "Bearer token-a" + }, + ), + router.send_graphql_request( + query, + None, + some_header_map! { + "X-TENANT" => "acme", + "authorization" => "Bearer token-b" + }, + ) + ); + + assert!( + response_a.status().is_success(), + "Expected first request 200 OK" + ); + assert!( + response_b.status().is_success(), + "Expected second request 200 OK" + ); + + let products_requests = subgraphs + .get_requests_log("products") + .unwrap_or_default() + .len(); + + assert_eq!( + products_requests, 1, + "expected exactly one products subgraph request when allowlisted header matches case-insensitively" + ); + } } diff --git a/lib/executor/src/executors/dedupe.rs b/lib/executor/src/executors/dedupe.rs index 1729ed7d4..bd18e5f65 100644 --- a/lib/executor/src/executors/dedupe.rs +++ b/lib/executor/src/executors/dedupe.rs @@ -1,7 +1,6 @@ -use ahash::AHasher; use ahash::RandomState; use std::collections::BTreeMap; -use std::hash::{BuildHasherDefault, Hash, Hasher}; +use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; use xxhash_rust::xxh3::Xxh3; @@ -34,8 +33,6 @@ impl SendRequestOpts<'_> { } } -pub type ABuildHasher = BuildHasherDefault; - static LEADER_COUNTER: AtomicU64 = AtomicU64::new(1); static LEADER_SALT: OnceLock = OnceLock::new(); diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index fa81c9997..4fb11fda0 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -10,6 +10,7 @@ use crate::plugin_context::PluginRequestState; use crate::plugin_trait::{EndControlFlow, StartControlFlow}; use crate::response::subgraph_response::SubgraphResponse; use hive_router_config::HiveRouterConfig; +use hive_router_internal::inflight::InFlightRole; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; use hive_router_internal::telemetry::metrics::http_client_metrics::HttpClientRequestStateCapture; use hive_router_internal::telemetry::TelemetryContext; @@ -388,30 +389,17 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { ); let result: Result<_, SubgraphExecutorError> = async { - // Clone the cell from the map, dropping the lock from the DashMap immediately. - // Prevents any deadlocks. - let cell = self - .in_flight_requests - .entry(fingerprint) - .or_default() - .clone(); - // Mark it as a joiner span by default. - let mut is_leader = false; + let claim = self.in_flight_requests.claim(fingerprint); let mut leader_http_request_capture = None; - let (shared_response, leader_id) = cell + let (shared_response, role) = claim .get_or_try_init(|| async { - // Override the span to be a leader span for this request. - is_leader = true; let res = { // This unwrap is safe because the semaphore is never closed during the application's lifecycle. // `acquire()` only fails if the semaphore is closed, so this will always return `Ok`. let _permit = self.semaphore.acquire().await.unwrap(); send_request(send_request_opts).await }; - // It's important to remove the entry from the map before returning the result. - // This ensures that once the OnceCell is set, no future requests can join it. - // The cache is for the lifetime of the in-flight request only. - self.in_flight_requests.remove(&fingerprint); + res.map(|fetched_response| { leader_http_request_capture = Some(HttpRequestTelemetryCapture { @@ -425,10 +413,14 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { }) .await?; - if is_leader { - inflight_span.record_as_leader(leader_id); + let (shared_response, leader_id) = shared_response.as_ref(); + let shared_response = shared_response.clone(); + let leader_id = *leader_id; + + if role == InFlightRole::Leader { + inflight_span.record_as_leader(&leader_id); } else { - inflight_span.record_as_joiner(leader_id); + inflight_span.record_as_joiner(&leader_id); } inflight_span @@ -436,11 +428,11 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { deduplication_hint = DeduplicationHint::Deduped { fingerprint, - leader_id: *leader_id, - is_leader, + leader_id, + is_leader: role == InFlightRole::Leader, }; - Ok((shared_response.clone(), leader_http_request_capture)) + Ok((shared_response, leader_http_request_capture)) } .instrument(inflight_span.clone()) .await; diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index 400fc881c..80a2257bf 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -12,7 +12,8 @@ use hive_router_config::{ use hive_router_internal::expressions::vrl::core::Value as VrlValue; use hive_router_internal::expressions::{CompileExpression, DurationOrProgram, ExecutableProgram}; use hive_router_internal::{ - expressions::vrl::compiler::Program as VrlProgram, telemetry::TelemetryContext, + expressions::vrl::compiler::Program as VrlProgram, inflight::InFlightMap, + telemetry::TelemetryContext, }; use http::Uri; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; @@ -20,13 +21,12 @@ use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, rt::{TokioExecutor, TokioTimer}, }; -use tokio::sync::{OnceCell, Semaphore}; +use tokio::sync::Semaphore; use crate::{ execution::client_request_details::ClientRequestDetails, executors::{ common::{SubgraphExecutionRequest, SubgraphExecutor, SubgraphExecutorBoxedArc}, - dedupe::ABuildHasher, error::SubgraphExecutorError, http::{HTTPSubgraphExecutor, HttpClient, SubgraphHttpResponse}, }, @@ -52,8 +52,7 @@ struct ResolvedSubgraphConfig<'a> { dedupe_enabled: bool, } -pub type InflightRequestsMap = - Arc>, ABuildHasher>>; +pub type InflightRequestsMap = InFlightMap; pub struct SubgraphExecutorMap { executors_by_subgraph: ExecutorsBySubgraphMap, @@ -102,7 +101,7 @@ impl SubgraphExecutorMap { client: Arc::new(client), semaphores_by_origin: Default::default(), max_connections_per_host, - in_flight_requests: Arc::new(DashMap::with_hasher(ABuildHasher::default())), + in_flight_requests: InFlightMap::default(), timeouts_by_subgraph: Default::default(), global_timeout, telemetry_context, diff --git a/lib/executor/src/plugins/hooks/on_supergraph_load.rs b/lib/executor/src/plugins/hooks/on_supergraph_load.rs index f28fde553..5449e1e23 100644 --- a/lib/executor/src/plugins/hooks/on_supergraph_load.rs +++ b/lib/executor/src/plugins/hooks/on_supergraph_load.rs @@ -25,6 +25,13 @@ pub struct SupergraphData { pub supergraph_schema: Arc, } +impl SupergraphData { + #[inline] + pub fn schema_checksum(&self) -> u64 { + self.planner.consumer_schema.hash + } +} + pub type OnSupergraphLoadResult = Result; pub struct OnSupergraphLoadStartHookPayload { diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 554e286f5..7ddc1df39 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -33,6 +33,7 @@ async-trait = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } ahash = { workspace = true } +dashmap = { workspace = true } tokio-stream = "0.1.18" # telemetry diff --git a/lib/internal/src/inflight.rs b/lib/internal/src/inflight.rs new file mode 100644 index 000000000..7ac3c2237 --- /dev/null +++ b/lib/internal/src/inflight.rs @@ -0,0 +1,147 @@ +use std::{ + future::Future, + hash::{BuildHasher, BuildHasherDefault, Hash}, + sync::Arc, +}; + +use ahash::AHasher; +use dashmap::{mapref::entry::Entry, DashMap}; +use tokio::sync::OnceCell; + +pub type ABuildHasher = BuildHasherDefault; +type InFlightValue = Arc; +type InFlightCell = Arc>>; +type InFlightInnerMap = DashMap, S>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InFlightRole { + Leader, + Joiner, +} + +pub struct InFlightMap { + inner: Arc>, +} + +impl Clone for InFlightMap { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Default for InFlightMap +where + K: Eq + Hash, +{ + fn default() -> Self { + Self::with_hasher(ABuildHasher::default()) + } +} + +impl InFlightMap +where + K: Eq + Hash, + S: BuildHasher + Clone, +{ + #[inline] + pub fn with_hasher(hasher: S) -> Self { + Self { + inner: Arc::new(DashMap::with_hasher(hasher)), + } + } + + #[inline] + pub fn claim(&self, key: K) -> InFlightClaim + where + K: Clone, + { + match self.inner.entry(key.clone()) { + Entry::Occupied(entry) => InFlightClaim { + key, + cell: entry.get().clone(), + map: self.clone(), + }, + Entry::Vacant(entry) => { + let cell = Arc::new(OnceCell::new()); + entry.insert(cell.clone()); + InFlightClaim { + key, + cell, + map: self.clone(), + } + } + } + } + + #[inline] + pub fn remove(&self, key: &K) { + self.inner.remove(key); + } +} + +pub struct InFlightClaim { + key: K, + cell: InFlightCell, + map: InFlightMap, +} + +impl InFlightClaim +where + K: Eq + Hash + Clone, + S: BuildHasher + Clone, +{ + #[inline] + pub async fn get_or_try_init(self, init: F) -> Result<(Arc, InFlightRole), E> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let mut did_initialize = false; + let key = self.key.clone(); + let map = self.map.clone(); + + let value = self + .cell + .get_or_try_init(|| { + did_initialize = true; + async { + let _cleanup = InFlightCleanupGuard { key, map }; + init().await.map(Arc::new) + } + }) + .await? + .clone(); + + let role = if did_initialize { + InFlightRole::Leader + } else { + InFlightRole::Joiner + }; + + Ok((value, role)) + } +} + +struct InFlightCleanupGuard +where + K: Eq + Hash, + S: BuildHasher + Clone, +{ + key: K, + map: InFlightMap, +} + +impl Drop for InFlightCleanupGuard +where + K: Eq + Hash, + S: BuildHasher + Clone, +{ + fn drop(&mut self) { + // It's important to remove the entry from the map before returning the result. + // This ensures that once the OnceCell is set, no future requests can join it. + // The cache is for the lifetime of the in-flight request only. + self.map.remove(&self.key); + } +} diff --git a/lib/internal/src/lib.rs b/lib/internal/src/lib.rs index 5a3918101..7b6d626d0 100644 --- a/lib/internal/src/lib.rs +++ b/lib/internal/src/lib.rs @@ -3,6 +3,7 @@ pub mod background_tasks; pub mod expressions; pub mod graphql; pub mod http; +pub mod inflight; pub mod telemetry; pub type BoxError = Box; diff --git a/lib/router-config/src/traffic_shaping.rs b/lib/router-config/src/traffic_shaping.rs index a2c9a9bc1..8b362b6c6 100644 --- a/lib/router-config/src/traffic_shaping.rs +++ b/lib/router-config/src/traffic_shaping.rs @@ -3,6 +3,8 @@ use std::{collections::HashMap, time::Duration}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::primitives::http_header::HttpHeaderName; + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] pub struct TrafficShapingConfig { @@ -44,6 +46,10 @@ fn default_dedupe_enabled() -> bool { true } +fn default_router_dedupe_enabled() -> bool { + false +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] pub struct TrafficShapingExecutorSubgraphConfig { @@ -160,6 +166,9 @@ impl Default for TrafficShapingExecutorGlobalConfig { #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] pub struct TrafficShapingRouterConfig { + #[serde(default)] + pub dedupe: TrafficShapingRouterDedupeConfig, + /// Optional timeout configuration for incoming requests to the router. /// It starts from the moment the request is received by the router, /// and includes the entire processing of the request (validation, execution, etc.) until a response is sent back to the client. @@ -173,6 +182,58 @@ pub struct TrafficShapingRouterConfig { pub request_timeout: Duration, } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct TrafficShapingRouterDedupeConfig { + /// Enables/disables in-flight request deduplication at the router endpoint level. + /// + /// When enabled, identical incoming GraphQL query requests that are processed at the same time + /// share the same in-flight execution result. + #[serde(default = "default_router_dedupe_enabled")] + pub enabled: bool, + + /// Header configuration participating in the dedupe key. + /// + /// Accepted forms: + /// - `all` + /// - `none` + /// - `{ include: ["authorization", "cookie"] }` + /// + /// Header names are case-insensitive and validated as standard HTTP header names. + #[serde(default)] + pub headers: TrafficShapingRouterDedupeHeadersConfig, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TrafficShapingRouterDedupeHeadersKeyword { + #[default] + All, + None, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(untagged)] +pub enum TrafficShapingRouterDedupeHeadersConfig { + Keyword(TrafficShapingRouterDedupeHeadersKeyword), + Include { include: Vec }, +} + +impl Default for TrafficShapingRouterDedupeHeadersConfig { + fn default() -> Self { + Self::Keyword(TrafficShapingRouterDedupeHeadersKeyword::All) + } +} + +impl Default for TrafficShapingRouterDedupeConfig { + fn default() -> Self { + Self { + enabled: default_router_dedupe_enabled(), + headers: Default::default(), + } + } +} + fn default_router_request_timeout() -> Duration { Duration::from_secs(60) } @@ -180,6 +241,7 @@ fn default_router_request_timeout() -> Duration { impl Default for TrafficShapingRouterConfig { fn default() -> Self { Self { + dedupe: Default::default(), request_timeout: default_router_request_timeout(), } } From d2e46f43e3682978f2ba66277645fafa41996459 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:52:02 +0000 Subject: [PATCH 03/76] Add patch changeset for zmij migration Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> Agent-Logs-Url: https://github.com/graphql-hive/router/sessions/697ee76f-0b27-4111-9902-61d62a29e7b7 --- .changeset/replace-ryu-with-zmij.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/replace-ryu-with-zmij.md diff --git a/.changeset/replace-ryu-with-zmij.md b/.changeset/replace-ryu-with-zmij.md new file mode 100644 index 000000000..7a62f1d38 --- /dev/null +++ b/.changeset/replace-ryu-with-zmij.md @@ -0,0 +1,7 @@ +--- +graphql-tools: patch +hive-router-plan-executor: patch +--- + +Replace direct `ryu` usage with `zmij` for float formatting in `graphql-tools` and `hive-router-plan-executor`. + From 46be1a199f6891dc63fc14094efea05f5f4d083d Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Wed, 25 Mar 2026 11:27:09 +0200 Subject: [PATCH 04/76] fix(ci): use another supergraph for graphql-over-http audit (#877) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- audits/graphql-over-http.supergraph.graphql | 125 ++++++++++++++++++++ audits/graphql-over-http.test.ts | 4 +- audits/package.json | 4 +- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 audits/graphql-over-http.supergraph.graphql diff --git a/audits/graphql-over-http.supergraph.graphql b/audits/graphql-over-http.supergraph.graphql new file mode 100644 index 000000000..98413b558 --- /dev/null +++ b/audits/graphql-over-http.supergraph.graphql @@ -0,0 +1,125 @@ + + + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + + + + + + + + { + query: Query + + + } + + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + + + ) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + + + directive @join__implements( + graph: join__Graph! + interface: String! + ) repeatable on OBJECT | INTERFACE + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false + ) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__unionMember( + graph: join__Graph! + member: String! + ) repeatable on UNION + + scalar join__FieldSet + + + + directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + + scalar link__Import + + enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + + + + + + + +enum join__Graph { + PRODUCTS @join__graph( + name: "products" + url: "https://federation-demo.theguild.workers.dev/products" + ) + REVIEWS @join__graph(name: "reviews", url: "https://federation-demo.theguild.workers.dev/reviews") + USERS @join__graph(name: "users", url: "https://federation-demo.theguild.workers.dev/users") +} + +type Query @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) @join__type(graph: USERS) { + topProducts(first: Int = 5) : [Product] @join__field(graph: PRODUCTS) + me: User @join__field(graph: USERS) + user(id: ID!) : User @join__field(graph: USERS) + users: [User] @join__field(graph: USERS) +} + +type Product @join__type(graph: PRODUCTS, key: "upc") @join__type(graph: REVIEWS, key: "upc") { + upc: String! + name: String @join__field(graph: PRODUCTS) + price: Int @join__field(graph: PRODUCTS) + weight: Int @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String + author: User @join__field(graph: REVIEWS, provides: "username") + product: Product +} + +type User @join__type(graph: REVIEWS, key: "id") @join__type(graph: USERS, key: "id") { + username: String @join__field(graph: REVIEWS, external: true) @join__field(graph: USERS) + id: ID! + reviews: [Review] @join__field(graph: REVIEWS) + name: String @join__field(graph: USERS) +} diff --git a/audits/graphql-over-http.test.ts b/audits/graphql-over-http.test.ts index 175ccd50b..1c7434b7d 100644 --- a/audits/graphql-over-http.test.ts +++ b/audits/graphql-over-http.test.ts @@ -8,7 +8,7 @@ describe("GraphQL over HTTP", () => { })) { it(audit.name, async () => { const result = await audit.fn(); - assert.equal(result.status, "ok", (result as AuditFail).reason); + assert.equal(result.status, "ok", `${audit.name} failed: ${(result as AuditFail).reason}`); }); } it('preserve the order of the selection set', async () => { @@ -38,4 +38,4 @@ describe("GraphQL over HTTP", () => { "Order of selection set is not preserved" ); }) -}) \ No newline at end of file +}) diff --git a/audits/package.json b/audits/package.json index a97c7ea43..ea6d9386a 100644 --- a/audits/package.json +++ b/audits/package.json @@ -5,7 +5,7 @@ "scripts": { "test:federation-all": "graphql-federation-audit test --run-script=\"./run-router.sh\" --graphql=\"http://localhost:4000/graphql\" --healthcheck=\"http://localhost:4000/health\" --junit", "test:federation-single": "graphql-federation-audit test-suite --run-script=\"./run-router.sh\" --graphql=\"http://localhost:4000/graphql\" --healthcheck=\"http://localhost:4000/health\" --write=\"federation-audit-results.txt\"", - "start:test-router": "cd .. && SUPERGRAPH_FILE_PATH=lib/query-planner/fixture/spotify-supergraph.graphql cargo router", + "start:test-router": "cd .. && SUPERGRAPH_FILE_PATH=audits/graphql-over-http.supergraph.graphql cargo router", "test:graphql-over-http": "node --test graphql-over-http.test.ts", "test-junit:graphql-over-http": "node --test --test-reporter=junit --test-reporter-destination=\"./reports/graphql-over-http.xml\" graphql-over-http.test.ts", "ci:test:graphql-over-http": "start-server-and-test start:test-router http://localhost:4000 test-junit:graphql-over-http" @@ -18,4 +18,4 @@ "start-server-and-test": "2.1.5" }, "packageManager": "npm@11.11.1" -} \ No newline at end of file +} From 51c001f89d38615a033e0af4c170a065ac5c215b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Thu, 26 Mar 2026 11:05:37 +0100 Subject: [PATCH 05/76] chore: add inbound dedupe benchmark scenario (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add a dedicated benchmark config and CI scenario for inbound deduplication so it can be compared explicitly with `main`. the `default` and `plugins` benchmark configs no longer enable inbound deduplication, and the inbound-dedupe case now lives in its own `dedupe-inbound` config. --- ✨ Let Copilot coding agent [set things up for you](https://github.com/graphql-hive/router/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 ++++++ bench/configs/dedupe-inbound.config.yaml | 10 ++++++++++ bench/configs/default.config.yaml | 4 ---- bench/configs/plugins.config.yaml | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 bench/configs/dedupe-inbound.config.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f347e5356..dab46ced5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -153,6 +153,12 @@ jobs: binary: "hive_router" config: "bench/configs/default.config.yaml" compare_with_default: true + - name: "dedupe-inbound" + args: "" + package: "hive-router" + binary: "hive_router" + config: "bench/configs/dedupe-inbound.config.yaml" + compare_with_default: false - name: "no-dedupe" args: "" package: "hive-router" diff --git a/bench/configs/dedupe-inbound.config.yaml b/bench/configs/dedupe-inbound.config.yaml new file mode 100644 index 000000000..49961d576 --- /dev/null +++ b/bench/configs/dedupe-inbound.config.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +log: + level: info +traffic_shaping: + router: + dedupe: + enabled: true diff --git a/bench/configs/default.config.yaml b/bench/configs/default.config.yaml index 1c4bba3e1..26a68560a 100644 --- a/bench/configs/default.config.yaml +++ b/bench/configs/default.config.yaml @@ -4,7 +4,3 @@ supergraph: path: ../supergraph.graphql log: level: info -traffic_shaping: - router: - dedupe: - enabled: true # it's opt-in diff --git a/bench/configs/plugins.config.yaml b/bench/configs/plugins.config.yaml index 91cea452b..e0e66775b 100644 --- a/bench/configs/plugins.config.yaml +++ b/bench/configs/plugins.config.yaml @@ -6,4 +6,4 @@ log: level: info plugins: my_plugin: - enabled: true \ No newline at end of file + enabled: true From a34896d9ec747b7dca223296693d3944c6dca977 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Thu, 26 Mar 2026 13:36:28 +0200 Subject: [PATCH 06/76] fix(disable_introspection): allow to query `__typename` when introspection is disabled (#872) Related: https://github.com/graphql-hive/router/issues/871 Closes ROUTER-278 Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/typename_introspection_fix.md | 8 ++ .../src/pipeline/introspection_policy.rs | 2 + e2e/src/disable_introspection.rs | 96 +++++++++++++++++++ lib/executor/src/introspection/partition.rs | 13 +-- 4 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 .changeset/typename_introspection_fix.md diff --git a/.changeset/typename_introspection_fix.md b/.changeset/typename_introspection_fix.md new file mode 100644 index 000000000..9c9c87815 --- /dev/null +++ b/.changeset/typename_introspection_fix.md @@ -0,0 +1,8 @@ +--- +hive-router-plan-executor: patch +hive-router: patch +--- + +# Introspection Bug Fix + +Fixed an issue where, when introspection is disabled, querying root `__typename` was incorrectly rejected (https://github.com/graphql-hive/router/issues/871). diff --git a/bin/router/src/pipeline/introspection_policy.rs b/bin/router/src/pipeline/introspection_policy.rs index 59311df24..fd5caadb7 100644 --- a/bin/router/src/pipeline/introspection_policy.rs +++ b/bin/router/src/pipeline/introspection_policy.rs @@ -5,6 +5,7 @@ use hive_router_internal::expressions::{ values::boolean::BooleanOrProgram, CompileExpression, ExpressionCompileError, }; use hive_router_plan_executor::execution::client_request_details; +use tracing::debug; use vrl::core::Value as VrlValue; use crate::pipeline::error::PipelineError; @@ -35,6 +36,7 @@ pub fn handle_introspection_policy( .map_err(|e| PipelineError::IntrospectionPermissionEvaluationError(e.to_string()))?; if !is_enabled { + debug!("graphql request rejected because introspection is disabled"); Err(PipelineError::IntrospectionDisabled) } else { Ok(()) diff --git a/e2e/src/disable_introspection.rs b/e2e/src/disable_introspection.rs index 81b3c6ad4..022bfbf78 100644 --- a/e2e/src/disable_introspection.rs +++ b/e2e/src/disable_introspection.rs @@ -4,6 +4,102 @@ mod disable_introspection_e2e_tests { some_header_map, ClientResponseExt, EnvVarsGuard, TestRouter, TestSubgraphs, }; + #[ntex::test] + async fn should_not_allow_mixed_fields() { + let _env_var_guard = EnvVarsGuard::new() + .set("DISABLE_INTROSPECTION", "true") + .apply() + .await; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/disable_introspection_env.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request( + "{ me { __typename id } __schema { queryType { name } } }", + None, + None, + ) + .await; + + insta::assert_snapshot!(res.json_body_string_pretty().await, @r###" + { + "errors": [ + { + "message": "Introspection queries are disabled", + "extensions": { + "code": "INTROSPECTION_DISABLED" + } + } + ] + } + "###); + } + + #[ntex::test] + async fn should_still_allow_nested_typename_when_disabled() { + let _env_var_guard = EnvVarsGuard::new() + .set("DISABLE_INTROSPECTION", "true") + .apply() + .await; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/disable_introspection_env.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request("{ me { __typename id } }", None, None) + .await; + + insta::assert_snapshot!(res.json_body_string_pretty().await, @r###" + { + "data": { + "me": { + "__typename": "User", + "id": "1" + } + } + } + "###); + } + + #[ntex::test] + async fn should_still_allow_root_typename_when_disabled() { + let _env_var_guard = EnvVarsGuard::new() + .set("DISABLE_INTROSPECTION", "true") + .apply() + .await; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/disable_introspection_env.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request("{ __typename }", None, None) + .await; + + insta::assert_snapshot!(res.json_body_string_pretty().await, @r###" + { + "data": { + "__typename": "Query" + } + } + "###); + } + #[ntex::test] async fn should_disable_based_on_env_var() { let _env_var_guard = EnvVarsGuard::new() diff --git a/lib/executor/src/introspection/partition.rs b/lib/executor/src/introspection/partition.rs index 86231d6c7..adae381f4 100644 --- a/lib/executor/src/introspection/partition.rs +++ b/lib/executor/src/introspection/partition.rs @@ -57,12 +57,6 @@ enum SelectionSetLevel { Nested, } -impl SelectionSetLevel { - fn is_root(&self) -> bool { - matches!(self, SelectionSetLevel::Root) - } -} - fn partition_selection_set( selection_set: SelectionSet, level: SelectionSetLevel, @@ -73,12 +67,7 @@ fn partition_selection_set( for item in selection_set.items { match item { SelectionItem::Field(field) => { - // pass root level __typename to introspection - if (level.is_root() && field.name.starts_with("__")) - || - // do NOT pass non-root level __typename to introspection - field.name.starts_with("__") && field.name != "__typename" - { + if field.name.starts_with("__") && field.name != "__typename" { introspection_items.push(SelectionItem::Field(field)); } else { let is_leaf = field.is_leaf(); From 1ae81ec19fa4712b190c8cbcb94e2ac2480dfc28 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:31:08 +0200 Subject: [PATCH 07/76] chore(release): router crates and artifacts (#874) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/router-inflight-dedupe.md | 48 ---------------------- .changeset/typename_introspection_fix.md | 8 ---- Cargo.lock | 8 ++-- bin/router/CHANGELOG.md | 51 ++++++++++++++++++++++++ bin/router/Cargo.toml | 8 ++-- lib/executor/CHANGELOG.md | 51 ++++++++++++++++++++++++ lib/executor/Cargo.toml | 6 +-- lib/internal/CHANGELOG.md | 45 +++++++++++++++++++++ lib/internal/Cargo.toml | 4 +- lib/router-config/CHANGELOG.md | 45 +++++++++++++++++++++ lib/router-config/Cargo.toml | 2 +- 11 files changed, 206 insertions(+), 70 deletions(-) delete mode 100644 .changeset/router-inflight-dedupe.md delete mode 100644 .changeset/typename_introspection_fix.md diff --git a/.changeset/router-inflight-dedupe.md b/.changeset/router-inflight-dedupe.md deleted file mode 100644 index d2d3acf3b..000000000 --- a/.changeset/router-inflight-dedupe.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -hive-router: minor -hive-router-config: minor -hive-router-internal: minor -hive-router-plan-executor: minor ---- - -# Add router-level in-flight request deduplication for GraphQL queries - -The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. - -## Configuration - -A new router traffic-shaping section is available: - -- `traffic_shaping.router.dedupe.enabled` (default: `false`) -- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) - -Supported header config shapes: - -```yaml -headers: all -``` - -```yaml -headers: none -``` - -```yaml -headers: - include: - - authorization - - cookie -``` - -Header names are validated and normalized as standard HTTP header names. - -## Deduplication key behavior - -The router dedupe fingerprint includes: - -- request method and path -- selected request headers (based on dedupe header policy) -- normalized operation hash -- GraphQL variables hash -- schema checksum -- GraphQL extensions - diff --git a/.changeset/typename_introspection_fix.md b/.changeset/typename_introspection_fix.md deleted file mode 100644 index 9c9c87815..000000000 --- a/.changeset/typename_introspection_fix.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -hive-router-plan-executor: patch -hive-router: patch ---- - -# Introspection Bug Fix - -Fixed an issue where, when introspection is disabled, querying root `__typename` was incorrectly rejected (https://github.com/graphql-hive/router/issues/871). diff --git a/Cargo.lock b/Cargo.lock index 937f2c062..a622b34b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.42" +version = "0.0.43" dependencies = [ "ahash", "anyhow", @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.25" +version = "0.0.26" dependencies = [ "config", "envconfig", @@ -2376,7 +2376,7 @@ dependencies = [ [[package]] name = "hive-router-internal" -version = "0.0.14" +version = "0.0.15" dependencies = [ "ahash", "async-trait", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.8.0" +version = "6.9.0" dependencies = [ "ahash", "async-trait", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 37d1afcab..9c70cc1ce 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.43 (2026-03-26) + +### Features + +#### Add router-level in-flight request deduplication for GraphQL queries + +The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. + +### Configuration + +A new router traffic-shaping section is available: + +- `traffic_shaping.router.dedupe.enabled` (default: `false`) +- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) + +Supported header config shapes: + +```yaml +headers: all +``` + +```yaml +headers: none +``` + +```yaml +headers: + include: + - authorization + - cookie +``` + +Header names are validated and normalized as standard HTTP header names. + +### Deduplication key behavior + +The router dedupe fingerprint includes: + +- request method and path +- selected request headers (based on dedupe header policy) +- normalized operation hash +- GraphQL variables hash +- schema checksum +- GraphQL extensions + +### Fixes + +#### Introspection Bug Fix + +Fixed an issue where, when introspection is disabled, querying root `__typename` was incorrectly rejected (https://github.com/graphql-hive/router/issues/871). + ## 0.0.42 (2026-03-16) ### Features diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 493725cd2..16f480246 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.42" +version = "0.0.43" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -22,9 +22,9 @@ testing = [] [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.0" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.8.0" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.25" } -hive-router-internal = { path = "../../lib/internal", version = "0.0.14" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.0" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.26" } +hive-router-internal = { path = "../../lib/internal", version = "0.0.15" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index da3d033ff..dec2c0964 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.9.0 (2026-03-26) + +### Features + +#### Add router-level in-flight request deduplication for GraphQL queries + +The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. + +### Configuration + +A new router traffic-shaping section is available: + +- `traffic_shaping.router.dedupe.enabled` (default: `false`) +- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) + +Supported header config shapes: + +```yaml +headers: all +``` + +```yaml +headers: none +``` + +```yaml +headers: + include: + - authorization + - cookie +``` + +Header names are validated and normalized as standard HTTP header names. + +### Deduplication key behavior + +The router dedupe fingerprint includes: + +- request method and path +- selected request headers (based on dedupe header policy) +- normalized operation hash +- GraphQL variables hash +- schema checksum +- GraphQL extensions + +### Fixes + +#### Introspection Bug Fix + +Fixed an issue where, when introspection is disabled, querying root `__typename` was incorrectly rejected (https://github.com/graphql-hive/router/issues/871). + ## 6.8.0 (2026-03-16) ### Features diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 8287a02bd..62a23eca7 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.8.0" +version = "6.9.0" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -16,8 +16,8 @@ doctest = false [dependencies] hive-router-query-planner = { path = "../query-planner", version = "2.5.0" } -hive-router-config = { path = "../router-config", version = "0.0.25" } -hive-router-internal = { path = "../internal", version = "0.0.14" } +hive-router-config = { path = "../router-config", version = "0.0.26" } +hive-router-internal = { path = "../internal", version = "0.0.15" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } async-trait = { workspace = true } diff --git a/lib/internal/CHANGELOG.md b/lib/internal/CHANGELOG.md index 9a20ad2ee..c78023173 100644 --- a/lib/internal/CHANGELOG.md +++ b/lib/internal/CHANGELOG.md @@ -1,3 +1,48 @@ +## 0.0.15 (2026-03-26) + +### Features + +#### Add router-level in-flight request deduplication for GraphQL queries + +The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. + +### Configuration + +A new router traffic-shaping section is available: + +- `traffic_shaping.router.dedupe.enabled` (default: `false`) +- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) + +Supported header config shapes: + +```yaml +headers: all +``` + +```yaml +headers: none +``` + +```yaml +headers: + include: + - authorization + - cookie +``` + +Header names are validated and normalized as standard HTTP header names. + +### Deduplication key behavior + +The router dedupe fingerprint includes: + +- request method and path +- selected request headers (based on dedupe header policy) +- normalized operation hash +- GraphQL variables hash +- schema checksum +- GraphQL extensions + ## 0.0.14 (2026-03-12) ### Features diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 7ddc1df39..0cf55a4c4 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-internal" -version = "0.0.14" +version = "0.0.15" edition = "2021" description = "GraphQL Hive Router internal crate" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.25" } +hive-router-config = { path = "../router-config", version = "0.0.26" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index 2c147f04b..452171037 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.26 (2026-03-26) + +### Features + +#### Add router-level in-flight request deduplication for GraphQL queries + +The router now supports deduplicating identical incoming GraphQL query requests while they are in flight, so concurrent duplicates can share one execution result. + +### Configuration + +A new router traffic-shaping section is available: + +- `traffic_shaping.router.dedupe.enabled` (default: `false`) +- `traffic_shaping.router.dedupe.headers` as `all`, `none`, or `{ include: [...] }` (default: `all`) + +Supported header config shapes: + +```yaml +headers: all +``` + +```yaml +headers: none +``` + +```yaml +headers: + include: + - authorization + - cookie +``` + +Header names are validated and normalized as standard HTTP header names. + +### Deduplication key behavior + +The router dedupe fingerprint includes: + +- request method and path +- selected request headers (based on dedupe header policy) +- normalized operation hash +- GraphQL variables hash +- schema checksum +- GraphQL extensions + ## 0.0.25 (2026-03-12) ### Features diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index daef378f0..44e4fa9ab 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.25" +version = "0.0.26" edition = "2021" publish = true license = "MIT" From 1c2d3843e68401632ab3ad152263b5aaa180d01a Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Sun, 29 Mar 2026 10:06:08 +0200 Subject: [PATCH 08/76] fix(router): fix null field handling in entity request projection and prevent malformed JSON (#881) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/requires_projection_null.md | 8 + e2e/src/issues/mod.rs | 205 +++++++++++++++++++++++++ e2e/src/issues/supergraph.880.graphql | 99 ++++++++++++ e2e/src/lib.rs | 2 + lib/executor/src/projection/request.rs | 190 ++++++++++++++++++++++- 5 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 .changeset/requires_projection_null.md create mode 100644 e2e/src/issues/mod.rs create mode 100644 e2e/src/issues/supergraph.880.graphql diff --git a/.changeset/requires_projection_null.md b/.changeset/requires_projection_null.md new file mode 100644 index 000000000..7c3e3ac4d --- /dev/null +++ b/.changeset/requires_projection_null.md @@ -0,0 +1,8 @@ +--- +hive-router-plan-executor: patch +hive-router: patch +--- + +# Fix null field handling in entity request projection + +Fixed a bug in entity request projection where present `null` fields could be mishandled, which in some nested projection paths could also lead to malformed JSON output. [#880](https://github.com/graphql-hive/router/issues/880). diff --git a/e2e/src/issues/mod.rs b/e2e/src/issues/mod.rs new file mode 100644 index 000000000..18cb285d2 --- /dev/null +++ b/e2e/src/issues/mod.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod issues_e2e_tests { + use crate::testkit::{ClientResponseExt, TestRouter}; + + #[ntex::test] + /// https://github.com/graphql-hive/router/issues/880 + async fn issue_880_null_in_required_field() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: src/issues/supergraph.880.graphql + query_planner: + allow_expose: true + override_subgraph_urls: + accounts: + url: "http://{host}/accounts" + products: + url: "http://{host}/products" + "# + )) + .build() + .start() + .await; + + // QueryPlan { + // Sequence { + // Fetch(service: "products") { + // { + // ad(id: "1") { + // id + // branch { + // __typename + // id + // } + // } + // } + // }, + let products_query_mock = server + .mock("POST", "/products") + .match_request(|r| { + let body = r.body().unwrap(); + let body_str = String::from_utf8(body.clone()).unwrap(); + + body_str.contains("ad(") + }) + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#" + { + "data": { + "ad": { "id": "1", "branch": { "__typename": "Branch", "id": "branch-1" } } + } + } + "#, + ) + .create(); + + // Flatten(path: "ad.branch") { + // Fetch(service: "accounts") { + // { + // ... on Branch { + // __typename + // id + // } + // } => + // { + // ... on Branch { + // contactOptions { + // email + // user { + // name + // id + // } + // } + // } + // } + // }, + // }, + let accounts_mock = server + .mock("POST", "/accounts") + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": { + "_entities": [ + { "__typename": "Branch", "id": "branch-1", "contactOptions": null } + ] + } + } + "#, + ) + .create(); + + // Flatten(path: "ad") { + // Fetch(service: "products") { + // { + // ... on Ad { + // __typename + // branch { + // contactOptions { + // email + // user { + // id + // name + // } + // } + // } + // id + // } + // } => + // { + // ... on Ad { + // contactOptions { + // email + // } + // } + // } + // }, + // }, + // }, + // }, + let _products_entities_mock_valid_json = server + .mock("POST", "/products") + .match_request(|r| { + let body = r.body().unwrap(); + let body_str = String::from_utf8(body.clone()).unwrap(); + if !body_str.contains("$representations") { + return false; + } + + sonic_rs::from_slice::(body).is_ok() + }) + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": { + "_entities": [ + { "__typename": "Ad", "contactOptions": null } + ] + } + } + "#, + ) + .create(); + let _products_entities_mock_invalid_json = server + .mock("POST", "/products") + .match_request(|r| { + let body = r.body().unwrap(); + let body_str = String::from_utf8(body.clone()).unwrap(); + if !body_str.contains("$representations") { + return false; + } + + sonic_rs::from_slice::(body).is_err() + }) + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "data": { + "_entities": [null] + }, + "errors": [ + { "message": "invalid json" } + ] + } + "#, + ) + .create(); + + let res = router + .send_graphql_request( + "{ ad(id: \"1\") { id contactOptions { email } } }", + None, + None, + ) + .await; + + accounts_mock.assert(); + products_query_mock.assert(); + + insta::assert_snapshot!(res.json_body_string_pretty().await, @r#" + { + "data": { + "ad": { + "id": "1", + "contactOptions": null + } + } + } + "#); + } +} diff --git a/e2e/src/issues/supergraph.880.graphql b/e2e/src/issues/supergraph.880.graphql new file mode 100644 index 000000000..6435329f7 --- /dev/null +++ b/e2e/src/issues/supergraph.880.graphql @@ -0,0 +1,99 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +type Ad @join__type(graph: PRODUCTS, key: "id") { + id: ID! + branch: Branch + contactOptions: ContactOptions + @join__field( + graph: PRODUCTS + requires: "branch { contactOptions { email user { id name } } }" + ) +} + +type Branch + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: PRODUCTS, key: "id", extension: true, resolvable: false) { + id: ID! + contactOptions: ContactOptions + @join__field(graph: ACCOUNTS) + @join__field(graph: PRODUCTS, external: true) +} + +type BranchUser @join__type(graph: ACCOUNTS) @join__type(graph: PRODUCTS) { + id: ID! + name: String +} + +type ContactOptions @join__type(graph: ACCOUNTS) @join__type(graph: PRODUCTS) { + email: String + user: BranchUser +} + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts", url: "http://0.0.0.0:4200/accounts") + PRODUCTS @join__graph(name: "products", url: "http://0.0.0.0:4200/products") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Query @join__type(graph: ACCOUNTS) @join__type(graph: PRODUCTS) { + branch(id: ID!): Branch @join__field(graph: ACCOUNTS) + ad(id: ID!): Ad @join__field(graph: PRODUCTS) +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 1ed27f53d..a8cc50597 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -23,6 +23,8 @@ mod http; #[cfg(test)] mod introspection; #[cfg(test)] +mod issues; +#[cfg(test)] mod jwt; #[cfg(test)] mod max_aliases; diff --git a/lib/executor/src/projection/request.rs b/lib/executor/src/projection/request.rs index f1ba213ba..a8ca6e202 100644 --- a/lib/executor/src/projection/request.rs +++ b/lib/executor/src/projection/request.rs @@ -7,8 +7,8 @@ use crate::{ projection::response::serialize_value_to_buffer, response::value::Value, utils::consts::{ - CLOSE_BRACE, CLOSE_BRACKET, COLON, COMMA, FALSE, OPEN_BRACE, OPEN_BRACKET, QUOTE, TRUE, - TYPENAME, TYPENAME_FIELD_NAME, + CLOSE_BRACE, CLOSE_BRACKET, COLON, COMMA, FALSE, NULL, OPEN_BRACE, OPEN_BRACKET, QUOTE, + TRUE, TYPENAME, TYPENAME_FIELD_NAME, }, }; @@ -143,14 +143,18 @@ fn project_requires_map_mut( .ok() .map(|idx| &entity_obj[idx].1) } - }) - .unwrap_or(&Value::Null); + }); - if original.is_null() { + let Some(original) = original else { continue; - } + }; + + // In most requests, required fields are present and projection succeeds. + // If projection ends up writing nothing, we rewind to this offset. + let mut object_start_offset = None; if *first { + object_start_offset = Some(buffer.len()); write_response_key(parent_first, parent_response_key, buffer); buffer.put(OPEN_BRACE); // Write __typename only if the object has other fields @@ -164,11 +168,18 @@ fn project_requires_map_mut( buffer.put(QUOTE); buffer.put(COLON); write_and_escape_string(buffer, type_name); - // We wrote the first field *first = false; } } + if original.is_null() { + // The field exists and is null, so keep it in the representation. + write_response_key(*first, Some(response_key), buffer); + buffer.put(NULL); + *first = false; + continue; + } + let projected = project_requires( possible_types, &requires_selection.selections.items, @@ -177,8 +188,15 @@ fn project_requires_map_mut( *first, Some(response_key), ); + if projected { *first = false; + } else if *first { + // We opened '{' but produced no field output. + // Roll back to keep valid JSON and avoid malformed '{...'. + if let Some(offset) = object_start_offset { + buffer.truncate(offset); + } } } SelectionItem::InlineFragment(requires_selection) => { @@ -213,3 +231,161 @@ fn project_requires_map_mut( } } } + +#[cfg(test)] +mod tests { + use super::project_requires; + use crate::{introspection::schema::PossibleTypes, response::value::Value}; + use graphql_tools::parser::query; + use hive_router_query_planner::ast::{ + selection_item::SelectionItem, selection_set::SelectionSet, + }; + use hive_router_query_planner::utils::parsing::parse_operation; + use sonic_rs::json; + + fn requires_from_str(requires: &str) -> Vec { + let operation = parse_operation(&format!("query {{ {requires} }}")); + + let selection_set = operation + .definitions + .into_iter() + .find_map(|def| { + let query::Definition::Operation(op) = def else { + return None; + }; + + match op { + query::OperationDefinition::SelectionSet(sel) => Some(sel), + query::OperationDefinition::Query(q) => Some(q.selection_set), + query::OperationDefinition::Mutation(m) => Some(m.selection_set), + query::OperationDefinition::Subscription(s) => Some(s.selection_set), + } + }) + .expect("operation must contain a selection set"); + + let selection_set: SelectionSet = selection_set.into(); + selection_set.items + } + + fn project_requires_pretty(requires: &str, entity_json: sonic_rs::Value) -> Option { + let requires = requires_from_str(requires); + let entity = Value::from(entity_json.as_ref()); + + let mut buffer = Vec::new(); + let projected = project_requires( + &PossibleTypes::default(), + &requires, + &entity, + &mut buffer, + true, + None, + ); + + if !projected { + return None; + } + + let json: Value = sonic_rs::from_slice(&buffer).unwrap(); + Some(sonic_rs::to_string_pretty(&json).unwrap()) + } + + #[test] + fn project_requires_variants() { + insta::assert_snapshot!( + &project_requires_pretty( + "contactOptions id", + json!({ + "__typename": "Ad", + "contactOptions": null, + "id": "1" + }), + ) + .expect("projection should produce output"), + @r#" + { + "__typename": "Ad", + "contactOptions": null, + "id": "1" + } + "#); + + insta::assert_snapshot!( + &project_requires_pretty( + "id contactOptions", + json!({ + "__typename": "Ad", + "contactOptions": null, + "id": "1" + }), + ).expect("projection should produce output"), + @r#" + { + "__typename": "Ad", + "contactOptions": null, + "id": "1" + } + "#); + + insta::assert_snapshot!( + &project_requires_pretty( + "contactOptions id", + json!({ + "__typename": "Ad", + "id": "1" + }), + ) + .expect("projection should produce output"), + @r#" + { + "__typename": "Ad", + "id": "1" + } + "#); + + insta::assert_snapshot!( + &project_requires_pretty( + "branch { contactOptions { email } } id", + json!({ + "__typename": "Ad", + "branch": { + "contactOptions": {} + }, + "id": "1" + }), + ) + .expect("projection should produce output"), + @r#" + { + "__typename": "Ad", + "id": "1" + } + "#); + + insta::assert_snapshot!( + &project_requires_pretty( + "branch { contactOptions { email user { id name } } } id", + json!({ + "__typename": "Ad", + "branch": { + "__typename": "Branch", + "contactOptions": null + }, + "id": "1" + }), + ) + .expect("projection should produce output"), + @r#" + { + "__typename": "Ad", + "branch": { + "__typename": "Branch", + "contactOptions": null + }, + "id": "1" + } + "#); + + let pretty = project_requires_pretty("contactOptions", json!({})); + assert_eq!(pretty, None); + } +} From 225587c70fdb5749b5d8b30e9f372b18e7171aa9 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:12:38 +0300 Subject: [PATCH 09/76] chore(release): router crates and artifacts (#883) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/requires_projection_null.md | 8 -------- Cargo.lock | 4 ++-- bin/router/CHANGELOG.md | 10 ++++++++++ bin/router/Cargo.toml | 4 ++-- lib/executor/CHANGELOG.md | 8 ++++++++ lib/executor/Cargo.toml | 2 +- 6 files changed, 23 insertions(+), 13 deletions(-) delete mode 100644 .changeset/requires_projection_null.md diff --git a/.changeset/requires_projection_null.md b/.changeset/requires_projection_null.md deleted file mode 100644 index 7c3e3ac4d..000000000 --- a/.changeset/requires_projection_null.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -hive-router-plan-executor: patch -hive-router: patch ---- - -# Fix null field handling in entity request projection - -Fixed a bug in entity request projection where present `null` fields could be mishandled, which in some nested projection paths could also lead to malformed JSON output. [#880](https://github.com/graphql-hive/router/issues/880). diff --git a/Cargo.lock b/Cargo.lock index a622b34b1..4b0008f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.43" +version = "0.0.44" dependencies = [ "ahash", "anyhow", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.9.0" +version = "6.9.1" dependencies = [ "ahash", "async-trait", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 9c70cc1ce..71f453459 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.44 (2026-03-29) + +### Fixes + +- fix null field handling in entity request projection and prevent malformed JSON (#881) + +#### Fix null field handling in entity request projection + +Fixed a bug in entity request projection where present `null` fields could be mishandled, which in some nested projection paths could also lead to malformed JSON output. [#880](https://github.com/graphql-hive/router/issues/880). + ## 0.0.43 (2026-03-26) ### Features diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 16f480246..792a8221d 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.43" +version = "0.0.44" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -22,7 +22,7 @@ testing = [] [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.0" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.0" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.1" } hive-router-config = { path = "../../lib/router-config", version = "0.0.26" } hive-router-internal = { path = "../../lib/internal", version = "0.0.15" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index dec2c0964..18c053c54 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.9.1 (2026-03-29) + +### Fixes + +#### Fix null field handling in entity request projection + +Fixed a bug in entity request projection where present `null` fields could be mishandled, which in some nested projection paths could also lead to malformed JSON output. [#880](https://github.com/graphql-hive/router/issues/880). + ## 6.9.0 (2026-03-26) ### Features diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 62a23eca7..9ff5f9b45 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.9.0" +version = "6.9.1" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" From 9637749a90560620f9621adc0e98b26e653a7093 Mon Sep 17 00:00:00 2001 From: Heriberto Gonzalez Date: Tue, 31 Mar 2026 01:18:26 -0700 Subject: [PATCH 10/76] fix(qp): preserve client aliases in mismatch output rewrites (#870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes ROUTER-279 Closes: #876 # Generating conflicting aliases when conflicts are across fragments Consider a schema like: ```graphql interface CommonInterface { id: ID! } type TypeA implements CommonInterface { id: ID! field: EnumA! } type TypeB implements CommonInterface { id: ID! field: EnumB! } type TypeC implements CommonInterface { id: ID! field: EnumC! } enum EnumA { VALUE_A1 VALUE_A2 VALUE_A3 } enum EnumB { VALUE_B1 VALUE_B2 VALUE_B3 } enum EnumC { VALUE_C1 VALUE_C2 VALUE_C3 } type Query { getTypes: CommonInterface } ``` Consider a query like: ```graphql query GetAllTypesWithConflictingField { getTypes { id # ✅ on CommonInterface — no conflict ... on TypeA { field } # field: EnumA ... on TypeB { userAlias1: field } # field: EnumB — user-provided alias ... on TypeC { userAlias2: field } # field: EnumC — user-provided alias } } ``` The Rust Planner Generates: ```graphql QueryPlan { Fetch(service: "service") { { getTypes { id __typename ... on TypeA { field } ... on TypeB { _internal_qp_alias_0: field } ... on TypeC { _internal_qp_alias_0: field } ... on TypeD { _internal_qp_alias_0: field } } } }, }, ``` The problem here is that this is invalid GraphQL syntax and causes the subgraph query to be malformed. Resulting in a response from the subgraph like below: ```graphql GraphQLError: Fields "_internal_qp_alias_0" conflict because they return conflicting types "EnumB" and "EnumC". Use different aliases on the fields to fetch both if this was intentional. ``` --- ### Explanation: Why `SafeSelectionSetMerger` Causes Identical Alias Names: The problem is about scope and state persistence of the merger's internal counter. ``` rust for (root_def_name, mismatch_path) in mismatches_paths { let mut merger = SafeSelectionSetMerger::default(); // Line 61: RESET EACH ITERATION // ... let next_alias = merger.safe_next_alias_name(&selection_set.items); } ``` A new `SafeSelectionSetMerger` is created for each mismatch in the list, which means its aliases_counter (initialized to 0) resets every iteration. When processing multiple mismatches across fragments: 1. Mismatch # 1 → merger starts at counter 0 → generates _internal_qp_alias_0 2. Mismatch # 2 → NEW merger starts at counter 0 → generates _internal_qp_alias_0 (COLLISION!) 3. Mismatch # 3 → NEW merger starts at counter 0 → generates _internal_qp_alias_0 (COLLISION!) --- --------- Co-authored-by: Kamil Kisiela Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/alias-fix.md | 9 + ...ismatch-mix-enum-scalar.supergraph.graphql | 159 +++++++++++ lib/query-planner/src/ast/mismatch_finder.rs | 9 +- .../planner/fetch/optimize/type_mismatches.rs | 7 +- lib/query-planner/src/tests/alias.rs | 266 ++++++++++++++++++ 5 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 .changeset/alias-fix.md create mode 100644 lib/query-planner/fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql diff --git a/.changeset/alias-fix.md b/.changeset/alias-fix.md new file mode 100644 index 000000000..c9220b782 --- /dev/null +++ b/.changeset/alias-fix.md @@ -0,0 +1,9 @@ +--- +hive-router-query-planner: patch +hive-router-plan-executor: patch +hive-router: patch +--- + +# Preserve client aliases in mismatch rewrites + +Fixed query planner mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. diff --git a/lib/query-planner/fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql b/lib/query-planner/fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql new file mode 100644 index 000000000..700ea7035 --- /dev/null +++ b/lib/query-planner/fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql @@ -0,0 +1,159 @@ +schema +@link(url: "https://specs.apollo.dev/link/v1.0") +@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) +{ + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +"""Root Query Type""" +type Query @join__type(graph: SERVICE) { + """Get a root entity""" + getTypes: CommonInterface @join__field(graph: SERVICE) +} + +""" +Type A - field is an EnumA +""" +type TypeA implements CommonInterface +@join__type(graph: SERVICE, key: "id") +@join__implements(graph: SERVICE, interface: "CommonInterface") +{ + id: ID! + identifier: String! + code: String! + """Field with EnumA type""" + field: EnumA! + isScalar: Int + metadata: Data! +} + + + +""" +Type B - field is an EnumB +""" +type TypeB implements CommonInterface +@join__type(graph: SERVICE, key: "id") +@join__implements(graph: SERVICE, interface: "CommonInterface") +{ + id: ID! + identifier: String! + code: String! + """Field with EnumB type (different from EnumA)""" + field: EnumB! + isScalar: Boolean + metadata: Data! +} + +""" +Type C - field is an EnumC +""" +type TypeC implements CommonInterface +@join__type(graph: SERVICE, key: "id") +@join__implements(graph: SERVICE, interface: "CommonInterface") +{ + id: ID! + identifier: String! + code: String! + """Field with EnumC type (different from EnumA and EnumB)""" + field: EnumC! + isScalar: Float + metadata: Data! +} + +""" +Type D - field is an EnumD +""" +type TypeD implements CommonInterface +@join__type(graph: SERVICE, key: "id") +@join__implements(graph: SERVICE, interface: "CommonInterface") +{ + id: ID! + identifier: String! + code: String! + """Field with EnumD type (different from others)""" + field: EnumD! + isScalar: ID + metadata: Data! +} + +""" +Common interface for all types +Note: The field field is intentionally NOT defined here to force the query planner +to resolve conflicts when accessing it on the union type +""" +interface CommonInterface @join__type(graph: SERVICE, key: "id") { + id: ID! + identifier: String! @join__field(graph: SERVICE) + code: String! @join__field(graph: SERVICE) + metadata: Data! @join__field(graph: SERVICE) +} + +"""Enum A""" +enum EnumA { + VALUE_A1 + VALUE_A2 + VALUE_A3 + VALUE_A4 +} + +"""Enum B - intentionally different from EnumA""" +enum EnumB { + VALUE_B1 + VALUE_B2 + VALUE_B3 + VALUE_B4 +} + +"""Enum C - intentionally different from EnumA and EnumB""" +enum EnumC { + VALUE_C1 + VALUE_C2 + VALUE_C3 + VALUE_C4 +} + +"""Enum D - intentionally different from all""" +enum EnumD { + VALUE_D1 + VALUE_D2 + VALUE_D3 + VALUE_D4 +} + +"""Data representation""" +type Data @join__type(graph: SERVICE){ + value: Float! @join__field(graph: SERVICE) + unit: String! @join__field(graph: SERVICE) +} + +"""Link directive definition""" +scalar link__Import + +enum link__Purpose { + SECURITY + EXECUTION +} + +"""Join directive scalar for field set""" +scalar join__FieldSet + +"""Graph enumeration""" +enum join__Graph { + SERVICE @join__graph(name: "service", url: "") +} + diff --git a/lib/query-planner/src/ast/mismatch_finder.rs b/lib/query-planner/src/ast/mismatch_finder.rs index 74a6a474c..045fdad02 100644 --- a/lib/query-planner/src/ast/mismatch_finder.rs +++ b/lib/query-planner/src/ast/mismatch_finder.rs @@ -96,7 +96,7 @@ fn handle_selection_set<'field, 'schema>( } let next_path = path.push(Segment::Field( - field.name.clone(), + field.selection_identifier().to_string(), field.arguments_hash(), field.into(), )); @@ -165,6 +165,7 @@ fn handle_field<'field, 'schema>( ) -> Option<&'schema str> { let parent_def_fields = parent_def.fields().unwrap(); let field_name = field.name.as_str(); + let field_identifier = field.selection_identifier(); let field_type = parent_def_fields .iter() .find_map(|f| { @@ -181,7 +182,7 @@ fn handle_field<'field, 'schema>( }) .unwrap(); - if let Some(maybe_conflicting_type) = encountered_field_to_type.get(field_name) { + if let Some(maybe_conflicting_type) = encountered_field_to_type.get(field_identifier) { if !maybe_conflicting_type.can_be_merged_with(field_type) { let left_is_composite = state .definitions @@ -195,7 +196,7 @@ fn handle_field<'field, 'schema>( if !left_is_composite || !right_is_composite { trace!( "found a conflicting type for a selection field '{}', conflict is: '{}' <-> '{}', path: {}", - field_name, + field_identifier, maybe_conflicting_type, field_type, field_path, @@ -205,7 +206,7 @@ fn handle_field<'field, 'schema>( } } } else { - encountered_field_to_type.insert(field_name, field_type); + encountered_field_to_type.insert(field_identifier, field_type); } if field.is_leaf() { diff --git a/lib/query-planner/src/planner/fetch/optimize/type_mismatches.rs b/lib/query-planner/src/planner/fetch/optimize/type_mismatches.rs index 1e111e7ef..5a98c19e0 100644 --- a/lib/query-planner/src/planner/fetch/optimize/type_mismatches.rs +++ b/lib/query-planner/src/planner/fetch/optimize/type_mismatches.rs @@ -73,9 +73,12 @@ impl FetchGraph { let item = selection_set .items .iter_mut() - .find(|v| matches!(v, SelectionItem::Field(field) if field.name == *field_lookup && field.arguments_hash() == *args_hash_lookup && field_condition_equal(condition, field))); + .find(|v| matches!(v, SelectionItem::Field(field) if field.selection_identifier() == *field_lookup && field.arguments_hash() == *args_hash_lookup && field_condition_equal(condition, field))); if let Some(SelectionItem::Field(field_to_alias)) = item { + let original_response_key = + field_to_alias.selection_identifier().to_string(); + trace!( "applying alias '{}' to existing field '{}' at path '{}'", next_alias, @@ -90,7 +93,7 @@ impl FetchGraph { pending_output_rewrites.push(( node_index, FetchRewrite::KeyRenamer(KeyRenamer { - rename_key_to: field_to_alias.name.to_string(), + rename_key_to: original_response_key, path: output_rewrite_path, }), )); diff --git a/lib/query-planner/src/tests/alias.rs b/lib/query-planner/src/tests/alias.rs index c535d98ac..3723526ad 100644 --- a/lib/query-planner/src/tests/alias.rs +++ b/lib/query-planner/src/tests/alias.rs @@ -1320,3 +1320,269 @@ fn deeply_nested_no_conflicts() -> Result<(), Box> { Ok(()) } + +#[test] +fn multi_enum_mismatch_across_fragments() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query GetAllTypesWithConflictingField { + getTypes { + id + identifier + code + metadata { + value + unit + } + ... on TypeA { + field # maps to EnumA + } + ... on TypeB { + typeBField: field # maps to EnumB + } + ... on TypeC { + typeCField: field # maps to EnumC + } + ... on TypeD { + typeDField: field # maps to EnumD + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "service") { + { + getTypes { + id + identifier + code + metadata { + value + unit + } + __typename + ... on TypeA { + field + } + ... on TypeB { + typeBField: field + } + ... on TypeC { + typeCField: field + } + ... on TypeD { + typeDField: field + } + } + } + }, + }, + "#); + + insta::assert_snapshot!(format!("{}", sonic_rs::to_string_pretty(&query_plan).unwrap_or_default()), @r#" + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "service", + "operationKind": "query", + "operation": "{getTypes{id identifier code metadata{value unit} __typename ...on TypeA{field} ...on TypeB{typeBField: field} ...on TypeC{typeCField: field} ...on TypeD{typeDField: field}}}" + } + } + "#); + + Ok(()) +} + +#[test] +fn multi_scalar_mismatch_across_fragments() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query GetAllTypesWithConflictingField { + getTypes { + id + identifier + code + metadata { + value + unit + } + ... on TypeA { + isScalar # is a Int + } + ... on TypeB { + scalarB: isScalar # is a Boolean + } + ... on TypeC { + scalarC: isScalar # is a Float + } + ... on TypeD { + scalarD: isScalar # is a Int + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/mismatch-mix-enum-scalar.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "service") { + { + getTypes { + id + identifier + code + metadata { + value + unit + } + __typename + ... on TypeA { + isScalar + } + ... on TypeB { + scalarB: isScalar + } + ... on TypeC { + scalarC: isScalar + } + ... on TypeD { + scalarD: isScalar + } + } + } + }, + }, + "#); + + insta::assert_snapshot!(format!("{}", sonic_rs::to_string_pretty(&query_plan).unwrap_or_default()), @r#" + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "service", + "operationKind": "query", + "operation": "{getTypes{id identifier code metadata{value unit} __typename ...on TypeA{isScalar} ...on TypeB{scalarB: isScalar} ...on TypeC{scalarC: isScalar} ...on TypeD{scalarD: isScalar}}}" + } + } + "#); + + Ok(()) +} + +#[test] +fn conflict_list_type_in_interface_preserves_client_alias() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query { + i { + ... on TypeA { + clientStrFieldA: strField # [String] + } + ... on TypeB { + clientStrFieldB: strField # String + } + } + } +"#, + ); + let query_plan = build_query_plan("fixture/tests/mismatch-mix.supergraph.graphql", document)?; + + insta::assert_snapshot!(format!("{}", query_plan), @r###" + QueryPlan { + Fetch(service: "a") { + { + i { + __typename + ... on TypeA { + clientStrFieldA: strField + } + ... on TypeB { + clientStrFieldB: strField + } + } + } + }, + }, + "###); + + insta::assert_snapshot!(format!("{}", sonic_rs::to_string_pretty(&query_plan).unwrap_or_default()), @r#" + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "a", + "operationKind": "query", + "operation": "{i{__typename ...on TypeA{clientStrFieldA: strField} ...on TypeB{clientStrFieldB: strField}}}" + } + } + "#); + + Ok(()) +} + +#[test] +fn conflict_list_type_in_interface_with_distinct_response_keys() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query { + i { + ... on TypeA { + typeAField: strField # [String] + } + ... on TypeB { + typeBField: strField # String + } + } + } +"#, + ); + let query_plan = build_query_plan("fixture/tests/mismatch-mix.supergraph.graphql", document)?; + + insta::assert_snapshot!(format!("{}", query_plan), @r###" + QueryPlan { + Fetch(service: "a") { + { + i { + __typename + ... on TypeA { + typeAField: strField + } + ... on TypeB { + typeBField: strField + } + } + } + }, + }, + "###); + + insta::assert_snapshot!(format!("{}", sonic_rs::to_string_pretty(&query_plan).unwrap_or_default()), @r#" + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "a", + "operationKind": "query", + "operation": "{i{__typename ...on TypeA{typeAField: strField} ...on TypeB{typeBField: strField}}}" + } + } + "#); + + Ok(()) +} From bbc8f75cf385ca4b6b156eda9d393d4b2eb5a1d7 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:44:58 +0300 Subject: [PATCH 11/76] chore(release): router crates and artifacts (#885) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/alias-fix.md | 9 --------- Cargo.lock | 6 +++--- bin/router/CHANGELOG.md | 10 ++++++++++ bin/router/Cargo.toml | 6 +++--- lib/executor/CHANGELOG.md | 8 ++++++++ lib/executor/Cargo.toml | 4 ++-- lib/query-planner/CHANGELOG.md | 10 ++++++++++ lib/query-planner/Cargo.toml | 2 +- 8 files changed, 37 insertions(+), 18 deletions(-) delete mode 100644 .changeset/alias-fix.md diff --git a/.changeset/alias-fix.md b/.changeset/alias-fix.md deleted file mode 100644 index c9220b782..000000000 --- a/.changeset/alias-fix.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -hive-router-query-planner: patch -hive-router-plan-executor: patch -hive-router: patch ---- - -# Preserve client aliases in mismatch rewrites - -Fixed query planner mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. diff --git a/Cargo.lock b/Cargo.lock index 4b0008f9f..9e3f0776e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.44" +version = "0.0.45" dependencies = [ "ahash", "anyhow", @@ -2416,7 +2416,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.9.1" +version = "6.9.2" dependencies = [ "ahash", "async-trait", @@ -2456,7 +2456,7 @@ dependencies = [ [[package]] name = "hive-router-query-planner" -version = "2.5.0" +version = "2.5.1" dependencies = [ "bitflags 2.11.0", "criterion", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 71f453459..966a8ae04 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.45 (2026-03-31) + +### Fixes + +- preserve client aliases in mismatch output rewrites (#870) + +#### Preserve client aliases in mismatch rewrites + +Fixed query planner mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. + ## 0.0.44 (2026-03-29) ### Fixes diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 792a8221d..bfeb5521e 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.44" +version = "0.0.45" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -21,8 +21,8 @@ noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] [dependencies] -hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.0" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.1" } +hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.1" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.2" } hive-router-config = { path = "../../lib/router-config", version = "0.0.26" } hive-router-internal = { path = "../../lib/internal", version = "0.0.15" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index 18c053c54..124ca02eb 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.9.2 (2026-03-31) + +### Fixes + +#### Preserve client aliases in mismatch rewrites + +Fixed query planner mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. + ## 6.9.1 (2026-03-29) ### Fixes diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 9ff5f9b45..8485eaaaa 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.9.1" +version = "6.9.2" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] doctest = false [dependencies] -hive-router-query-planner = { path = "../query-planner", version = "2.5.0" } +hive-router-query-planner = { path = "../query-planner", version = "2.5.1" } hive-router-config = { path = "../router-config", version = "0.0.26" } hive-router-internal = { path = "../internal", version = "0.0.15" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } diff --git a/lib/query-planner/CHANGELOG.md b/lib/query-planner/CHANGELOG.md index 06f6a65e3..630570ca7 100644 --- a/lib/query-planner/CHANGELOG.md +++ b/lib/query-planner/CHANGELOG.md @@ -30,6 +30,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 2.5.1 (2026-03-31) + +### Fixes + +- preserve client aliases in mismatch output rewrites (#870) + +#### Preserve client aliases in mismatch rewrites + +Fixed query planner mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. + ## 2.5.0 (2026-03-16) ### Features diff --git a/lib/query-planner/Cargo.toml b/lib/query-planner/Cargo.toml index bf8e00cb9..0675deadd 100644 --- a/lib/query-planner/Cargo.toml +++ b/lib/query-planner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-query-planner" -version = "2.5.0" +version = "2.5.1" edition = "2021" description = "GraphQL query planner for Federation specification" license = "MIT" From a109ad8b74aeed5a4f738297f1bb7b205493ebf9 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 1 Apr 2026 07:00:49 -0400 Subject: [PATCH 12/76] chore(node-addon): changeset for the fixes in the query planner (#886) This patch includes the fixes in the query planner including the fixes for mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/update-node-addon.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/update-node-addon.md diff --git a/.changeset/update-node-addon.md b/.changeset/update-node-addon.md new file mode 100644 index 000000000..a5c5ee8f9 --- /dev/null +++ b/.changeset/update-node-addon.md @@ -0,0 +1,5 @@ +--- +node-addon: patch +--- + +This patch includes the fixes in the query planner including the fixes for mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. \ No newline at end of file From bfd41c220119620c3eae84d9d2faaa0f8493a96f Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:01:49 +0300 Subject: [PATCH 13/76] chore(release): router crates and artifacts (#887) > [!IMPORTANT] > Merging this pull request will create these releases # node-addon 0.0.17 (2026-04-01) ## Fixes - This patch includes the fixes in the query planner including the fixes for mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/update-node-addon.md | 5 ----- Cargo.lock | 2 +- lib/node-addon/CHANGELOG.md | 6 ++++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/update-node-addon.md diff --git a/.changeset/update-node-addon.md b/.changeset/update-node-addon.md deleted file mode 100644 index a5c5ee8f9..000000000 --- a/.changeset/update-node-addon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -node-addon: patch ---- - -This patch includes the fixes in the query planner including the fixes for mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9e3f0776e..4761c81a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.16" +version = "0.0.17" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 79a92acf8..1f55ba689 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,10 @@ # @graphql-hive/router-query-planner changelog +## 0.0.17 (2026-04-01) + +### Fixes + +- This patch includes the fixes in the query planner including the fixes for mismatch handling so conflicting fields are tracked by response key (alias-aware), and internal alias rewrites restore the original client-facing key (alias-or-name) instead of always the schema field name. + ## 0.0.16 (2026-03-16) ### Fixes diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 0db1cf877..9cae9bb25 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.16" +version = "0.0.17" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index dec47c871..2af38147c 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.16", + "version": "0.0.17", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", From 5630f4371642ab3837ebe32b109597e26b4bd1ec Mon Sep 17 00:00:00 2001 From: Michael Skorokhodov Date: Wed, 1 Apr 2026 20:20:13 +0200 Subject: [PATCH 14/76] Replace graphiql with laboratory (#791) Co-authored-by: Dotan Simha Co-authored-by: Denis Badurina Co-authored-by: Denis Badurina Co-authored-by: Arda TANRIKULU Co-authored-by: theguild-bot Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .../replace_graphiql_with_hive_laboratory.md | 27 + bin/router/build.rs | 203 ++ bin/router/src/http_utils/landing_page.rs | 2 +- bin/router/src/lib.rs | 2 +- bin/router/src/pipeline/header.rs | 16 +- bin/router/src/pipeline/mod.rs | 8 +- bin/router/static/graphiql.html | 75 - bin/router/static/landing-page.html | 4 +- docs/README.md | 46 +- lib/router-config/src/env_overrides.rs | 12 +- lib/router-config/src/graphiql.rs | 24 - lib/router-config/src/laboratory.rs | 24 + lib/router-config/src/lib.rs | 8 +- package-lock.json | 2465 ++++++++++++++--- package.json | 3 +- 15 files changed, 2380 insertions(+), 539 deletions(-) create mode 100644 .changeset/replace_graphiql_with_hive_laboratory.md create mode 100644 bin/router/build.rs delete mode 100644 bin/router/static/graphiql.html delete mode 100644 lib/router-config/src/graphiql.rs create mode 100644 lib/router-config/src/laboratory.rs diff --git a/.changeset/replace_graphiql_with_hive_laboratory.md b/.changeset/replace_graphiql_with_hive_laboratory.md new file mode 100644 index 000000000..85ddaa171 --- /dev/null +++ b/.changeset/replace_graphiql_with_hive_laboratory.md @@ -0,0 +1,27 @@ +--- +hive-router: patch +hive-router-config: patch +--- + +# Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` diff --git a/bin/router/build.rs b/bin/router/build.rs new file mode 100644 index 000000000..95e3fa721 --- /dev/null +++ b/bin/router/build.rs @@ -0,0 +1,203 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing manifest dir")); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); + let output_file = out_dir.join("laboratory.html"); + let product_logo = manifest_dir.join("static/product_logo.svg"); + let node_modules_dist = manifest_dir.join("../../node_modules/@graphql-hive/laboratory/dist"); + + println!("cargo:rerun-if-changed={}", product_logo.display()); + println!( + "cargo:rerun-if-changed={}", + manifest_dir.join("../../package.json").display() + ); + println!( + "cargo:rerun-if-changed={}", + manifest_dir.join("../../package-lock.json").display() + ); + + if !node_modules_dist.exists() { + let status = Command::new("npm") + .args(["install", "--include=dev"]) // NODE_ENV=production will skip dev deps - make sure they're in + .current_dir(manifest_dir.join("../../")) + .status() + .expect("Failed to execute npm install"); + + if !status.success() { + panic!("npm install failed"); + } + } + + println!( + "cargo:rerun-if-changed={}", + node_modules_dist.join("hive-laboratory.umd.js").display() + ); + println!( + "cargo:rerun-if-changed={}", + node_modules_dist + .join("monacoeditorwork/editor.worker.bundle.js") + .display() + ); + println!( + "cargo:rerun-if-changed={}", + node_modules_dist + .join("monacoeditorwork/graphql.worker.bundle.js") + .display() + ); + println!( + "cargo:rerun-if-changed={}", + node_modules_dist + .join("monacoeditorwork/json.worker.bundle.js") + .display() + ); + println!( + "cargo:rerun-if-changed={}", + node_modules_dist + .join("monacoeditorwork/ts.worker.bundle.js") + .display() + ); + + let html = build_inline_laboratory_html(&node_modules_dist, &product_logo); + + fs::write(output_file, html).expect("failed to write generated laboratory.html"); +} + +fn build_inline_laboratory_html(dist_dir: &Path, product_logo: &Path) -> String { + let js_contents = fs::read_to_string(dist_dir.join("hive-laboratory.umd.js")) + .expect("failed to read hive-laboratory.umd.js"); + let editor_worker = + fs::read_to_string(dist_dir.join("monacoeditorwork/editor.worker.bundle.js")) + .expect("failed to read editor worker"); + let graphql_worker = + fs::read_to_string(dist_dir.join("monacoeditorwork/graphql.worker.bundle.js")) + .expect("failed to read graphql worker"); + let json_worker = fs::read_to_string(dist_dir.join("monacoeditorwork/json.worker.bundle.js")) + .expect("failed to read json worker"); + let typescript_worker = + fs::read_to_string(dist_dir.join("monacoeditorwork/ts.worker.bundle.js")) + .expect("failed to read typescript worker"); + let product_logo_data_url = format!( + "data:image/svg+xml;base64,{}", + base64_encode(&fs::read(product_logo).expect("failed to read product logo")) + ); + + format!( + r##" + + + + Hive Router Laboratory + + + + + +
+ + + + +"##, + product_logo_data_url = product_logo_data_url, + editor_worker = js_string_literal(&editor_worker), + typescript_worker = js_string_literal(&typescript_worker), + json_worker = js_string_literal(&json_worker), + graphql_worker = js_string_literal(&graphql_worker), + js_contents = escape_inline_script(&js_contents), + ) +} + +fn escape_inline_script(value: &str) -> String { + value + .replace(" String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + '\u{2028}' => escaped.push_str("\\u2028"), + '\u{2029}' => escaped.push_str("\\u2029"), + _ => escaped.push(ch), + } + } + escaped.push('"'); + escape_inline_script(&escaped) +} + +fn base64_encode(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); + + let mut chunks = bytes.chunks_exact(3); + for chunk in &mut chunks { + let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32; + output.push(TABLE[((n >> 18) & 0x3f) as usize] as char); + output.push(TABLE[((n >> 12) & 0x3f) as usize] as char); + output.push(TABLE[((n >> 6) & 0x3f) as usize] as char); + output.push(TABLE[(n & 0x3f) as usize] as char); + } + + let remainder = chunks.remainder(); + if !remainder.is_empty() { + let first = remainder[0] as u32; + let second = remainder.get(1).copied().unwrap_or_default() as u32; + let n = (first << 16) | (second << 8); + + output.push(TABLE[((n >> 18) & 0x3f) as usize] as char); + output.push(TABLE[((n >> 12) & 0x3f) as usize] as char); + if remainder.len() == 2 { + output.push(TABLE[((n >> 6) & 0x3f) as usize] as char); + output.push('='); + } else { + output.push('='); + output.push('='); + } + } + + output +} diff --git a/bin/router/src/http_utils/landing_page.rs b/bin/router/src/http_utils/landing_page.rs index 30585f3a6..1806bda6a 100644 --- a/bin/router/src/http_utils/landing_page.rs +++ b/bin/router/src/http_utils/landing_page.rs @@ -6,7 +6,7 @@ static PRODUCT_LOGO_SVG: &str = include_str!("../../static/product_logo.svg"); pub async fn landing_page_handler(graphql_endpoint: String) -> impl Responder { let rendered_html = LANDING_PAGE_HTML - .replace("__GRAPHIQL_LINK__", &graphql_endpoint) + .replace("__LABORATORY_LINK__", &graphql_endpoint) .replace("__PRODUCT_NAME__", "Hive Router") .replace( "__PRODUCT_DESCRIPTION__", diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 945cfaad4..b00a8d0a8 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -71,7 +71,7 @@ pub use tokio; pub use tracing; use tracing::{info, warn, Instrument}; -static GRAPHIQL_HTML: &str = include_str!("../static/graphiql.html"); +static LABORATORY_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/laboratory.html")); async fn graphql_endpoint_handler( request: HttpRequest, diff --git a/bin/router/src/pipeline/header.rs b/bin/router/src/pipeline/header.rs index 7ffc0ea9c..116a282dc 100644 --- a/bin/router/src/pipeline/header.rs +++ b/bin/router/src/pipeline/header.rs @@ -12,7 +12,7 @@ use tracing::error; use crate::pipeline::error::PipelineError; -/// Non-GraphQL content type, used to detect if the client can accept GraphiQL responses. +/// Non-GraphQL content type, used to detect if the client can accept Laboratory responses. pub const TEXT_HTML_MIME: &str = "text/html"; // IMPORTANT: make sure that the serialized string representations are valid because @@ -175,9 +175,9 @@ pub enum ResponseMode { StreamOnly(StreamContentType), /// Will always respond, queries are single responses, subscriptions are streams. errors are single responses. Dual(SingleContentType, StreamContentType), - /// Render the GraphiQL IDE for the client. Used when the client prefers accepting HTML responses. + /// Render the Laboratory IDE for the client. Used when the client prefers accepting HTML responses. /// It is different from the other modes because it does not represent a GraphQL response mode. - GraphiQL, + Laboratory, } // `#[default]` attribute may only be used on unit enum variants, so we have to implement it @@ -251,14 +251,14 @@ impl RequestAccepts for HttpRequest { if self.method() == Method::GET { // if the client GETs we negotiate with the all supported media type, including HTML - // to see if the client wants GraphiQL. we negotiate with everything because browsers + // to see if the client wants Laboratory. we negotiate with everything because browsers // tend to send very broad accept headers that include text/html with highest q-weight, // but would also accept */* which we would interpret as "I want normal GraphQL responses" - let has_agreed_graphiql = accept + let has_agreed_laboratory = accept .negotiate(ALL_RESPONSE_MODES_CONTENT_TYPE_MEDIA_TYPES.iter()) .is_some_and(|t| *t == HTML_MEDIA_TYPE); - if has_agreed_graphiql { - return Ok(ResponseMode::GraphiQL); + if has_agreed_laboratory { + return Ok(ResponseMode::Laboratory); } } @@ -342,7 +342,7 @@ mod tests { // actual browser request loading a page Method::GET, r#"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"#, - ResponseMode::GraphiQL, + ResponseMode::Laboratory, ), ( // browser accept header snippet but for a POST request diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index ab00898ce..ee6722dca 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -49,7 +49,7 @@ use crate::{ }, schema_state::SchemaState, shared_state::{RouterRequestDedupeHeaderPolicy, RouterSharedState, SharedRouterResponse}, - GRAPHIQL_HTML, + LABORATORY_HTML, }; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; @@ -94,11 +94,11 @@ pub async fn graphql_request_handler( // agree on the response content type *response_mode = req.negotiate()?; - if *response_mode == ResponseMode::GraphiQL { - if shared_state.router_config.graphiql.enabled { + if *response_mode == ResponseMode::Laboratory { + if shared_state.router_config.laboratory.enabled { return Ok(web::HttpResponse::Ok() .header(CONTENT_TYPE, TEXT_HTML_MIME) - .body(GRAPHIQL_HTML)); + .body(LABORATORY_HTML)); } else { return Ok(web::HttpResponse::NotFound().into()); } diff --git a/bin/router/static/graphiql.html b/bin/router/static/graphiql.html deleted file mode 100644 index d11b430d3..000000000 --- a/bin/router/static/graphiql.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - Hive Router GraphiQL - - - - - - - - -
Loading GraphiQL...
- - - - - \ No newline at end of file diff --git a/bin/router/static/landing-page.html b/bin/router/static/landing-page.html index efa0976f2..a40109eb2 100644 --- a/bin/router/static/landing-page.html +++ b/bin/router/static/landing-page.html @@ -161,7 +161,7 @@

__PRODUCT_NAME__


@@ -173,7 +173,7 @@

ℹ️ Not the Page You Expected To See?

this page to be the GraphQL route, you need to configure Hive Router.
Currently, the GraphQL route is configured to be on - __GRAPHIQL_LINK__. + __LABORATORY_LINK__.

diff --git a/docs/README.md b/docs/README.md index 3b53337ae..f8c4c205f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,11 +7,11 @@ |[**authorization**](#authorization)|`object`|Default: `{"directives":{"enabled":true,"unauthorized":{"mode":"filter"}}}`
|yes| |[**cors**](#cors)|`object`|Configuration for CORS (Cross-Origin Resource Sharing).
Default: `{"allow_any_origin":false,"allow_credentials":false,"enabled":false,"policies":[]}`
|yes| |[**csrf**](#csrf)|`object`|Configuration for CSRF prevention.
Default: `{"enabled":false,"required_headers":[]}`
|| -|[**graphiql**](#graphiql)|`object`|Configuration for the GraphiQL interface.
Default: `{"enabled":true}`
|| |[**headers**](#headers)|`object`|Configuration for the headers.
Default: `{}`
|| |[**http**](#http)|`object`|Configuration for the HTTP server/listener.
Default: `{"graphql_endpoint":"/graphql","host":"0.0.0.0","port":4000}`
|| |**introspection**||Configuration to enable or disable introspection queries.
|| |[**jwt**](#jwt)|`object`|Configuration for JWT authentication plugin.
|yes| +|[**laboratory**](#laboratory)|`object`|Configuration for the Hive Laboratory interface.
Default: `{"enabled":true}`
|| |[**limits**](#limits)|`object`|Configuration for checking the limits such as query depth, complexity, etc.
Default: `{"max_request_body_size":"2 MB"}`
|| |[**log**](#log)|`object`|The router logger configuration.
Default: `{"filter":null,"format":"json","level":"info"}`
|| |[**override\_labels**](#override_labels)|`object`|Configuration for overriding labels.
|| @@ -48,8 +48,6 @@ csrf: enabled: true required_headers: - x-csrf-token -graphiql: - enabled: true headers: all: request: @@ -93,6 +91,8 @@ jwt: - name: authorization prefix: Bearer source: header +laboratory: + enabled: true limits: max_request_body_size: 2 MB log: @@ -501,26 +501,6 @@ A valid HTTP header name, according to RFC 7230. **Item Type:** `string` **Item Pattern:** `^[A-Za-z0-9!#$%&'*+\-.^_\`\|~]+$` - -## graphiql: object - -Configuration for the GraphiQL interface. - - -**Properties** - -|Name|Type|Description|Required| -|----|----|-----------|--------| -|**enabled**|`boolean`|Enables/disables the GraphiQL interface. By default, the GraphiQL interface is enabled.

You can override this setting by setting the `GRAPHIQL_ENABLED` environment variable to `true` or `false`.
Default: `true`
|| - -**Additional Properties:** not allowed -**Example** - -```yaml -enabled: true - -``` - ## headers: object @@ -1710,6 +1690,26 @@ The first one that is found will be used. ``` + +## laboratory: object + +Configuration for the Hive Laboratory interface. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`|Enables/disables the Hive Laboratory interface. By default, the Hive Laboratory interface is enabled.

You can override this setting by setting the `LABORATORY_ENABLED` environment variable to `true` or `false`.
Default: `true`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +enabled: true + +``` + ## limits: object diff --git a/lib/router-config/src/env_overrides.rs b/lib/router-config/src/env_overrides.rs index 8d437219b..4bb3aaeb3 100644 --- a/lib/router-config/src/env_overrides.rs +++ b/lib/router-config/src/env_overrides.rs @@ -14,9 +14,9 @@ pub struct EnvVarOverrides { #[envconfig(from = "LOG_FILTER")] pub log_filter: Option, - // GraphiQL overrides - #[envconfig(from = "GRAPHIQL_ENABLED")] - pub graphiql_enabled: Option, + // Laboratory overrides + #[envconfig(from = "LABORATORY_ENABLED")] + pub laboratory_enabled: Option, // HTTP overrides #[envconfig(from = "PORT")] @@ -132,9 +132,9 @@ impl EnvVarOverrides { config = config.set_override("telemetry.hive.target", hive_target)?; } - // GraphiQL overrides - if let Some(graphiql_enabled) = self.graphiql_enabled.take() { - config = config.set_override("graphiql.enabled", graphiql_enabled)?; + // Laboratory overrides + if let Some(laboratory_enabled) = self.laboratory_enabled.take() { + config = config.set_override("laboratory.enabled", laboratory_enabled)?; } Ok(config) diff --git a/lib/router-config/src/graphiql.rs b/lib/router-config/src/graphiql.rs deleted file mode 100644 index 6d453c26d..000000000 --- a/lib/router-config/src/graphiql.rs +++ /dev/null @@ -1,24 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] -#[serde(deny_unknown_fields)] -pub struct GraphiQLConfig { - /// Enables/disables the GraphiQL interface. By default, the GraphiQL interface is enabled. - /// - /// You can override this setting by setting the `GRAPHIQL_ENABLED` environment variable to `true` or `false`. - #[serde(default = "default_graphiql_enabled")] - pub enabled: bool, -} - -fn default_graphiql_enabled() -> bool { - true -} - -impl Default for GraphiQLConfig { - fn default() -> Self { - Self { - enabled: default_graphiql_enabled(), - } - } -} diff --git a/lib/router-config/src/laboratory.rs b/lib/router-config/src/laboratory.rs new file mode 100644 index 000000000..28b556a9f --- /dev/null +++ b/lib/router-config/src/laboratory.rs @@ -0,0 +1,24 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct LaboratoryConfig { + /// Enables/disables the Hive Laboratory interface. By default, the Hive Laboratory interface is enabled. + /// + /// You can override this setting by setting the `LABORATORY_ENABLED` environment variable to `true` or `false`. + #[serde(default = "default_laboratory_enabled")] + pub enabled: bool, +} + +fn default_laboratory_enabled() -> bool { + true +} + +impl Default for LaboratoryConfig { + fn default() -> Self { + Self { + enabled: default_laboratory_enabled(), + } + } +} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index 2f7ebd852..26e5da516 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -2,11 +2,11 @@ pub mod authorization; pub mod cors; pub mod csrf; mod env_overrides; -pub mod graphiql; pub mod headers; pub mod http_server; pub mod introspection_policy; pub mod jwt_auth; +pub mod laboratory; pub mod limits; pub mod log; pub mod override_labels; @@ -28,9 +28,9 @@ use std::{collections::HashMap, convert::Infallible}; use crate::{ env_overrides::{EnvVarOverrides, EnvVarOverridesError}, - graphiql::GraphiQLConfig, http_server::HttpServerConfig, introspection_policy::IntrospectionPermissionConfig, + laboratory::LaboratoryConfig, log::LoggingConfig, override_labels::OverrideLabelsConfig, primitives::file_path::with_start_path, @@ -51,9 +51,9 @@ pub struct HiveRouterConfig { #[serde(default)] pub log: LoggingConfig, - /// Configuration for the GraphiQL interface. + /// Configuration for the Hive Laboratory interface. #[serde(default)] - pub graphiql: GraphiQLConfig, + pub laboratory: LaboratoryConfig, /// Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`). /// Each source has a different set of configuration, depending on the source type. diff --git a/package-lock.json b/package-lock.json index a8cd767f0..ce36ec4d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "bench" ], "devDependencies": { + "@graphql-hive/laboratory": "0.1.2", "cross-spawn": "^7.0.6", "qs": "^6.15.0" } @@ -123,7 +124,7 @@ }, "lib/node-addon": { "name": "@graphql-hive/router-query-planner", - "version": "0.0.14", + "version": "0.0.17", "license": "MIT", "devDependencies": { "@napi-rs/cli": "3.5.1", @@ -136,23 +137,132 @@ }, "node_modules/@apollo/cache-control-types": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", - "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", "dev": true, "license": "MIT", "peerDependencies": { "graphql": "14.x || 15.x || 16.x" } }, - "node_modules/@apollo/composition": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.13.2.tgz", - "integrity": "sha512-E44BbSKuKkDdsNBtQzSU12D9FdggiOZzNR1TghqPjSPEyB1fl9Io1m2gMP0nkwrJUaftsBSP6ZSFF3SgYJfTVg==", + "node_modules/@babel/runtime": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@envelop/core": { + "version": "5.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/types": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-hive/federation-gateway-audit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@graphql-hive/federation-gateway-audit/-/federation-gateway-audit-0.0.2.tgz", + "integrity": "sha512-9+jHme3iNfUHksM20N+ODkzz3t6wkICOBTkge/w29+U7qWRirXn9VLUXs3pbOs1/PpOvoLJXVvJpE7+3mVQN1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apollo/composition": "^2.13.2", + "@apollo/subgraph": "^2.13.2", + "async-retry": "^1.3.3", + "detect-port": "^2.1.0", + "fets": "^0.8.5", + "get-port": "^7.1.0", + "graphql-yoga": "^5.18.1", + "jest-diff": "^30.3.0", + "kill-port-process": "^4.0.2", + "wait-on": "^9.0.4", + "yargs": "^18.0.0" + }, + "bin": { + "graphql-federation-audit": "dist/cli.js" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/composition": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.13.3.tgz", + "integrity": "sha512-0/vr6vbk1BynEKY6/UV0SZUHBV33p5Ok0y6RhkNnX5BA+ysyOkcyTLPlEHo+ce62nqWx4ml3iOxeKIEHqbKelQ==", "dev": true, "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.13.2", - "@apollo/query-graphs": "2.13.2" + "@apollo/federation-internals": "2.13.3", + "@apollo/query-graphs": "2.13.3" }, "engines": { "node": ">=18" @@ -161,10 +271,10 @@ "graphql": "^16.5.0" } }, - "node_modules/@apollo/federation-internals": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.13.2.tgz", - "integrity": "sha512-m9waen8iZhrzn23qkemlytqBCPoKU6AMv5k3eFYnu6yzoJ5/ONh6SMGE7CvrWsECcpnOLYOinxD19igEHm6Azw==", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/federation-internals": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.13.3.tgz", + "integrity": "sha512-4zHgqznZza5Qx2CZy1qlrh83BB/3Yd8BqD/dmhGzLCvHJQ1LBTyZbWN7xwKs5z5FbxdSPkL5TvPoLm5Rek8/4g==", "dev": true, "license": "Elastic-2.0", "dependencies": { @@ -180,14 +290,14 @@ "graphql": "^16.5.0" } }, - "node_modules/@apollo/query-graphs": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.13.2.tgz", - "integrity": "sha512-iXb7Ut72BxOIKYT1Xbmmt5jONa/kB0iuQyWKhKIJdwIw+p8VSwJvaSFtzjDZ7tYTo/QH9l8yGzAdliJVR+/7cA==", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/query-graphs": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.13.3.tgz", + "integrity": "sha512-X/bgsIVmamAzd8IFMDo9upO5Oi/uhAZlcBmna6yEFlN0fzdQZMHL5pp1fyzRG1wxCFi3lVjDXw2dc/380uWB9g==", "dev": true, "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.13.2", + "@apollo/federation-internals": "2.13.3", "deep-equal": "^2.0.5", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" @@ -199,15 +309,15 @@ "graphql": "^16.5.0" } }, - "node_modules/@apollo/subgraph": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.13.2.tgz", - "integrity": "sha512-hNJv6kcGhtWdBfGWnSZwgfmSm4ZAJ3AoPmDFOXaloSE1wKmKz0NmPsef4nkC2/AUD9/FrQMKm5xzyBYTDrpNSw==", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/subgraph": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.13.3.tgz", + "integrity": "sha512-nmdACpdTe+kgmNF0Yow3MoDkE0SMfvK6tTsEs3h3M9GLYCgBWZWBoDWQfNdr7kWRW6aan2SylU0K0ktF643h3g==", "dev": true, "license": "MIT", "dependencies": { "@apollo/cache-control-types": "^1.0.2", - "@apollo/federation-internals": "2.13.2" + "@apollo/federation-internals": "2.13.3" }, "engines": { "node": ">=14.15.0" @@ -216,85 +326,90 @@ "graphql": "^16.5.0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.4", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@envelop/core": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", - "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@envelop/instrumentation": "^1.0.0", - "@envelop/types": "^5.2.1", - "@whatwg-node/promise-helpers": "^1.2.4", - "tslib": "^2.5.0" - }, "engines": { - "node": ">=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@envelop/instrumentation": { - "version": "1.0.0", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/promise-helpers": "^1.2.1", - "tslib": "^2.5.0" + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@envelop/types": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", - "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "node_modules/@graphql-hive/federation-gateway-audit/node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.5.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@graphql-hive/federation-gateway-audit": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@graphql-hive/federation-gateway-audit/-/federation-gateway-audit-0.0.2.tgz", - "integrity": "sha512-9+jHme3iNfUHksM20N+ODkzz3t6wkICOBTkge/w29+U7qWRirXn9VLUXs3pbOs1/PpOvoLJXVvJpE7+3mVQN1w==", + "node_modules/@graphql-hive/laboratory": { + "version": "0.1.2", "dev": true, "license": "MIT", "dependencies": { - "@apollo/composition": "^2.13.2", - "@apollo/subgraph": "^2.13.2", - "async-retry": "^1.3.3", - "detect-port": "^2.1.0", - "fets": "^0.8.5", - "get-port": "^7.1.0", - "graphql-yoga": "^5.18.1", - "jest-diff": "^30.3.0", - "kill-port-process": "^4.0.2", - "wait-on": "^9.0.4", - "yargs": "^18.0.0" - }, - "bin": { - "graphql-federation-audit": "dist/cli.js" + "radix-ui": "^1.4.3", + "uuid": "^13.0.0" }, "peerDependencies": { - "graphql": "*" + "@tanstack/react-form": "^1.23.8", + "date-fns": "^4.1.0", + "graphql-ws": "^6.0.6", + "lucide-react": "^0.548.0", + "lz-string": "^1.5.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "tslib": "^2.8.1", + "zod": "^4.1.12" + } + }, + "node_modules/@graphql-hive/laboratory/node_modules/uuid": { + "version": "13.0.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" } }, "node_modules/@graphql-hive/router-query-planner": { @@ -303,8 +418,6 @@ }, "node_modules/@graphql-tools/executor": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.1.tgz", - "integrity": "sha512-n94Qcu875Mji9GQ52n5UbgOTxlgvFJicBPYD+FRks9HKIQpdNPjkkrKZUYNG51XKa+bf03rxNflm4+wXhoHHrA==", "dev": true, "license": "MIT", "dependencies": { @@ -324,8 +437,6 @@ }, "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", - "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", "dependencies": { @@ -343,8 +454,6 @@ }, "node_modules/@graphql-tools/merge": { "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", - "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -360,8 +469,6 @@ }, "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", - "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", "dependencies": { @@ -379,8 +486,6 @@ }, "node_modules/@graphql-tools/schema": { "version": "10.0.31", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", - "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -397,8 +502,6 @@ }, "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", - "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", "dependencies": { @@ -416,8 +519,6 @@ }, "node_modules/@graphql-tools/utils": { "version": "10.11.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", - "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -435,8 +536,6 @@ }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -445,8 +544,6 @@ }, "node_modules/@graphql-yoga/logger": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.1.tgz", - "integrity": "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==", "dev": true, "license": "MIT", "dependencies": { @@ -458,8 +555,6 @@ }, "node_modules/@graphql-yoga/subscription": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.5.tgz", - "integrity": "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==", "dev": true, "license": "MIT", "dependencies": { @@ -474,8 +569,6 @@ }, "node_modules/@graphql-yoga/typed-event-target": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.2.tgz", - "integrity": "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==", "dev": true, "license": "MIT", "dependencies": { @@ -840,16 +933,6 @@ } } }, - "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/get-type": { "version": "30.1.0", "dev": true, @@ -860,8 +943,6 @@ }, "node_modules/@jest/schemas": { "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", "dependencies": { @@ -1001,21 +1082,6 @@ "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.4.5", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/tar": { "version": "1.1.0", "dev": true, @@ -1042,21 +1108,6 @@ "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, - "node_modules/@napi-rs/tar-linux-x64-gnu": { - "version": "1.1.0", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/wasm-tools": { "version": "1.0.1", "dev": true, @@ -1080,21 +1131,6 @@ "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, - "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { - "version": "1.0.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "dev": true, @@ -1229,50 +1265,1588 @@ "node": ">= 20" } }, - "node_modules/@octokit/types": { - "version": "16.0.0", + "node_modules/@octokit/types": { + "version": "16.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.28.6", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.6.tgz", + "integrity": "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@octokit/openapi-types": "^27.0.0" + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@repeaterjs/repeater": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", - "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.41", + "node_modules/@tanstack/react-form": { + "version": "1.28.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.28.6.tgz", + "integrity": "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/form-core": "1.28.6", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "peer": true, + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } }, "node_modules/@types/k6": { "version": "1.6.0", @@ -1293,8 +2867,6 @@ }, "node_modules/@types/uuid": { "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true, "license": "MIT" }, @@ -1324,8 +2896,6 @@ }, "node_modules/@whatwg-node/events": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", - "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1429,10 +2999,19 @@ "version": "2.0.1", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -1461,8 +3040,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1477,8 +3054,6 @@ }, "node_modules/axios": { "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1503,8 +3078,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -1644,8 +3217,6 @@ }, "node_modules/cross-inspect": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", - "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", "dev": true, "license": "MIT", "dependencies": { @@ -1657,8 +3228,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1672,14 +3241,24 @@ }, "node_modules/cross-spawn/node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "dev": true, @@ -1698,8 +3277,6 @@ }, "node_modules/deep-equal": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "license": "MIT", "dependencies": { @@ -1731,8 +3308,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1749,8 +3324,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -1773,6 +3346,11 @@ "node": ">=0.4.0" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, "node_modules/detect-port": { "version": "2.1.0", "dev": true, @@ -1846,8 +3424,6 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, "license": "MIT", "dependencies": { @@ -1923,8 +3499,6 @@ }, "node_modules/execa": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -1950,8 +3524,6 @@ }, "node_modules/execa/node_modules/human-signals": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1960,8 +3532,6 @@ }, "node_modules/execa/node_modules/strip-final-newline": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { @@ -2007,8 +3577,6 @@ }, "node_modules/figures": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, "license": "MIT", "dependencies": { @@ -2042,8 +3610,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2086,8 +3652,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -2136,6 +3700,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-port": { "version": "7.1.0", "dev": true, @@ -2161,8 +3733,6 @@ }, "node_modules/get-stream": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", "dependencies": { @@ -2194,18 +3764,42 @@ }, "node_modules/graphql": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", - "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-ws": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", + "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/graphql-yoga": { "version": "5.18.1", - "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.18.1.tgz", - "integrity": "sha512-GiDSlvibfY/vyurbkBOKMO+adF6bTCzKr80FYWta59s1Lx87oVo8T6Nu/0hrxrGv0SrAUkqUpcpr4Ee7XlYmBw==", "dev": true, "license": "MIT", "dependencies": { @@ -2231,8 +3825,6 @@ }, "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -2252,8 +3844,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -2329,8 +3919,6 @@ }, "node_modules/internal-slot": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -2344,8 +3932,6 @@ }, "node_modules/is-arguments": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2361,8 +3947,6 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -2379,8 +3963,6 @@ }, "node_modules/is-bigint": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2395,8 +3977,6 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2412,8 +3992,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -2425,8 +4003,6 @@ }, "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -2442,8 +4018,6 @@ }, "node_modules/is-map": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -2455,8 +4029,6 @@ }, "node_modules/is-number-object": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -2472,8 +4044,6 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", "engines": { @@ -2485,8 +4055,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -2504,8 +4072,6 @@ }, "node_modules/is-set": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -2517,8 +4083,6 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -2533,8 +4097,6 @@ }, "node_modules/is-stream": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { @@ -2546,8 +4108,6 @@ }, "node_modules/is-string": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -2563,8 +4123,6 @@ }, "node_modules/is-symbol": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2581,8 +4139,6 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { @@ -2594,8 +4150,6 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -2607,8 +4161,6 @@ }, "node_modules/is-weakset": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2624,34 +4176,14 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, - "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.3.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/joi": { "version": "18.0.2", "dev": true, @@ -2671,8 +4203,6 @@ }, "node_modules/js-levenshtein": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", "dev": true, "license": "MIT", "engines": { @@ -2703,8 +4233,6 @@ }, "node_modules/kill-port-process": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kill-port-process/-/kill-port-process-4.0.2.tgz", - "integrity": "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w==", "dev": true, "license": "ISC", "dependencies": { @@ -2728,18 +4256,36 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.548.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz", + "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/map-stream": { "version": "0.1.0", "dev": true @@ -2806,8 +4352,6 @@ }, "node_modules/npm-run-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { @@ -2834,8 +4378,6 @@ }, "node_modules/object-is": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2851,8 +4393,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -2861,8 +4401,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -2905,8 +4443,6 @@ }, "node_modules/parse-ms": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, "license": "MIT", "engines": { @@ -2918,8 +4454,6 @@ }, "node_modules/path-key": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", "engines": { @@ -2942,8 +4476,6 @@ }, "node_modules/pid-port": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pid-port/-/pid-port-2.0.1.tgz", - "integrity": "sha512-pnLo01AmMclw8l+/gfknsP2N351oe8VkVmCLFUvJZ11NRPPmghJrv0OcwsdgPQxsZkFYwm6hPWW0JKmXYCaXAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2958,46 +4490,14 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/pretty-ms": { "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3031,8 +4531,6 @@ }, "node_modules/qs": { "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3045,17 +4543,180 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/react-is": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -3095,8 +4756,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -3116,6 +4775,14 @@ "dev": true, "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.3", "dev": true, @@ -3129,8 +4796,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3147,8 +4812,6 @@ }, "node_modules/set-function-name": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3163,8 +4826,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -3176,8 +4837,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -3276,8 +4935,6 @@ }, "node_modules/start-server-and-test": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.5.tgz", - "integrity": "sha512-A/SbXpgXE25ScSkpLLqvGvVZT0ykN6+AzS8tVqMBCTxbJy2Nwuen59opT+afalK5aS+AuQmZs0EsLwjnuDN+/g==", "dev": true, "license": "MIT", "dependencies": { @@ -3301,8 +4958,6 @@ }, "node_modules/start-server-and-test/node_modules/execa": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,8 +4980,6 @@ }, "node_modules/start-server-and-test/node_modules/get-stream": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { @@ -3338,8 +4991,6 @@ }, "node_modules/start-server-and-test/node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { @@ -3351,8 +5002,6 @@ }, "node_modules/start-server-and-test/node_modules/npm-run-path": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { @@ -3364,8 +5013,6 @@ }, "node_modules/start-server-and-test/node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -3374,15 +5021,11 @@ }, "node_modules/start-server-and-test/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3462,8 +5105,6 @@ }, "node_modules/ts-graphviz": { "version": "1.8.2", - "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-1.8.2.tgz", - "integrity": "sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA==", "dev": true, "license": "MIT", "engines": { @@ -3513,8 +5154,6 @@ }, "node_modules/unicorn-magic": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", "engines": { @@ -3534,10 +5173,57 @@ "dev": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -3550,8 +5236,6 @@ }, "node_modules/wait-on": { "version": "9.0.4", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", - "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3570,8 +5254,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -3586,8 +5268,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -3606,8 +5286,6 @@ }, "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3625,8 +5303,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3706,8 +5382,6 @@ }, "node_modules/yoctocolors": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { @@ -3716,6 +5390,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 82e9c6c80..6ea2a8485 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ ], "packageManager": "npm@11.11.1", "devDependencies": { + "@graphql-hive/laboratory": "0.1.2", "cross-spawn": "^7.0.6", "qs": "^6.15.0" }, @@ -17,4 +18,4 @@ "cross-spawn": "^7.0.6", "qs": "^6.15.0" } -} \ No newline at end of file +} From 4f1be62af5041b23a5f68889c49ba7f7949c8446 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 3 Apr 2026 16:14:33 +0200 Subject: [PATCH 15/76] chore: refactor install script for better UX (#892) Introduce color support, progress indicators, and more user-friendly messages. Update documentation links. Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- install.sh | 116 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/install.sh b/install.sh index 0368dab8f..70cfc39b6 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,23 @@ #!/bin/sh set -e +# Color support: check NO_COLOR or non-TTY (stdout) +if [ -n "${NO_COLOR+x}" ] || [ ! -t 1 ]; then + BLUE='' + GREEN='' + RED='' + BOLD='' + DIM='' + NC='' +else + BLUE='\033[0;34m' + GREEN='\033[0;32m' + RED='\033[0;31m' + BOLD='\033[1m' + DIM='\033[2m' + NC='\033[0m' +fi + # ============================================================================== # Hive Router # @@ -14,40 +31,32 @@ GH_REPO="router" BINARY_NAME="hive_router" CARGO_PKG_NAME="hive-router" -info() { - echo "\033[34m[INFO]\033[0m $1" +print_step() { + printf '%b\n' "${BLUE}▸${NC} $1" +} + +print_success() { + # Move up one line and clear it + printf '%b' "\033[1A\033[2K\r" + printf '%b\n' "${GREEN}✓${NC} $1" } -error() { - echo "\033[31m[ERROR]\033[0m $1" >&2 - exit 1 +print_error() { + printf '%b\n' "${RED}✗${NC} $1" } check_tool() { if ! command -v "$1" >/dev/null 2>&1; then - error "'$1' is required but it's not installed. Please install it to continue." + print_error "'$1' is required but it's not installed. Please install it to continue." + exit 1 fi } -banner() { - echo " @@@@@@@@@@@@@ " - echo " @@ " - echo " @@@ #++++# +@@ @@@ @@@ @@ @@@@@@@@@ @@# " - echo " @@@ @@@@@@@@@ @@@ @@@ =@@ @@ =@@ @@@ " - echo " @@@ @@@ @@ @@@ @@@ @@ @@ @@# @@@ @@@@@@@ @@ @@ @@@@@@@ @@ @@ @@@@@+ @@@@@@@ @@#@@@ " - echo " @@@ @@@ @@@ @@@ @@@@@@@@@@@ @@ @@ @@ @@ @@ @@@@@@@@@ @@# @@ @@ @@ @@% @@ @@ @@@ " - echo " @@@ @@ @@@ @@@ @@@ @@ @@ @@ @@ @@@@@@@@@ @@+ @@@ @@ @@ @@ @@ @@@ @@@@@@@@@ @@ " - echo " @@@ @@@@@@@@@ @@@ @@@ =@@ @@ @@ @@ @@ @@ @@* @@ @@@ @@ @@ @@ @@% @@ @@ @@ " - echo " @ @@ @@@ @@@ @@ @@@ @@@@@@@ @@@ @@ :@@@@@@ @@@@@@@@ @@@* #@@@@@@ @@= " - echo " @@@@@@@@@@@@@@@ " - echo " @@@@@@@@@@@@ " -} - detect_arch() { OS_TYPE=$(uname -s) ARCH=$(uname -m) - info "Detecting operating system and architecture..." + print_step "Detecting system architecture..." case $OS_TYPE in Linux) @@ -57,7 +66,8 @@ detect_arch() { OS="macos" ;; *) - error "Unsupported operating system: '$OS_TYPE'. You may use Hive Router using Docker by following the instructions at https://github.com/graphql-hive/router#docker" + print_error "Unsupported operating system: '$OS_TYPE'. You may use Hive Router using Docker by following the instructions at https://the-guild.dev/graphql/hive/docs/router/getting-started" + exit 1 ;; esac @@ -69,19 +79,20 @@ detect_arch() { ARCH="arm64" ;; *) - error "Unsupported architecture: '$ARCH'. You may use Hive Router using Docker by following the instructions at https://github.com/graphql-hive/router#docker" + print_error "Unsupported architecture: '$ARCH'. You may use Hive Router using Docker by following the instructions at https://the-guild.dev/graphql/hive/docs/router/getting-started" + exit 1 ;; esac - info "System detected: ${OS}-${ARCH}" + print_success "Detected ${OS}-${ARCH}" } get_version() { + print_step "Resolving version..." + if [ -n "$1" ]; then VERSION="$1" - info "Installing specified version: $VERSION" + print_success "Using specified version: $VERSION" else - info "No version specified. Fetching the latest version from crates.io index..." - # Uses index.crates.io which is more reliable than the rate-limited Crates API # Path structure: /first_two_chars/next_two_chars/full_name CRATE_FIRST_TWO=$(echo "${CARGO_PKG_NAME}" | cut -c1-2) @@ -93,44 +104,65 @@ get_version() { VERSION="v$(curl -sL "$INDEX_URL" | tail -1 | grep -o '"vers":"[^"]*"' | sed 's/"vers":"\([^"]*\)"/\1/')" if [ -z "$VERSION" ] || [ "$VERSION" = "v" ]; then - error "Could not determine the latest version from crates.io index. Please check the crate name or specify a version manually." + print_error "Could not determine the latest version from crates.io index. Please check the crate name or specify a version manually." + exit 1 fi - info "Latest version found: $VERSION" + print_success "Latest version found: $VERSION" fi } -download_and_install() { +download_binary() { ASSET_NAME="${BINARY_NAME}_${OS}_${ARCH}" DOWNLOAD_URL="https://github.com/${GH_OWNER}/${GH_REPO}/releases/download/hive-router%2F${VERSION}/${ASSET_NAME}" - info "Downloading binary from: ${DOWNLOAD_URL}" + print_step "Downloading Hive Router binary..." + printf '%b\n' "${DIM} Download URL: ${DOWNLOAD_URL}${NC}" # -f: Fail silently on server errors (like 404) # -L: Follow redirects - if ! curl -fL -o "./${BINARY_NAME}" "${DOWNLOAD_URL}"; then - error "Download failed. Please check if the version '$VERSION' and architecture '$OS_TYPE/$ARCH' exist for this release." + if ! curl -fL --progress-bar -o "./${BINARY_NAME}" "${DOWNLOAD_URL}"; then + print_error "Download failed. Please check if the version '$VERSION' and architecture '$OS_TYPE/$ARCH' exist for this release." + exit 1 fi - chmod +x "./${BINARY_NAME}" + if [ -t 1 ]; then + printf '%b' "\033[1A\033[2K\r" + printf '%b' "\033[1A\033[2K\r" + fi - info "✅ Successfully installed '${BINARY_NAME}' to the current directory." - info "You can now run it with: ./${BINARY_NAME}" - info "" - info "Getting started instructions: https://github.com/graphql-hive/router#try-it-out" - info "Config file reference: https://github.com/graphql-hive/router/blob/main/docs/README.md" + print_success "Binary downloaded" +} + +install_binary() { + print_step "Finalizing installation..." + chmod +x "./${BINARY_NAME}" + print_success "Binary made executable" } main() { + printf '\n' + printf '%b\n' "${BOLD}Hive Router Installer${NC}" + printf '\n' + check_tool "curl" check_tool "grep" check_tool "sed" check_tool "uname" - banner - detect_arch get_version "$1" - download_and_install + download_binary + install_binary + + printf '\n' + printf '%b\n' "${BOLD}${GREEN}✨ Installation Complete!${NC}" + printf '\n' + printf '%b\n' "${BOLD}Start using Hive Router:${NC}" + printf '%b\n' " ${BOLD}${BINARY_NAME}${NC}" + printf '\n' + printf '%b\n' "${BOLD}Documentation:${NC}" + printf '%b\n' " https://the-guild.dev/graphql/hive/docs/router" + printf '\n' } main "$@" From fc1b7281920ed7a681d3db3a965a1cd6b23d33a1 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 12 Apr 2026 10:34:42 +0200 Subject: [PATCH 16/76] fix(deps): update ntex and its code, retry e2e tests before failing ci, update npm lockfile, deflakefy tests (#899) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .github/workflows/ci.yaml | 7 +- Cargo.lock | 148 +- Cargo.toml | 2 +- e2e/src/hive_cdn_supergraph.rs | 37 +- e2e/src/http.rs | 22 +- e2e/src/probes.rs | 8 +- e2e/src/supergraph.rs | 137 +- e2e/src/telemetry/metrics.rs | 28 +- e2e/src/telemetry/tracing/hive.rs | 50 +- e2e/src/telemetry/tracing/otlp_attributes.rs | 12 +- e2e/src/telemetry/tracing/otlp_basic.rs | 73 +- e2e/src/telemetry/tracing/otlp_propagation.rs | 19 +- e2e/src/telemetry/tracing/otlp_sampling.rs | 26 +- e2e/src/testkit/mod.rs | 55 +- e2e/src/testkit/otel.rs | 54 +- lib/internal/src/background_tasks/mod.rs | 17 +- package-lock.json | 2416 ++++++++++++++--- 17 files changed, 2423 insertions(+), 688 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dab46ced5..57bd1073f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,11 +38,14 @@ jobs: - name: setup rust uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 - name: test e2e - run: cargo test_e2e - timeout-minutes: 10 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 env: RUST_BACKTRACE: full RUST_LOG: debug + with: + timeout_minutes: 10 + max_attempts: 3 + command: cargo test_e2e plugin_examples: name: test / plugin examples diff --git a/Cargo.lock b/Cargo.lock index 4761c81a0..860693372 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,15 @@ dependencies = [ "regex", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -472,6 +481,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base16" version = "0.2.1" @@ -1404,6 +1428,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.116", + "unicode-xid", +] + [[package]] name = "diff" version = "0.1.13" @@ -2088,6 +2134,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "glob" version = "0.3.3" @@ -3536,12 +3588,13 @@ dependencies = [ [[package]] name = "ntex" -version = "3.4.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a4416847c6b284a2ce38952e0883cee3e20599b201d7b6a2d92afafb0fbc3e" +checksum = "f4735978b410d8496a1d89bac416af3277f6d36e9ae56d1e3977b96b81ab8048" dependencies = [ "base64", "bitflags 2.11.0", + "derive_more", "encoding_rs", "env_logger", "httparse", @@ -3552,6 +3605,7 @@ dependencies = [ "ntex-bytes", "ntex-codec", "ntex-dispatcher", + "ntex-error", "ntex-h2", "ntex-http", "ntex-io", @@ -3577,9 +3631,9 @@ dependencies = [ [[package]] name = "ntex-bytes" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733600524409c09fdcbe17da91e832dd03f7690de727d844341ece58e867b3d6" +checksum = "11f03e42e23f0ab33b86f4af78f19c39a0cc253b20c073e5e9b92408ba0e91ff" dependencies = [ "bytes", "serde", @@ -3609,11 +3663,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ntex-error" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba4b4124a2b50218182d3420428ccf56ee95c4f28a15efba1be9e97cc271d9a" +dependencies = [ + "backtrace", + "foldhash 0.2.0", + "ntex-bytes", + "thiserror 2.0.18", +] + [[package]] name = "ntex-h2" -version = "3.7.1" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff063201f811d97557a5053dc75cbbd417adf4234e57ed8ec11fb05e3f2fdc3d" +checksum = "2924795c85efb587dffe38b2d7f9295d8dfacc5cb6d00270dd96d7c34d0eb0eb" dependencies = [ "bitflags 2.11.0", "foldhash 0.2.0", @@ -3622,6 +3688,7 @@ dependencies = [ "ntex-bytes", "ntex-codec", "ntex-dispatcher", + "ntex-error", "ntex-http", "ntex-io", "ntex-net", @@ -3634,9 +3701,9 @@ dependencies = [ [[package]] name = "ntex-http" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb1f0643064d8ab21b70e0c9660b513ad3253744700c874668e6d7693ab3a39" +checksum = "f7a02a55ca3286342030ba369d4176e280989e97958455963efce846e8abdca6" dependencies = [ "foldhash 0.2.0", "futures-core", @@ -3650,9 +3717,9 @@ dependencies = [ [[package]] name = "ntex-io" -version = "3.9.0" +version = "3.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de281bab9b3f6330a30b620e1b7cbca4bb92de4c4abe634c4f08c472faaa4e01" +checksum = "e6c1d3d9a67a9abcad8981967bb1f3c59ec2c34e442771d16ed33acf663f0361" dependencies = [ "bitflags 2.11.0", "log", @@ -3677,26 +3744,27 @@ dependencies = [ [[package]] name = "ntex-macros" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1d0933027d63b26d4e841e91a572a796824b522f5c077feba1ab2c7cbd3d78" +checksum = "51138717dfe591b9b4063bf167ddcdc6fa8e3552157316f29f12c321493e3710" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.116", ] [[package]] name = "ntex-net" -version = "3.7.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28bdb6cd6694011070753d228ba76f7683a0dd78d30ea0d0354702296f5f78bf" +checksum = "83c7c631404d704766913028124c60712457b1157923d85156aaf49fbd2551e9" dependencies = [ "bitflags 2.11.0", "cfg-if", "libc", "log", "ntex-bytes", + "ntex-error", "ntex-http", "ntex-io", "ntex-io-uring", @@ -3740,9 +3808,9 @@ dependencies = [ [[package]] name = "ntex-rt" -version = "3.7.1" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eacccab337bc61d01092d2a3fbf27f1c1c8696fbb691ffcd5e5d6c355d358511" +checksum = "5a0f8d34b0f586e276ffdd34a4265db243672c6fe7e90b100e573319a7d3eddf" dependencies = [ "async-channel", "async-task", @@ -3751,8 +3819,9 @@ dependencies = [ "foldhash 0.2.0", "futures-timer", "log", + "ntex-error", "ntex-service", - "oneshot", + "oneshot 0.2.1", "scoped-tls", "swap-buffer-queue", "tokio", @@ -3760,9 +3829,9 @@ dependencies = [ [[package]] name = "ntex-server" -version = "3.8.0" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1235b8aaa4e01a6b54d30d37065b6f57c4ec82cdae72b51e56423d13e45292a" +checksum = "85ac36e4b11c0cf0ae53fea574a1ab37e0c54331eb78f0b5a5e02cf6604a1f04" dependencies = [ "async-channel", "atomic-waker", @@ -3775,7 +3844,7 @@ dependencies = [ "ntex-rt", "ntex-service", "ntex-util", - "oneshot", + "oneshot 0.1.13", "signal-hook", "socket2", "uuid", @@ -3783,9 +3852,9 @@ dependencies = [ [[package]] name = "ntex-service" -version = "4.5.0" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc9eb453f1ce1e1561edd50892249fe8ce35e5740f347ff09e53c079ba7784e" +checksum = "8f69442f89962c8c76a76f563c8d5ec0585fe645770d62a42e551f91ccc62278" dependencies = [ "foldhash 0.2.0", "log", @@ -3794,12 +3863,13 @@ dependencies = [ [[package]] name = "ntex-tls" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df010ac89eab9dc4ab3ec7507cea08788f964c476b6c0c50de5a88869ac7dc6b" +checksum = "2e987231c62973660d743d599ddc26e82ed4f2f54050ce3e0f6d4bf8d3586106" dependencies = [ "log", "ntex-bytes", + "ntex-error", "ntex-io", "ntex-net", "ntex-service", @@ -3808,9 +3878,9 @@ dependencies = [ [[package]] name = "ntex-util" -version = "3.4.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092a46f60e64950809011a95d5ec6f634394b80d984bc44d8057b89a6a78c1f1" +checksum = "1d799e658d04ad8be6d750b09a82fa01ad11dde264d9ec40fac873f835e87e85" dependencies = [ "bitflags 2.11.0", "foldhash 0.2.0", @@ -3818,6 +3888,7 @@ dependencies = [ "futures-timer", "log", "ntex-bytes", + "ntex-error", "ntex-rt", "ntex-service", "pin-project-lite", @@ -3961,6 +4032,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "octseq" version = "0.5.2" @@ -4002,6 +4082,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + [[package]] name = "onig" version = "6.5.1" @@ -5419,6 +5505,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -6031,7 +6123,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.116", diff --git a/Cargo.toml b/Cargo.toml index 4b6edc678..7c6a3addd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ tokio = { version = "1.47.1", features = ["full"] } tokio-util = { version = "0.7.16" } rand = "0.10.0" jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } -ntex = { version = "3.4.0", features = ["tokio"] } +ntex = { version = "3.7.2", features = ["tokio"] } tonic = { version = "0.14.2", features = ["tls-aws-lc"] } reqwest = { version = "0.12.23", default-features = false, features = ["http2", "rustls-tls"] } reqwest-retry = "0.8.0" diff --git a/e2e/src/hive_cdn_supergraph.rs b/e2e/src/hive_cdn_supergraph.rs index c56b366bb..7f8b67f5a 100644 --- a/e2e/src/hive_cdn_supergraph.rs +++ b/e2e/src/hive_cdn_supergraph.rs @@ -8,7 +8,9 @@ mod hive_cdn_supergraph_e2e_tests { use sonic_rs::{JsonContainerTrait, JsonValueTrait}; use tokio::time::sleep; - use crate::testkit::{ClientResponseExt, EnvVarsGuard, TestRouter, TestSubgraphs}; + use crate::testkit::{ + wait_until_mock_matched, ClientResponseExt, EnvVarsGuard, TestRouter, TestSubgraphs, + }; #[ntex::test] async fn should_load_supergraph_from_endpoint() { @@ -182,21 +184,12 @@ mod hive_cdn_supergraph_e2e_tests { let host = server.host_with_port(); let mock1 = server - .mock("GET", "/supergraph") - .expect(1) - .match_header("x-hive-cdn-key", "dummy_key") - .with_status(200) - .with_header("content-type", "text/plain") - .with_body("type Query { dummy: String }") - .create(); - - let mock2 = server .mock("GET", "/supergraph") .expect_at_least(1) .match_header("x-hive-cdn-key", "dummy_key") .with_status(200) .with_header("content-type", "text/plain") - .with_body(include_str!("../supergraph.graphql")) + .with_body("type Query { dummy: String }") .create(); let router = TestRouter::builder() @@ -206,15 +199,13 @@ mod hive_cdn_supergraph_e2e_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 800ms + poll_interval: 500ms "#, )) .build() .start() .await; - mock1.assert(); - let res = router .send_graphql_request("{ __schema { types { name } } }", None, None) .await; @@ -233,9 +224,21 @@ mod hive_cdn_supergraph_e2e_tests { .unwrap(); assert_eq!(types_arr.len(), 14); - // Now wait for the schema to be reloaded and updated - sleep(Duration::from_millis(900)).await; - mock2.assert(); + // Remove first mock and register the new supergraph mock + mock1.remove(); + let mock2 = server + .mock("GET", "/supergraph") + .expect_at_least(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body(include_str!("../supergraph.graphql")) + .create(); + + // Wait for the poller to pick up the new supergraph + wait_until_mock_matched(&mock2) + .await + .expect("Expected mock2 to be matched"); let res = router .send_graphql_request("{ __schema { types { name } } }", None, None) diff --git a/e2e/src/http.rs b/e2e/src/http.rs index 069abc9a7..cce589075 100644 --- a/e2e/src/http.rs +++ b/e2e/src/http.rs @@ -7,26 +7,12 @@ mod http_tests { use futures::{stream::FuturesUnordered, StreamExt}; use hive_router::pipeline::execution::EXPOSE_QUERY_PLAN_HEADER; - use mockito::Mock; use ntex::time; use sonic_rs::JsonValueTrait; - use crate::testkit::{some_header_map, ClientResponseExt, TestRouter, TestSubgraphs}; - - async fn wait_until_mock_matched(mock: &Mock, timeout: Duration) -> Result<(), String> { - let started = Instant::now(); - loop { - if mock.matched_async().await { - return Ok(()); - } - - time::sleep(Duration::from_millis(10)).await; - - if started.elapsed() > timeout { - return Err(format!("timeout after {:?}", started.elapsed())); - } - } - } + use crate::testkit::{ + some_header_map, wait_until_mock_matched, ClientResponseExt, TestRouter, TestSubgraphs, + }; #[ntex::test] async fn should_allow_to_customize_graphql_endpoint() { @@ -511,7 +497,7 @@ mod http_tests { .with_body(changed_supergraph) .create(); - wait_until_mock_matched(&mock_changed, Duration::from_secs(2)) + wait_until_mock_matched(&mock_changed) .await .expect("expected schema reload poll to fetch changed supergraph"); diff --git a/e2e/src/probes.rs b/e2e/src/probes.rs index b3bc7bf62..a7749ff28 100644 --- a/e2e/src/probes.rs +++ b/e2e/src/probes.rs @@ -40,11 +40,15 @@ mod probes_e2e_tests { // At the point, if supergraph is not loaded yet, health should be OK 200 let res = router.serv().post("/health").send().await.unwrap(); - assert!(res.status().is_success()); + assert_eq!(res.status(), 200); // And readiness should be 500 with server error let res = router.serv().post("/readiness").send().await.unwrap(); - assert!(res.status().is_server_error()); + assert!( + res.status().is_server_error(), + "Expected response status to be 5XX, but got {}", + res.status() + ); router.wait_for_ready(None).await; diff --git a/e2e/src/supergraph.rs b/e2e/src/supergraph.rs index 56e26ec76..16435f5c1 100644 --- a/e2e/src/supergraph.rs +++ b/e2e/src/supergraph.rs @@ -1,32 +1,22 @@ #[cfg(test)] mod supergraph_e2e_tests { - use std::time::{Duration, Instant}; + use std::time::Duration; use hive_router::invoke_shutdown_hooks; - use mockito::Mock; - use ntex::time; use sonic_rs::JsonValueTrait; - use crate::testkit::{ClientResponseExt, EnvVarsGuard, TestRouter, TestSubgraphs}; + use crate::testkit::{wait_until_mock_matched, ClientResponseExt, TestRouter, TestSubgraphs}; #[ntex::test] async fn should_clear_internal_caches_when_supergraph_changes() { let mut server = mockito::Server::new_async().await; let host = server.host_with_port(); let mock1 = server - .mock("GET", "/supergraph") - .expect(1) - .with_status(200) - .with_header("content-type", "text/plain") - .with_body("type Query { dummy: String }") - .create(); - - let mock2 = server .mock("GET", "/supergraph") .expect_at_least(1) .with_status(200) .with_header("content-type", "text/plain") - .with_body("type Query { dummyNew: NewType } type NewType { id: ID! }") + .with_body("type Query { dummy: String }") .create(); let router = TestRouter::builder() @@ -43,8 +33,6 @@ mod supergraph_e2e_tests { .start() .await; - mock1.assert(); - assert_eq!(router.schema_state().plan_cache.entry_count(), 0); assert_eq!(router.schema_state().normalize_cache.entry_count(), 0); @@ -63,13 +51,27 @@ mod supergraph_e2e_tests { router.schema_state().plan_cache.run_pending_tasks().await; invoke_shutdown_hooks(router.shared_state()).await; + ntex::time::sleep(Duration::from_millis(100)).await; + // Now it should have the record assert_eq!(router.schema_state().plan_cache.entry_count(), 1); assert_eq!(router.schema_state().normalize_cache.entry_count(), 1); - // Now let's wait a bit and let the service re-load and get the new supergraph - time::sleep(Duration::from_millis(600)).await; - mock2.assert(); + // Remove the first mock and register the new supergraph so the poller picks it up + mock1.remove(); + let mock2 = server + .mock("GET", "/supergraph") + .expect_at_least(1) + .with_status(200) + .with_header("content-type", "text/plain") + .with_body("type Query { dummyNew: NewType } type NewType { id: ID! }") + .create(); + + // Wait for the poller to pick up the new supergraph + wait_until_mock_matched(&mock2) + .await + .expect("Expected mock2 to be matched"); + router .schema_state() .normalize_cache @@ -95,13 +97,12 @@ mod supergraph_e2e_tests { /// 6. New request should use the new supergraph and new state, so running the same query should fail now with a validation error. #[ntex::test] async fn should_not_change_supergraph_for_in_flight_requests() { - let _delay_guard = EnvVarsGuard::new() - .set("SUBGRAPH_DELAY_MS", "500") - .apply() + let subgraphs = TestSubgraphs::builder() + .with_delay(Duration::from_millis(500)) + .build() + .start() .await; - let subgraphs = TestSubgraphs::builder().build().start().await; - let mut server = mockito::Server::new_async().await; let host = server.host_with_port(); @@ -109,14 +110,44 @@ mod supergraph_e2e_tests { let supergraph1_sdl = subgraphs.supergraph(include_str!("../supergraph.graphql")); let mock1 = server .mock("GET", "/supergraph") - .expect(1) + .expect_at_least(1) .with_status(200) .with_header("content-type", "text/plain") .with_header("etag", "1") .with_body(supergraph1_sdl) .create(); - // Second supergraph + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: hive + endpoint: http://{host}/supergraph + key: dummy_key + poll_interval: 100ms + "#, + )) + .build() + .start() + .await; + + mock1.assert(); + + let res = router + .send_graphql_request("{ users { id name reviews { id body } } }", None, None) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let body_json = res.json_body().await; + + assert!(body_json["data"].is_object()); + assert!(body_json["errors"].is_null()); + + mock1.remove(); + + // Second supergraph - only registered after the first request completes so the poller + // cannot swap the schema while the first request is in flight let supergraph2_sdl = subgraphs.supergraph( r#"schema @link(url: "https://specs.apollo.dev/link/v1.0") @@ -204,34 +235,9 @@ mod supergraph_e2e_tests { .with_body(supergraph2_sdl) .create(); - let router = TestRouter::builder() - .inline_config(format!( - r#" - supergraph: - source: hive - endpoint: http://{host}/supergraph - key: dummy_key - poll_interval: 300ms - "#, - )) - .build() - .start() - .await; - - mock1.assert(); - - let res = router - .send_graphql_request("{ users { id name reviews { id body } } }", None, None) - .await; - - assert!(res.status().is_success(), "Expected 200 OK"); - - let body_json = res.json_body().await; - - assert!(body_json["data"].is_object()); - assert!(body_json["errors"].is_null()); - - mock2.assert(); + wait_until_mock_matched(&mock2) + .await + .expect("Expected mock2 to be matched"); let res_new_supergraph = router .send_graphql_request("{ users { id name reviews { id body } } }", None, None) @@ -264,7 +270,7 @@ mod supergraph_e2e_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 50ms + poll_interval: 200ms "#, )) .build() @@ -304,13 +310,10 @@ mod supergraph_e2e_tests { .with_status(404) .create(); - wait_until_mock_matched(&mock_404, Duration::from_millis(500)) + wait_until_mock_matched(&mock_404) .await .expect("Expected to match 404 mock"); - // Give it some time to process it - time::sleep(Duration::from_millis(50)).await; - // Router should still be using the initial supergraph let res = router .send_graphql_request( @@ -344,12 +347,10 @@ mod supergraph_e2e_tests { .with_body("type Query { updated: String }") .create(); - wait_until_mock_matched(&mock_final, Duration::from_millis(120)) + wait_until_mock_matched(&mock_final) .await .expect("Expected to match final mock"); mock_final.assert(); - // Give it some time to process it - time::sleep(Duration::from_millis(200)).await; // Check if final supergraph is working let res = router @@ -374,18 +375,4 @@ mod supergraph_e2e_tests { } "#); } - - async fn wait_until_mock_matched(mock: &Mock, timeout: Duration) -> Result<(), String> { - let now = Instant::now(); - loop { - if mock.matched_async().await { - return Ok(()); - } - time::sleep(Duration::from_millis(10)).await; - - if now.elapsed() > timeout { - return Err(format!("timeout after {:?}", now.elapsed())); - } - } - } } diff --git a/e2e/src/telemetry/metrics.rs b/e2e/src/telemetry/metrics.rs index 31311d19b..ea03997ca 100644 --- a/e2e/src/telemetry/metrics.rs +++ b/e2e/src/telemetry/metrics.rs @@ -14,7 +14,7 @@ use hive_router::{ use hive_router_internal::telemetry::metrics::catalog::{labels, labels_for, names, values}; async fn wait_for_metrics_export() { - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(500)).await; } fn assert_counter_eq( @@ -90,7 +90,7 @@ async fn test_otlp_http_metrics_export_with_graphql_request() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -163,7 +163,7 @@ async fn test_otlp_cache_size_metrics_exported_as_gauges() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -233,7 +233,7 @@ async fn test_otlp_http_server_semconv_metrics_for_graphql_handler() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -304,7 +304,7 @@ async fn test_otlp_http_client_semconv_metrics_for_subgraph_request() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s temporality: cumulative "#, supergraph_path.to_str().unwrap(), @@ -370,7 +370,7 @@ async fn test_otlp_all_metrics_path_attribute_names() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -467,7 +467,7 @@ async fn test_otlp_all_metrics_happy_path_attribute_names() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -567,7 +567,7 @@ async fn test_otlp_metric_can_be_disabled_via_instrumentation_config() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s instrumentation: instruments: http.server.request.duration: false @@ -628,7 +628,7 @@ async fn test_otlp_metric_attribute_can_be_opted_out() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s instrumentation: instruments: http.server.request.duration: @@ -700,7 +700,7 @@ async fn test_otlp_metric_attribute_true_override_is_noop() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s instrumentation: instruments: http.server.request.duration: @@ -771,7 +771,7 @@ async fn test_otlp_graphql_errors_total_for_parsing_error() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -861,7 +861,7 @@ async fn test_otlp_graphql_errors_total_uses_post_plugin_error_codes() { endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s plugins: test_graphql_error_mapping: @@ -943,7 +943,7 @@ async fn test_otlp_http_server_bad_request_sets_graphql_status_and_error_type() endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint @@ -1005,7 +1005,7 @@ async fn test_otlp_http_client_transport_failure_sets_graphql_status_and_error_t endpoint: {} protocol: http interval: 30ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, supergraph_path.to_str().unwrap(), otlp_endpoint diff --git a/e2e/src/telemetry/tracing/hive.rs b/e2e/src/telemetry/tracing/hive.rs index cb715cedb..ddeac0ad1 100644 --- a/e2e/src/telemetry/tracing/hive.rs +++ b/e2e/src/telemetry/tracing/hive.rs @@ -34,7 +34,7 @@ async fn test_hive_http_export() { enabled: true batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s usage_reporting: enabled: false "#, @@ -72,28 +72,48 @@ async fn test_hive_http_export() { assert_eq!(authorization_header, Some(format!("Bearer {}", token))); assert_eq!(target_ref_header, Some(target)); - let all_traces = otlp_collector.traces().await; - let trace = all_traces.first().expect("Failed to get first trace"); - // Hive Console requires to drop the http.server span // and make the graphql.operation the root span. + // Wait for all expected spans first, then snapshot traces for the http.server absence check. + let operation_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.operation") + .await; + let parse_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.parse") + .await; + let validate_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.validate") + .await; + let variable_coercion_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.variable_coercion") + .await; + let normalization_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.normalize") + .await; + let plan_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.plan") + .await; + let execution_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.execute") + .await; + let subgraph_operation_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.subgraph.operation") + .await; + let http_inflight_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.inflight") + .await; + let http_client_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.client") + .await; + + let all_traces = otlp_collector.traces().await; + let trace = all_traces.first().expect("Failed to get first trace"); assert_eq!( trace.has_span_by_hive_kind("http.server"), false, "Unexpected http.server spans" ); - let operation_span = trace.span_by_hive_kind_one("graphql.operation"); - let parse_span = trace.span_by_hive_kind_one("graphql.parse"); - let validate_span = trace.span_by_hive_kind_one("graphql.validate"); - let variable_coercion_span = trace.span_by_hive_kind_one("graphql.variable_coercion"); - let normalization_span = trace.span_by_hive_kind_one("graphql.normalize"); - let plan_span = trace.span_by_hive_kind_one("graphql.plan"); - let execution_span = trace.span_by_hive_kind_one("graphql.execute"); - let subgraph_operation_span = trace.span_by_hive_kind_one("graphql.subgraph.operation"); - let http_inflight_span = trace.span_by_hive_kind_one("http.inflight"); - let http_client_span = trace.span_by_hive_kind_one("http.client"); - insta::assert_snapshot!( operation_span, @r" diff --git a/e2e/src/telemetry/tracing/otlp_attributes.rs b/e2e/src/telemetry/tracing/otlp_attributes.rs index f519e3a54..74aac22fc 100644 --- a/e2e/src/telemetry/tracing/otlp_attributes.rs +++ b/e2e/src/telemetry/tracing/otlp_attributes.rs @@ -35,7 +35,7 @@ async fn test_deprecated_span_attributes() { protocol: grpc batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -165,7 +165,7 @@ async fn test_spec_and_deprecated_span_attributes() { protocol: grpc batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -318,7 +318,7 @@ async fn test_default_client_identification() { custom-header: custom-value batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -398,7 +398,7 @@ async fn test_custom_client_identification() { custom-header: custom-value batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -471,7 +471,7 @@ async fn test_default_resource_attributes() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -542,7 +542,7 @@ async fn test_custom_resource_attributes() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) diff --git a/e2e/src/telemetry/tracing/otlp_basic.rs b/e2e/src/telemetry/tracing/otlp_basic.rs index cc1931837..f936e3d69 100644 --- a/e2e/src/telemetry/tracing/otlp_basic.rs +++ b/e2e/src/telemetry/tracing/otlp_basic.rs @@ -35,7 +35,7 @@ async fn test_otlp_http_export_with_graphql_request() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -50,20 +50,39 @@ async fn test_otlp_http_export_with_graphql_request() { assert!(res.status().is_success()); // Wait for exports to be sent - let all_traces = otlp_collector.wait_for_traces_count(1).await; - let trace = all_traces.first().unwrap(); - - let http_server_span = trace.span_by_hive_kind_one("http.server"); - let operation_span = trace.span_by_hive_kind_one("graphql.operation"); - let parse_span = trace.span_by_hive_kind_one("graphql.parse"); - let validate_span = trace.span_by_hive_kind_one("graphql.validate"); - let variable_coercion_span = trace.span_by_hive_kind_one("graphql.variable_coercion"); - let normalization_span = trace.span_by_hive_kind_one("graphql.normalize"); - let plan_span = trace.span_by_hive_kind_one("graphql.plan"); - let execution_span = trace.span_by_hive_kind_one("graphql.execute"); - let subgraph_operation_span = trace.span_by_hive_kind_one("graphql.subgraph.operation"); - let http_inflight_span = trace.span_by_hive_kind_one("http.inflight"); - let http_client_span = trace.span_by_hive_kind_one("http.client"); + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + let operation_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.operation") + .await; + let parse_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.parse") + .await; + let validate_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.validate") + .await; + let variable_coercion_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.variable_coercion") + .await; + let normalization_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.normalize") + .await; + let plan_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.plan") + .await; + let execution_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.execute") + .await; + let subgraph_operation_span = otlp_collector + .wait_for_span_by_hive_kind_one("graphql.subgraph.operation") + .await; + let http_inflight_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.inflight") + .await; + let http_client_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.client") + .await; insta::assert_snapshot!( http_server_span, @@ -273,7 +292,7 @@ async fn test_otlp_grpc_export_with_graphql_request() { protocol: grpc batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -531,14 +550,14 @@ async fn test_otlp_disabled() { enabled: false batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s - kind: otlp endpoint: {otlp_http_endpoint} enabled: false protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -594,7 +613,7 @@ async fn test_otlp_http_headers() { custom-header: custom-value batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -658,7 +677,7 @@ async fn test_otlp_grpc_metadata() { custom-header: custom-value batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -720,7 +739,7 @@ async fn test_otlp_cache_hits() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -734,7 +753,9 @@ async fn test_otlp_cache_hits() { assert!(res.status().is_success()); // Wait for exports to be sent - otlp_collector.wait_for_traces_count(1).await; + otlp_collector + .wait_for_traces_with_span(1, "graphql.validate") + .await; // Should hit the caches let res = router @@ -742,8 +763,10 @@ async fn test_otlp_cache_hits() { .await; assert!(res.status().is_success()); - // Wait for exports to be sent - let all_traces = otlp_collector.wait_for_traces_count(2).await; + // Wait for both traces to have all expected spans + let all_traces = otlp_collector + .wait_for_traces_with_span(2, "graphql.validate") + .await; let first_trace = all_traces.first().unwrap(); let second_trace = all_traces.get(1).unwrap(); @@ -815,7 +838,7 @@ async fn test_otlp_no_trace_id_collision() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) diff --git a/e2e/src/telemetry/tracing/otlp_propagation.rs b/e2e/src/telemetry/tracing/otlp_propagation.rs index 4d06dcdd4..2a3f9aa76 100644 --- a/e2e/src/telemetry/tracing/otlp_propagation.rs +++ b/e2e/src/telemetry/tracing/otlp_propagation.rs @@ -33,7 +33,7 @@ async fn test_otlp_http_trace_context_propagation() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -147,7 +147,7 @@ async fn test_otlp_http_baggage_propagation() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -239,7 +239,7 @@ async fn test_otlp_http_b3_propagation() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -359,7 +359,7 @@ async fn test_otlp_http_jaeger_propagation() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -381,11 +381,12 @@ async fn test_otlp_http_jaeger_propagation() { assert!(res.status().is_success()); // Wait for exports to be sent - let all_traces = otlp_collector.wait_for_traces_count(1).await; - let trace = all_traces.first().unwrap(); - - let http_server_span = trace.span_by_hive_kind_one("http.server"); - let http_client_span = trace.span_by_hive_kind_one("http.client"); + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + let http_client_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.client") + .await; // Verify that http.server has corrent parent span, // the one from upstream traceparent diff --git a/e2e/src/telemetry/tracing/otlp_sampling.rs b/e2e/src/telemetry/tracing/otlp_sampling.rs index a087030fb..245117de6 100644 --- a/e2e/src/telemetry/tracing/otlp_sampling.rs +++ b/e2e/src/telemetry/tracing/otlp_sampling.rs @@ -38,7 +38,7 @@ async fn test_otlp_parent_based_sampler() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) @@ -95,24 +95,10 @@ async fn test_otlp_parent_based_sampler() { assert!(res.status().is_success()); - // Verify trace was collected - let all_traces = otlp_collector.wait_for_traces_count(1).await; - let trace = all_traces.first().unwrap(); - - assert_eq!( - trace.id, upstream_trace_id, - "Trace should have correct trace_id" - ); - - // Verify we have spans in the trace - let spans = &trace.spans; - assert!( - !spans.is_empty(), - "Trace should contain spans when parent is sampled" - ); - - // Find http.server span by hive.kind attribute - let http_server_span = trace.span_by_hive_kind_one("http.server"); + // Verify trace was collected, and find the http.server span + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; assert_eq!( http_server_span.trace_id, upstream_trace_id, @@ -151,7 +137,7 @@ async fn test_otlp_zero_sample_rate() { protocol: http batch_processor: scheduled_delay: 50ms - max_export_timeout: 50ms + max_export_timeout: 2s "#, )) .with_subgraphs(&subgraphs) diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index 43b33d15e..d74727052 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -5,6 +5,7 @@ use bytes::Bytes; use dashmap::DashMap; use hive_router_plan_executor::plugin_trait::RouterPlugin; use lazy_static::lazy_static; +use mockito::Mock; use ntex::{ client::ClientResponse, web::{self, test}, @@ -12,13 +13,19 @@ use ntex::{ use reqwest::header::{ACCEPT, CONTENT_TYPE}; use sonic_rs::json; use std::{ - any::Any, future::Future, marker::PhantomData, net::SocketAddr, path::PathBuf, sync::Arc, - time::Duration, + any::Any, + future::Future, + marker::PhantomData, + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, }; use tempfile::{NamedTempFile, TempPath}; use tokio::{ net::TcpListener, sync::{oneshot, Semaphore}, + time, }; use tracing::{info, warn}; @@ -234,11 +241,15 @@ type OnRequest = dyn Fn(RequestLike) -> Option + Send + Sync; pub struct TestSubgraphsBuilder { on_request: Option>, + delay: Option, } impl TestSubgraphsBuilder { pub fn new() -> Self { - Self { on_request: None } + Self { + on_request: None, + delay: None, + } } #[allow(unused)] @@ -250,9 +261,20 @@ impl TestSubgraphsBuilder { self } + /// Adds a cooperative async delay to every subgraph request. + /// Unlike `with_on_request` with `std::thread::sleep`, this yields + /// back to the tokio runtime, allowing other tasks (like schema + /// pollers) to make progress during the delay. + #[allow(unused)] + pub fn with_delay(mut self, delay: Duration) -> Self { + self.delay = Some(delay); + self + } + pub fn build(self) -> TestSubgraphs { TestSubgraphs { on_request: self.on_request, + delay: self.delay, handle: None, _state: PhantomData, } @@ -273,6 +295,7 @@ struct TestSubgraphsHandle { pub struct TestSubgraphs { on_request: Option>, + delay: Option, handle: Option, _state: PhantomData, } @@ -369,6 +392,14 @@ impl TestSubgraphs { handle_on_request, )); } + if let Some(delay) = self.delay { + app = app.layer(axum::middleware::from_fn( + move |req, next: axum::middleware::Next| async move { + tokio::time::sleep(delay).await; + next.run(req).await + }, + )); + } let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); tokio::spawn(async move { @@ -382,6 +413,7 @@ impl TestSubgraphs { TestSubgraphs { on_request: self.on_request, + delay: self.delay, handle: Some(TestSubgraphsHandle { shutdown_tx: Some(shutdown_tx), addr, @@ -838,3 +870,20 @@ impl ClientResponseExt for ClientResponse { .expect("failed to pretty print JSON body") } } + +pub async fn wait_until_mock_matched(mock: &Mock) -> Result<(), String> { + let now = Instant::now(); + let timeout = Duration::from_secs(5); // always a sane default + loop { + if mock.matched_async().await { + return Ok(()); + } + + // anything less will congest the router, keep the interval chill + time::sleep(Duration::from_millis(100)).await; + + if now.elapsed() > timeout { + return Err(format!("timeout after {:?}", now.elapsed())); + } + } +} diff --git a/e2e/src/testkit/otel.rs b/e2e/src/testkit/otel.rs index 644e2fd9f..9903519cb 100644 --- a/e2e/src/testkit/otel.rs +++ b/e2e/src/testkit/otel.rs @@ -2,6 +2,7 @@ use hive_router_internal::telemetry::traces::spans::attributes::HIVE_KIND; use opentelemetry_proto::tonic::resource::v1::Resource; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Display; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::{oneshot, Mutex}; @@ -93,19 +94,28 @@ impl<'a> TraceParent<'a> { } pub fn random_trace_id() -> String { - let random: u128 = std::time::SystemTime::now() + // combine a monotonic counter with wall clock nanos to guarantee + // uniqueness even when multiple calls land in the same nanosecond + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_nanos(); - format!("{:032x}", random) + .as_nanos() as u64; + let hi = nanos as u128; + let lo = seq as u128; + format!("{:016x}{:016x}", hi, lo) } pub fn random_span_id() -> String { - let random: u64 = std::time::SystemTime::now() + // same counter trick; span ids only need 8 bytes so xor the two halves + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_micros() as u64; - format!("{:016x}", random) + .as_nanos() as u64; + format!("{:016x}", nanos ^ seq.wrapping_add(1)) } } @@ -704,6 +714,38 @@ impl OtlpCollector { .expect("waiting for traces with count timed out") } + /// Waits for at least `count` traces where each has a span with the given hive.kind. + /// This is more robust than `wait_for_traces_count` because spans may arrive in + /// separate batches after the trace ID is first seen. + pub async fn wait_for_traces_with_span( + &self, + count: usize, + hive_kind: &str, + ) -> Vec { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let traces = self.traces().await; + let matching = traces + .iter() + .filter(|t| t.has_span_by_hive_kind(hive_kind)) + .count(); + + if matching >= count { + return traces; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "waiting for {} traces with hive.kind={} timed out", + count, hive_kind + ) + }) + } + pub async fn wait_for_span_by_hive_kind_one(&self, hive_kind: &str) -> CollectedSpan { tokio::time::timeout(Duration::from_secs(5), async { loop { diff --git a/lib/internal/src/background_tasks/mod.rs b/lib/internal/src/background_tasks/mod.rs index 00ed97c0f..d6d0907e1 100644 --- a/lib/internal/src/background_tasks/mod.rs +++ b/lib/internal/src/background_tasks/mod.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use ntex::rt::Arbiter; +use ntex::rt::{spawn, JoinHandle}; use std::future::Future; pub use tokio_util::sync::CancellationToken; use tracing::info; @@ -12,7 +12,7 @@ pub trait BackgroundTask: Send + Sync { pub struct BackgroundTasksManager { cancellation_token: CancellationToken, - arbiter: Arbiter, + handles: Vec>, } impl Default for BackgroundTasksManager { @@ -25,7 +25,7 @@ impl BackgroundTasksManager { pub fn new() -> Self { Self { cancellation_token: CancellationToken::new(), - arbiter: Arbiter::new(), + handles: Vec::new(), } } @@ -35,22 +35,25 @@ impl BackgroundTasksManager { { info!("registering background task: {}", task.id()); let child_token = self.cancellation_token.clone(); - self.arbiter.spawn(async move { + let handle = spawn(async move { task.run(child_token).await; }); + self.handles.push(handle); } pub fn register_handle(&mut self, f: F) where F: Future + Send + 'static, { - self.arbiter.spawn(f); + self.handles.push(spawn(f)); } - pub fn shutdown(&self) { + pub fn shutdown(&mut self) { info!("shutdown triggered, stopping all background tasks..."); self.cancellation_token.cancel(); - self.arbiter.stop(); + for handle in self.handles.drain(..) { + handle.cancel(); + } info!("all background tasks have been shut down gracefully."); } } diff --git a/package-lock.json b/package-lock.json index ce36ec4d5..a2b3cbd75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,18 +27,14 @@ "typescript": "5.9.3" } }, - "audits/node_modules/graphql-http": { - "version": "1.22.4", + "audits/node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", "dev": true, "license": "MIT", - "workspaces": [ - "implementations/**/*" - ], "engines": { - "node": ">=12" - }, - "peerDependencies": { - "graphql": ">=0.11 <=16" + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "bench": { @@ -54,97 +50,133 @@ "jsonschema2mk": "2.1.1" } }, - "docs/generator/node_modules/explain-json-schema": { - "version": "1.1.1", + "lib/node-addon": { + "name": "@graphql-hive/router-query-planner", + "version": "0.0.17", "license": "MIT", - "dependencies": { - "minimist": "^1.2.5" + "devDependencies": { + "@napi-rs/cli": "3.5.1", + "@types/node": "25.5.0" }, - "bin": { - "explain-json-schema": "cli.js" + "engines": { + "bun": "^1", + "node": ">=20" } }, - "docs/generator/node_modules/handlebars": { - "version": "4.7.8", + "node_modules/@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "dev": true, "license": "MIT", + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/composition": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.13.3.tgz", + "integrity": "sha512-0/vr6vbk1BynEKY6/UV0SZUHBV33p5Ok0y6RhkNnX5BA+ysyOkcyTLPlEHo+ce62nqWx4ml3iOxeKIEHqbKelQ==", + "dev": true, + "license": "Elastic-2.0", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" + "@apollo/federation-internals": "2.13.3", + "@apollo/query-graphs": "2.13.3" }, "engines": { - "node": ">=0.4.7" + "node": ">=18" }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "peerDependencies": { + "graphql": "^16.5.0" } }, - "docs/generator/node_modules/jsonschema2mk": { - "version": "2.1.1", - "license": "MIT", + "node_modules/@apollo/federation-internals": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.13.3.tgz", + "integrity": "sha512-4zHgqznZza5Qx2CZy1qlrh83BB/3Yd8BqD/dmhGzLCvHJQ1LBTyZbWN7xwKs5z5FbxdSPkL5TvPoLm5Rek8/4g==", + "dev": true, + "license": "Elastic-2.0", "dependencies": { - "explain-json-schema": "^1.1.1", - "handlebars": "^4.7.7", - "js-yaml": "^4.1.0", - "minimist": "^1.2.6" + "@types/uuid": "^9.0.0", + "chalk": "^4.1.0", + "js-levenshtein": "^1.1.6", + "uuid": "^9.0.0" }, - "bin": { - "jsonschema2mk": "cli.js" - } - }, - "docs/generator/node_modules/neo-async": { - "version": "2.6.2", - "license": "MIT" - }, - "docs/generator/node_modules/source-map": { - "version": "0.6.1", - "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "peerDependencies": { + "graphql": "^16.5.0" } }, - "docs/generator/node_modules/uglify-js": { - "version": "3.19.3", - "license": "BSD-2-Clause", - "optional": true, + "node_modules/@apollo/federation-internals/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", "bin": { - "uglifyjs": "bin/uglifyjs" + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@apollo/query-graphs": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.13.3.tgz", + "integrity": "sha512-X/bgsIVmamAzd8IFMDo9upO5Oi/uhAZlcBmna6yEFlN0fzdQZMHL5pp1fyzRG1wxCFi3lVjDXw2dc/380uWB9g==", + "dev": true, + "license": "Elastic-2.0", + "dependencies": { + "@apollo/federation-internals": "2.13.3", + "deep-equal": "^2.0.5", + "ts-graphviz": "^1.5.4", + "uuid": "^9.0.0" }, "engines": { - "node": ">=0.8.0" + "node": ">=18" + }, + "peerDependencies": { + "graphql": "^16.5.0" } }, - "docs/generator/node_modules/wordwrap": { - "version": "1.0.0", - "license": "MIT" - }, - "lib/node-addon": { - "name": "@graphql-hive/router-query-planner", - "version": "0.0.17", + "node_modules/@apollo/query-graphs/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "devDependencies": { - "@napi-rs/cli": "3.5.1", - "@types/node": "25.5.0" - }, - "engines": { - "bun": "^1", - "node": ">=20" + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/@apollo/cache-control-types": { - "version": "1.0.3", + "node_modules/@apollo/subgraph": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.13.3.tgz", + "integrity": "sha512-nmdACpdTe+kgmNF0Yow3MoDkE0SMfvK6tTsEs3h3M9GLYCgBWZWBoDWQfNdr7kWRW6aan2SylU0K0ktF643h3g==", "dev": true, "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.2", + "@apollo/federation-internals": "2.13.3" + }, + "engines": { + "node": ">=14.15.0" + }, "peerDependencies": { - "graphql": "14.x || 15.x || 16.x" + "graphql": "^16.5.0" } }, "node_modules/@babel/runtime": { - "version": "7.28.4", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "dev": true, "license": "MIT", "engines": { @@ -153,6 +185,8 @@ }, "node_modules/@envelop/core": { "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", "dev": true, "license": "MIT", "dependencies": { @@ -167,6 +201,8 @@ }, "node_modules/@envelop/instrumentation": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", "dev": true, "license": "MIT", "dependencies": { @@ -179,6 +215,8 @@ }, "node_modules/@envelop/types": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", "dev": true, "license": "MIT", "dependencies": { @@ -191,11 +229,15 @@ }, "node_modules/@fastify/busboy": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "dev": true, "license": "MIT" }, "node_modules/@floating-ui/core": { "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -204,6 +246,8 @@ }, "node_modules/@floating-ui/dom": { "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "dev": true, "license": "MIT", "dependencies": { @@ -213,6 +257,8 @@ }, "node_modules/@floating-ui/react-dom": { "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "dev": true, "license": "MIT", "dependencies": { @@ -225,6 +271,8 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "dev": true, "license": "MIT" }, @@ -254,238 +302,112 @@ "graphql": "*" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/composition": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.13.3.tgz", - "integrity": "sha512-0/vr6vbk1BynEKY6/UV0SZUHBV33p5Ok0y6RhkNnX5BA+ysyOkcyTLPlEHo+ce62nqWx4ml3iOxeKIEHqbKelQ==", + "node_modules/@graphql-hive/laboratory": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.2.tgz", + "integrity": "sha512-Memzux8zIWGzyY7FPHTmf6GUrnL+AixpWYOCJg5AnSfWCjakM7rc+yB4RHgcm04Gbcqy7C3Zb+CcPn7YOgNNJA==", "dev": true, - "license": "Elastic-2.0", + "license": "MIT", "dependencies": { - "@apollo/federation-internals": "2.13.3", - "@apollo/query-graphs": "2.13.3" - }, - "engines": { - "node": ">=18" + "radix-ui": "^1.4.3", + "uuid": "^13.0.0" }, "peerDependencies": { - "graphql": "^16.5.0" + "@tanstack/react-form": "^1.23.8", + "date-fns": "^4.1.0", + "graphql-ws": "^6.0.6", + "lucide-react": "^0.548.0", + "lz-string": "^1.5.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "tslib": "^2.8.1", + "zod": "^4.1.12" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/federation-internals": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.13.3.tgz", - "integrity": "sha512-4zHgqznZza5Qx2CZy1qlrh83BB/3Yd8BqD/dmhGzLCvHJQ1LBTyZbWN7xwKs5z5FbxdSPkL5TvPoLm5Rek8/4g==", + "node_modules/@graphql-hive/router-query-planner": { + "resolved": "lib/node-addon", + "link": true + }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.1.tgz", + "integrity": "sha512-n94Qcu875Mji9GQ52n5UbgOTxlgvFJicBPYD+FRks9HKIQpdNPjkkrKZUYNG51XKa+bf03rxNflm4+wXhoHHrA==", "dev": true, - "license": "Elastic-2.0", + "license": "MIT", "dependencies": { - "@types/uuid": "^9.0.0", - "chalk": "^4.1.0", - "js-levenshtein": "^1.1.6", - "uuid": "^9.0.0" + "@graphql-tools/utils": "^11.0.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" }, "engines": { - "node": ">=18" + "node": ">=16.0.0" }, "peerDependencies": { - "graphql": "^16.5.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/query-graphs": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.13.3.tgz", - "integrity": "sha512-X/bgsIVmamAzd8IFMDo9upO5Oi/uhAZlcBmna6yEFlN0fzdQZMHL5pp1fyzRG1wxCFi3lVjDXw2dc/380uWB9g==", + "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, - "license": "Elastic-2.0", + "license": "MIT", "dependencies": { - "@apollo/federation-internals": "2.13.3", - "deep-equal": "^2.0.5", - "ts-graphviz": "^1.5.4", - "uuid": "^9.0.0" + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" }, "engines": { - "node": ">=18" + "node": ">=16.0.0" }, "peerDependencies": { - "graphql": "^16.5.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@apollo/subgraph": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.13.3.tgz", - "integrity": "sha512-nmdACpdTe+kgmNF0Yow3MoDkE0SMfvK6tTsEs3h3M9GLYCgBWZWBoDWQfNdr7kWRW6aan2SylU0K0ktF643h3g==", + "node_modules/@graphql-tools/merge": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", + "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", "dev": true, "license": "MIT", "dependencies": { - "@apollo/cache-control-types": "^1.0.2", - "@apollo/federation-internals": "2.13.3" + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" }, "engines": { - "node": ">=14.15.0" + "node": ">=16.0.0" }, "peerDependencies": { - "graphql": "^16.5.0" - } - }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, "engines": { - "node": ">=10" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.3.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@graphql-hive/federation-gateway-audit/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@graphql-hive/laboratory": { - "version": "0.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "radix-ui": "^1.4.3", - "uuid": "^13.0.0" - }, - "peerDependencies": { - "@tanstack/react-form": "^1.23.8", - "date-fns": "^4.1.0", - "graphql-ws": "^6.0.6", - "lucide-react": "^0.548.0", - "lz-string": "^1.5.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "tslib": "^2.8.1", - "zod": "^4.1.12" - } - }, - "node_modules/@graphql-hive/laboratory/node_modules/uuid": { - "version": "13.0.0", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/@graphql-hive/router-query-planner": { - "resolved": "lib/node-addon", - "link": true - }, - "node_modules/@graphql-tools/executor": { - "version": "1.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^11.0.0", - "@graphql-typed-document-node/core": "^3.2.0", - "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { - "version": "11.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/merge": { - "version": "9.1.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^11.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { - "version": "11.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema": { - "version": "10.0.31", + "node_modules/@graphql-tools/schema": { + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", + "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -502,6 +424,8 @@ }, "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", "dev": true, "license": "MIT", "dependencies": { @@ -519,6 +443,8 @@ }, "node_modules/@graphql-tools/utils": { "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -536,6 +462,8 @@ }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -544,6 +472,8 @@ }, "node_modules/@graphql-yoga/logger": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.1.tgz", + "integrity": "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==", "dev": true, "license": "MIT", "dependencies": { @@ -555,6 +485,8 @@ }, "node_modules/@graphql-yoga/subscription": { "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.5.tgz", + "integrity": "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==", "dev": true, "license": "MIT", "dependencies": { @@ -569,6 +501,8 @@ }, "node_modules/@graphql-yoga/typed-event-target": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.2.tgz", + "integrity": "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==", "dev": true, "license": "MIT", "dependencies": { @@ -581,6 +515,8 @@ }, "node_modules/@hapi/address": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -592,21 +528,29 @@ }, "node_modules/@hapi/formula": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/hoek": { "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/pinpoint": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.4", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -615,6 +559,8 @@ }, "node_modules/@hapi/topo": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -622,7 +568,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.2", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", "dev": true, "license": "MIT", "engines": { @@ -630,14 +578,16 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "5.0.2", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.3.tgz", + "integrity": "sha512-+G7I8CT+EHv/hasNfUl3P37DVoMoZfpA+2FXmM54dA8MxYle1YqucxbacxHalw1iAFSdKNEDTGNV7F+j1Ldqcg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.2", - "@inquirer/core": "^11.0.2", - "@inquirer/figures": "^2.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -652,12 +602,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.2", + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.11.tgz", + "integrity": "sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -672,17 +624,19 @@ } }, "node_modules/@inquirer/core": { - "version": "11.0.2", + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.8.tgz", + "integrity": "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.2", - "@inquirer/figures": "^2.0.2", - "@inquirer/type": "^4.0.2", + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^9.0.2" + "signal-exit": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -697,13 +651,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.2", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.0.tgz", + "integrity": "sha512-6wlkYl65Qfayy48gPCfU4D7li6KCAGN79mLXa/tYHZH99OfZ820yY+HA+DgE88r8YwwgeuY6PQgNqMeK6LuMmw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/external-editor": "^2.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -718,12 +674,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.2", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.11.tgz", + "integrity": "sha512-yxSO89MQ7t4LTCwtsXQ/ppcfw2otLsum6nF+TM9pKesy3k2AhVDUIkaiJIwG6lzm/csc5n38MaFKLY0TrSHzEA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -738,12 +696,14 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "2.0.2", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "iconv-lite": "^0.7.2" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -758,7 +718,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.2", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", "dev": true, "license": "MIT", "engines": { @@ -766,12 +728,14 @@ } }, "node_modules/@inquirer/input": { - "version": "5.0.2", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.11.tgz", + "integrity": "sha512-twUWidn4ocPO8qi6fRM7tNWt7W1FOnOZqQ+/+PsfLUacMR5rFLDPK9ql0nBPwxi0oELbo8T5NhRs8B2+qQEqFQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -786,12 +750,14 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.2", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.11.tgz", + "integrity": "sha512-Vscmim9TCksQsfjPtka/JwPUcbLhqWYrgfPf1cHrCm24X/F2joFwnageD50yMKsaX14oNGOyKf/RNXAFkNjWpA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -806,13 +772,15 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.2", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.11.tgz", + "integrity": "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.2", - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -827,20 +795,22 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.0.2", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.0.tgz", + "integrity": "sha512-Z3pFkae4WSzK95tvbaxR3rD9JlScFIh6/Ufw60H8Ck7GugdzYCe/3FwZCfvXwHZXjyk671w8FnVuwvxx1eP7ug==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.0.2", - "@inquirer/confirm": "^6.0.2", - "@inquirer/editor": "^5.0.2", - "@inquirer/expand": "^5.0.2", - "@inquirer/input": "^5.0.2", - "@inquirer/number": "^4.0.2", - "@inquirer/password": "^5.0.2", - "@inquirer/rawlist": "^5.0.2", - "@inquirer/search": "^4.0.2", - "@inquirer/select": "^5.0.2" + "@inquirer/checkbox": "^5.1.3", + "@inquirer/confirm": "^6.0.11", + "@inquirer/editor": "^5.1.0", + "@inquirer/expand": "^5.0.11", + "@inquirer/input": "^5.0.11", + "@inquirer/number": "^4.0.11", + "@inquirer/password": "^5.0.11", + "@inquirer/rawlist": "^5.2.7", + "@inquirer/search": "^4.1.7", + "@inquirer/select": "^5.1.3" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -855,12 +825,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.0.2", + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.7.tgz", + "integrity": "sha512-AqRMiD9+uE1lskDPrdqHwrV/EUmxKEBLX44SR7uxK3vD2413AmVfE5EQaPeNzYf5Pq5SitHJDYUFVF0poIr09w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -875,13 +847,15 @@ } }, "node_modules/@inquirer/search": { - "version": "4.0.2", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.7.tgz", + "integrity": "sha512-1y7+0N65AWk5RdlXH/Kn13txf3IjIQ7OEfhCEkDTU+h5wKMLq8DUF3P6z+/kLSxDGDtQT1dRBWEUC3o/VvImsQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.0.2", - "@inquirer/figures": "^2.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -896,14 +870,16 @@ } }, "node_modules/@inquirer/select": { - "version": "5.0.2", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.3.tgz", + "integrity": "sha512-zYyqWgGQi3NhBcNq4Isc5rB3oEdQEh1Q/EcAnOW0FK4MpnXWkvSBYgA4cYrTM4A9UB573omouZbnL9JJ74Mq3A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.2", - "@inquirer/core": "^11.0.2", - "@inquirer/figures": "^2.0.2", - "@inquirer/type": "^4.0.2" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -918,7 +894,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.2", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", "dev": true, "license": "MIT", "engines": { @@ -933,8 +911,20 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/get-type": { "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", "engines": { @@ -943,6 +933,8 @@ }, "node_modules/@jest/schemas": { "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", "dependencies": { @@ -994,6 +986,8 @@ }, "node_modules/@napi-rs/cross-toolchain": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", "dev": true, "license": "MIT", "workspaces": [ @@ -1053,6 +1047,8 @@ }, "node_modules/@napi-rs/lzma": { "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.4.5.tgz", + "integrity": "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==", "dev": true, "license": "MIT", "engines": { @@ -1082,57 +1078,864 @@ "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, - "node_modules/@napi-rs/tar": { - "version": "1.1.0", + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.4.5.tgz", + "integrity": "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.4.5.tgz", + "integrity": "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.4.5.tgz", + "integrity": "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.4.5.tgz", + "integrity": "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.4.5.tgz", + "integrity": "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.4.5.tgz", + "integrity": "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.4.5.tgz", + "integrity": "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.4.5.tgz", + "integrity": "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.4.5.tgz", + "integrity": "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.4.5.tgz", + "integrity": "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.4.5.tgz", + "integrity": "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.4.5.tgz", + "integrity": "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.4.5.tgz", + "integrity": "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.4.5.tgz", + "integrity": "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.4.5.tgz", + "integrity": "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.4.5.tgz", + "integrity": "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.4.5.tgz", + "integrity": "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.0.tgz", + "integrity": "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.0", + "@napi-rs/tar-android-arm64": "1.1.0", + "@napi-rs/tar-darwin-arm64": "1.1.0", + "@napi-rs/tar-darwin-x64": "1.1.0", + "@napi-rs/tar-freebsd-x64": "1.1.0", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/tar-linux-arm64-gnu": "1.1.0", + "@napi-rs/tar-linux-arm64-musl": "1.1.0", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", + "@napi-rs/tar-linux-s390x-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-musl": "1.1.0", + "@napi-rs/tar-wasm32-wasi": "1.1.0", + "@napi-rs/tar-win32-arm64-msvc": "1.1.0", + "@napi-rs/tar-win32-ia32-msvc": "1.1.0", + "@napi-rs/tar-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.0.tgz", + "integrity": "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.0.tgz", + "integrity": "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.0.tgz", + "integrity": "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.0.tgz", + "integrity": "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.0.1.tgz", + "integrity": "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", + "@napi-rs/wasm-tools-android-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-x64": "1.0.1", + "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", + "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.0.1.tgz", + "integrity": "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.0.1.tgz", + "integrity": "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/tar-android-arm-eabi": "1.1.0", - "@napi-rs/tar-android-arm64": "1.1.0", - "@napi-rs/tar-darwin-arm64": "1.1.0", - "@napi-rs/tar-darwin-x64": "1.1.0", - "@napi-rs/tar-freebsd-x64": "1.1.0", - "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", - "@napi-rs/tar-linux-arm64-gnu": "1.1.0", - "@napi-rs/tar-linux-arm64-musl": "1.1.0", - "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", - "@napi-rs/tar-linux-s390x-gnu": "1.1.0", - "@napi-rs/tar-linux-x64-gnu": "1.1.0", - "@napi-rs/tar-linux-x64-musl": "1.1.0", - "@napi-rs/tar-wasm32-wasi": "1.1.0", - "@napi-rs/tar-win32-arm64-msvc": "1.1.0", - "@napi-rs/tar-win32-ia32-msvc": "1.1.0", - "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, - "node_modules/@napi-rs/wasm-tools": { + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", - "@napi-rs/wasm-tools-android-arm64": "1.0.1", - "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", - "@napi-rs/wasm-tools-darwin-x64": "1.0.1", - "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", - "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", - "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", - "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", - "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", - "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", - "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", - "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", - "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "node_modules/@octokit/auth-token": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "dev": true, "license": "MIT", "engines": { @@ -1141,8 +1944,11 @@ }, "node_modules/@octokit/core": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1157,7 +1963,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "11.0.2", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", "dev": true, "license": "MIT", "dependencies": { @@ -1170,6 +1978,8 @@ }, "node_modules/@octokit/graphql": { "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", "dev": true, "license": "MIT", "dependencies": { @@ -1183,11 +1993,15 @@ }, "node_modules/@octokit/openapi-types": { "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", "dev": true, "license": "MIT", "dependencies": { @@ -1202,6 +2016,8 @@ }, "node_modules/@octokit/plugin-request-log": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", "dev": true, "license": "MIT", "engines": { @@ -1213,6 +2029,8 @@ }, "node_modules/@octokit/plugin-rest-endpoint-methods": { "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1226,14 +2044,17 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.7", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/endpoint": "^11.0.2", + "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1242,6 +2063,8 @@ }, "node_modules/@octokit/request-error": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", "dev": true, "license": "MIT", "dependencies": { @@ -1253,6 +2076,8 @@ }, "node_modules/@octokit/rest": { "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", "dev": true, "license": "MIT", "dependencies": { @@ -1267,6 +2092,8 @@ }, "node_modules/@octokit/types": { "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1275,16 +2102,22 @@ }, "node_modules/@radix-ui/number": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", "dev": true, "license": "MIT", "dependencies": { @@ -1307,6 +2140,8 @@ }, "node_modules/@radix-ui/react-accordion": { "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", "dev": true, "license": "MIT", "dependencies": { @@ -1337,6 +2172,8 @@ }, "node_modules/@radix-ui/react-alert-dialog": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", "dev": true, "license": "MIT", "dependencies": { @@ -1364,6 +2201,8 @@ }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "dev": true, "license": "MIT", "dependencies": { @@ -1386,6 +2225,8 @@ }, "node_modules/@radix-ui/react-aspect-ratio": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", "dev": true, "license": "MIT", "dependencies": { @@ -1408,6 +2249,8 @@ }, "node_modules/@radix-ui/react-avatar": { "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", "dev": true, "license": "MIT", "dependencies": { @@ -1434,6 +2277,8 @@ }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1463,6 +2308,8 @@ }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "dev": true, "license": "MIT", "dependencies": { @@ -1492,6 +2339,8 @@ }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "dev": true, "license": "MIT", "dependencies": { @@ -1517,6 +2366,8 @@ }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1531,6 +2382,8 @@ }, "node_modules/@radix-ui/react-context": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1545,6 +2398,8 @@ }, "node_modules/@radix-ui/react-context-menu": { "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "dev": true, "license": "MIT", "dependencies": { @@ -1572,6 +2427,8 @@ }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1607,6 +2464,8 @@ }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1621,6 +2480,8 @@ }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "dev": true, "license": "MIT", "dependencies": { @@ -1647,6 +2508,8 @@ }, "node_modules/@radix-ui/react-dropdown-menu": { "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", "dev": true, "license": "MIT", "dependencies": { @@ -1675,6 +2538,8 @@ }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1689,6 +2554,8 @@ }, "node_modules/@radix-ui/react-focus-scope": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "dev": true, "license": "MIT", "dependencies": { @@ -1713,6 +2580,8 @@ }, "node_modules/@radix-ui/react-form": { "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1740,6 +2609,8 @@ }, "node_modules/@radix-ui/react-hover-card": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", "dev": true, "license": "MIT", "dependencies": { @@ -1770,6 +2641,8 @@ }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "dev": true, "license": "MIT", "dependencies": { @@ -1787,6 +2660,8 @@ }, "node_modules/@radix-ui/react-label": { "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1809,6 +2684,8 @@ }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1848,6 +2725,8 @@ }, "node_modules/@radix-ui/react-menubar": { "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", "dev": true, "license": "MIT", "dependencies": { @@ -1879,6 +2758,8 @@ }, "node_modules/@radix-ui/react-navigation-menu": { "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1914,6 +2795,8 @@ }, "node_modules/@radix-ui/react-one-time-password-field": { "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", "dev": true, "license": "MIT", "dependencies": { @@ -1947,6 +2830,8 @@ }, "node_modules/@radix-ui/react-password-toggle-field": { "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", "dev": true, "license": "MIT", "dependencies": { @@ -1976,6 +2861,8 @@ }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,6 +2899,8 @@ }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "dev": true, "license": "MIT", "dependencies": { @@ -2043,6 +2932,8 @@ }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2066,6 +2957,8 @@ }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2089,6 +2982,8 @@ }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2111,6 +3006,8 @@ }, "node_modules/@radix-ui/react-progress": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", "dev": true, "license": "MIT", "dependencies": { @@ -2134,6 +3031,8 @@ }, "node_modules/@radix-ui/react-radio-group": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,6 +3064,8 @@ }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -2195,6 +3096,8 @@ }, "node_modules/@radix-ui/react-scroll-area": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", "dev": true, "license": "MIT", "dependencies": { @@ -2225,6 +3128,8 @@ }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2267,6 +3172,8 @@ }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "dev": true, "license": "MIT", "dependencies": { @@ -2289,6 +3196,8 @@ }, "node_modules/@radix-ui/react-slider": { "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", "dev": true, "license": "MIT", "dependencies": { @@ -2321,6 +3230,8 @@ }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", "dependencies": { @@ -2338,6 +3249,8 @@ }, "node_modules/@radix-ui/react-switch": { "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2366,6 +3279,8 @@ }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", "dev": true, "license": "MIT", "dependencies": { @@ -2395,6 +3310,8 @@ }, "node_modules/@radix-ui/react-toast": { "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", "dev": true, "license": "MIT", "dependencies": { @@ -2428,6 +3345,8 @@ }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2452,6 +3371,8 @@ }, "node_modules/@radix-ui/react-toggle-group": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2480,6 +3401,8 @@ }, "node_modules/@radix-ui/react-toolbar": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2508,6 +3431,8 @@ }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "dev": true, "license": "MIT", "dependencies": { @@ -2541,6 +3466,8 @@ }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2555,6 +3482,8 @@ }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "dev": true, "license": "MIT", "dependencies": { @@ -2573,6 +3502,8 @@ }, "node_modules/@radix-ui/react-use-effect-event": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "dev": true, "license": "MIT", "dependencies": { @@ -2590,6 +3521,8 @@ }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "dev": true, "license": "MIT", "dependencies": { @@ -2607,6 +3540,8 @@ }, "node_modules/@radix-ui/react-use-is-hydrated": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", "dev": true, "license": "MIT", "dependencies": { @@ -2624,6 +3559,8 @@ }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2638,6 +3575,8 @@ }, "node_modules/@radix-ui/react-use-previous": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2652,6 +3591,8 @@ }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "dev": true, "license": "MIT", "dependencies": { @@ -2669,6 +3610,8 @@ }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2686,6 +3629,8 @@ }, "node_modules/@radix-ui/react-visually-hidden": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "dev": true, "license": "MIT", "dependencies": { @@ -2708,26 +3653,36 @@ }, "node_modules/@radix-ui/rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "dev": true, "license": "MIT" }, "node_modules/@repeaterjs/repeater": { "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", "dev": true, "license": "MIT" }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "dev": true, "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", "engines": { @@ -2738,7 +3693,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -2748,7 +3705,6 @@ "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "intent": "bin/intent.js" }, @@ -2766,7 +3722,6 @@ "integrity": "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", @@ -2783,7 +3738,6 @@ "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2822,7 +3776,6 @@ "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" @@ -2842,12 +3795,22 @@ "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/k6": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.6.0.tgz", @@ -2861,17 +3824,22 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } }, "node_modules/@types/uuid": { "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true, "license": "MIT" }, "node_modules/@whatwg-node/cookie-store": { "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@whatwg-node/cookie-store/-/cookie-store-0.2.3.tgz", + "integrity": "sha512-LPDv38Hv+RrVA8o7x4YOjQx4qhqOs3Lm8aPTsPrQCUF3MxXEUBEYcUqD5wRTaTxzQEd7ZXbDEnXBLDECFoOfjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2884,6 +3852,8 @@ }, "node_modules/@whatwg-node/disposablestack": { "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", "dev": true, "license": "MIT", "dependencies": { @@ -2896,6 +3866,8 @@ }, "node_modules/@whatwg-node/events": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", + "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2907,6 +3879,8 @@ }, "node_modules/@whatwg-node/fetch": { "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2918,7 +3892,9 @@ } }, "node_modules/@whatwg-node/node-fetch": { - "version": "0.8.4", + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.5.tgz", + "integrity": "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2933,6 +3909,8 @@ }, "node_modules/@whatwg-node/promise-helpers": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", "dependencies": { @@ -2943,7 +3921,9 @@ } }, "node_modules/@whatwg-node/server": { - "version": "0.10.17", + "version": "0.10.18", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz", + "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==", "dev": true, "license": "MIT", "dependencies": { @@ -2959,6 +3939,8 @@ }, "node_modules/address": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", "dev": true, "license": "MIT", "engines": { @@ -2967,6 +3949,8 @@ }, "node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -2978,6 +3962,8 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2992,15 +3978,21 @@ }, "node_modules/arg": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true, "license": "MIT" }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/aria-hidden": { "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "dev": true, "license": "MIT", "dependencies": { @@ -3012,6 +4004,8 @@ }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -3027,6 +4021,8 @@ }, "node_modules/async-retry": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -3035,11 +4031,15 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3053,17 +4053,21 @@ } }, "node_modules/axios": { - "version": "1.13.5", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/before-after-hook": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "dev": true, "license": "Apache-2.0" }, @@ -3073,11 +4077,15 @@ }, "node_modules/bluebird": { "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true, "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -3095,6 +4103,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3107,6 +4117,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -3122,6 +4134,8 @@ }, "node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -3137,11 +4151,15 @@ }, "node_modules/chardet": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "dev": true, "license": "MIT" }, "node_modules/check-more-types": { "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", "dev": true, "license": "MIT", "engines": { @@ -3150,6 +4168,8 @@ }, "node_modules/cli-width": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { @@ -3158,6 +4178,8 @@ }, "node_modules/clipanion": { "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", "dev": true, "license": "MIT", "workspaces": [ @@ -3172,6 +4194,8 @@ }, "node_modules/cliui": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { @@ -3185,6 +4209,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3196,16 +4222,22 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", "dependencies": { @@ -3217,6 +4249,8 @@ }, "node_modules/cross-inspect": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", "dev": true, "license": "MIT", "dependencies": { @@ -3228,6 +4262,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3239,14 +4275,6 @@ "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -3261,6 +4289,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3277,6 +4307,8 @@ }, "node_modules/deep-equal": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "license": "MIT", "dependencies": { @@ -3308,6 +4340,8 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3324,6 +4358,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -3340,6 +4376,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", "engines": { @@ -3348,11 +4386,15 @@ }, "node_modules/detect-node-es": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "dev": true, "license": "MIT" }, "node_modules/detect-port": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3372,6 +4414,8 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3385,11 +4429,15 @@ }, "node_modules/duplexer": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true, "license": "MIT" }, "node_modules/emnapi": { - "version": "1.7.1", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-1.9.2.tgz", + "integrity": "sha512-OdUoQe/8so7FvubnE/DNV9sNNSFwDYQiK4ZCAz4agMnD1s6faLuDn2gzxfJrmMoKfxZhhsckqGNwqPnS5K140A==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3403,11 +4451,15 @@ }, "node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -3416,6 +4468,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -3424,6 +4478,8 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, "license": "MIT", "dependencies": { @@ -3443,6 +4499,8 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -3454,6 +4512,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -3467,7 +4527,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.43.0", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", "dev": true, "license": "MIT", "workspaces": [ @@ -3477,6 +4539,8 @@ }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -3485,6 +4549,8 @@ }, "node_modules/event-stream": { "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", "dev": true, "license": "MIT", "dependencies": { @@ -3499,6 +4565,8 @@ }, "node_modules/execa": { "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -3522,27 +4590,22 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/human-signals": { - "version": "8.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/execa/node_modules/strip-final-newline": { - "version": "4.0.0", - "dev": true, + "node_modules/explain-json-schema": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/explain-json-schema/-/explain-json-schema-1.1.1.tgz", + "integrity": "sha512-nCKSlt2mtRzY41adcqTqjgJmQWZJeuA2HMli4fPyDoXd5zWIOTVt+hWIykSsucDTZZ8uEygkCwTYdoMt9dya/g==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "minimist": "^1.2.5" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "explain-json-schema": "cli.js" } }, "node_modules/fast-content-type-parse": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", "dev": true, "funding": [ { @@ -3556,18 +4619,47 @@ ], "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fets": { - "version": "0.8.5", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/fets/-/fets-0.8.6.tgz", + "integrity": "sha512-BurmiEk8hPIR6yBjrWnsaZ7lkVUp9tc10Icen0Q+SXqbE/hzdHhe0jNyeulMH3SJcsCovm+mNU3Vrfg8qNcSAw==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0", + "@sinclair/typebox": "^0.34.48", "@whatwg-node/cookie-store": "^0.2.0", - "@whatwg-node/fetch": "^0.10.0", - "@whatwg-node/server": "^0.10.0", + "@whatwg-node/fetch": "^0.10.12", + "@whatwg-node/server": "^0.10.18", "hotscript": "^1.0.11", "json-schema-to-ts": "^3.0.0", - "qs": "^6.13.1", + "qs": "^6.14.2", "ts-toolbelt": "^9.6.0", "tslib": "^2.3.1" }, @@ -3577,6 +4669,8 @@ }, "node_modules/figures": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, "license": "MIT", "dependencies": { @@ -3591,6 +4685,8 @@ }, "node_modules/follow-redirects": { "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -3610,6 +4706,8 @@ }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,6 +4722,8 @@ }, "node_modules/form-data": { "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -3639,11 +4739,15 @@ }, "node_modules/from": { "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", "dev": true, "license": "MIT" }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -3652,6 +4756,8 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -3660,6 +4766,8 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { @@ -3667,7 +4775,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -3679,6 +4789,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3702,6 +4814,8 @@ }, "node_modules/get-nonce": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "dev": true, "license": "MIT", "engines": { @@ -3709,7 +4823,9 @@ } }, "node_modules/get-port": { - "version": "7.1.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "dev": true, "license": "MIT", "engines": { @@ -3721,6 +4837,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -3733,6 +4851,8 @@ }, "node_modules/get-stream": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", "dependencies": { @@ -3748,11 +4868,15 @@ }, "node_modules/get-them-args": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", + "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", "dev": true, "license": "MIT" }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -3763,13 +4887,32 @@ } }, "node_modules/graphql": { - "version": "16.13.1", + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/graphql-ws": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", @@ -3799,7 +4942,9 @@ } }, "node_modules/graphql-yoga": { - "version": "5.18.1", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.21.0.tgz", + "integrity": "sha512-PS37UoDihx8209RRl1ogttzWevNYDnGvP7beHkwHzUpUdfZTHsVRTVe1ysGXre1EjwUAePbpez302YSrq70Ngw==", "dev": true, "license": "MIT", "dependencies": { @@ -3823,8 +4968,31 @@ "graphql": "^15.2.0 || ^16.0.0" } }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -3836,6 +5004,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -3844,6 +5014,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -3855,6 +5027,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -3866,6 +5040,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3880,6 +5056,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3891,19 +5069,25 @@ }, "node_modules/hotscript": { "version": "1.0.13", + "resolved": "https://registry.npmjs.org/hotscript/-/hotscript-1.0.13.tgz", + "integrity": "sha512-C++tTF1GqkGYecL+2S1wJTfoH6APGAsbb7PAWQ3iVIwgG/EFseAfEVOKFgAFq4yK3+6j1EjUD4UQ9dRJHX/sSQ==", "dev": true, "license": "ISC" }, "node_modules/human-signals": { - "version": "2.1.0", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=10.17.0" + "node": ">=18.18.0" } }, "node_modules/iconv-lite": { - "version": "0.7.1", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", "dependencies": { @@ -3919,6 +5103,8 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -3932,6 +5118,8 @@ }, "node_modules/is-arguments": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3947,6 +5135,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -3963,6 +5153,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3977,6 +5169,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3992,6 +5186,8 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -4003,6 +5199,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -4018,6 +5216,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -4029,6 +5229,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -4044,6 +5246,8 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", "engines": { @@ -4055,6 +5259,8 @@ }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -4072,6 +5278,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -4083,6 +5291,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4097,6 +5307,8 @@ }, "node_modules/is-stream": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { @@ -4108,6 +5320,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -4123,6 +5337,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4139,6 +5355,8 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { @@ -4150,6 +5368,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -4161,6 +5381,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4176,16 +5398,38 @@ }, "node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/joi": { - "version": "18.0.2", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4195,7 +5439,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -4203,6 +5447,8 @@ }, "node_modules/js-levenshtein": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", "dev": true, "license": "MIT", "engines": { @@ -4211,6 +5457,8 @@ }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4221,6 +5469,8 @@ }, "node_modules/json-schema-to-ts": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "dev": true, "license": "MIT", "dependencies": { @@ -4231,8 +5481,32 @@ "node": ">=16" } }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonschema2mk": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/jsonschema2mk/-/jsonschema2mk-2.1.1.tgz", + "integrity": "sha512-QNTpK8IU36rMxVMlXfzEI4s7ZLDaCzDHYX9gSjqW9AE2d7cvHFTE5JajCEHCcNVVxR/V3Z2+VIsvYTDn+X/3qg==", + "license": "MIT", + "dependencies": { + "explain-json-schema": "^1.1.1", + "handlebars": "^4.7.7", + "js-yaml": "^4.1.0", + "minimist": "^1.2.6" + }, + "bin": { + "jsonschema2mk": "cli.js" + } + }, "node_modules/kill-port-process": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kill-port-process/-/kill-port-process-4.0.2.tgz", + "integrity": "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w==", "dev": true, "license": "ISC", "dependencies": { @@ -4248,6 +5522,8 @@ }, "node_modules/lazy-ass": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", "dev": true, "license": "MIT", "engines": { @@ -4255,12 +5531,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, @@ -4288,10 +5568,14 @@ }, "node_modules/map-stream": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", "dev": true }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { @@ -4300,11 +5584,15 @@ }, "node_modules/merge-stream": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, "license": "MIT" }, "node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { @@ -4313,6 +5601,8 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { @@ -4324,6 +5614,8 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", "engines": { @@ -4332,6 +5624,8 @@ }, "node_modules/minimist": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4339,19 +5633,31 @@ }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/mute-stream": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { @@ -4365,8 +5671,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -4378,6 +5699,8 @@ }, "node_modules/object-is": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4393,6 +5716,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -4401,6 +5726,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -4420,6 +5747,8 @@ }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4429,6 +5758,8 @@ }, "node_modules/onetime": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4443,6 +5774,8 @@ }, "node_modules/parse-ms": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, "license": "MIT", "engines": { @@ -4453,18 +5786,19 @@ } }, "node_modules/path-key": { - "version": "4.0.0", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/pause-stream": { "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", "dev": true, "license": [ "MIT", @@ -4476,6 +5810,8 @@ }, "node_modules/pid-port": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pid-port/-/pid-port-2.0.1.tgz", + "integrity": "sha512-pnLo01AmMclw8l+/gfknsP2N351oe8VkVmCLFUvJZ11NRPPmghJrv0OcwsdgPQxsZkFYwm6hPWW0JKmXYCaXAw==", "dev": true, "license": "MIT", "dependencies": { @@ -4490,14 +5826,46 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4511,12 +5879,19 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/ps-tree": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", "dev": true, "license": "MIT", "dependencies": { @@ -4531,6 +5906,8 @@ }, "node_modules/qs": { "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4545,6 +5922,8 @@ }, "node_modules/radix-ui": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", "dev": true, "license": "MIT", "dependencies": { @@ -4646,11 +6025,15 @@ }, "node_modules/react-is": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, "node_modules/react-remove-scroll": { "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4675,6 +6058,8 @@ }, "node_modules/react-remove-scroll-bar": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4696,6 +6081,8 @@ }, "node_modules/react-style-singleton": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4717,6 +6104,8 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -4736,6 +6125,8 @@ }, "node_modules/retry": { "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "dev": true, "license": "MIT", "engines": { @@ -4748,6 +6139,8 @@ }, "node_modules/rxjs": { "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4756,6 +6149,8 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -4772,6 +6167,8 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT" }, @@ -4780,11 +6177,12 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4796,6 +6194,8 @@ }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4812,6 +6212,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4826,6 +6228,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -4837,6 +6241,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -4845,6 +6251,8 @@ }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { @@ -4863,6 +6271,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", "dependencies": { @@ -4878,6 +6288,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -4895,6 +6307,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -4913,6 +6327,8 @@ }, "node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { @@ -4922,8 +6338,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split": { "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", "dev": true, "license": "MIT", "dependencies": { @@ -4935,6 +6362,8 @@ }, "node_modules/start-server-and-test": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.5.tgz", + "integrity": "sha512-A/SbXpgXE25ScSkpLLqvGvVZT0ykN6+AzS8tVqMBCTxbJy2Nwuen59opT+afalK5aS+AuQmZs0EsLwjnuDN+/g==", "dev": true, "license": "MIT", "dependencies": { @@ -4958,6 +6387,8 @@ }, "node_modules/start-server-and-test/node_modules/execa": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "dependencies": { @@ -4980,6 +6411,8 @@ }, "node_modules/start-server-and-test/node_modules/get-stream": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { @@ -4989,8 +6422,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/start-server-and-test/node_modules/is-stream": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { @@ -5002,6 +6447,8 @@ }, "node_modules/start-server-and-test/node_modules/npm-run-path": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { @@ -5011,21 +6458,27 @@ "node": ">=8" } }, - "node_modules/start-server-and-test/node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/start-server-and-test/node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, + "node_modules/start-server-and-test/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5038,6 +6491,8 @@ }, "node_modules/stream-combiner": { "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", "dev": true, "license": "MIT", "dependencies": { @@ -5046,6 +6501,8 @@ }, "node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5061,11 +6518,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -5075,15 +6534,22 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -5095,16 +6561,22 @@ }, "node_modules/through": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true, "license": "MIT" }, "node_modules/ts-algebra": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "dev": true, "license": "MIT" }, "node_modules/ts-graphviz": { "version": "1.8.2", + "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-1.8.2.tgz", + "integrity": "sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA==", "dev": true, "license": "MIT", "engines": { @@ -5117,16 +6589,23 @@ }, "node_modules/ts-toolbelt": { "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/typanion": { "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", "dev": true, "license": "MIT", "workspaces": [ @@ -5135,6 +6614,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5145,6 +6626,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -5154,6 +6648,8 @@ }, "node_modules/unicorn-magic": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", "engines": { @@ -5165,16 +6661,22 @@ }, "node_modules/universal-user-agent": { "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "dev": true, "license": "ISC" }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", "dev": true, "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "dev": true, "license": "MIT", "dependencies": { @@ -5195,6 +6697,8 @@ }, "node_modules/use-sidecar": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5216,6 +6720,8 @@ }, "node_modules/use-sync-external-store": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5223,7 +6729,9 @@ } }, "node_modules/uuid": { - "version": "9.0.1", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -5231,11 +6739,13 @@ ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/wait-on": { "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5254,6 +6764,8 @@ }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -5268,6 +6780,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -5286,6 +6800,8 @@ }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -5303,6 +6819,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5321,8 +6839,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { @@ -5339,6 +6865,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -5350,6 +6878,8 @@ }, "node_modules/y18n": { "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", "engines": { @@ -5358,6 +6888,8 @@ }, "node_modules/yargs": { "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { @@ -5374,6 +6906,8 @@ }, "node_modules/yargs-parser": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { @@ -5382,6 +6916,8 @@ }, "node_modules/yoctocolors": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { From 5c78153cad6913c656c6b84c8bc017771d7b2bad Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:35:06 +0300 Subject: [PATCH 17/76] chore(release): router crates and artifacts (#890) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .../replace_graphiql_with_hive_laboratory.md | 27 ------------------- Cargo.lock | 4 +-- bin/router/CHANGELOG.md | 27 +++++++++++++++++++ bin/router/Cargo.toml | 4 +-- lib/executor/Cargo.toml | 2 +- lib/internal/Cargo.toml | 2 +- lib/router-config/CHANGELOG.md | 27 +++++++++++++++++++ lib/router-config/Cargo.toml | 2 +- 8 files changed, 61 insertions(+), 34 deletions(-) delete mode 100644 .changeset/replace_graphiql_with_hive_laboratory.md diff --git a/.changeset/replace_graphiql_with_hive_laboratory.md b/.changeset/replace_graphiql_with_hive_laboratory.md deleted file mode 100644 index 85ddaa171..000000000 --- a/.changeset/replace_graphiql_with_hive_laboratory.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -hive-router: patch -hive-router-config: patch ---- - -# Replace GraphiQL with Hive Laboratory - -The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. - -Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). - -### Breaking Changes: - -The top-level config option has been renamed. - -```diff -- graphiql: -+ laboratory: - enabled: true -``` - -So was the environment variable override. - -```diff -- GRAPHIQL_ENABLED=true -+ LABORATORY_ENABLED=true -``` diff --git a/Cargo.lock b/Cargo.lock index 860693372..b3b7bdbb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,7 +2350,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.45" +version = "0.0.46" dependencies = [ "ahash", "anyhow", @@ -2406,7 +2406,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.26" +version = "0.0.27" dependencies = [ "config", "envconfig", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 966a8ae04..bb5f11422 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.46 (2026-04-12) + +### Fixes + +#### Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +#### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` + ## 0.0.45 (2026-03-31) ### Fixes diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index bfeb5521e..593a691a7 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.45" +version = "0.0.46" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -23,7 +23,7 @@ testing = [] [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.1" } hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.2" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.26" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.27" } hive-router-internal = { path = "../../lib/internal", version = "0.0.15" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 8485eaaaa..928cd7e52 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -16,7 +16,7 @@ doctest = false [dependencies] hive-router-query-planner = { path = "../query-planner", version = "2.5.1" } -hive-router-config = { path = "../router-config", version = "0.0.26" } +hive-router-config = { path = "../router-config", version = "0.0.27" } hive-router-internal = { path = "../internal", version = "0.0.15" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 0cf55a4c4..2a0c66f85 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.26" } +hive-router-config = { path = "../router-config", version = "0.0.27" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index 452171037..ca6a6c32b 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.27 (2026-04-12) + +### Fixes + +#### Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +#### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` + ## 0.0.26 (2026-03-26) ### Features diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 44e4fa9ab..1e424306f 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.26" +version = "0.0.27" edition = "2021" publish = true license = "MIT" From fd4955a8891553188c27edd84316ee869db3c019 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Sun, 12 Apr 2026 21:54:45 +0300 Subject: [PATCH 18/76] fix(release): fix workspace root discovery issues in build script Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- Cargo.lock | 16 ++++++++++++++++ bin/router/Cargo.toml | 3 +++ bin/router/build.rs | 13 +++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3b7bdbb2..0f5960472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,6 +2093,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "get_dir" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be53a543a609b266f8ed4237af223fa96bdaa54d69fd6845c71a421a4a4748" + [[package]] name = "getrandom" version = "0.2.17" @@ -2401,6 +2407,7 @@ dependencies = [ "tracing-tree", "ulid", "vrl", + "workspace_root", "xxhash-rust", ] @@ -7810,6 +7817,15 @@ dependencies = [ "regex", ] +[[package]] +name = "workspace_root" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d804157714956faa87af2ccf3ea2917a7e588c84c6e0a473311c1fca161818" +dependencies = [ + "get_dir", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 593a691a7..4d52814b1 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -20,6 +20,9 @@ path = "src/main.rs" noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] +[build-dependencies] +workspace_root = "0.2.0" + [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.1" } hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.2" } diff --git a/bin/router/build.rs b/bin/router/build.rs index 95e3fa721..798c1fc7f 100644 --- a/bin/router/build.rs +++ b/bin/router/build.rs @@ -3,28 +3,29 @@ use std::{ path::{Path, PathBuf}, process::Command, }; +use workspace_root::get_workspace_root; fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing manifest dir")); + let workspace_root_dir: PathBuf = get_workspace_root(); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); let output_file = out_dir.join("laboratory.html"); - let product_logo = manifest_dir.join("static/product_logo.svg"); - let node_modules_dist = manifest_dir.join("../../node_modules/@graphql-hive/laboratory/dist"); + let product_logo = workspace_root_dir.join("bin/router/static/product_logo.svg"); + let node_modules_dist = workspace_root_dir.join("node_modules/@graphql-hive/laboratory/dist"); println!("cargo:rerun-if-changed={}", product_logo.display()); println!( "cargo:rerun-if-changed={}", - manifest_dir.join("../../package.json").display() + workspace_root_dir.join("package.json").display() ); println!( "cargo:rerun-if-changed={}", - manifest_dir.join("../../package-lock.json").display() + workspace_root_dir.join("package-lock.json").display() ); if !node_modules_dist.exists() { let status = Command::new("npm") .args(["install", "--include=dev"]) // NODE_ENV=production will skip dev deps - make sure they're in - .current_dir(manifest_dir.join("../../")) + .current_dir(workspace_root_dir) .status() .expect("Failed to execute npm install"); From 70006ff32c4dfe9bcc99cf8e0a1bc8addf4444a7 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 07:42:48 +0300 Subject: [PATCH 19/76] fix(router): move build script to the right place to avoid publish errors Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .npmrc | 2 + Cargo.lock | 16 - bin/router/.npmrc | 2 + bin/router/Cargo.toml | 3 - bin/router/build.rs | 14 +- bin/router/package-lock.json | 2109 ++++++++++++++++++++++++++++++++++ bin/router/package.json | 9 + package-lock.json | 2098 +-------------------------------- package.json | 1 - 9 files changed, 2139 insertions(+), 2115 deletions(-) create mode 100644 .npmrc create mode 100644 bin/router/.npmrc create mode 100644 bin/router/package-lock.json create mode 100644 bin/router/package.json diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..09b35cdd0 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +audit=false +fund=false diff --git a/Cargo.lock b/Cargo.lock index 0f5960472..b3b7bdbb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,12 +2093,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "get_dir" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7be53a543a609b266f8ed4237af223fa96bdaa54d69fd6845c71a421a4a4748" - [[package]] name = "getrandom" version = "0.2.17" @@ -2407,7 +2401,6 @@ dependencies = [ "tracing-tree", "ulid", "vrl", - "workspace_root", "xxhash-rust", ] @@ -7817,15 +7810,6 @@ dependencies = [ "regex", ] -[[package]] -name = "workspace_root" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d804157714956faa87af2ccf3ea2917a7e588c84c6e0a473311c1fca161818" -dependencies = [ - "get_dir", -] - [[package]] name = "writeable" version = "0.6.2" diff --git a/bin/router/.npmrc b/bin/router/.npmrc new file mode 100644 index 000000000..09b35cdd0 --- /dev/null +++ b/bin/router/.npmrc @@ -0,0 +1,2 @@ +audit=false +fund=false diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 4d52814b1..593a691a7 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -20,9 +20,6 @@ path = "src/main.rs" noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] -[build-dependencies] -workspace_root = "0.2.0" - [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.1" } hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.2" } diff --git a/bin/router/build.rs b/bin/router/build.rs index 798c1fc7f..0ad2a038d 100644 --- a/bin/router/build.rs +++ b/bin/router/build.rs @@ -3,29 +3,29 @@ use std::{ path::{Path, PathBuf}, process::Command, }; -use workspace_root::get_workspace_root; fn main() { - let workspace_root_dir: PathBuf = get_workspace_root(); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + println!("build script using workspace root: {:?}", manifest_dir); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); let output_file = out_dir.join("laboratory.html"); - let product_logo = workspace_root_dir.join("bin/router/static/product_logo.svg"); - let node_modules_dist = workspace_root_dir.join("node_modules/@graphql-hive/laboratory/dist"); + let product_logo = manifest_dir.join("static/product_logo.svg"); + let node_modules_dist = manifest_dir.join("node_modules/@graphql-hive/laboratory/dist"); println!("cargo:rerun-if-changed={}", product_logo.display()); println!( "cargo:rerun-if-changed={}", - workspace_root_dir.join("package.json").display() + manifest_dir.join("package.json").display() ); println!( "cargo:rerun-if-changed={}", - workspace_root_dir.join("package-lock.json").display() + manifest_dir.join("package-lock.json").display() ); if !node_modules_dist.exists() { let status = Command::new("npm") .args(["install", "--include=dev"]) // NODE_ENV=production will skip dev deps - make sure they're in - .current_dir(workspace_root_dir) + .current_dir(manifest_dir) .status() .expect("Failed to execute npm install"); diff --git a/bin/router/package-lock.json b/bin/router/package-lock.json new file mode 100644 index 000000000..1a8fdc5bc --- /dev/null +++ b/bin/router/package-lock.json @@ -0,0 +1,2109 @@ +{ + "name": "hive-router-js-deps", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hive-router-js-deps", + "devDependencies": { + "@graphql-hive/laboratory": "0.1.2" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-hive/laboratory": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.2.tgz", + "integrity": "sha512-Memzux8zIWGzyY7FPHTmf6GUrnL+AixpWYOCJg5AnSfWCjakM7rc+yB4RHgcm04Gbcqy7C3Zb+CcPn7YOgNNJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "radix-ui": "^1.4.3", + "uuid": "^13.0.0" + }, + "peerDependencies": { + "@tanstack/react-form": "^1.23.8", + "date-fns": "^4.1.0", + "graphql-ws": "^6.0.6", + "lucide-react": "^0.548.0", + "lz-string": "^1.5.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "tslib": "^2.8.1", + "zod": "^4.1.12" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "dev": true, + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.29.0.tgz", + "integrity": "sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.29.0.tgz", + "integrity": "sha512-jj425NNX0QKqbUzqSNiYI3HCPHSk2df47acXCJyXczWOTmG81ECZGkgofgqamFsSU9kMiH6Di5RLUnftrlhWSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/form-core": "1.29.0", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", + "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/lucide-react": { + "version": "0.548.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz", + "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "peer": true + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bin/router/package.json b/bin/router/package.json new file mode 100644 index 000000000..8bce25511 --- /dev/null +++ b/bin/router/package.json @@ -0,0 +1,9 @@ +{ + "name": "hive-router-js-deps", + "type": "module", + "private": true, + "packageManager": "npm@11.11.1", + "devDependencies": { + "@graphql-hive/laboratory": "0.1.2" + } +} diff --git a/package-lock.json b/package-lock.json index a2b3cbd75..3ebab7614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "bench" ], "devDependencies": { - "@graphql-hive/laboratory": "0.1.2", "cross-spawn": "^7.0.6", "qs": "^6.15.0" } @@ -234,48 +233,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "dev": true, - "license": "MIT" - }, "node_modules/@graphql-hive/federation-gateway-audit": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@graphql-hive/federation-gateway-audit/-/federation-gateway-audit-0.0.2.tgz", @@ -302,28 +259,6 @@ "graphql": "*" } }, - "node_modules/@graphql-hive/laboratory": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.2.tgz", - "integrity": "sha512-Memzux8zIWGzyY7FPHTmf6GUrnL+AixpWYOCJg5AnSfWCjakM7rc+yB4RHgcm04Gbcqy7C3Zb+CcPn7YOgNNJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "radix-ui": "^1.4.3", - "uuid": "^13.0.0" - }, - "peerDependencies": { - "@tanstack/react-form": "^1.23.8", - "date-fns": "^4.1.0", - "graphql-ws": "^6.0.6", - "lucide-react": "^0.548.0", - "lz-string": "^1.5.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "tslib": "^2.8.1", - "zod": "^4.1.12" - } - }, "node_modules/@graphql-hive/router-query-planner": { "resolved": "lib/node-addon", "link": true @@ -2086,1577 +2021,19 @@ "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", - "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", - "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", - "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", - "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", - "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", - "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", - "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", - "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", - "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", - "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", - "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", - "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.11" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } }, "node_modules/@repeaterjs/repeater": { "version": "3.0.6", @@ -3699,107 +2076,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tanstack/devtools-event-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", - "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", - "dev": true, - "license": "MIT", - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/form-core": { - "version": "1.28.6", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.6.tgz", - "integrity": "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/devtools-event-client": "^0.4.1", - "@tanstack/pacer-lite": "^0.1.1", - "@tanstack/store": "^0.9.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/pacer-lite": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", - "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-form": { - "version": "1.28.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.28.6.tgz", - "integrity": "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@tanstack/form-core": "1.28.6", - "@tanstack/react-store": "^0.9.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@tanstack/react-start": { - "optional": true - } - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", - "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/store": "0.9.3", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3989,19 +2265,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4275,18 +2538,6 @@ "node": ">= 8" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4384,13 +2635,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, - "license": "MIT" - }, "node_modules/detect-port": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", @@ -4812,16 +3056,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-port": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", @@ -4913,34 +3147,6 @@ "graphql": ">=0.11 <=16" } }, - "node_modules/graphql-ws": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", - "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@fastify/websocket": "^10 || ^11", - "crossws": "~0.3", - "graphql": "^15.10.1 || ^16", - "ws": "^8" - }, - "peerDependenciesMeta": { - "@fastify/websocket": { - "optional": true - }, - "crossws": { - "optional": true - }, - "ws": { - "optional": true - } - } - }, "node_modules/graphql-yoga": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.21.0.tgz", @@ -5544,28 +3750,6 @@ "dev": true, "license": "ISC" }, - "node_modules/lucide-react": { - "version": "0.548.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz", - "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==", - "dev": true, - "license": "ISC", - "peer": true, - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/map-stream": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", @@ -5920,109 +4104,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/radix-ui": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", - "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-accessible-icon": "1.1.7", - "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-alert-dialog": "1.1.15", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.7", - "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-form": "0.1.8", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-menubar": "1.1.16", - "@radix-ui/react-navigation-menu": "1.2.14", - "@radix-ui/react-one-time-password-field": "0.1.8", - "@radix-ui/react-password-toggle-field": "0.1.3", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-progress": "1.1.7", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toast": "1.2.15", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-toggle-group": "1.1.11", - "@radix-ui/react-toolbar": "1.1.11", - "@radix-ui/react-tooltip": "1.2.8", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-escape-keydown": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -6030,78 +4111,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6172,13 +4181,6 @@ "dev": true, "license": "MIT" }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6673,75 +4675,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/wait-on": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", @@ -6926,17 +4859,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 6ea2a8485..e4fbe32af 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ ], "packageManager": "npm@11.11.1", "devDependencies": { - "@graphql-hive/laboratory": "0.1.2", "cross-spawn": "^7.0.6", "qs": "^6.15.0" }, From bbbb50e4bc51b65bfa6b4b76b8721d69f4347a4b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Sun, 12 Apr 2026 18:49:28 +0200 Subject: [PATCH 20/76] fix(query-planner): planning for conditional inline fragments and field conditions (#894) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/planner_conditional.md | 12 ++ .../products-example.supergraph.graphql | 117 +++++++++++++ .../src/ast/normalization/mod.rs | 107 ++++++++++++ .../pipeline/flatten_fragments.rs | 19 ++ lib/query-planner/src/ast/selection_set.rs | 118 ++++++++++++- lib/query-planner/src/graph/mod.rs | 10 ++ lib/query-planner/src/graph/tests.rs | 10 +- lib/query-planner/src/tests/include_skip.rs | 162 ++++++++++++++++++ 8 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 .changeset/planner_conditional.md create mode 100644 lib/query-planner/fixture/products-example.supergraph.graphql diff --git a/.changeset/planner_conditional.md b/.changeset/planner_conditional.md new file mode 100644 index 000000000..1138d9d93 --- /dev/null +++ b/.changeset/planner_conditional.md @@ -0,0 +1,12 @@ +--- +hive-router-query-planner: patch +node-addon: patch +hive-router-plan-executor: patch +hive-router: patch +--- + +# Fix planning for conditional inline fragments and field conditions + +Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. + +This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. diff --git a/lib/query-planner/fixture/products-example.supergraph.graphql b/lib/query-planner/fixture/products-example.supergraph.graphql new file mode 100644 index 000000000..631e291e2 --- /dev/null +++ b/lib/query-planner/fixture/products-example.supergraph.graphql @@ -0,0 +1,117 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts", url: "http://0.0.0.0:4200/accounts") + INVENTORY + @join__graph(name: "inventory", url: "http://0.0.0.0:4200/inventory") + PRODUCTS @join__graph(name: "products", url: "http://0.0.0.0:4200/products") + REVIEWS @join__graph(name: "reviews", url: "http://0.0.0.0:4200/reviews") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + price: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + inStock: Boolean @join__field(graph: INVENTORY) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + notes: String @join__field(graph: PRODUCTS) + internal: String @join__field(graph: PRODUCTS) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + user(id: ID!): User @join__field(graph: ACCOUNTS) + users: [User] @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String + product: Product + author: User @join__field(graph: REVIEWS, provides: "username") +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + birthday: Int @join__field(graph: ACCOUNTS) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/lib/query-planner/src/ast/normalization/mod.rs b/lib/query-planner/src/ast/normalization/mod.rs index a568312a5..b730841c3 100644 --- a/lib/query-planner/src/ast/normalization/mod.rs +++ b/lib/query-planner/src/ast/normalization/mod.rs @@ -1145,6 +1145,113 @@ mod tests { ", ); } + + #[test] + fn directive_only_inline_fragment_on_abstract_parent_include() { + let schema_str = + std::fs::read_to_string("./fixture/tests/requires-with-fragments.supergraph.graphql") + .expect("Unable to read supergraph"); + let schema = parse_schema(&schema_str); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($guest: Boolean!) { + userFromA { + profile { + ... @include(if: $guest) { + displayName + ... on Account { + accountType + } + } + } + } + } + "#, + ) + .expect("to parse"), + None, + ) + .unwrap() + .to_string() + ), + @r" + query($guest: Boolean!) { + userFromA { + profile { + ... on AdminAccount @include(if: $guest) { + displayName + accountType + } + ... on GuestAccount @include(if: $guest) { + displayName + accountType + } + } + } + } + " + ); + } + + #[test] + fn directive_only_inline_fragment_on_abstract_parent_skip() { + let schema_str = + std::fs::read_to_string("./fixture/tests/requires-with-fragments.supergraph.graphql") + .expect("Unable to read supergraph"); + let schema = parse_schema(&schema_str); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($guest: Boolean!) { + userFromA { + profile { + ... @skip(if: $guest) { + displayName + ... on Account { + accountType + } + } + } + } + } + "#, + ) + .expect("to parse"), + None, + ) + .unwrap() + .to_string() + ), + @r" + query($guest: Boolean!) { + userFromA { + profile { + ... on AdminAccount @skip(if: $guest) { + displayName + accountType + } + ... on GuestAccount @skip(if: $guest) { + displayName + accountType + } + } + } + } + " + ); + } + #[test] fn nested_fragment_spreads_1() { let schema_str = diff --git a/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs b/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs index ecc80d6a8..c0c372fa5 100644 --- a/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs +++ b/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs @@ -212,6 +212,7 @@ fn process_inline_fragment( parent_type_def: &SupergraphDefinition, mut fragment: InlineFragment<'static, String>, ) -> Result>, NormalizationError> { + let had_no_type_condition = fragment.type_condition.is_none(); let type_condition_matches_parent = fragment .type_condition .as_ref() @@ -235,6 +236,24 @@ fn process_inline_fragment( parent_type_def, &mut fragment.selection_set, )?; + + if had_no_type_condition { + fragment.type_condition = + Some(TypeCondition::On(parent_type_def.name().to_string())); + + if matches!( + parent_type_def, + SupergraphDefinition::Interface(_) | SupergraphDefinition::Union(_) + ) { + return expand_abstract_fragment( + state, + possible_types, + parent_type_def, + fragment, + ); + } + } + Ok(vec![Selection::InlineFragment(fragment)]) } } else { diff --git a/lib/query-planner/src/ast/selection_set.rs b/lib/query-planner/src/ast/selection_set.rs index b79f61ee5..050ae71c7 100644 --- a/lib/query-planner/src/ast/selection_set.rs +++ b/lib/query-planner/src/ast/selection_set.rs @@ -444,7 +444,12 @@ pub fn merge_selection_set(target: &mut SelectionSet, source: &SelectionSet, as_ for target_item in target.items.iter_mut() { match (source_item, target_item) { (SelectionItem::Field(source_field), SelectionItem::Field(target_field)) => { - if source_field == target_field { + if source_field == target_field + && field_condition_equal( + &Option::::from(source_field), + target_field, + ) + { found = true; merge_selection_set( &mut target_field.selections, @@ -458,7 +463,12 @@ pub fn merge_selection_set(target: &mut SelectionSet, source: &SelectionSet, as_ SelectionItem::InlineFragment(source_fragment), SelectionItem::InlineFragment(target_fragment), ) => { - if source_fragment.type_condition == target_fragment.type_condition { + if source_fragment.type_condition == target_fragment.type_condition + && fragment_condition_equal( + &Option::::from(source_fragment), + target_fragment, + ) + { found = true; merge_selection_set( &mut target_fragment.selections, @@ -750,4 +760,108 @@ mod tests { @r#"{field1(id: 1, list: [1, 2], name: "test", obj: {key: "value"})}"# ) } + + #[test] + fn merge_selection_set_keeps_fields_with_different_conditions_separate() { + let mut target = SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "reviews".to_string(), + selections: SelectionSet::default(), + alias: None, + arguments: None, + skip_if: None, + include_if: Some("first".to_string()), + })], + }; + let source = SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "reviews".to_string(), + selections: SelectionSet::default(), + alias: None, + arguments: None, + skip_if: None, + include_if: Some("second".to_string()), + })], + }; + + merge_selection_set(&mut target, &source, false); + + assert_eq!(target.items.len(), 2); + } + + #[test] + fn merge_selection_set_merges_fields_with_same_condition() { + let mut target = SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "reviews".to_string(), + selections: SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "id".to_string(), + selections: SelectionSet::default(), + alias: None, + arguments: None, + skip_if: None, + include_if: None, + })], + }, + alias: None, + arguments: None, + skip_if: None, + include_if: Some("cond".to_string()), + })], + }; + let source = SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "reviews".to_string(), + selections: SelectionSet { + items: vec![SelectionItem::Field(FieldSelection { + name: "body".to_string(), + selections: SelectionSet::default(), + alias: None, + arguments: None, + skip_if: None, + include_if: None, + })], + }, + alias: None, + arguments: None, + skip_if: None, + include_if: Some("cond".to_string()), + })], + }; + + merge_selection_set(&mut target, &source, false); + + assert_eq!(target.items.len(), 1); + + let SelectionItem::Field(field) = &target.items[0] else { + panic!("expected field selection"); + }; + + assert_eq!(field.selections.items.len(), 2); + } + + #[test] + fn merge_selection_set_keeps_inline_fragments_with_different_conditions_separate() { + let mut target = SelectionSet { + items: vec![SelectionItem::InlineFragment(InlineFragmentSelection { + type_condition: "User".to_string(), + selections: SelectionSet::default(), + skip_if: None, + include_if: Some("first".to_string()), + })], + }; + let source = SelectionSet { + items: vec![SelectionItem::InlineFragment(InlineFragmentSelection { + type_condition: "User".to_string(), + selections: SelectionSet::default(), + skip_if: None, + include_if: Some("second".to_string()), + })], + }; + + merge_selection_set(&mut target, &source, false); + + assert_eq!(target.items.len(), 2); + } } diff --git a/lib/query-planner/src/graph/mod.rs b/lib/query-planner/src/graph/mod.rs index 6c6b110b1..b8aa7c115 100644 --- a/lib/query-planner/src/graph/mod.rs +++ b/lib/query-planner/src/graph/mod.rs @@ -983,6 +983,8 @@ impl Graph { SubgraphTypeSpecialization::Provides(view_id), )); + self.upsert_edge(tail, tail, Edge::Selfie(return_type_name.to_string())); + trace!( "Creating viewed (#{}) field edge for '{}.{}' (type: {})", view_id, @@ -1042,6 +1044,8 @@ impl Graph { SubgraphTypeSpecialization::Provides(view_id), )); + self.upsert_edge(tail, tail, Edge::Selfie(type_name_from_cond.to_string())); + // because it's abstract -> object move, add an abstract move edge self.upsert_edge( head, @@ -1120,6 +1124,12 @@ impl Graph { SubgraphTypeSpecialization::Provides(view_id), )); + self.upsert_edge( + tail, + tail, + Edge::Selfie(return_type_name.to_string()), + ); + trace!( "Creating viewed (#{}) link for provided field '{}.{}/{:?}' (type: {})", view_id, def_name, field_name, join_type.graph_id, return_type_name diff --git a/lib/query-planner/src/graph/tests.rs b/lib/query-planner/src/graph/tests.rs index bfea8de64..048a4cc9b 100644 --- a/lib/query-planner/src/graph/tests.rs +++ b/lib/query-planner/src/graph/tests.rs @@ -369,8 +369,8 @@ mod graph_tests { // Verify that each provided path points only to the relevant, provided fields let (viewed_incoming, viewed_outgoing) = find_node(&graph, &node1.display_name()); viewed_outgoing.assert_field_edge("id", "String/foo"); - assert_eq!(viewed_incoming.edges.len(), 1); - assert_eq!(viewed_outgoing.edges.len(), 2); + assert_eq!(viewed_incoming.edges.len(), 2); // +1 for Selfie + assert_eq!(viewed_outgoing.edges.len(), 3); // +1 for Selfie let (_, to) = outgoing .edges_field("user") @@ -383,8 +383,8 @@ mod graph_tests { // Verify that each provided path points only to the relevant, provided fields let (viewed_incoming, viewed_outgoing) = find_node(&graph, &node2.display_name()); viewed_outgoing.assert_field_edge("name", "String/foo"); - assert_eq!(viewed_incoming.edges.len(), 1); - assert_eq!(viewed_outgoing.edges.len(), 3); + assert_eq!(viewed_incoming.edges.len(), 2); // +1 for Selfie + assert_eq!(viewed_outgoing.edges.len(), 4); // +1 for Selfie let (nested_provides_id, nested_provides_node) = viewed_outgoing .edge_field("profile") @@ -397,7 +397,7 @@ mod graph_tests { .starts_with("Profile/foo/")); let mut nested_edges = graph.edges_from(nested_provides_id); - assert_eq!(nested_edges.clone().count(), 1); + assert_eq!(nested_edges.clone().count(), 2); // +1 for Selfie assert_eq!( nested_edges.next().unwrap().weight().display_name(), String::from("age") diff --git a/lib/query-planner/src/tests/include_skip.rs b/lib/query-planner/src/tests/include_skip.rs index 6e2db65cb..fcca985c7 100644 --- a/lib/query-planner/src/tests/include_skip.rs +++ b/lib/query-planner/src/tests/include_skip.rs @@ -435,3 +435,165 @@ fn include_union_fragment_at_root_fetch_test() -> Result<(), Box> { Ok(()) } + +#[test] +fn plans_query_with_nested_directive_only_inline_fragments() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + fragment User on User { + id + username + name + } + + fragment Review on Review { + id + body + } + + fragment Product on Product { + inStock + name + price + shippingEstimate + upc + weight + } + + query TestQuery($user: Boolean = true, $product: Boolean = true, $reviews: Boolean = true) { + users { + ...User + ... @include(if: $reviews) { + reviews { + ...Review + product { + ...Product + reviews { + ...Review + ... @include(if: $user) { + author { + ...User + reviews { + ...Review + ... @include(if: $product) { + product { + ...Product + } + } + } + } + } + } + } + } + } + } + ... @include(if: $product) { + topProducts { + ...Product + reviews { + ...Review + author { + ...User + ... @include(if: $reviews) { + reviews { + ...Review + product { + ...Product + } + } + } + } + } + } + } + } + "#, + ); + + let query_plan = build_query_plan("fixture/products-example.supergraph.graphql", document); + assert!( + query_plan.is_ok(), + "expected query planning to succeed, got: {:?}", + query_plan.err() + ); + + Ok(()) +} + +#[test] +fn plans_query_with_field_level_include_skip_conditions() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + fragment User on User { + id + username + name + } + + fragment Review on Review { + id + body + } + + fragment Product on Product { + inStock + name + price + shippingEstimate + upc + weight + } + + query TestQuery($user: Boolean = true, $product: Boolean = true, $reviews: Boolean = true) { + users { + ...User + reviews @include(if: $reviews) { + ...Review + product { + ...Product + reviews { + ...Review + author @include(if: $user) { + ...User + reviews { + ...Review + product @include(if: $product) { + ...Product + } + } + } + } + } + } + } + topProducts @include(if: $product) { + ...Product + reviews { + ...Review + author { + ...User + reviews @include(if: $reviews) { + ...Review + product { + ...Product + } + } + } + } + } + } + "#, + ); + + let query_plan = build_query_plan("fixture/products-example.supergraph.graphql", document); + assert!( + query_plan.is_ok(), + "expected query planning to succeed, got: {:?}", + query_plan.err() + ); + + Ok(()) +} From c789b62e97a8446fa052d777a845c121057fceef Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sun, 12 Apr 2026 13:46:42 -0400 Subject: [PATCH 21/76] fix(executor): correct timeout error message (#901) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/fix_timeout_error.md | 8 + .../timeout_per_subgraph_dynamic.router.yaml | 4 +- e2e/src/timeout_per_subgraph.rs | 137 +++++++++++++++++- lib/executor/src/executors/error.rs | 4 +- lib/executor/src/executors/http.rs | 9 +- 5 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix_timeout_error.md diff --git a/.changeset/fix_timeout_error.md b/.changeset/fix_timeout_error.md new file mode 100644 index 000000000..00e52d25c --- /dev/null +++ b/.changeset/fix_timeout_error.md @@ -0,0 +1,8 @@ +--- +hive-router-plan-executor: patch +hive-router: patch +--- + +# Fix timeout error message to include the timeout duration instead of the endpoint URL + +Previously by mistake, the error message for subgraph request timeouts included the endpoint URL instead of the timeout duration like `Request to subgraph timed out after http://ACCOUNT_ENDPOINT:PORT/accounts milliseconds`. This change simplifies the error message like `Request to subgraph timed out`. \ No newline at end of file diff --git a/e2e/configs/timeout_per_subgraph_dynamic.router.yaml b/e2e/configs/timeout_per_subgraph_dynamic.router.yaml index fb897b843..2d246114e 100644 --- a/e2e/configs/timeout_per_subgraph_dynamic.router.yaml +++ b/e2e/configs/timeout_per_subgraph_dynamic.router.yaml @@ -4,7 +4,7 @@ supergraph: path: ../supergraph.graphql traffic_shaping: all: - request_timeout: 2s + request_timeout: 200ms # Disable deduplication to better hunt for deadlocks in tests dedupe_enabled: false subgraphs: @@ -12,7 +12,7 @@ traffic_shaping: request_timeout: expression: | if (.request.headers."x-timeout" == "short") { - "10s" + "1s" } else { .default } diff --git a/e2e/src/timeout_per_subgraph.rs b/e2e/src/timeout_per_subgraph.rs index 207830bc3..05c7b78f9 100644 --- a/e2e/src/timeout_per_subgraph.rs +++ b/e2e/src/timeout_per_subgraph.rs @@ -1,9 +1,144 @@ #[cfg(test)] -mod override_subgraph_urls_e2e_tests { +mod timeout_per_subgraph_e2e_tests { + use std::{thread::sleep, time::Duration}; + + use ntex::http::StatusCode; use sonic_rs::json; use crate::testkit::{some_header_map, ClientResponseExt, TestRouter, TestSubgraphs}; + const SUBGRAPH_DELAY: Duration = Duration::from_millis(300); + + #[ntex::test] + async fn should_apply_static_subgraph_timeout_override() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/accounts" { + sleep(SUBGRAPH_DELAY); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/timeout_per_subgraph_static.router.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request("{ users { id } }", None, None) + .await; + + assert_eq!(res.status(), StatusCode::OK, "Expected 200 OK"); + let expected_json = json!({ + "data": { + "users": [ + { "id": "1" }, + { "id": "2" }, + { "id": "3" }, + { "id": "4" }, + { "id": "5" }, + { "id": "6" } + ] + } + }); + + let json_body = res.json_body().await; + assert_eq!(json_body, expected_json); + } + + #[ntex::test] + async fn should_apply_dynamic_subgraph_timeout_override_and_fallback_to_default() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|request| { + if request.path == "/accounts" { + sleep(SUBGRAPH_DELAY); + } + None + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/timeout_per_subgraph_dynamic.router.yaml") + .build() + .start() + .await; + + let short_timeout_res = router + .send_graphql_request( + "{ users { id } }", + None, + some_header_map! { + http::header::HeaderName::from_static("x-timeout") => "short" + }, + ) + .await; + + assert_eq!( + short_timeout_res.status(), + StatusCode::OK, + "Expected 200 OK" + ); + + let expected_json = json!({ + "data": { + "users": [ + { "id": "1" }, + { "id": "2" }, + { "id": "3" }, + { "id": "4" }, + { "id": "5" }, + { "id": "6" } + ] + } + }); + + let json_body = short_timeout_res.json_body().await; + assert_eq!(json_body, expected_json); + + let default_timeout_res = router + .send_graphql_request( + "{ users { id } }", + None, + some_header_map! { + http::header::HeaderName::from_static("x-timeout") => "long" + }, + ) + .await; + + assert_eq!( + default_timeout_res.status(), + StatusCode::OK, + "Expected 200 OK" + ); + insta::assert_snapshot!( + default_timeout_res.json_body_string_pretty().await, + @r#" + { + "data": { + "users": null + }, + "errors": [ + { + "message": "Request to subgraph timed out", + "extensions": { + "code": "SUBGRAPH_REQUEST_TIMEOUT", + "serviceName": "accounts" + } + } + ] + } + "# + ); + } + #[ntex::test] async fn should_not_deadlock_when_overriding_subgraph_timeout_statically() { let subgraphs = TestSubgraphs::builder().build().start().await; diff --git a/lib/executor/src/executors/error.rs b/lib/executor/src/executors/error.rs index 6a4541b13..e85137d2d 100644 --- a/lib/executor/src/executors/error.rs +++ b/lib/executor/src/executors/error.rs @@ -38,9 +38,9 @@ pub enum SubgraphExecutorError { #[error("Failed to resolve VRL expression for timeout for subgraph. Runtime error: {0}")] #[strum(serialize = "SUBGRAPH_TIMEOUT_EXPRESSION_RESOLUTION_FAILURE")] TimeoutExpressionResolution(String), - #[error("Request to subgraph timed out after {0} milliseconds")] + #[error("Request to subgraph timed out")] #[strum(serialize = "SUBGRAPH_REQUEST_TIMEOUT")] - RequestTimeout(String, u128), + RequestTimeout(#[from] tokio::time::error::Elapsed), #[error("Failed to read response body from subgraph \"{0}\": {1}")] #[strum(serialize = "SUBGRAPH_RESPONSE_BODY_READ_FAILURE")] ResponseBodyReadFailure(String, String), diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index 4fb11fda0..6f92dbad0 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -223,14 +223,7 @@ async fn send_request<'a>( let res_fut = http_client.request(req); let res = if let Some(timeout_duration) = timeout { - tokio::time::timeout(timeout_duration, res_fut) - .await - .map_err(|_| { - SubgraphExecutorError::RequestTimeout( - endpoint.to_string(), - timeout_duration.as_millis(), - ) - })? + tokio::time::timeout(timeout_duration, res_fut).await? } else { res_fut.await }?; From 9ef2f800e805b9ea4fd853dead4119baa8e68059 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 08:29:56 +0300 Subject: [PATCH 22/76] chore(bump): added missing changeset to bump release flow Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/version_bump_to_fix_release_issues.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/version_bump_to_fix_release_issues.md diff --git a/.changeset/version_bump_to_fix_release_issues.md b/.changeset/version_bump_to_fix_release_issues.md new file mode 100644 index 000000000..8b86ef1bc --- /dev/null +++ b/.changeset/version_bump_to_fix_release_issues.md @@ -0,0 +1,9 @@ +--- +hive-router-query-planner: patch +hive-router-internal: patch +hive-router: patch +hive-router-plan-executor: patch +hive-router-config: patch +--- + +# Version bump to fix release issues From 1623897d56125d9075612977890a56e266721af1 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 09:35:48 +0300 Subject: [PATCH 23/76] chore: added missing changeset Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .../replace_graphiql_with_hive_laboratory.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/replace_graphiql_with_hive_laboratory.md diff --git a/.changeset/replace_graphiql_with_hive_laboratory.md b/.changeset/replace_graphiql_with_hive_laboratory.md new file mode 100644 index 000000000..85ddaa171 --- /dev/null +++ b/.changeset/replace_graphiql_with_hive_laboratory.md @@ -0,0 +1,27 @@ +--- +hive-router: patch +hive-router-config: patch +--- + +# Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` From 1d126613914a7b8ae39a119d647dd912f115a6fd Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:42:50 +0300 Subject: [PATCH 24/76] chore(release): router crates and artifacts (#904) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/fix_timeout_error.md | 8 ---- .changeset/planner_conditional.md | 12 ------ .../replace_graphiql_with_hive_laboratory.md | 27 ------------- .../version_bump_to_fix_release_issues.md | 9 ----- Cargo.lock | 12 +++--- bin/router/CHANGELOG.md | 40 +++++++++++++++++++ bin/router/Cargo.toml | 10 ++--- lib/executor/CHANGELOG.md | 17 ++++++++ lib/executor/Cargo.toml | 8 ++-- lib/internal/CHANGELOG.md | 6 +++ lib/internal/Cargo.toml | 4 +- lib/node-addon/CHANGELOG.md | 10 +++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- lib/query-planner/CHANGELOG.md | 13 ++++++ lib/query-planner/Cargo.toml | 2 +- lib/router-config/CHANGELOG.md | 29 ++++++++++++++ lib/router-config/Cargo.toml | 2 +- 18 files changed, 136 insertions(+), 77 deletions(-) delete mode 100644 .changeset/fix_timeout_error.md delete mode 100644 .changeset/planner_conditional.md delete mode 100644 .changeset/replace_graphiql_with_hive_laboratory.md delete mode 100644 .changeset/version_bump_to_fix_release_issues.md diff --git a/.changeset/fix_timeout_error.md b/.changeset/fix_timeout_error.md deleted file mode 100644 index 00e52d25c..000000000 --- a/.changeset/fix_timeout_error.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -hive-router-plan-executor: patch -hive-router: patch ---- - -# Fix timeout error message to include the timeout duration instead of the endpoint URL - -Previously by mistake, the error message for subgraph request timeouts included the endpoint URL instead of the timeout duration like `Request to subgraph timed out after http://ACCOUNT_ENDPOINT:PORT/accounts milliseconds`. This change simplifies the error message like `Request to subgraph timed out`. \ No newline at end of file diff --git a/.changeset/planner_conditional.md b/.changeset/planner_conditional.md deleted file mode 100644 index 1138d9d93..000000000 --- a/.changeset/planner_conditional.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -hive-router-query-planner: patch -node-addon: patch -hive-router-plan-executor: patch -hive-router: patch ---- - -# Fix planning for conditional inline fragments and field conditions - -Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. - -This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. diff --git a/.changeset/replace_graphiql_with_hive_laboratory.md b/.changeset/replace_graphiql_with_hive_laboratory.md deleted file mode 100644 index 85ddaa171..000000000 --- a/.changeset/replace_graphiql_with_hive_laboratory.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -hive-router: patch -hive-router-config: patch ---- - -# Replace GraphiQL with Hive Laboratory - -The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. - -Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). - -### Breaking Changes: - -The top-level config option has been renamed. - -```diff -- graphiql: -+ laboratory: - enabled: true -``` - -So was the environment variable override. - -```diff -- GRAPHIQL_ENABLED=true -+ LABORATORY_ENABLED=true -``` diff --git a/.changeset/version_bump_to_fix_release_issues.md b/.changeset/version_bump_to_fix_release_issues.md deleted file mode 100644 index 8b86ef1bc..000000000 --- a/.changeset/version_bump_to_fix_release_issues.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -hive-router-query-planner: patch -hive-router-internal: patch -hive-router: patch -hive-router-plan-executor: patch -hive-router-config: patch ---- - -# Version bump to fix release issues diff --git a/Cargo.lock b/Cargo.lock index b3b7bdbb2..09b24214c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,7 +2350,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.46" +version = "0.0.47" dependencies = [ "ahash", "anyhow", @@ -2406,7 +2406,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.27" +version = "0.0.28" dependencies = [ "config", "envconfig", @@ -2428,7 +2428,7 @@ dependencies = [ [[package]] name = "hive-router-internal" -version = "0.0.15" +version = "0.0.16" dependencies = [ "ahash", "async-trait", @@ -2468,7 +2468,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.9.2" +version = "6.9.3" dependencies = [ "ahash", "async-trait", @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "hive-router-query-planner" -version = "2.5.1" +version = "2.5.2" dependencies = [ "bitflags 2.11.0", "criterion", @@ -3527,7 +3527,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.17" +version = "0.0.18" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index bb5f11422..365fd1424 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.47 (2026-04-13) + +### Fixes + +- correct timeout error message (#901) +- Version bump to fix release issues + +#### Fix timeout error message to include the timeout duration instead of the endpoint URL + +Previously by mistake, the error message for subgraph request timeouts included the endpoint URL instead of the timeout duration like `Request to subgraph timed out after http://ACCOUNT_ENDPOINT:PORT/accounts milliseconds`. This change simplifies the error message like `Request to subgraph timed out`. + +#### Fix planning for conditional inline fragments and field conditions + +Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. + +This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. + +#### Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +#### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` + ## 0.0.46 (2026-04-12) ### Fixes diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 593a691a7..b53e63e80 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.46" +version = "0.0.47" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -21,10 +21,10 @@ noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] [dependencies] -hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.1" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.2" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.27" } -hive-router-internal = { path = "../../lib/internal", version = "0.0.15" } +hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.2" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.3" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.28" } +hive-router-internal = { path = "../../lib/internal", version = "0.0.16" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index 124ca02eb..399858458 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.9.3 (2026-04-13) + +### Fixes + +- correct timeout error message (#901) +- Version bump to fix release issues + +#### Fix timeout error message to include the timeout duration instead of the endpoint URL + +Previously by mistake, the error message for subgraph request timeouts included the endpoint URL instead of the timeout duration like `Request to subgraph timed out after http://ACCOUNT_ENDPOINT:PORT/accounts milliseconds`. This change simplifies the error message like `Request to subgraph timed out`. + +#### Fix planning for conditional inline fragments and field conditions + +Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. + +This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. + ## 6.9.2 (2026-03-31) ### Fixes diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 928cd7e52..c74589fec 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.9.2" +version = "6.9.3" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -15,9 +15,9 @@ authors = ["The Guild"] doctest = false [dependencies] -hive-router-query-planner = { path = "../query-planner", version = "2.5.1" } -hive-router-config = { path = "../router-config", version = "0.0.27" } -hive-router-internal = { path = "../internal", version = "0.0.15" } +hive-router-query-planner = { path = "../query-planner", version = "2.5.2" } +hive-router-config = { path = "../router-config", version = "0.0.28" } +hive-router-internal = { path = "../internal", version = "0.0.16" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } async-trait = { workspace = true } diff --git a/lib/internal/CHANGELOG.md b/lib/internal/CHANGELOG.md index c78023173..0a6ae79fc 100644 --- a/lib/internal/CHANGELOG.md +++ b/lib/internal/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.0.16 (2026-04-13) + +### Fixes + +- Version bump to fix release issues + ## 0.0.15 (2026-03-26) ### Features diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 2a0c66f85..3f33f3b9c 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-internal" -version = "0.0.15" +version = "0.0.16" edition = "2021" description = "GraphQL Hive Router internal crate" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.27" } +hive-router-config = { path = "../router-config", version = "0.0.28" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 1f55ba689..db0a4bd81 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,14 @@ # @graphql-hive/router-query-planner changelog +## 0.0.18 (2026-04-13) + +### Fixes + +#### Fix planning for conditional inline fragments and field conditions + +Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. + +This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. + ## 0.0.17 (2026-04-01) ### Fixes diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 9cae9bb25..3957552e5 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.17" +version = "0.0.18" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index 2af38147c..c88baf827 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.17", + "version": "0.0.18", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/lib/query-planner/CHANGELOG.md b/lib/query-planner/CHANGELOG.md index 630570ca7..22e4b3155 100644 --- a/lib/query-planner/CHANGELOG.md +++ b/lib/query-planner/CHANGELOG.md @@ -30,6 +30,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 2.5.2 (2026-04-13) + +### Fixes + +- planning for conditional inline fragments and field conditions (#894) +- Version bump to fix release issues + +#### Fix planning for conditional inline fragments and field conditions + +Fixed a query-planner bug where directive-only inline fragments (using `@include`/`@skip` without an explicit type condition) could fail during normalization/planning for deeply nested operations. + +This update improves planner handling for conditional selections and adds regression tests to prevent these failures in the future. + ## 2.5.1 (2026-03-31) ### Fixes diff --git a/lib/query-planner/Cargo.toml b/lib/query-planner/Cargo.toml index 0675deadd..24e1d4ae2 100644 --- a/lib/query-planner/Cargo.toml +++ b/lib/query-planner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-query-planner" -version = "2.5.1" +version = "2.5.2" edition = "2021" description = "GraphQL query planner for Federation specification" license = "MIT" diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index ca6a6c32b..59c0b2471 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.28 (2026-04-13) + +### Fixes + +- Version bump to fix release issues + +#### Replace GraphiQL with Hive Laboratory + +The Laboratory is Hive's powerful GraphQL playground that provides a comprehensive environment for exploring, testing, and experimenting with your GraphQL APIs. Whether you're developing new queries, debugging issues, or sharing operations with your team, the Laboratory offers all the tools you need. + +Read more about Hive Laboratory in [the introduction blog post](https://the-guild.dev/graphql/hive/product-updates/2026-01-28-new-laboratory) or [the documentation](https://the-guild.dev/graphql/hive/docs/new-laboratory). + +#### Breaking Changes: + +The top-level config option has been renamed. + +```diff +- graphiql: ++ laboratory: + enabled: true +``` + +So was the environment variable override. + +```diff +- GRAPHIQL_ENABLED=true ++ LABORATORY_ENABLED=true +``` + ## 0.0.27 (2026-04-12) ### Fixes diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 1e424306f..5139a0bf1 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.27" +version = "0.0.28" edition = "2021" publish = true license = "MIT" From 6e026f9e85e5bdebc6bcfafce511f8e2c4f75f7a Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 10:29:40 +0300 Subject: [PATCH 25/76] fix: release issues with build.rs Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- bin/router/Cargo.toml | 1 + bin/router/build.rs | 54 +++++++++++++++++-------------------------- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index b53e63e80..7231cc26b 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -9,6 +9,7 @@ authors = ["The Guild"] repository = "https://github.com/graphql-hive/router" homepage = "https://github.com/graphql-hive/router" readme = "README.crate.md" +exclude = ["node_modules"] [lib] diff --git a/bin/router/build.rs b/bin/router/build.rs index 0ad2a038d..7e953f896 100644 --- a/bin/router/build.rs +++ b/bin/router/build.rs @@ -6,17 +6,31 @@ use std::{ fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - println!("build script using workspace root: {:?}", manifest_dir); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); let output_file = out_dir.join("laboratory.html"); let product_logo = manifest_dir.join("static/product_logo.svg"); - let node_modules_dist = manifest_dir.join("node_modules/@graphql-hive/laboratory/dist"); + let node_modules_dist = out_dir.join("node_modules/@graphql-hive/laboratory/dist"); + + fs::copy( + manifest_dir.join("package.json"), + out_dir.join("package.json"), + ) + .expect("Failed to copy package.json"); + fs::copy( + manifest_dir.join("package-lock.json"), + out_dir.join("package-lock.json"), + ) + .expect("Failed to copy package-lock.json"); println!("cargo:rerun-if-changed={}", product_logo.display()); println!( "cargo:rerun-if-changed={}", manifest_dir.join("package.json").display() ); + println!( + "cargo:rerun-if-changed={}", + manifest_dir.join("node_modules").display() + ); println!( "cargo:rerun-if-changed={}", manifest_dir.join("package-lock.json").display() @@ -24,8 +38,11 @@ fn main() { if !node_modules_dist.exists() { let status = Command::new("npm") - .args(["install", "--include=dev"]) // NODE_ENV=production will skip dev deps - make sure they're in - .current_dir(manifest_dir) + .args([ + "install", + "--include=dev", // NODE_ENV=production will skip dev deps - make sure they're in + ]) + .current_dir(out_dir) .status() .expect("Failed to execute npm install"); @@ -34,35 +51,6 @@ fn main() { } } - println!( - "cargo:rerun-if-changed={}", - node_modules_dist.join("hive-laboratory.umd.js").display() - ); - println!( - "cargo:rerun-if-changed={}", - node_modules_dist - .join("monacoeditorwork/editor.worker.bundle.js") - .display() - ); - println!( - "cargo:rerun-if-changed={}", - node_modules_dist - .join("monacoeditorwork/graphql.worker.bundle.js") - .display() - ); - println!( - "cargo:rerun-if-changed={}", - node_modules_dist - .join("monacoeditorwork/json.worker.bundle.js") - .display() - ); - println!( - "cargo:rerun-if-changed={}", - node_modules_dist - .join("monacoeditorwork/ts.worker.bundle.js") - .display() - ); - let html = build_inline_laboratory_html(&node_modules_dist, &product_logo); fs::write(output_file, html).expect("failed to write generated laboratory.html"); From f427bf15a6b4d3f2b5b5983454e494a94c94be57 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 10:43:26 +0300 Subject: [PATCH 26/76] chore(release): remove `scopes` from knope in order to use changeset file only Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- knope.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/knope.toml b/knope.toml index 20684a8f9..c387baff2 100644 --- a/knope.toml +++ b/knope.toml @@ -8,42 +8,34 @@ repo = "router" [packages.hive-router-query-planner] versioned_files = ["lib/query-planner/Cargo.toml", { path = "lib/executor/Cargo.toml", dependency = "hive-router-query-planner" }, { path = "bin/router/Cargo.toml", dependency = "hive-router-query-planner" }, "Cargo.lock"] -scopes = ["hive-router-query-planner", "qp", "query-planner"] changelog = "lib/query-planner/CHANGELOG.md" [packages.hive-router-plan-executor] versioned_files = ["lib/executor/Cargo.toml", { path = "bin/router/Cargo.toml", dependency = "hive-router-plan-executor" }, "Cargo.lock"] -scopes = ["hive-router-plan-executor", "executor"] changelog = "lib/executor/CHANGELOG.md" [packages.hive-router-config] versioned_files = ["lib/router-config/Cargo.toml", { path = "lib/executor/Cargo.toml", dependency = "hive-router-config" }, { path = "bin/router/Cargo.toml", dependency = "hive-router-config" }, { path = "lib/internal/Cargo.toml", dependency = "hive-router-config" }, "Cargo.lock"] -scopes = ["config"] changelog = "lib/router-config/CHANGELOG.md" [packages.hive-router-internal] versioned_files = ["lib/internal/Cargo.toml", { path = "lib/executor/Cargo.toml", dependency = "hive-router-internal" }, { path = "bin/router/Cargo.toml", dependency = "hive-router-internal" }, "Cargo.lock"] -scopes = ["internal"] changelog = "lib/internal/CHANGELOG.md" [packages.graphql-tools] versioned_files = ["lib/graphql-tools/Cargo.toml", { path = "lib/executor/Cargo.toml", dependency = "graphql-tools" }, { path = "lib/query-planner/Cargo.toml", dependency = "graphql-tools" }, { path = "lib/hive-console-sdk/Cargo.toml", dependency = "graphql-tools" }, { path = "bin/router/Cargo.toml", dependency = "graphql-tools" }, "Cargo.lock"] -scopes = ["graphql-tools"] changelog = "lib/graphql-tools/CHANGELOG.md" [packages.hive-console-sdk] versioned_files = ["lib/hive-console-sdk/Cargo.toml", { path = "bin/router/Cargo.toml", dependency = "hive-console-sdk" }, "Cargo.lock"] -scopes = ["hive-console-sdk"] changelog = "lib/hive-console-sdk/CHANGELOG.md" [packages.hive-router] versioned_files = ["bin/router/Cargo.toml", "Cargo.lock"] -scopes = ["hive-router", "router", "config", "qp", "executor", "internal", "graphql-tools"] changelog = "bin/router/CHANGELOG.md" [packages.node-addon] versioned_files = ["lib/node-addon/package.json", "lib/node-addon/Cargo.toml", "Cargo.lock"] -scopes = ["node-addon"] changelog = "lib/node-addon/CHANGELOG.md" # "release" pipeline that prepares the release and pushes a release PR From ab28d3a6d80201b35f8bff10332fe9224ecd2d26 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 10:46:37 +0300 Subject: [PATCH 27/76] fix: configure knope to ignore conventional_commits Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- knope.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/knope.toml b/knope.toml index c387baff2..f06d964bf 100644 --- a/knope.toml +++ b/knope.toml @@ -1,3 +1,6 @@ +[changes] +ignore_conventional_commits = true + [bot.releases] enabled = true pull_request.title = "chore(release): router crates and artifacts" @@ -43,7 +46,6 @@ changelog = "lib/node-addon/CHANGELOG.md" name = "release" [[workflows.steps]] type = "PrepareRelease" - ignore_conventional_commits = true [[workflows.steps]] type = "Command" command = 'git commit -m "chore: prepare releases"' From bd2717704d0bd11ff9082389ac4685748a13dc4d Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 13 Apr 2026 15:59:40 +0300 Subject: [PATCH 28/76] chore(release): relax knope bot check config Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- knope.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/knope.toml b/knope.toml index f06d964bf..ae4c981f7 100644 --- a/knope.toml +++ b/knope.toml @@ -1,6 +1,9 @@ [changes] ignore_conventional_commits = true +[bot.checks] +skip_labels = ["chore", "internal"] + [bot.releases] enabled = true pull_request.title = "chore(release): router crates and artifacts" From 9e668d677064ffa37ea20620168d9aab52c9b13e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 13 Apr 2026 16:43:39 +0200 Subject: [PATCH 29/76] chore: More deflakeify of E2Es (#907) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .../timeout_per_subgraph_dynamic.router.yaml | 2 +- ..._per_subgraph_dynamic_deadlock.router.yaml | 18 ++++ e2e/src/http.rs | 54 ++---------- e2e/src/supergraph.rs | 86 ++++++++++++------- e2e/src/testkit/mod.rs | 9 +- e2e/src/timeout_per_subgraph.rs | 18 +--- 6 files changed, 89 insertions(+), 98 deletions(-) create mode 100644 e2e/configs/timeout_per_subgraph_dynamic_deadlock.router.yaml diff --git a/e2e/configs/timeout_per_subgraph_dynamic.router.yaml b/e2e/configs/timeout_per_subgraph_dynamic.router.yaml index 2d246114e..5d627398c 100644 --- a/e2e/configs/timeout_per_subgraph_dynamic.router.yaml +++ b/e2e/configs/timeout_per_subgraph_dynamic.router.yaml @@ -12,7 +12,7 @@ traffic_shaping: request_timeout: expression: | if (.request.headers."x-timeout" == "short") { - "1s" + "5s" } else { .default } diff --git a/e2e/configs/timeout_per_subgraph_dynamic_deadlock.router.yaml b/e2e/configs/timeout_per_subgraph_dynamic_deadlock.router.yaml new file mode 100644 index 000000000..caae80d13 --- /dev/null +++ b/e2e/configs/timeout_per_subgraph_dynamic_deadlock.router.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +traffic_shaping: + all: + request_timeout: 5s + # Disable deduplication to better hunt for deadlocks in tests + dedupe_enabled: false + subgraphs: + accounts: + request_timeout: + expression: | + if (.request.headers."x-timeout" == "short") { + "5s" + } else { + .default + } diff --git a/e2e/src/http.rs b/e2e/src/http.rs index cce589075..488591c6f 100644 --- a/e2e/src/http.rs +++ b/e2e/src/http.rs @@ -1,9 +1,6 @@ #[cfg(test)] mod http_tests { - use std::{ - thread::sleep, - time::{Duration, Instant}, - }; + use std::time::{Duration, Instant}; use futures::{stream::FuturesUnordered, StreamExt}; use hive_router::pipeline::execution::EXPOSE_QUERY_PLAN_HEADER; @@ -218,12 +215,7 @@ mod http_tests { #[ntex::test] async fn should_not_dedupe_inflight_router_requests_by_default() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -283,12 +275,7 @@ mod http_tests { #[ntex::test] async fn should_dedupe_inflight_router_requests_when_enabled() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -351,12 +338,7 @@ mod http_tests { #[ntex::test] async fn should_not_dedupe_inflight_router_mutation_requests() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -415,12 +397,7 @@ mod http_tests { #[ntex::test] async fn should_not_share_inflight_dedupe_entry_across_schema_reload() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(1500)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -529,12 +506,7 @@ mod http_tests { #[ntex::test] async fn should_use_all_headers_in_router_dedupe_key_by_default() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -607,12 +579,7 @@ mod http_tests { #[ntex::test] async fn should_ignore_headers_in_router_dedupe_key_when_headers_is_empty() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; @@ -686,12 +653,7 @@ mod http_tests { #[ntex::test] async fn should_use_case_insensitive_header_allowlist_in_router_dedupe_key() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/products" { - sleep(Duration::from_millis(50)); - } - None - }) + .with_delay(Duration::from_millis(100)) .build() .start() .await; diff --git a/e2e/src/supergraph.rs b/e2e/src/supergraph.rs index 16435f5c1..44598274a 100644 --- a/e2e/src/supergraph.rs +++ b/e2e/src/supergraph.rs @@ -2,7 +2,6 @@ mod supergraph_e2e_tests { use std::time::Duration; - use hive_router::invoke_shutdown_hooks; use sonic_rs::JsonValueTrait; use crate::testkit::{wait_until_mock_matched, ClientResponseExt, TestRouter, TestSubgraphs}; @@ -26,38 +25,41 @@ mod supergraph_e2e_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 500ms + poll_interval: 300ms "#, )) .build() .start() .await; - assert_eq!(router.schema_state().plan_cache.entry_count(), 0); - assert_eq!(router.schema_state().normalize_cache.entry_count(), 0); - let res = router .send_graphql_request("{ __schema { types { name } } }", None, None) .await; - assert!(res.status().is_success(), "Expected 200 OK"); - // Flush the caches - router - .schema_state() - .normalize_cache - .run_pending_tasks() - .await; - router.schema_state().plan_cache.run_pending_tasks().await; - invoke_shutdown_hooks(router.shared_state()).await; - - ntex::time::sleep(Duration::from_millis(100)).await; - - // Now it should have the record - assert_eq!(router.schema_state().plan_cache.entry_count(), 1); - assert_eq!(router.schema_state().normalize_cache.entry_count(), 1); + // wait for caches to populate + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + router + .schema_state() + .normalize_cache + .run_pending_tasks() + .await; + router.schema_state().plan_cache.run_pending_tasks().await; + if router.schema_state().plan_cache.entry_count() >= 1 + && router.schema_state().normalize_cache.entry_count() >= 1 + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for caches to populate: plan={}, normalize={}", + router.schema_state().plan_cache.entry_count(), + router.schema_state().normalize_cache.entry_count() + ); + ntex::time::sleep(Duration::from_millis(100)).await; + } - // Remove the first mock and register the new supergraph so the poller picks it up mock1.remove(); let mock2 = server .mock("GET", "/supergraph") @@ -67,22 +69,35 @@ mod supergraph_e2e_tests { .with_body("type Query { dummyNew: NewType } type NewType { id: ID! }") .create(); - // Wait for the poller to pick up the new supergraph wait_until_mock_matched(&mock2) .await .expect("Expected mock2 to be matched"); - router - .schema_state() - .normalize_cache - .run_pending_tasks() - .await; - router.schema_state().plan_cache.run_pending_tasks().await; - invoke_shutdown_hooks(router.shared_state()).await; - - // Now cache should be empty again, if supergraph has changes - assert_eq!(router.schema_state().plan_cache.entry_count(), 0); - assert_eq!(router.schema_state().normalize_cache.entry_count(), 0); + // wait for the router to finish rebuilding with the new supergraph + router.wait_for_ready(None).await; + + // wait for cache invalidation to be reflected (moka invalidate_all is lazy) + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + router + .schema_state() + .normalize_cache + .run_pending_tasks() + .await; + router.schema_state().plan_cache.run_pending_tasks().await; + if router.schema_state().plan_cache.entry_count() == 0 + && router.schema_state().normalize_cache.entry_count() == 0 + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for caches to clear: plan={}, normalize={}", + router.schema_state().plan_cache.entry_count(), + router.schema_state().normalize_cache.entry_count() + ); + ntex::time::sleep(Duration::from_millis(100)).await; + } } /// In this test we are testing that the supergraph is not changed for in-flight requests. @@ -239,6 +254,11 @@ mod supergraph_e2e_tests { .await .expect("Expected mock2 to be matched"); + // wait for the router to finish applying the new supergraph state before asserting; + // the mock being matched only means the poller fetched the sdl, not that the router + // has finished rebuilding its query planner + router.wait_for_ready(None).await; + let res_new_supergraph = router .send_graphql_request("{ users { id name reviews { id body } } }", None, None) .await; diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index d74727052..2e83fbd8f 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -382,10 +382,6 @@ impl TestSubgraphs { let middleware_state = Arc::new(TestSubgraphsMiddlewareState { request_log: DashMap::new(), }); - app = app.layer(axum::middleware::from_fn_with_state( - middleware_state.clone(), - record_requests, - )); if let Some(on_request) = self.on_request.clone() { app = app.layer(axum::middleware::from_fn_with_state( on_request, @@ -400,6 +396,11 @@ impl TestSubgraphs { }, )); } + // record_requests must be outermost so it logs the request before any blocking on_request handler runs + app = app.layer(axum::middleware::from_fn_with_state( + middleware_state.clone(), + record_requests, + )); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); tokio::spawn(async move { diff --git a/e2e/src/timeout_per_subgraph.rs b/e2e/src/timeout_per_subgraph.rs index 05c7b78f9..27c6bd4ba 100644 --- a/e2e/src/timeout_per_subgraph.rs +++ b/e2e/src/timeout_per_subgraph.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod timeout_per_subgraph_e2e_tests { - use std::{thread::sleep, time::Duration}; + use std::time::Duration; use ntex::http::StatusCode; use sonic_rs::json; @@ -12,12 +12,7 @@ mod timeout_per_subgraph_e2e_tests { #[ntex::test] async fn should_apply_static_subgraph_timeout_override() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/accounts" { - sleep(SUBGRAPH_DELAY); - } - None - }) + .with_delay(SUBGRAPH_DELAY) .build() .start() .await; @@ -54,12 +49,7 @@ mod timeout_per_subgraph_e2e_tests { #[ntex::test] async fn should_apply_dynamic_subgraph_timeout_override_and_fallback_to_default() { let subgraphs = TestSubgraphs::builder() - .with_on_request(|request| { - if request.path == "/accounts" { - sleep(SUBGRAPH_DELAY); - } - None - }) + .with_delay(SUBGRAPH_DELAY) .build() .start() .await; @@ -185,7 +175,7 @@ mod timeout_per_subgraph_e2e_tests { let subgraphs = TestSubgraphs::builder().build().start().await; let router = TestRouter::builder() .with_subgraphs(&subgraphs) - .file_config("configs/timeout_per_subgraph_dynamic.router.yaml") + .file_config("configs/timeout_per_subgraph_dynamic_deadlock.router.yaml") .build() .start() .await; From 3e51da45e6a0907b1d113210517e4554132d3a8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:41:48 +0300 Subject: [PATCH 30/76] chore(deps): bump rand from 0.10.0 to 0.10.1 in the cargo group across 1 directory (#908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the cargo group with 1 update in the / directory: [rand](https://github.com/rust-random/rand). Updates `rand` from 0.10.0 to 0.10.1
Changelog

Sourced from rand's changelog.

[0.10.1] — 2026-02-11

This release includes a fix for a soundness bug; see #1763.

Changes

  • Document panic behavior of make_rng and add #[track_caller] (#1761)
  • Deprecate feature log (#1763)

#1761: rust-random/rand#1761 #1763: rust-random/rand#1763

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rand&package-manager=cargo&previous-version=0.10.0&new-version=0.10.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/graphql-hive/router/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 09b24214c..45d321bc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,7 +2382,7 @@ dependencies = [ "moka", "ntex", "prometheus", - "rand 0.10.0", + "rand 0.10.1", "regex-automata", "reqwest", "reqwest-middleware", @@ -5018,9 +5018,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", "getrandom 0.4.1", @@ -6299,7 +6299,7 @@ dependencies = [ "async-graphql-axum", "axum", "lazy_static", - "rand 0.10.0", + "rand 0.10.1", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 7c6a3addd..c05b29c29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ thiserror = "2.0.14" xxhash-rust = { version = "0.8.15", features = ["xxh3"] } tokio = { version = "1.47.1", features = ["full"] } tokio-util = { version = "0.7.16" } -rand = "0.10.0" +rand = "0.10.1" jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } ntex = { version = "3.7.2", features = ["tokio"] } tonic = { version = "0.14.2", features = ["tls-aws-lc"] } From 83b15430dcdb77bc5b230f963707918e333c472e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 14 Apr 2026 18:22:18 +0200 Subject: [PATCH 31/76] Subscriptions (#620) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Arda TANRIKULU Co-authored-by: theguild-bot Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/federated_graphql_subscriptions.md | 17 + .changeset/query_plan_subscriptions_node.md | 8 + Cargo.lock | 31 + Cargo.toml | 3 +- bench/subgraphs/Cargo.toml | 12 + bench/subgraphs/graphql_with_subscriptions.rs | 456 ++++ bench/subgraphs/lib.rs | 28 +- bench/subgraphs/reviews.rs | 79 +- bench/supergraph.graphql | 8 + bin/router/Cargo.toml | 5 + bin/router/src/error.rs | 2 + bin/router/src/lib.rs | 133 +- .../src/pipeline/active_subscriptions.rs | 98 + bin/router/src/pipeline/error.rs | 80 +- bin/router/src/pipeline/execution.rs | 57 +- bin/router/src/pipeline/http_callback.rs | 251 +++ .../src/pipeline/long_lived_client_limit.rs | 191 ++ bin/router/src/pipeline/mod.rs | 293 +-- .../src/pipeline/multipart_subscribe.rs | 133 ++ bin/router/src/pipeline/sse.rs | 48 + bin/router/src/pipeline/websocket_server.rs | 754 +++++++ bin/router/src/schema_state.rs | 116 +- bin/router/src/shared_state.rs | 156 +- bin/router/src/telemetry.rs | 4 +- docs/README.md | 182 +- e2e/configs/header_propagation.router.yaml | 4 +- e2e/src/file_supergraph.rs | 2 +- e2e/src/hive_cdn_supergraph.rs | 7 +- e2e/src/http.rs | 6 +- e2e/src/http_callback.rs | 338 +++ e2e/src/lib.rs | 6 + e2e/src/subscriptions.rs | 1829 +++++++++++++++++ e2e/src/supergraph.rs | 44 +- e2e/src/testkit/mod.rs | 162 +- e2e/src/testkit/otel.rs | 6 +- e2e/src/websocket.rs | 478 +++++ e2e/supergraph.graphql | 10 + lib/executor/Cargo.toml | 4 + .../src/execution/client_request_details.rs | 6 +- lib/executor/src/execution/plan.rs | 319 ++- lib/executor/src/executors/common.rs | 13 +- lib/executor/src/executors/error.rs | 41 +- .../src/executors/graphql_transport_ws.rs | 433 ++++ lib/executor/src/executors/http.rs | 283 ++- lib/executor/src/executors/http_callback.rs | 283 +++ lib/executor/src/executors/map.rs | 322 ++- lib/executor/src/executors/mod.rs | 7 + .../src/executors/multipart_subscribe.rs | 627 ++++++ lib/executor/src/executors/sse.rs | 381 ++++ lib/executor/src/executors/websocket.rs | 196 ++ .../src/executors/websocket_client.rs | 480 +++++ .../src/executors/websocket_common.rs | 200 ++ lib/executor/src/headers/mod.rs | 24 +- lib/executor/src/introspection/resolve.rs | 37 +- .../src/plugins/hooks/on_supergraph_load.rs | 4 +- .../src/response/subgraph_response.rs | 29 +- lib/internal/src/inflight.rs | 44 +- lib/query-planner/src/planner/plan_nodes.rs | 34 +- .../src/planner/query_plan/optimize.rs | 5 +- lib/router-config/Cargo.toml | 3 +- lib/router-config/src/env_overrides.rs | 16 + lib/router-config/src/http_server.rs | 24 +- lib/router-config/src/lib.rs | 42 + .../src/primitives/absolute_path.rs | 107 + lib/router-config/src/primitives/mod.rs | 1 + lib/router-config/src/subscriptions.rs | 206 ++ lib/router-config/src/traffic_shaping.rs | 49 +- lib/router-config/src/websocket.rs | 118 ++ 68 files changed, 9826 insertions(+), 549 deletions(-) create mode 100644 .changeset/federated_graphql_subscriptions.md create mode 100644 .changeset/query_plan_subscriptions_node.md create mode 100644 bench/subgraphs/graphql_with_subscriptions.rs create mode 100644 bin/router/src/pipeline/active_subscriptions.rs create mode 100644 bin/router/src/pipeline/http_callback.rs create mode 100644 bin/router/src/pipeline/long_lived_client_limit.rs create mode 100644 bin/router/src/pipeline/multipart_subscribe.rs create mode 100644 bin/router/src/pipeline/sse.rs create mode 100644 bin/router/src/pipeline/websocket_server.rs create mode 100644 e2e/src/http_callback.rs create mode 100644 e2e/src/subscriptions.rs create mode 100644 e2e/src/websocket.rs create mode 100644 lib/executor/src/executors/graphql_transport_ws.rs create mode 100644 lib/executor/src/executors/http_callback.rs create mode 100644 lib/executor/src/executors/multipart_subscribe.rs create mode 100644 lib/executor/src/executors/sse.rs create mode 100644 lib/executor/src/executors/websocket.rs create mode 100644 lib/executor/src/executors/websocket_client.rs create mode 100644 lib/executor/src/executors/websocket_common.rs create mode 100644 lib/router-config/src/primitives/absolute_path.rs create mode 100644 lib/router-config/src/subscriptions.rs create mode 100644 lib/router-config/src/websocket.rs diff --git a/.changeset/federated_graphql_subscriptions.md b/.changeset/federated_graphql_subscriptions.md new file mode 100644 index 000000000..f298883d9 --- /dev/null +++ b/.changeset/federated_graphql_subscriptions.md @@ -0,0 +1,17 @@ +--- +hive-router: minor +hive-router-config: minor +hive-router-plan-executor: minor +--- + +# Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) diff --git a/.changeset/query_plan_subscriptions_node.md b/.changeset/query_plan_subscriptions_node.md new file mode 100644 index 000000000..0d05d59b2 --- /dev/null +++ b/.changeset/query_plan_subscriptions_node.md @@ -0,0 +1,8 @@ +--- +hive-router-query-planner: minor +node-addon: minor +--- + +# Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. diff --git a/Cargo.lock b/Cargo.lock index 45d321bc0..7dc7ddf40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + [[package]] name = "context-data-plugin-example" version = "0.0.1" @@ -2355,12 +2361,17 @@ dependencies = [ "ahash", "anyhow", "arc-swap", + "async-stream", "async-trait", + "bytes", "combine", + "const-str", "cookie", "criterion", "dashmap", "futures", + "futures-timer", + "futures-util", "graphql-tools", "headers-accept", "hive-console-sdk", @@ -2424,6 +2435,7 @@ dependencies = [ "thiserror 2.0.18", "tonic", "tracing", + "url", ] [[package]] @@ -2471,12 +2483,14 @@ name = "hive-router-plan-executor" version = "6.9.3" dependencies = [ "ahash", + "async-stream", "async-trait", "bumpalo", "bytes", "criterion", "dashmap", "futures", + "futures-util", "graphql-tools", "hive-router-config", "hive-router-internal", @@ -2502,6 +2516,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ulid", "xxhash-rust", "zmij", ] @@ -3620,6 +3635,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "regex", + "rustls", "serde", "serde_json", "serde_urlencoded", @@ -3627,6 +3643,7 @@ dependencies = [ "thiserror 2.0.18", "uuid", "variadics_please", + "webpki-roots", ] [[package]] @@ -3874,6 +3891,7 @@ dependencies = [ "ntex-net", "ntex-service", "ntex-util", + "rustls", ] [[package]] @@ -5678,6 +5696,7 @@ dependencies = [ "schemars_derive 1.2.1", "serde", "serde_json", + "url", ] [[package]] @@ -6297,10 +6316,21 @@ version = "0.0.1" dependencies = [ "async-graphql", "async-graphql-axum", + "async-stream", "axum", + "bytes", + "futures", + "futures-timer", + "futures-util", + "hive-router", + "http", "lazy_static", "rand 0.10.1", + "reqwest", + "serde", + "serde_json", "tokio", + "tower-service", ] [[package]] @@ -7064,6 +7094,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c05b29c29..21a626928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,9 +64,10 @@ thiserror = "2.0.14" xxhash-rust = { version = "0.8.15", features = ["xxh3"] } tokio = { version = "1.47.1", features = ["full"] } tokio-util = { version = "0.7.16" } +tokio-stream = { version = "0.1.17" } rand = "0.10.1" jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } -ntex = { version = "3.7.2", features = ["tokio"] } +ntex = { version = "3.7.2", features = ["tokio", "rustls"] } tonic = { version = "0.14.2", features = ["tls-aws-lc"] } reqwest = { version = "0.12.23", default-features = false, features = ["http2", "rustls-tls"] } reqwest-retry = "0.8.0" diff --git a/bench/subgraphs/Cargo.toml b/bench/subgraphs/Cargo.toml index bfa0859da..f19b79db9 100644 --- a/bench/subgraphs/Cargo.toml +++ b/bench/subgraphs/Cargo.toml @@ -17,6 +17,18 @@ lazy_static = { workspace = true } rand = { workspace = true } tokio = { workspace = true } axum = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +reqwest = { workspace = true } +# TODO: can this be a workspace = true? +hive-router = { path = "../../bin/router" } +bytes = "1.10.1" async-graphql = "7.0.17" async-graphql-axum = "7.0.17" +futures-util = "0.3.31" +tower-service = "0.3.3" +async-stream = "0.3.6" +futures-timer = "3.0.3" diff --git a/bench/subgraphs/graphql_with_subscriptions.rs b/bench/subgraphs/graphql_with_subscriptions.rs new file mode 100644 index 000000000..540e20043 --- /dev/null +++ b/bench/subgraphs/graphql_with_subscriptions.rs @@ -0,0 +1,456 @@ +use std::time::Duration; +use std::{ + convert::Infallible, + io, + task::{Context, Poll}, +}; + +use async_graphql::{Executor, Response as GraphQLResponse}; +use async_graphql_axum::GraphQLBatchRequest; +use async_graphql_axum::{rejection::GraphQLRejection, GraphQLRequest}; +use axum::{ + body::{Body, HttpBody}, + extract::FromRequest, + http::{Request as HttpRequest, Response as HttpResponse}, + response::IntoResponse, + BoxError, +}; +use bytes::Bytes; +use futures_util::{future::BoxFuture, stream::BoxStream, Stream, StreamExt}; +use hive_router::pipeline::multipart_subscribe::APOLLO_MULTIPART_HTTP_CONTENT_TYPE; +use hive_router::pipeline::sse::SSE_HEADER; +use hive_router::pipeline::{multipart_subscribe, sse}; +use hive_router::tracing::error; +use serde::{Deserialize, Serialize}; +use tower_service::Service; + +use crate::HTTPStreamingSubscriptionProtocol; + +#[derive(Clone)] +pub struct GraphQL { + executor: E, + subscriptions_protocol: HTTPStreamingSubscriptionProtocol, +} + +impl GraphQL +where + E: Clone, +{ + pub fn new(executor: E, subscriptions_protocol: HTTPStreamingSubscriptionProtocol) -> Self { + Self { + executor, + subscriptions_protocol, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CallbackSubscriptionExt { + callback_url: String, + subscription_id: String, + verifier: String, + #[serde(default)] + heartbeat_interval_ms: u64, +} + +#[derive(Serialize)] +struct CallbackMessage<'a> { + kind: &'a str, + action: &'a str, + id: &'a str, + verifier: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + errors: Option>, +} + +// yeah I know there's a graphql error struct, but I want to avoid overengineering this +#[derive(Serialize)] +struct GraphQLError { + message: String, +} + +impl Service> for GraphQL +where + B: HttpBody + Send + 'static, + B::Data: Into, + B::Error: Into, + E: Executor, +{ + type Response = HttpResponse; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: HttpRequest) -> Self::Future { + let does_accept_callback = req + .headers() + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| + // we dont handle spaces, we dont handle q weights, we dont care atm + accept.contains("application/json;callbackSpec=1.0")); + + let does_accept_multipart_mixed = req + .headers() + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| + // we dont use is_accept_multipart_mixed here because it requires the boundary + // to also be set in the accept header, which is not necessary according to + // the spec. hive router obeys the spec, and will set exactly the necessary accept + // as per https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol + accept.contains(r#"multipart/mixed;subscriptionSpec="1.0""#)); + + let does_accept_event_stream = req + .headers() + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains(SSE_HEADER)); + + // for testing purposes. abruptly terminate the stream after N messages + let break_after_count = req + .headers() + .get("x-break-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + + let disable_heartbeats = req + .headers() + .get("x-disable-http-callback-heartbeats") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == "true"); + + let sub_prot = self.subscriptions_protocol.clone(); + + let executor = self.executor.clone(); + let req = req.map(Body::new); + + Box::pin(async move { + if does_accept_callback { + let req = match GraphQLRequest::::from_request(req, &()).await { + Ok(req) => req, + Err(err) => return Ok(err.into_response()), + }; + + let gql_request = req.into_inner(); + + let sub_ext = match gql_request.extensions.get("subscription") { + Some(val) => { + let json = serde_json::to_value(val).unwrap(); + match serde_json::from_value::(json) { + Ok(ext) => ext, + Err(_) => { + return Ok(HttpResponse::builder() + .status(400) + .body(Body::from( + r#"{"errors":[{"message":"Invalid subscription extension"}]}"#, + )) + .unwrap()); + } + } + } + None => { + return Ok(HttpResponse::builder() + .status(400) + .body(Body::from( + r#"{"errors":[{"message":"Missing subscription extension"}]}"#, + )) + .unwrap()); + } + }; + + // no hyper, just keeping it simple + let client = reqwest::Client::new(); + + let check_msg = CallbackMessage { + kind: "subscription", + action: "check", + id: &sub_ext.subscription_id, + verifier: &sub_ext.verifier, + payload: None, + errors: None, + }; + let check_resp = client + .post(&sub_ext.callback_url) + .header("subscription-protocol", "callback/1.0") + .json(&check_msg) + .send() + .await; + + match check_resp { + Ok(resp) if resp.status() == 204 => {} + Ok(resp) => { + error!("callback check responded with: {}", resp.status()); + return Ok(HttpResponse::builder() + .status(400) + .body(Body::from( + r#"{"errors":[{"message":"Callback check failed for whatever reason"}]}"#, + )) + .unwrap()); + } + Err(err) => { + error!("failed to send callback check request: {}", err); + return Ok(HttpResponse::builder() + .status(400) + .body(Body::from( + r#"{"errors":[{"message":"Failed to send callback check request"}]}"#, + )) + .unwrap()); + } + } + + let stream = executor.execute_stream(gql_request, None); + + tokio::spawn(emit_subscription_events( + client, + sub_ext.callback_url, + sub_ext.subscription_id, + sub_ext.verifier, + sub_ext.heartbeat_interval_ms, + disable_heartbeats, + stream, + )); + + return Ok(HttpResponse::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(r#"{"data":null}"#)) + .unwrap()); + } + + if !does_accept_multipart_mixed && !does_accept_event_stream { + let req = + match GraphQLBatchRequest::::from_request(req, &()).await { + Ok(req) => req, + Err(err) => return Ok(err.into_response()), + }; + return Ok(async_graphql_axum::GraphQLResponse( + executor.execute_batch(req.0).await, + ) + .into_response()); + } + + let req = match GraphQLRequest::::from_request(req, &()).await { + Ok(req) => req, + Err(err) => return Ok(err.into_response()), + }; + let stream = executor.execute_stream(req.0, None); + + let use_sse = match sub_prot { + HTTPStreamingSubscriptionProtocol::PreferMultipartFallbackSse => { + does_accept_event_stream + } + HTTPStreamingSubscriptionProtocol::SseOnly => true, + HTTPStreamingSubscriptionProtocol::MultipartOnly => false, + }; + + if use_sse { + let byte_stream = + create_sse_stream(stream, Duration::from_secs(10)).map(Ok::<_, io::Error>); + let body = if let Some(count) = break_after_count { + Body::from_stream(abrupt_terminate_after(byte_stream, count)) + } else { + Body::from_stream(byte_stream) + }; + Ok(HttpResponse::builder() + .header(http::header::CONTENT_TYPE, SSE_HEADER) + .header(http::header::CACHE_CONTROL, "no-cache") + .header(http::header::CONNECTION, "keep-alive") + .body(body) + .unwrap()) + } else { + let byte_stream = + create_multipart_subscribe_stream(stream, Duration::from_secs(30)) + .map(Ok::<_, io::Error>); + let body = if let Some(count) = break_after_count { + Body::from_stream(abrupt_terminate_after(byte_stream, count)) + } else { + Body::from_stream(byte_stream) + }; + Ok(HttpResponse::builder() + .header( + http::header::CONTENT_TYPE, + APOLLO_MULTIPART_HTTP_CONTENT_TYPE, + ) + .header(http::header::CACHE_CONTROL, "no-cache") + .header(http::header::CONNECTION, "keep-alive") + .body(body) + .unwrap()) + } + }) + } +} + +async fn emit_subscription_events( + client: reqwest::Client, + callback_url: String, + subscription_id: String, + verifier: String, + heartbeat_interval_ms: u64, + disable_heartbeats: bool, + stream: impl Stream + Send + Unpin + 'static, +) { + let mut stream = std::pin::pin!(stream); + + let heartbeat_enabled = heartbeat_interval_ms > 0; + let mut heartbeat_interval = if heartbeat_enabled { + Some(tokio::time::interval(Duration::from_millis( + heartbeat_interval_ms, + ))) + } else { + None + }; + + // skip the first immediate tick + if let Some(ref mut interval) = heartbeat_interval { + interval.tick().await; + } + + loop { + let next_event = if let Some(ref mut interval) = heartbeat_interval { + tokio::select! { + item = stream.next() => item.map(|r| CallbackEvent::Next(Box::new(r))), + _ = interval.tick() => Some(CallbackEvent::Heartbeat), + } + } else { + stream + .next() + .await + .map(|r| CallbackEvent::Next(Box::new(r))) + }; + + match next_event { + Some(CallbackEvent::Next(response)) => { + let payload = + serde_json::to_value(&response).expect("Failed to serialize GraphQLResponse"); + let msg = CallbackMessage { + kind: "subscription", + action: "next", + id: &subscription_id, + verifier: &verifier, + payload: Some(payload), + errors: None, + }; + match send_callback(&client, &callback_url, &msg).await { + Ok(status) if status.is_success() => {} + // 404, or any other non-success status or error terminates the + // subscription. this subgraph is for testing, we dont dwell + // too much about learning the exact reason + _ => return, + } + } + Some(CallbackEvent::Heartbeat) => { + if disable_heartbeats { + continue; + } + let msg = CallbackMessage { + kind: "subscription", + action: "check", + id: &subscription_id, + verifier: &verifier, + payload: None, + errors: None, + }; + match send_callback(&client, &callback_url, &msg).await { + Ok(status) if status.is_success() => {} + // 404, or any other non-success status or error terminates the + // subscription. this subgraph is for testing, we dont dwell + // too much about learning the exact reason + _ => return, + } + } + None => { + let msg = CallbackMessage { + kind: "subscription", + action: "complete", + id: &subscription_id, + verifier: &verifier, + payload: None, + errors: None, + }; + let _ = send_callback(&client, &callback_url, &msg).await; + return; + } + } + } +} + +enum CallbackEvent { + Next(Box), + Heartbeat, +} + +async fn send_callback( + client: &reqwest::Client, + url: &str, + msg: &CallbackMessage<'_>, +) -> Result { + let resp = client + .post(url) + .header("subscription-protocol", "callback/1.0") + .json(msg) + .send() + .await?; + Ok(resp.status()) +} + +fn abrupt_terminate_after( + stream: S, + count: usize, +) -> impl Stream> +where + S: Stream> + Send + 'static, +{ + async_stream::stream! { + let mut stream = std::pin::pin!(stream); + let mut emitted = 0; + + while let Some(item) = stream.next().await { + yield item; + emitted += 1; + if emitted > count { + // error abruptly killing the connection + yield Err(io::Error::new(io::ErrorKind::ConnectionReset, "connection abruptly terminated")); + break; + } + } + } +} + +pub fn create_sse_stream( + input: impl Stream + Send + Unpin + 'static, + heartbeat_interval: Duration, +) -> BoxStream<'static, Bytes> { + // GraphQLResponse stream to Vec stream + let byte_stream = + input.map(|resp| serde_json::to_vec(&resp).expect("Failed to serialize GraphQLResponse")); + sse::create_stream(byte_stream, heartbeat_interval) + .map(|result| { + // Convert Result to bytes::Bytes + // Unwrap is safe here as we control the serialization above + Bytes::copy_from_slice(&result.expect("SSE stream error")) + }) + .boxed() +} + +pub fn create_multipart_subscribe_stream( + input: impl Stream + Send + Unpin + 'static, + heartbeat_interval: Duration, +) -> BoxStream<'static, Bytes> { + // GraphQLResponse stream to Vec stream + let byte_stream = + input.map(|resp| serde_json::to_vec(&resp).expect("Failed to serialize GraphQLResponse")); + multipart_subscribe::create_apollo_multipart_http_stream(byte_stream, heartbeat_interval) + .map(|result| { + // Convert Result to bytes::Bytes + // Unwrap is safe here as we control the serialization above + Bytes::copy_from_slice(&result.expect("Multipart stream error")) + }) + .boxed() +} diff --git a/bench/subgraphs/lib.rs b/bench/subgraphs/lib.rs index eac0c4aa9..f5d600f11 100644 --- a/bench/subgraphs/lib.rs +++ b/bench/subgraphs/lib.rs @@ -1,9 +1,10 @@ pub mod accounts; +pub mod graphql_with_subscriptions; pub mod inventory; pub mod products; pub mod reviews; -use async_graphql_axum::GraphQL; +use async_graphql_axum::{GraphQL, GraphQLSubscription}; use axum::{ extract::Request, http::StatusCode, @@ -57,7 +58,7 @@ pub fn start_subgraphs_server(port: Option) -> (JoinHandle<()>, Sender<()>) .map(|v| v.to_string()) .unwrap_or(std::env::var("PORT").unwrap_or("4200".to_owned())); - let mut app = subgraphs_app(); + let mut app = subgraphs_app(HTTPStreamingSubscriptionProtocol::default()); app = app.route("/health", get(health_check_handler)); println!("Starting server on http://{}:{}", host, port); @@ -80,7 +81,19 @@ pub fn start_subgraphs_server(port: Option) -> (JoinHandle<()>, Sender<()>) (server_handle, shutdown_tx) } -pub fn subgraphs_app() -> Router<()> { +/// The protocol to use for GraphQL subscriptions over HTTP streaming. +/// It is purely the streaming HTTP protocol, other subscription protocols +/// are handled automatically through HTTP negotiation (like websocket upgrades +/// or http callbacks). +#[derive(Clone, Default)] +pub enum HTTPStreamingSubscriptionProtocol { + #[default] + PreferMultipartFallbackSse, + MultipartOnly, + SseOnly, +} + +pub fn subgraphs_app(subscriptions_protocol: HTTPStreamingSubscriptionProtocol) -> Router<()> { Router::new() .route( "/accounts", @@ -94,9 +107,16 @@ pub fn subgraphs_app() -> Router<()> { "/products", post_service(GraphQL::new(products::get_subgraph())), ) + .route_service( + "/reviews/ws", + GraphQLSubscription::new(reviews::get_subgraph()), + ) .route( "/reviews", - post_service(GraphQL::new(reviews::get_subgraph())), + post_service(graphql_with_subscriptions::GraphQL::new( + reviews::get_subgraph(), + subscriptions_protocol, + )), ) .route_layer(middleware::from_fn(add_subgraph_header)) .route_layer(middleware::from_fn(delay_middleware)) diff --git a/bench/subgraphs/reviews.rs b/bench/subgraphs/reviews.rs index 0dc9cebff..86d839fc0 100644 --- a/bench/subgraphs/reviews.rs +++ b/bench/subgraphs/reviews.rs @@ -1,6 +1,7 @@ -use async_graphql::{ - ComplexObject, EmptyMutation, EmptySubscription, Object, Schema, SimpleObject, ID, -}; +use std::time::Duration; + +use async_graphql::{ComplexObject, EmptyMutation, Object, Schema, SimpleObject, Subscription, ID}; +use futures::stream::{self, Stream}; use lazy_static::lazy_static; lazy_static! { @@ -167,8 +168,76 @@ impl Query { } } -pub fn get_subgraph() -> Schema { - Schema::build(Query, EmptyMutation, EmptySubscription) +pub struct Subscription; + +#[Subscription] +impl Subscription { + async fn review_added( + &self, + #[graphql(default = 1)] step: usize, + #[graphql(default = 1_000)] interval_in_ms: u64, + ) -> impl Stream { + stream::unfold( + ( + 0, + if interval_in_ms > 0 { + Some(tokio::time::interval(Duration::from_millis(interval_in_ms))) + } else { + None + }, + ), + move |(i, mut interval)| async move { + match REVIEWS.get(i) { + Some(review) => { + if let Some(int) = &mut interval { + int.tick().await; + } + Some((review.clone(), (i + step, interval))) + } + None => None, + } + }, + ) + } + + async fn review_added_for_product( + &self, + product_upc: String, + #[graphql(default = 1_000)] interval_in_ms: u64, + ) -> impl Stream { + let reviews_for_product: Vec = REVIEWS + .iter() + .filter(move |r| r.product.as_ref().unwrap().upc == product_upc) + .cloned() + .collect(); + + stream::unfold( + ( + reviews_for_product, + 0, + if interval_in_ms > 0 { + Some(tokio::time::interval(Duration::from_millis(interval_in_ms))) + } else { + None + }, + ), + move |(reviews_for_product, i, mut interval)| async move { + match reviews_for_product.get(i) { + Some(review) => { + if let Some(int) = &mut interval { + int.tick().await; + } + Some((review.clone(), (reviews_for_product, i + 1, interval))) + } + None => None, + } + }, + ) + } +} + +pub fn get_subgraph() -> Schema { + Schema::build(Query, EmptyMutation, Subscription) .enable_federation() .finish() } diff --git a/bench/supergraph.graphql b/bench/supergraph.graphql index 631e291e2..52b6f2ca3 100644 --- a/bench/supergraph.graphql +++ b/bench/supergraph.graphql @@ -2,6 +2,7 @@ schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { query: Query + subscription: Subscription } directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE @@ -97,6 +98,13 @@ type Query topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) } +type Subscription @join__type(graph: REVIEWS) { + reviewAdded(step: Int = 1, intervalInMs: Int = 1000): Review + @join__field(graph: REVIEWS) + reviewAddedForProduct(productUpc: String!, intervalInMs: Int = 1000): Review + @join__field(graph: REVIEWS) +} + type Review @join__type(graph: REVIEWS, key: "id") { id: ID! body: String diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 7231cc26b..2b488abdb 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -73,7 +73,12 @@ insta = { workspace = true } mimalloc = { version = "0.1.48", features = ["v3"] } mediatype = "0.21.0" headers-accept = "0.3.0" +async-stream = "0.3.6" +futures-util = "0.3.31" +futures-timer = "3.0.3" +const-str = "1.0.0" md5 = "0.8.0" +bytes = { workspace = true } [dev-dependencies] criterion = { workspace = true } diff --git a/bin/router/src/error.rs b/bin/router/src/error.rs index 4ea59439b..d083ccdce 100644 --- a/bin/router/src/error.rs +++ b/bin/router/src/error.rs @@ -14,6 +14,8 @@ pub enum RouterInitError { SupergraphManagerError(#[from] SupergraphManagerError), #[error("Failed to bind HTTP server to address: {0}. Error: {1}")] HttpServerBindError(String, std::io::Error), + #[error("Failed to bind HTTP callback server to address: {0}. Error: {1}")] + HttpCallbackServerBindError(String, std::io::Error), #[error("Failed to start HTTP server: {0}")] HttpServerStartError(std::io::Error), #[error(transparent)] diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index b00a8d0a8..06bf96266 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -22,9 +22,12 @@ use crate::{ }, jwt::JwtAuthRuntime, pipeline::{ + active_subscriptions::ActiveSubscriptions, error::handle_pipeline_error, graphql_request_handler, header::ResponseMode, + http_callback::handler, + long_lived_client_limit::LongLivedClientLimitService, request_extensions::{ read_graphql_operation_metric_identity, read_graphql_response_metric_status, read_request_body_size, write_graphql_response_metric_status, @@ -35,6 +38,7 @@ use crate::{ max_aliases_rule::MaxAliasesRule, max_depth_rule::MaxDepthRule, max_directives_rule::MaxDirectivesRule, }, + websocket_server::ws_index, }, plugins::plugins_service::PluginService, telemetry::{HeaderExtractor, PrometheusAttached}, @@ -49,8 +53,9 @@ pub use dashmap::DashMap; pub use graphql_tools; use graphql_tools::validation::rules::default_rules_validation_plan; pub use hive_router_config::humantime_serde; -use hive_router_config::{load_config, HiveRouterConfig}; +use hive_router_config::{load_config, subscriptions::CallbackConfig, HiveRouterConfig}; pub use hive_router_internal::background_tasks; +use hive_router_internal::background_tasks::{BackgroundTask, CancellationToken}; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; use hive_router_internal::telemetry::{ otel::tracing_opentelemetry::OpenTelemetrySpanExt, @@ -73,6 +78,31 @@ use tracing::{info, warn, Instrument}; static LABORATORY_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/laboratory.html")); +struct CallbackServer(std::sync::Mutex>); + +impl From for CallbackServer { + fn from(server: ntex::server::Server) -> Self { + Self(std::sync::Mutex::new(Some(server))) + } +} + +#[async_trait] +impl BackgroundTask for CallbackServer { + fn id(&self) -> &str { + "callback_server" + } + + async fn run(&self, token: CancellationToken) { + token.cancelled().await; + // only poisoned if a thread panicked while holding the lock; since the only + // operation inside is .take(), that can't happen + let server = self.0.lock().unwrap().take(); + if let Some(server) = server { + server.stop(true).await; + } + } +} + async fn graphql_endpoint_handler( request: HttpRequest, body_stream: web::types::Payload, @@ -170,8 +200,10 @@ pub async fn router_entrypoint(plugin_registry: PluginRegistry) -> Result<(), Ro .as_ref() .and_then(|prom| prom.to_attached()); info!("hive-router@{} starting...", ROUTER_VERSION); - let http_config = router_config.http.clone(); - let addr = router_config.http.address(); + let addr = router_config.address(); + let graphql_path = router_config.graphql_path().to_string(); + let websocket_path = router_config.websocket_path().map(|p| p.to_string()); + let callback_conf = router_config.callback_conf().cloned(); let mut bg_tasks_manager = background_tasks::BackgroundTasksManager::new(); let (shared_state, schema_state) = configure_app_from_config( router_config, @@ -182,20 +214,62 @@ pub async fn router_entrypoint(plugin_registry: PluginRegistry) -> Result<(), Ro .await?; let shared_state_clone = shared_state.clone(); - let graphql_path = http_config.graphql_endpoint(); + let callback_subscriptions_for_handler = schema_state.callback_subscriptions.clone(); + + // when `listen` is set, the callback route lives on a dedicated server bound to that address + // otherwise, the callback route is mounted on the main server on the `callback_path` + let callback_path = match callback_conf { + Some(CallbackConfig { + listen: Some(listen), + ref path, + .. + }) => { + let cb_path = path.to_string(); + let cb_addr = listen.to_string(); + let cb_subs = callback_subscriptions_for_handler.clone(); + let cb_server = web::HttpServer::new(async move || { + let cb_subs = cb_subs.clone(); + let cb_path = cb_path.clone(); + web::App::new() + .state(cb_subs) + .configure(move |m| add_callback_handler(m, &cb_path)) + }) + .bind(&cb_addr) + .map_err(|err| RouterInitError::HttpCallbackServerBindError(cb_addr, err))? + .run(); + + bg_tasks_manager.register_task(CallbackServer::from(cb_server)); + + None + } + Some(ref cb) => Some(cb.path.to_string()), + None => None, + }; - let paths = RouterPaths::new(graphql_path.to_string()); + // after callback config check because there we decide if callback_path should be set + let paths = RouterPaths::new(graphql_path.clone(), websocket_path, callback_path); paths.detect_conflicts(&prometheus)?; - let graphql_path = graphql_path.to_string(); + let long_lived_client_limit_service = + LongLivedClientLimitService::new(&shared_state.router_config); + let maybe_error = web::HttpServer::new(async move || { let landing_page_path = graphql_path.clone(); let prometheus = prometheus.clone(); + let long_lived_client_limit_service = long_lived_client_limit_service.clone(); web::App::new() + .middleware(long_lived_client_limit_service) .middleware(PluginService) .state(shared_state.clone()) .state(schema_state.clone()) .configure(|m| configure_ntex_app(m, &paths, prometheus)) + .configure(|m| { + if let Some(ref callback) = paths.callback { + // callback path will be some only if callback is enabled and if + // its listen is not configured to be on another server + add_callback_handler(m, callback); + } + }) .default_service(web::to(move || { landing_page_handler(landing_page_path.clone()) })) @@ -243,6 +317,8 @@ pub async fn configure_app_from_config( }; let plugins_arc = plugin_registry.initialize_plugins(&router_config, bg_tasks_manager)?; + let active_subscriptions = + ActiveSubscriptions::new(router_config.subscriptions.broadcast_capacity); let router_config_arc = Arc::new(router_config); let telemetry_context_arc = Arc::new(telemetry_context); let cache_state = Arc::new(CacheState::new()); @@ -257,6 +333,7 @@ pub async fn configure_app_from_config( router_config_arc.clone(), plugins_arc.clone(), cache_state.clone(), + active_subscriptions.clone(), ) .await?; let schema_state_arc = Arc::new(schema_state); @@ -284,6 +361,7 @@ pub async fn configure_app_from_config( telemetry_context_arc, plugins_arc, cache_state, + active_subscriptions.clone(), )?); Ok((shared_state, schema_state_arc)) @@ -292,14 +370,18 @@ pub async fn configure_app_from_config( #[derive(Clone)] pub struct RouterPaths { graphql: String, + websocket: Option, + callback: Option, health: String, readiness: String, } impl RouterPaths { - pub fn new(graphql: String) -> Self { + pub fn new(graphql: String, websocket: Option, callback: Option) -> Self { RouterPaths { graphql, + websocket, + callback, health: "/health".to_string(), readiness: "/readiness".to_string(), } @@ -309,13 +391,24 @@ impl RouterPaths { &self, prometheus: &Option, ) -> Result<(), RouterInitError> { - // A pair of context and actual path + // A pair of context and actual path (only include optional paths when present) let mut paths = vec![ ("graphql", self.graphql.as_str()), ("health", self.health.as_str()), ("readiness", self.readiness.as_str()), ]; + if let Some(ws) = self.websocket.as_deref() { + // its safe to have graphql and websocket on same path + if ws != self.graphql.as_str() { + paths.push(("websocket", ws)); + } + } + + if let Some(cb) = self.callback.as_deref() { + paths.push(("callback", cb)); + } + if let Some(prom) = prometheus { paths.push(("prometheus", prom.endpoint.as_str())); } @@ -338,11 +431,35 @@ impl RouterPaths { } } +pub fn add_callback_handler(cfg: &mut web::ServiceConfig, callback_path: &str) { + let callback_route = format!( + "{}/{{subscription_id}}", + callback_path.trim_end_matches('/'), + ); + cfg.route(&callback_route, web::post().to(handler)); +} + pub fn configure_ntex_app( cfg: &mut web::ServiceConfig, paths: &RouterPaths, prometheus: Option, ) { + if let Some(websocket) = &paths.websocket { + cfg.service( + web::resource(websocket.as_str()) + // guard ensures this resource is only matched for actual ws upgrade requests, + // so a plain GET to the same path (e.g. graphql GET request) falls through + // to the next registered resource instead of hitting the ws handshake + .guard(web::guard::fn_guard(|head| { + head.headers() + .get(ntex::http::header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")) + })) + .route(web::get().to(ws_index)), + ); + } + cfg.route(paths.graphql.as_str(), web::to(graphql_endpoint_handler)) .route(paths.health.as_str(), web::to(health_check_handler)) .route(paths.readiness.as_str(), web::to(readiness_check_handler)); diff --git a/bin/router/src/pipeline/active_subscriptions.rs b/bin/router/src/pipeline/active_subscriptions.rs new file mode 100644 index 000000000..f51d18c64 --- /dev/null +++ b/bin/router/src/pipeline/active_subscriptions.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use bytes::Bytes; +use dashmap::DashMap; +use hive_router_plan_executor::response::graphql_error::GraphQLError; +use tokio::sync::broadcast; +use tracing::trace; +use ulid::Ulid; + +use crate::shared_state::SharedRouterResponseGuard; + +pub type SubscriptionId = String; + +#[derive(Clone, Debug)] +pub enum SubscriptionEvent { + /// A normal subscription event from the upstream, already serialized. + /// Uses Bytes for zero-copy cloning across broadcast receivers. + Raw(Bytes), + /// An error pushed externally (e.g. supergraph reload, shutdown). + /// Consumers should yield this as the final event and then stop. + Error(Vec), +} + +#[derive(Clone)] +pub struct ActiveSubscriptions { + map: Arc>>, + broadcast_capacity: usize, +} + +impl ActiveSubscriptions { + pub fn new(broadcast_capacity: usize) -> Self { + Self { + map: Arc::new(DashMap::new()), + broadcast_capacity, + } + } + + /// Register a new active subscription. Returns a producer handle for the upstream pump + /// and a pre-subscribed receiver for the leader consumer. The pump task owns the handle + /// for the full lifetime of the upstream stream - when the handle drops (pump done or all + /// receivers gone) the broadcast channel closes and all consumer receivers terminate. + pub fn register( + &self, + guard: Option, + ) -> (ProducerHandle, broadcast::Receiver) { + let (sender, receiver) = broadcast::channel(self.broadcast_capacity); + let id = Ulid::new().to_string(); + self.map.insert(id.clone(), sender.clone()); + + let handle = ProducerHandle { + id: id.clone(), + map: self.map.clone(), + sender, + _guard: guard, + }; + + trace!(subscription_id = %id, "registered new subscription"); + + (handle, receiver) + } + + /// Close all active subscriptions with an error and clear the registry. + pub fn close_all_with_error(&self, errors: Vec) { + let item = SubscriptionEvent::Error(errors); + for entry in self.map.iter() { + let _ = entry.send(item.clone()); + } + self.map.clear(); + } +} + +/// Held by the upstream pump task for the full lifetime of the stream. Dropping it removes +/// the subscription from the registry, closes the broadcast channel, and drops the inflight +/// cleanup guard - which removes the dedupe entry so new requests start a fresh upstream. +pub struct ProducerHandle { + id: SubscriptionId, + map: Arc>>, + sender: broadcast::Sender, + _guard: Option, +} + +impl ProducerHandle { + pub fn sender(&self) -> &broadcast::Sender { + &self.sender + } + + /// Returns false when all consumers have gone and the event cannot be delivered. + pub fn send(&self, item: SubscriptionEvent) -> bool { + self.sender.send(item).is_ok() + } +} + +impl Drop for ProducerHandle { + fn drop(&mut self) { + self.map.remove(&self.id); + trace!(subscription_id = %self.id, "producer dropped, upstream closed"); + } +} diff --git a/bin/router/src/pipeline/error.rs b/bin/router/src/pipeline/error.rs index b9c2ae65e..e5f53bd01 100644 --- a/bin/router/src/pipeline/error.rs +++ b/bin/router/src/pipeline/error.rs @@ -1,8 +1,11 @@ use std::{sync::Arc, vec}; +use futures_util::stream; use graphql_tools::validation::utils::ValidationError; use hive_router_plan_executor::{ - execution::{error::PlanExecutionError, jwt_forward::JwtForwardingError}, + execution::{ + error::PlanExecutionError, jwt_forward::JwtForwardingError, plan::FailedExecutionResult, + }, headers::errors::HeaderRuleRuntimeError, hooks::on_graphql_error::handle_graphql_errors_with_plugins, response::graphql_error::GraphQLError, @@ -10,19 +13,25 @@ use hive_router_plan_executor::{ use hive_router_query_planner::{ ast::normalization::error::NormalizationError, planner::PlannerError, }; +use http::header; use http::{header::RETRY_AFTER, HeaderName, Method, StatusCode}; use ntex::{ http::ResponseBuilder, web::{self, error::QueryPayloadError}, }; -use serde::Serialize; use strum::IntoStaticStr; use crate::{ jwt::errors::JwtError, pipeline::{ - authorization::AuthorizationError, body_read::ReadBodyStreamError, header::ResponseMode, + authorization::AuthorizationError, + body_read::ReadBodyStreamError, + header::{ResponseMode, StreamContentType}, + multipart_subscribe::{ + self, APOLLO_MULTIPART_HTTP_CONTENT_TYPE, INCREMENTAL_DELIVERY_CONTENT_TYPE, + }, progressive_override::LabelEvaluationError, + sse, }, RouterSharedState, }; @@ -230,38 +239,32 @@ impl PipelineError { } } -#[derive(Serialize)] -struct FailedExecutionResult { - errors: Vec, -} - #[inline] pub fn handle_pipeline_error( err: PipelineError, shared_state: &RouterSharedState, response_mode: &ResponseMode, ) -> web::HttpResponse { - let single_content_type = response_mode.single_content_type(); - - let prefer_ok = response_mode.prefer_status_ok_for_errors(); - - let status = err.default_status_code(prefer_ok); + let status = if matches!(response_mode, ResponseMode::StreamOnly(_)) { + // alwats status OK for streaming response modes, because we accept + // the stream and then stream the error from within the stream by default + StatusCode::OK + } else { + let prefer_ok = response_mode.prefer_status_ok_for_errors(); + err.default_status_code(prefer_ok) + }; let mut res = ResponseBuilder::new(status); - if let Some(single_content_type) = single_content_type { - res.content_type(single_content_type.as_ref()); - } - if matches!(err, PipelineError::NoSupergraphAvailable) { res.header(RETRY_AFTER, "10"); } let mut errors = match err { - PipelineError::ValidationErrors(validation_errors) => { + PipelineError::ValidationErrors(ref validation_errors) => { validation_errors.iter().map(|error| error.into()).collect() } - PipelineError::AuthorizationFailed(authorization_errors) => authorization_errors + PipelineError::AuthorizationFailed(ref authorization_errors) => authorization_errors .iter() .map(|error| error.into()) .collect(), @@ -291,5 +294,42 @@ pub fn handle_pipeline_error( .record_errors(|| errors.iter().map(|error| error.extensions.code.as_deref())); } - res.json(&FailedExecutionResult { errors }) + let data = FailedExecutionResult { errors }.serialize(); + + match response_mode { + ResponseMode::SingleOnly(content_type) | ResponseMode::Dual(content_type, _) => res + .header(header::CONTENT_TYPE, content_type.as_ref()) + .body(data), + ResponseMode::StreamOnly(StreamContentType::IncrementalDelivery) => res + .header( + header::CONTENT_TYPE, + http::HeaderValue::from_static(INCREMENTAL_DELIVERY_CONTENT_TYPE), + ) + .streaming(multipart_subscribe::create_incremental_delivery_stream( + Box::pin(stream::once(async move { data })), + )), + ResponseMode::StreamOnly(StreamContentType::SSE) => res + .header( + header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + ) + .streaming(sse::create_stream( + Box::pin(stream::once(async move { data })), + std::time::Duration::from_secs(10), + )), + ResponseMode::StreamOnly(StreamContentType::ApolloMultipartHTTP) => res + .header( + header::CONTENT_TYPE, + http::HeaderValue::from_static(APOLLO_MULTIPART_HTTP_CONTENT_TYPE), + ) + .streaming(multipart_subscribe::create_apollo_multipart_http_stream( + Box::pin(stream::once(async move { data })), + std::time::Duration::from_secs(10), + )), + ResponseMode::Laboratory => { + unreachable!( + "Laboratory can not be a response mode because Laboratory requests can not execute operations" + ) + } + } } diff --git a/bin/router/src/pipeline/execution.rs b/bin/router/src/pipeline/execution.rs index 57da014be..e4a6870be 100644 --- a/bin/router/src/pipeline/execution.rs +++ b/bin/router/src/pipeline/execution.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use crate::pipeline::authorization::AuthorizationError; use crate::pipeline::coerce_variables::CoerceVariablesPayload; @@ -11,7 +12,7 @@ use hive_router_internal::telemetry::traces::spans::graphql::{ use hive_router_plan_executor::execution::client_request_details::ClientRequestDetails; use hive_router_plan_executor::execution::jwt_forward::JwtAuthForwardingPlan; use hive_router_plan_executor::execution::plan::{ - execute_query_plan, PlanExecutionOutput, QueryPlanExecutionOpts, + execute_query_plan, PlanExecutionOutput, QueryPlanExecutionOpts, QueryPlanExecutionResult, }; use hive_router_plan_executor::hooks::on_supergraph_load::SupergraphData; use hive_router_plan_executor::introspection::resolve::IntrospectionContext; @@ -31,32 +32,31 @@ pub enum ExposeQueryPlanMode { } pub struct PlannedRequest<'req> { - pub normalized_payload: &'req GraphQLNormalizationPayload, + pub normalized_payload: Arc, pub query_plan_payload: &'req QueryPlan, - pub variable_payload: &'req CoerceVariablesPayload, - pub client_request_details: &'req ClientRequestDetails<'req>, + pub variable_payload: CoerceVariablesPayload, + pub client_request_details: Arc>, pub authorization_errors: Vec, - pub plugin_req_state: &'req Option>, + pub plugin_req_state: Option>, } #[inline] -pub async fn execute_plan( +pub async fn execute_plan<'exec>( supergraph: &SupergraphData, app_state: &RouterSharedState, - planned_request: PlannedRequest<'_>, - span: &GraphQLOperationSpan, -) -> Result { + planned_request: PlannedRequest<'exec>, + span: GraphQLOperationSpan, +) -> Result { let execute_span = GraphQLExecuteSpan::new(); + let introspection_context = IntrospectionContext { + query: planned_request + .normalized_payload + .operation_for_introspection + .clone(), + schema: Arc::clone(&supergraph.planner.consumer_schema.document), + metadata: Arc::clone(&supergraph.metadata), + }; async { - let introspection_context = IntrospectionContext { - query: planned_request - .normalized_payload - .operation_for_introspection - .as_deref(), - schema: &supergraph.planner.consumer_schema.document, - metadata: &supergraph.metadata, - }; - let mut extensions = HashMap::new(); let mut expose_query_plan = ExposeQueryPlanMode::No; @@ -92,10 +92,10 @@ pub async fn execute_plan( })) .map_err(PipelineError::QueryPlanSerializationFailed)?; - return Ok(PlanExecutionOutput { + return Ok(QueryPlanExecutionResult::Single(PlanExecutionOutput { body, ..Default::default() - }); + })); } let jwt_auth_forwarding: Option = if app_state @@ -119,17 +119,20 @@ pub async fn execute_plan( let result = execute_query_plan(QueryPlanExecutionOpts { query_plan: planned_request.query_plan_payload, - operation_for_plan: &planned_request.normalized_payload.operation_for_plan, - projection_plan: &planned_request.normalized_payload.projection_plan, - headers_plan: &app_state.headers_plan, - variable_values: &planned_request.variable_payload.variables_map, + operation_for_plan: planned_request + .normalized_payload + .operation_for_plan + .clone(), + projection_plan: planned_request.normalized_payload.projection_plan.clone(), + headers_plan: app_state.headers_plan.clone(), + variable_values: Arc::new(planned_request.variable_payload.variables_map), extensions, client_request: planned_request.client_request_details, - introspection_context: &introspection_context, + introspection_context: introspection_context.into(), operation_type_name: planned_request.normalized_payload.root_type_name, - jwt_auth_forwarding, + jwt_auth_forwarding: jwt_auth_forwarding.map(|j| j.into()), graphql_error_recorder: app_state.telemetry_context.metrics.graphql.error_recorder(), - executors: &supergraph.subgraph_executor_map, + executors: Arc::clone(&supergraph.subgraph_executor_map), initial_errors: planned_request .authorization_errors .iter() diff --git a/bin/router/src/pipeline/http_callback.rs b/bin/router/src/pipeline/http_callback.rs new file mode 100644 index 000000000..66c13de74 --- /dev/null +++ b/bin/router/src/pipeline/http_callback.rs @@ -0,0 +1,251 @@ +use bytes::Bytes as BytesLib; +use dashmap::mapref::one::Ref; +use hive_router_plan_executor::executors::http_callback::{ + CallbackMessage, CallbackSubscription, CallbackSubscriptionsMap, CALLBACK_PROTOCOL_VERSION, + SUBSCRIPTION_PROTOCOL_HEADER, +}; +use hive_router_plan_executor::response::graphql_error::GraphQLError; +use http::StatusCode; +use ntex::util::Bytes; +use ntex::web::WebResponseError; +use ntex::web::{self, types::Path, HttpRequest, HttpResponse}; +use serde::Deserialize; +use strum::EnumString; +use tokio::sync::mpsc; +use tracing::{debug, error, trace, warn}; + +#[derive(Debug, Deserialize, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +enum CallbackKind { + Subscription, +} + +#[derive(Debug, Deserialize, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +enum CallbackAction { + Check, + Next, + Complete, +} + +#[derive(Debug, Deserialize)] +struct CallbackPayload<'a> { + // unused in code, but used for validation + #[allow(unused)] + kind: CallbackKind, + action: CallbackAction, + id: String, + verifier: String, + #[serde(borrow, default)] + payload: Option>, + #[serde(default)] + errors: Option>, +} + +#[derive(thiserror::Error, Debug)] +pub enum CallbackError { + #[error( + "Invalid or missing {} header, expected {}", + SUBSCRIPTION_PROTOCOL_HEADER, + CALLBACK_PROTOCOL_VERSION + )] + InvalidProtocolHeader, + #[error("Failed to parse callback payload: {0}")] + PayloadParseError(#[from] sonic_rs::Error), + #[error("Subscription ID mismatch: path='{path}', body='{body}'")] + SubscriptionIdMismatch { path: String, body: String }, + #[error("Missing payload in next message for subscription ID '{subscription_id}'")] + MissingPayload { subscription_id: String }, + #[error( + "Subscription not found, may have been terminated for subscription ID '{subscription_id}'" + )] + SubscriptionNotFound { subscription_id: String }, + #[error("Invalid verifier for subscription ID '{subscription_id}'")] + InvalidVerifier { subscription_id: String }, + #[error("Subscription receiver dropped for subscription ID '{subscription_id}'")] + SubscriptionDropped { subscription_id: String }, + // NOTE: intentionally a different variant from SubscriptionDropped + #[error( + "Client consuming too slowly. Event buffer full for subscription ID '{subscription_id}'" + )] + ClientTooSlow { subscription_id: String }, +} + +impl CallbackError { + fn log(&self) { + match self { + CallbackError::InvalidProtocolHeader => warn!("{}", self), + CallbackError::PayloadParseError(_) => error!("{}", self), + CallbackError::SubscriptionIdMismatch { .. } => warn!("{}", self), + CallbackError::MissingPayload { .. } => warn!("{}", self), + CallbackError::SubscriptionNotFound { .. } => warn!("{}", self), + CallbackError::InvalidVerifier { .. } => warn!("{}", self), + CallbackError::SubscriptionDropped { .. } => debug!("{}", self), + CallbackError::ClientTooSlow { .. } => warn!("{}", self), + } + } +} + +impl WebResponseError for CallbackError { + fn status_code(&self) -> StatusCode { + match self { + CallbackError::InvalidProtocolHeader + | CallbackError::PayloadParseError(_) + | CallbackError::MissingPayload { .. } + | CallbackError::InvalidVerifier { .. } => StatusCode::BAD_REQUEST, + CallbackError::SubscriptionNotFound { .. } + | CallbackError::SubscriptionDropped { .. } + | CallbackError::SubscriptionIdMismatch { .. } => StatusCode::NOT_FOUND, + // 503 signals the subgraph that the router is temporarily unable to accept events, + // the subgraph can decide to retry or close the subscription on its end + CallbackError::ClientTooSlow { .. } => StatusCode::SERVICE_UNAVAILABLE, + } + } + fn error_response(&self, _: &HttpRequest) -> HttpResponse { + self.log(); + HttpResponse::build(self.status_code()) + .header(SUBSCRIPTION_PROTOCOL_HEADER, CALLBACK_PROTOCOL_VERSION) + .finish() + } +} + +fn validate_protocol(req: &HttpRequest) -> Result<(), CallbackError> { + let protocol_header = req + .headers() + .get(SUBSCRIPTION_PROTOCOL_HEADER) + .and_then(|v| v.to_str().ok()); + + if protocol_header != Some(CALLBACK_PROTOCOL_VERSION) { + return Err(CallbackError::InvalidProtocolHeader); + } + + Ok(()) +} + +fn parse_payload(body: &Bytes) -> Result, CallbackError> { + Ok(sonic_rs::from_slice(body)?) +} + +fn validate_payload( + payload: &CallbackPayload<'_>, + subscription_id_from_path: &str, +) -> Result<(), CallbackError> { + if payload.id != subscription_id_from_path { + return Err(CallbackError::SubscriptionIdMismatch { + path: subscription_id_from_path.to_string(), + body: payload.id.to_string(), + }); + } + + Ok(()) +} + +fn handle_check(subscription_id: &str, subscription: &Ref<'_, String, CallbackSubscription>) { + trace!(subscription_id = %subscription_id, "Received check message"); + subscription.record_heartbeat(); +} + +fn handle_next( + subscription_id: &str, + payload: &CallbackPayload<'_>, + subscription: Ref<'_, String, CallbackSubscription>, + callback_subscriptions: &CallbackSubscriptionsMap, +) -> Result<(), CallbackError> { + trace!(subscription_id = %subscription_id, "Received next message"); + + let data = match &payload.payload { + Some(p) => BytesLib::copy_from_slice(p.as_raw_str().as_bytes()), + None => { + return Err(CallbackError::MissingPayload { + subscription_id: subscription_id.to_string(), + }); + } + }; + + match subscription + .sender + .try_send(CallbackMessage::Next { payload: data }) + { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => { + // if the channel is full it means the consuming client is too slow and unable to keep + // up. we terminate the subscription without an error message because it anyways cant go through + warn!(subscription_id = %subscription_id, "Subscription client is too slow"); + drop(subscription); + callback_subscriptions.remove(subscription_id); + Err(CallbackError::ClientTooSlow { + subscription_id: subscription_id.to_string(), + }) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!(subscription_id = %subscription_id, "Subscription receiver dropped"); + drop(subscription); + callback_subscriptions.remove(subscription_id); + Err(CallbackError::SubscriptionDropped { + subscription_id: subscription_id.to_string(), + }) + } + } +} + +fn handle_complete( + subscription_id: &str, + payload: &CallbackPayload<'_>, + subscription: Ref<'_, String, CallbackSubscription>, + callback_subscriptions: &CallbackSubscriptionsMap, +) { + trace!(subscription_id = %subscription_id, "Received complete message"); + // if the buffer is full or closed we ignore and remove the subscription, we dont send + // the final error message because the client is already unable to consume + let _ = subscription.sender.try_send(CallbackMessage::Complete { + errors: payload.errors.clone(), + }); + drop(subscription); + callback_subscriptions.remove(subscription_id); +} + +pub async fn handler( + req: HttpRequest, + path: Path, + body: Bytes, + callback_subscriptions: web::types::State, +) -> Result { + let subscription_id_from_path = path.into_inner(); + + validate_protocol(&req)?; + + let payload = parse_payload(&body)?; + + validate_payload(&payload, &subscription_id_from_path)?; + + let subscription = match callback_subscriptions.get(&payload.id) { + Some(sub) => sub, + None => { + return Err(CallbackError::SubscriptionNotFound { + subscription_id: payload.id.clone(), + }); + } + }; + + if subscription.verifier != payload.verifier { + return Err(CallbackError::InvalidVerifier { + subscription_id: payload.id.clone(), + }); + } + + match payload.action { + CallbackAction::Check => handle_check(&payload.id, &subscription), + CallbackAction::Next => { + handle_next(&payload.id, &payload, subscription, &callback_subscriptions)?; + } + CallbackAction::Complete => { + handle_complete(&payload.id, &payload, subscription, &callback_subscriptions) + } + }; + + Ok(HttpResponse::NoContent() + .header(SUBSCRIPTION_PROTOCOL_HEADER, CALLBACK_PROTOCOL_VERSION) + .finish()) +} diff --git a/bin/router/src/pipeline/long_lived_client_limit.rs b/bin/router/src/pipeline/long_lived_client_limit.rs new file mode 100644 index 000000000..fc65768b8 --- /dev/null +++ b/bin/router/src/pipeline/long_lived_client_limit.rs @@ -0,0 +1,191 @@ +use std::{ + rc::Rc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; + +use http::{header, StatusCode}; +use ntex::{ + http::body::{Body, BodySize, MessageBody}, + service::{Service, ServiceCtx}, + util::Bytes, + web::{self, DefaultError}, + Middleware, SharedCfg, +}; + +use crate::RouterSharedState; + +#[derive(Clone)] +pub struct LongLivedClientLimitService { + // false means the middleware is entirely bypassed on every request + enabled: bool, +} + +impl LongLivedClientLimitService { + pub fn new(router_config: &hive_router_config::HiveRouterConfig) -> Self { + let limit = router_config.traffic_shaping.router.max_long_lived_clients; + let has_long_lived = router_config.subscriptions.enabled || router_config.websocket.enabled; + Self { + enabled: limit > 0 && has_long_lived, + } + } +} + +impl Middleware for LongLivedClientLimitService { + type Service = LongLivedClientLimitMiddleware; + + fn create(&self, service: S, _cfg: SharedCfg) -> Self::Service { + LongLivedClientLimitMiddleware { + service, + enabled: self.enabled, + } + } +} + +pub struct LongLivedClientLimitMiddleware { + service: S, + enabled: bool, +} + +impl Service> for LongLivedClientLimitMiddleware +where + S: Service, Response = web::WebResponse, Error = web::Error>, +{ + type Response = web::WebResponse; + type Error = S::Error; + + ntex::forward_ready!(service); + + async fn call( + &self, + req: web::WebRequest, + ctx: ServiceCtx<'_, Self>, + ) -> Result { + if !self.enabled { + return ctx.call(&self.service, req).await; + } + + if !is_long_lived_request(req.headers()) { + return ctx.call(&self.service, req).await; + } + + let shared_state = match req.app_state::>() { + Some(s) => s, + None => return ctx.call(&self.service, req).await, + }; + + let limit = shared_state + .router_config + .traffic_shaping + .router + .max_long_lived_clients; + let counter = shared_state.long_lived_client_count.clone(); + + // try to reserve a slot, bail if at the limit + let prev = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + if current < limit { + Some(current + 1) + } else { + None + } + }); + if prev.is_err() { + let error_response = web::HttpResponse::build(StatusCode::SERVICE_UNAVAILABLE) + .header(header::RETRY_AFTER, "5") + .body("Too many long-lived clients"); + return Ok(req.into_response(error_response)); + } + + let guard = LongLivedClientGuard(counter); + let response = ctx.call(&self.service, req).await?; + + // wrap the body so the guard lives until the stream is fully consumed + let response = response.map_body(|_head, body| { + let wrapped = GuardedBody { + inner: body.into_body().into(), + _guard: guard, + }; + Body::from_message(wrapped).into() + }); + + Ok(response) + } +} + +// decrements the counter when dropped +struct LongLivedClientGuard(Arc); + +impl Drop for LongLivedClientGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +// wraps the body and keeps the guard alive until it's fully consumed and dropped. +// one extra vtable call per chunk on top of the Box dispatch streaming bodies +// already go through - negligible next to actual I/O cost per chunk. +struct GuardedBody { + inner: Body, + _guard: LongLivedClientGuard, +} + +impl MessageBody for GuardedBody { + fn size(&self) -> BodySize { + self.inner.size() + } + + fn poll_next_chunk( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>>> { + self.inner.poll_next_chunk(cx) + } +} + +// cheapest check first: +// 1. upgrade: websocket - two header lookups, no parsing +// 2. accept: streaming - one header lookup + fast substring pre-filter, full parse only if needed +#[inline] +fn is_long_lived_request(headers: &ntex::http::HeaderMap) -> bool { + // websocket: Connection: Upgrade + Upgrade: websocket + // both headers must be present and contain the expected values (case-insensitive) + if headers + .get(header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")) + && headers + .get(header::CONNECTION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.to_ascii_lowercase().contains("upgrade")) + { + return true; + } + + // fast substring scan before full Accept parse to avoid cost on regular requests + let accept = match headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) { + Some(v) if !v.is_empty() => v, + _ => return false, + }; + + if !looks_like_streaming_accept(accept) { + return false; + } + + use crate::pipeline::header::StreamContentType; + use headers_accept::Accept; + use std::str::FromStr; + + Accept::from_str(accept) + .ok() + .and_then(|a| a.negotiate(StreamContentType::media_types().iter())) + .is_some() +} + +#[inline] +fn looks_like_streaming_accept(accept: &str) -> bool { + // covers: multipart/mixed, text/event-stream + accept.contains("multipart") || accept.contains("event-stream") +} diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index ee6722dca..352365da0 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -1,33 +1,37 @@ -use std::{ - collections::HashMap, - hash::{Hash, Hasher}, - sync::Arc, - time::Instant, -}; -use tracing::{error, Instrument}; -use xxhash_rust::xxh3::Xxh3; - +use futures::StreamExt; use hive_router_internal::telemetry::traces::spans::{ graphql::GraphQLOperationSpan, http_request::HttpServerRequestSpan, }; use hive_router_plan_executor::{ execution::{ client_request_details::{ClientRequestDetails, JwtRequestDetails, OperationDetails}, - plan::PlanExecutionOutput, + plan::QueryPlanExecutionResult, }, - hooks::on_graphql_params::GraphQLParams, - hooks::on_supergraph_load::SupergraphData, + hooks::{on_graphql_params::GraphQLParams, on_supergraph_load::SupergraphData}, plugin_context::{PluginContext, PluginRequestState}, }; use hive_router_query_planner::{ state::supergraph_state::OperationKind, utils::cancellation::CancellationToken, }; use http::{header::CONTENT_TYPE, Method}; -use ntex::web::{self, HttpRequest}; +use ntex::{ + http::HeaderMap, + rt, + web::{self, HttpRequest}, +}; use sonic_rs::{JsonContainerTrait, JsonType, JsonValueTrait, Value}; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + sync::Arc, + time::Instant, +}; +use tracing::{error, Instrument}; +use xxhash_rust::xxh3::Xxh3; use crate::{ pipeline::{ + active_subscriptions::SubscriptionEvent, authorization::enforce_operation_authorization, body_read::read_body_stream, coerce_variables::{coerce_request_variables, CoerceVariablesPayload}, @@ -35,7 +39,7 @@ use crate::{ error::PipelineError, execution::{execute_plan, PlannedRequest}, execution_request::{deserialize_graphql_params, DeserializationResult, GetQueryStr}, - header::{RequestAccepts, ResponseMode, SingleContentType, TEXT_HTML_MIME}, + header::{RequestAccepts, ResponseMode, TEXT_HTML_MIME}, introspection_policy::handle_introspection_policy, normalize::{normalize_request_with_cache, GraphQLNormalizationPayload}, parser::{parse_operation_with_cache, ParseResult}, @@ -48,12 +52,16 @@ use crate::{ validation::validate_operation_with_cache, }, schema_state::SchemaState, - shared_state::{RouterRequestDedupeHeaderPolicy, RouterSharedState, SharedRouterResponse}, + shared_state::{ + RouterRequestDedupeHeaderPolicy, RouterSharedState, SharedRouterResponse, + SharedRouterResponseGuard, SharedRouterSingleResponse, SharedRouterStreamResponse, + }, LABORATORY_HTML, }; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; +pub mod active_subscriptions; pub mod authorization; pub mod body_read; pub mod coerce_variables; @@ -63,15 +71,20 @@ pub mod error; pub mod execution; pub mod execution_request; pub mod header; +pub mod http_callback; pub mod introspection_policy; +pub mod long_lived_client_limit; +pub mod multipart_subscribe; pub mod normalize; pub mod parser; pub mod progressive_override; pub mod query_plan; pub mod request_extensions; +pub mod sse; pub mod timeout; pub mod usage_reporting; pub mod validation; +pub mod websocket_server; #[inline] pub async fn graphql_request_handler( @@ -106,6 +119,7 @@ pub async fn graphql_request_handler( let started_at = Instant::now(); let operation_span = GraphQLOperationSpan::new(); + let span_clone = operation_span.clone(); async { perform_csrf_prevention(req, &shared_state.router_config.csrf)?; @@ -233,24 +247,20 @@ pub async fn graphql_request_handler( ); if is_subscription - // coming soon - // && !response_mode.can_stream() + && (!shared_state.router_config.subscriptions.enabled || !response_mode.can_stream()) { + // check early, even though we check again planned execution below return Err(PipelineError::SubscriptionsNotSupported); } - let Some(single_content_type) = response_mode.single_content_type() else { - // streaming responses coming soon - return Err(PipelineError::UnsupportedContentType); - }; - let request_dedupe_enabled = shared_state.router_config.traffic_shaping.router.dedupe.enabled; - let shared_response = if request_dedupe_enabled + let fingerprint = if request_dedupe_enabled && matches!( normalize_payload.operation_for_plan.operation_kind, - Some(OperationKind::Query) | None + // same deduplication applies for queries and subscriptions + None | Some(OperationKind::Query) | Some(OperationKind::Subscription) ) { let variables_hash = hash_graphql_variables(&graphql_params.variables); let extensions_hash = graphql_params @@ -259,47 +269,52 @@ pub async fn graphql_request_handler( .map_or(0, hash_graphql_extensions); let schema_checksum = supergraph.schema_checksum(); - let fingerprint = inbound_request_fingerprint( - req, + Some(inbound_request_fingerprint( + req.method(), + req.path(), + req.headers(), &shared_state.in_flight_requests_header_policy, schema_checksum, normalize_payload.normalized_operation_hash, variables_hash, extensions_hash, - ); - let (shared_response, _role) = shared_state - .in_flight_requests - .claim(fingerprint) - .get_or_try_init(|| async { - execute_planned_request( - req, - graphql_params, - &normalize_payload, - supergraph, - shared_state, - schema_state, - &operation_span, - &plugin_req_state, - single_content_type, - ) - .await - }) - .await?; + )) + } else { + None + }; + + let exec = |guard| execute_planned_request( + req.method(), + req.uri(), + req.headers(), + graphql_params, + &normalize_payload, + supergraph, + shared_state, + schema_state, + operation_span, + plugin_req_state, + response_mode, + guard, + ); + let shared_response = if let Some(fp) = fingerprint { + let (shared_response, _role) = if is_subscription { + shared_state + .in_flight_requests + .claim(fp) + .get_or_try_init_with_guard(|guard| exec(Some(guard))) + .await? + } else { + shared_state + .in_flight_requests + .claim(fp) + .get_or_try_init(|| exec(None)) + .await? + }; Arc::unwrap_or_clone(shared_response) } else { - execute_planned_request( - req, - graphql_params, - &normalize_payload, - supergraph, - shared_state, - schema_state, - &operation_span, - &plugin_req_state, - single_content_type, - ) - .await? + exec(None).await? }; if let Some(hive_usage_agent) = &shared_state.hive_usage_agent { @@ -323,26 +338,23 @@ pub async fn graphql_request_handler( // Thus, this expect should never panic. "Expected Usage Reporting options to be present when Hive Usage Agent is initialized", ), - shared_response.error_count, + shared_response.error_count(), ) .await; } - let pipeline_error_count = shared_response.error_count; - let response = shared_response.into(); - write_graphql_response_metric_status( req, - if pipeline_error_count > 0 { + if shared_response.error_count() > 0 { GraphQLResponseStatus::Error } else { GraphQLResponseStatus::Ok }, ); - Ok(response) + shared_response.into_response(response_mode) } - .instrument(operation_span.clone()) + .instrument(span_clone) .await .inspect_err(|_| { write_graphql_response_metric_status(req, GraphQLResponseStatus::Error); @@ -350,20 +362,23 @@ pub async fn graphql_request_handler( } #[allow(clippy::too_many_arguments)] -async fn execute_planned_request<'exec>( - req: &'exec HttpRequest, +pub async fn execute_planned_request<'exec>( + method: &'exec Method, + url: &'exec http::Uri, + headers: &'exec HeaderMap, mut graphql_params: GraphQLParams, normalize_payload: &Arc, supergraph: &'exec SupergraphData, shared_state: &'exec Arc, schema_state: &'exec Arc, - operation_span: &'exec GraphQLOperationSpan, - plugin_req_state: &'exec Option>, - single_content_type: &'exec SingleContentType, + operation_span: GraphQLOperationSpan, + plugin_req_state: Option>, + response_mode: &'exec ResponseMode, + guard: Option, ) -> Result { let jwt_request_details = match &shared_state.jwt_auth_runtime { Some(jwt_auth_runtime) => match jwt_auth_runtime - .validate_headers(req.headers(), &shared_state.jwt_claims_cache) + .validate_headers(headers, &shared_state.jwt_claims_cache) .await? { Some(jwt_context) => JwtRequestDetails::Authenticated { @@ -381,9 +396,9 @@ async fn execute_planned_request<'exec>( coerce_request_variables(supergraph, &mut graphql_params.variables, normalize_payload)?; let client_request_details = ClientRequestDetails { - method: req.method(), - url: req.uri(), - headers: req.headers(), + method, + url, + headers, operation: OperationDetails { name: normalize_payload.operation_for_plan.name.as_deref(), kind: match normalize_payload.operation_for_plan.operation_kind { @@ -394,55 +409,101 @@ async fn execute_planned_request<'exec>( }, query: graphql_params.get_query()?, }, - jwt: jwt_request_details, - }; + jwt: jwt_request_details.into(), + } + .into(); - let pipeline_result = execute_pipeline( + match execute_pipeline( &client_request_details, normalize_payload, - &variable_payload, + variable_payload, supergraph, shared_state, schema_state, operation_span, plugin_req_state, ) - .await?; - - let error_count = pipeline_result.error_count; - let mut response_builder = web::HttpResponse::Ok(); + .await? + { + QueryPlanExecutionResult::Stream(result) => { + // we dont use the stream content type because subscriptions + // can be deduplicated across transports - but we do store + // the header value in the shared response because the user + // might choose to not deduplicate across transport boundaries + let stream_content_type = response_mode + .stream_content_type() + .ok_or(PipelineError::SubscriptionsTransportNotSupported)?; + + let (producer_handle, receiver) = shared_state.active_subscriptions.register(guard); + + // subscribe the sender before spawning the pump so the channel always has + // at least one receiver - prevents events from being lost in the window + // between spawn and the consumer calling subscribe() + let sender = producer_handle.sender().clone(); + + let mut body_stream = result.body; + rt::spawn(async move { + while let Some(chunk) = body_stream.next().await { + if !producer_handle.send(SubscriptionEvent::Raw(bytes::Bytes::from(chunk))) { + // all receivers gone, stop draining + break; + } + } + // dropping producer_handle closes the broadcast channel + }); - if let Some(response_headers_aggregator) = pipeline_result.response_headers_aggregator { - response_headers_aggregator.modify_client_response_headers(&mut response_builder)?; + let mut builder = web::HttpResponse::Ok(); + if let Some(aggregator) = result.response_headers_aggregator { + aggregator.modify_client_response_headers(&mut builder)?; + }; + builder.content_type(stream_content_type.as_ref()); + let headers = Arc::new(builder.finish().headers().clone()); + + Ok(SharedRouterResponse::Stream(SharedRouterStreamResponse { + body: sender, + headers, + error_count: result.error_count, + receiver: Some(receiver), + })) + } + QueryPlanExecutionResult::Single(result) => { + let single_content_type = response_mode. + single_content_type(). + // TODO: streaming single responses + ok_or(PipelineError::UnsupportedContentType)?. + clone(); + + // drop the `guard` as soon as the response is ready + + let mut builder = web::HttpResponse::Ok(); + if let Some(aggregator) = result.response_headers_aggregator { + aggregator.modify_client_response_headers(&mut builder)?; + }; + builder.content_type(single_content_type.as_ref()); + let headers = Arc::new(builder.finish().headers().clone()); + + Ok(SharedRouterResponse::Single(SharedRouterSingleResponse { + body: ntex::util::Bytes::from(result.body), + headers, + status: result.status_code, + error_count: result.error_count, + })) + } } - - let body = ntex::util::Bytes::from(pipeline_result.body); - - let response = response_builder - .content_type(single_content_type.as_ref()) - .status(pipeline_result.status_code) - .body(body.clone()); - - Ok(SharedRouterResponse { - body, - headers: Arc::new(response.headers().clone()), - status: response.status(), - error_count, - }) } #[inline] #[allow(clippy::too_many_arguments)] pub async fn execute_pipeline<'exec>( - client_request_details: &ClientRequestDetails<'exec>, + client_request_details: &Arc>, normalize_payload: &Arc, - variable_payload: &CoerceVariablesPayload, + variable_payload: CoerceVariablesPayload, supergraph: &SupergraphData, shared_state: &Arc, schema_state: &Arc, - operation_span: &GraphQLOperationSpan, - plugin_req_state: &Option>, -) -> Result { + operation_span: GraphQLOperationSpan, + plugin_req_state: Option>, +) -> Result { if normalize_payload.operation_for_introspection.is_some() { handle_introspection_policy(&shared_state.introspection_policy, client_request_details)?; } @@ -460,7 +521,7 @@ pub async fn execute_pipeline<'exec>( normalize_payload, &supergraph.authorization, &supergraph.metadata, - variable_payload, + &variable_payload, &client_request_details.jwt, )?; @@ -470,22 +531,22 @@ pub async fn execute_pipeline<'exec>( &normalize_payload, &progressive_override_ctx, &cancellation_token, - plugin_req_state, + &plugin_req_state, ) .await?; let query_plan_payload = match query_plan_result { QueryPlanResult::QueryPlan(plan) => plan, QueryPlanResult::EarlyResponse(response) => { - return Ok(response); + return Ok(QueryPlanExecutionResult::Single(response)); } }; let planned_request = PlannedRequest { - normalized_payload: &normalize_payload, + normalized_payload: normalize_payload, query_plan_payload: &query_plan_payload, variable_payload, - client_request_details, + client_request_details: client_request_details.clone(), authorization_errors, plugin_req_state, }; @@ -493,8 +554,11 @@ pub async fn execute_pipeline<'exec>( execute_plan(supergraph, shared_state, planned_request, operation_span).await } -fn inbound_request_fingerprint( - req: &HttpRequest, +#[allow(clippy::too_many_arguments)] +pub fn inbound_request_fingerprint( + method: &http::Method, + path: &str, + request_headers: &HeaderMap, dedupe_header_policy: &RouterRequestDedupeHeaderPolicy, schema_checksum: u64, normalized_operation_hash: u64, @@ -503,8 +567,7 @@ fn inbound_request_fingerprint( ) -> u64 { let mut hasher = Xxh3::new(); - let mut headers: Vec<(&str, &str)> = req - .headers() + let mut headers: Vec<(&str, &str)> = request_headers .iter() .filter(|(name, _)| dedupe_header_policy.should_include(name.as_str())) .filter_map(|(name, value)| value.to_str().ok().map(|v_str| (name.as_str(), v_str))) @@ -515,8 +578,8 @@ fn inbound_request_fingerprint( .then_with(|| left_value.cmp(right_value)) }); - req.method().hash(&mut hasher); - req.path().hash(&mut hasher); + method.hash(&mut hasher); + path.hash(&mut hasher); headers.hash(&mut hasher); schema_checksum.hash(&mut hasher); normalized_operation_hash.hash(&mut hasher); @@ -526,7 +589,7 @@ fn inbound_request_fingerprint( hasher.finish() } -fn hash_graphql_variables(variables: &HashMap) -> u64 { +pub fn hash_graphql_variables(variables: &HashMap) -> u64 { let mut hasher = Xxh3::new(); let mut keys: Vec<&str> = variables.keys().map(String::as_str).collect(); @@ -543,7 +606,7 @@ fn hash_graphql_variables(variables: &HashMap) -> u64 { hasher.finish() } -fn hash_graphql_extensions(extensions: &HashMap) -> u64 { +pub fn hash_graphql_extensions(extensions: &HashMap) -> u64 { // reused as hash_graphql_variables has the same function signature hash_graphql_variables(extensions) } diff --git a/bin/router/src/pipeline/multipart_subscribe.rs b/bin/router/src/pipeline/multipart_subscribe.rs new file mode 100644 index 000000000..0a3317a41 --- /dev/null +++ b/bin/router/src/pipeline/multipart_subscribe.rs @@ -0,0 +1,133 @@ +// TODO: test thoroughly + +use const_str::concat; + +use futures_util::{Stream, StreamExt}; +use ntex::util::Bytes; +use std::time::Duration; +use tokio_util::bytes::BufMut; + +// we use macros to retain constness +macro_rules! make_content_type { + ($boundary:expr) => { + // wrapping with "" is not necessary in our case since the boundaries we use do not contain special + // characters - but clients out there probably rely on the quotes so we add them just in case + concat!("multipart/mixed;boundary=\"", $boundary, "\"") + }; +} +macro_rules! make_boundaries { + ($boundary:expr) => { + ( + // start + concat!( + "--", + $boundary, + "\r\nContent-Type: application/json\r\n\r\n" + ), + // end + concat!("--", $boundary, "--"), + ) + }; +} + +const INCREMENTAL_DELIVERY_BOUNDARY: &str = "-"; + +pub const INCREMENTAL_DELIVERY_CONTENT_TYPE: &str = + make_content_type!(INCREMENTAL_DELIVERY_BOUNDARY); + +/// Create a multipart subscription stream following the Official GraphQL over HTTP Incremental Delivery RFC. +/// +/// Will use `-` as boundary. +/// +/// NOTE: Incremental Delivery over HTTP does not support heartbeats. Please prefer Apollo's multiple HTTP where applicable. +/// +/// Read more: https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol +pub fn create_incremental_delivery_stream( + input: impl Stream> + Send + Unpin + 'static, +) -> impl Stream> + Unpin { + let mut input = input; + let (start_boundary, end_boundary) = make_boundaries!(INCREMENTAL_DELIVERY_BOUNDARY); + async_stream::stream! { + loop { + match input.next().await { + Some(resp) => { + match std::str::from_utf8(&resp) { + Ok(_) => { + yield Ok(Bytes::from(start_boundary)); + yield Ok(Bytes::from(resp)); + yield Ok(Bytes::from("\r\n")); + } + Err(e) => { + yield Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + break; + } + } + } + None => { + yield Ok(Bytes::from(end_boundary)); + break; + }, + } + } + } + .boxed() +} + +const APOLLO_MULTIPART_HTTP_BOUNDARY: &str = "graphql"; + +pub const APOLLO_MULTIPART_HTTP_CONTENT_TYPE: &str = concat!( + make_content_type!(APOLLO_MULTIPART_HTTP_BOUNDARY), + ";subscriptionSpec=1.0" +); + +/// Create a multipart subscription stream following Apollo's Multipart HTTP spec. +/// +/// Will use `graphql` as boundary. +/// +/// Read more: https://github.com/graphql/graphql-over-http/blob/d312e43384006fa323b918d49cfd9fbd76ac1257/rfcs/IncrementalDelivery.md +pub fn create_apollo_multipart_http_stream( + input: impl Stream> + Send + Unpin + 'static, + heartbeat_interval: Duration, +) -> impl Stream> + Unpin { + let mut input = input; + let (start_boundary, end_boundary) = make_boundaries!(APOLLO_MULTIPART_HTTP_BOUNDARY); + let ping = "{}\r\n"; + async_stream::stream! { + loop { + tokio::select! { + item = input.next() => { + match item { + Some(resp) => { + match std::str::from_utf8(&resp) { + Ok(_) => { + yield Ok(Bytes::from(start_boundary)); + // Wrap the GraphQL response in a payload field + // As per the spec. + let mut payload = ntex::util::BytesMut::with_capacity(resp.len() + 15); + payload.put_slice(br#"{"payload":"#); + payload.put_slice(&resp); + payload.put_slice(br"}"); + yield Ok(payload.freeze()); + yield Ok(Bytes::from("\r\n")); + } + Err(e) => { + // TODO: use transport level errors as per spec + yield Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + break; + } + } + } + None => { + yield Ok(Bytes::from(end_boundary)); + break; + }, + } + } + _ = tokio::time::sleep(heartbeat_interval) => { + yield Ok(Bytes::from(start_boundary)); + yield Ok(Bytes::from(ping)); + } + } + } + }.boxed() +} diff --git a/bin/router/src/pipeline/sse.rs b/bin/router/src/pipeline/sse.rs new file mode 100644 index 000000000..9c9acb7d4 --- /dev/null +++ b/bin/router/src/pipeline/sse.rs @@ -0,0 +1,48 @@ +// TODO: test thoroughly + +use futures_util::{Stream, StreamExt}; +use ntex::util::Bytes; +use std::time::Duration; +use tokio_util::bytes::BufMut; + +pub const SSE_HEADER: &str = "text/event-stream"; + +pub fn create_stream( + input: impl Stream> + Send + Unpin + 'static, + heartbeat_interval: Duration, +) -> impl Stream> + Unpin { + let mut input = input; + async_stream::stream! { + loop { + tokio::select! { + item = input.next() => { + match item { + Some(resp) => { + match std::str::from_utf8(&resp) { + Ok(json_str) => { + let mut sse_event = ntex::util::BytesMut::with_capacity(json_str.len() + 25); + sse_event.put_slice(b"event: next\ndata: "); + sse_event.put_slice(json_str.as_bytes()); + sse_event.put_slice(b"\n\n"); + yield Ok(sse_event.freeze()); + } + Err(e) => { + yield Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + break; + } + } + } + None => { + yield Ok(Bytes::from("event: complete\n\n")); + break; + }, + } + } + _ = tokio::time::sleep(heartbeat_interval) => { + yield Ok(Bytes::from(":\n\n")); + } + } + } + } + .boxed() +} diff --git a/bin/router/src/pipeline/websocket_server.rs b/bin/router/src/pipeline/websocket_server.rs new file mode 100644 index 000000000..03c3d4394 --- /dev/null +++ b/bin/router/src/pipeline/websocket_server.rs @@ -0,0 +1,754 @@ +use http::Method; +use ntex::channel::oneshot; +use ntex::http::{header::HeaderName, header::HeaderValue, HeaderMap}; +use ntex::router::Path; +use ntex::service::{fn_factory_with_config, fn_service, fn_shutdown, Service}; +use ntex::web::{self, ws, Error, HttpRequest, HttpResponse}; +use ntex::{chain, rt}; +use sonic_rs::{JsonContainerTrait, JsonValueTrait, Value}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::io; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::mpsc; +use tracing::{debug, error, trace, warn, Instrument}; + +use hive_router_internal::telemetry::traces::spans::graphql::GraphQLOperationSpan; +use hive_router_plan_executor::executors::graphql_transport_ws::{ + ClientMessage, CloseCode, ConnectionInitPayload, ServerMessage, WS_SUBPROTOCOL, +}; +use hive_router_plan_executor::executors::websocket_common::{ + handshake_timeout, heartbeat, parse_frame_to_text, FrameNotParsedToText, WsState, +}; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use hive_router_plan_executor::plugin_context::{ + PluginContext, PluginRequestState, RouterHttpRequest, +}; +use hive_router_plan_executor::response::graphql_error::{GraphQLError, GraphQLErrorExtensions}; +use hive_router_query_planner::state::supergraph_state::OperationKind; + +use crate::jwt::errors::JwtError; +use crate::pipeline::active_subscriptions::SubscriptionEvent; +use crate::pipeline::error::PipelineError; +use crate::pipeline::execute_planned_request; +use crate::pipeline::header::{ResponseMode, SingleContentType, StreamContentType}; +use crate::pipeline::{ + hash_graphql_extensions, hash_graphql_variables, inbound_request_fingerprint, + normalize::normalize_request_with_cache, parser::parse_operation_with_cache, usage_reporting, + validation::validate_operation_with_cache, +}; +use crate::schema_state::SchemaState; +use crate::shared_state::{RouterSharedState, SharedRouterResponse}; + +type WsStateRef = Rc>>>; + +pub async fn ws_index( + req: HttpRequest, + schema_state: web::types::State>, + shared_state: web::types::State>, +) -> Result { + let schema_state = schema_state.get_ref().clone(); + let shared_state = shared_state.get_ref().clone(); + + let accepted_subprotocol = ws::subprotocols(&req) + .find(|p| *p == WS_SUBPROTOCOL) + .map(|_| WS_SUBPROTOCOL); + + let plugin_context = req.extensions().get::>().cloned(); + + ws::start( + req, + accepted_subprotocol, + fn_factory_with_config(move |sink: ws::WsSink| { + let schema_state = schema_state.clone(); + let shared_state = shared_state.clone(); + let plugin_context = plugin_context.clone(); + async move { + ws_service( + accepted_subprotocol.is_some(), + sink, + schema_state, + shared_state, + plugin_context, + ) + .await + } + }), + ) + .await +} + +async fn ws_service( + has_accepted_subprotocol: bool, + sink: ws::WsSink, + schema_state: Arc, + shared_state: Arc, + plugin_context: Option>, +) -> Result, Error = io::Error>, web::Error> +{ + if !has_accepted_subprotocol { + debug!("WebSocket connection rejecting due to unacceptable subprotocol"); + let _ = sink.send(CloseCode::SubprotocolNotAcceptable.into()).await; + // we dont return an Err here because we want to gracefully close the + // connection for the client side with a close frame. returning an Err + // would result in an abrupt termination of the connection + } else { + debug!("WebSocket connection accepted"); + } + + let ws_uri: Rc = Rc::new( + shared_state + .router_config + .websocket_path() + .expect("websocket path must exist because the websocket handler wouldn't have been mounted otherwise") + .parse() + .unwrap_or_else(|_| http::Uri::from_static("/graphql")), + ); + let ws_path: Rc> = Rc::new(Path::new((*ws_uri).clone())); + + let (heartbeat_tx, heartbeat_rx) = oneshot::channel(); + let (acknowledged_tx, acknowledged_rx) = oneshot::channel(); + + let state: WsStateRef = Rc::new(RefCell::new(WsState::new(acknowledged_tx))); + + rt::spawn(heartbeat(state.clone(), sink.clone(), heartbeat_rx)); + rt::spawn(handshake_timeout( + state.clone(), + sink.clone(), + acknowledged_rx, + CloseCode::ConnectionInitTimeout, + )); + + let state_for_service = state.clone(); + let service = fn_service(move |frame| { + let sink = sink.clone(); + let state = state_for_service.clone(); + let schema_state = schema_state.clone(); + let shared_state = shared_state.clone(); + let plugin_context = plugin_context.clone(); + let ws_uri = ws_uri.clone(); + let ws_path = ws_path.clone(); + async move { + match parse_frame_to_text(frame, &state) { + Ok(text) => Ok(handle_text_frame( + text, + sink, + state, + &schema_state, + &shared_state, + plugin_context, + &ws_uri, + &ws_path, + ) + .await), + Err(FrameNotParsedToText::Message(msg)) => Ok(Some(msg)), + Err(FrameNotParsedToText::Closed) => { + // we dont need to emit anything here because the conneciton is already closed + Ok(None) + } + Err(FrameNotParsedToText::None) => Ok(None), + } + } + }); + + let on_shutdown = fn_shutdown(async move || { + // stop heartbeat and handshake timeout tasks on shutdown + let _ = heartbeat_tx.send(()); + if let Some(tx) = state.borrow_mut().acknowledged_tx.take() { + let _ = tx.send(()); + } + // clearing the map will drop all the senders, which will + // in turn cancel all active subscription streams and perform + // the cleanup in there + state.borrow_mut().subscriptions.clear(); + }); + + Ok(chain(service).and_then(on_shutdown)) +} + +/// Ensure a subscription is removed from active subscriptions when dropped (server-side). +struct SubscriptionGuard { + state: WsStateRef, + id: String, +} + +impl Drop for SubscriptionGuard { + fn drop(&mut self) { + self.state.borrow_mut().subscriptions.remove(&self.id); + trace!(id = %self.id, "Subscription removed from active subscriptions"); + } +} + +lazy_static::lazy_static! { + static ref WS_URI_PATH: http::Uri = http::Uri::from_static("/graphql"); +} + +#[allow(clippy::too_many_arguments)] +async fn handle_text_frame( + text: String, + sink: ws::WsSink, + state: WsStateRef, + schema_state: &Arc, + shared_state: &Arc, + plugin_context: Option>, + ws_uri: &http::Uri, + ws_path: &Path, +) -> Option { + let client_msg: ClientMessage = match sonic_rs::from_str(&text) { + Ok(msg) => msg, + Err(e) => { + error!("Failed to parse client message to JSON: {}", e); + return Some(CloseCode::BadRequest("Invalid message received from client").into()); + } + }; + + trace!("type" = client_msg.as_ref(), "Received client message"); + + match client_msg { + ClientMessage::ConnectionInit { payload } => { + if state.borrow().handshake_received { + return Some(CloseCode::TooManyInitialisationRequests.into()); + } + state.borrow_mut().handshake_received = true; + state.borrow_mut().init_payload = payload; + state.borrow_mut().complete_handshake(); + + let _ = sink.send(ServerMessage::ack()).await; + + debug!("Connection acknowledged"); + + let header_map = + parse_headers_from_connection_init_payload(state.borrow().init_payload.as_ref()); + if !header_map.is_empty() { + trace!("Connection init message contains headers in the payload"); + } else { + trace!("Connection init message does not contain headers in the payload"); + } + + None + } + ClientMessage::Subscribe { id, payload } => { + if let Some(msg) = state.borrow().check_acknowledged() { + return Some(msg); + } + + if state.borrow().subscriptions.contains_key(&id) { + return Some(CloseCode::SubscriberAlreadyExists(id).into()); + } + + let started_at = Instant::now(); + let operation_span = GraphQLOperationSpan::new(); + let span_clone = operation_span.clone(); + + let result = async { + let maybe_supergraph = schema_state.current_supergraph(); + let supergraph = match maybe_supergraph.as_ref() { + Some(supergraph) => supergraph, + None => { + warn!( + "No supergraph available yet, unable to process client subscribe message" + ); + return Some(ServerMessage::error( + &id, + &[GraphQLError::from_message_and_extensions( + "No supergraph available yet".to_string(), + GraphQLErrorExtensions::new_from_code("SERVICE_UNAVAILABLE"), + )], + )); + } + }; + + let config = &shared_state.router_config.websocket; + + let connection_init_headers = if config.headers.accepts_connection_headers() { + let state_borrow = state.borrow(); + parse_headers_from_connection_init_payload(state_borrow.init_payload.as_ref()) + } else { + HeaderMap::new() + }; + + let extensions_headers = if config.headers.accepts_operation_headers() { + parse_headers_from_extensions(payload.extensions.as_ref()) + } else { + HeaderMap::new() + }; + + // merge, extensions have precedence + let mut headers = connection_init_headers; + for (key, value) in extensions_headers.iter() { + headers.insert(key.clone(), value.clone()); + } + + // store the merged headers back to init_payload if configured to do so + if config.headers.persist { + if let Some(ref mut init_payload) = state.borrow_mut().init_payload { + for (key, value) in headers.iter() { + if let Ok(val_str) = value.to_str() { + init_payload + .fields + .insert(key.to_string(), Value::from(val_str)); + } + } + } + } + + let payload = GraphQLParams { + query: Some(payload.query), + operation_name: payload.operation_name, + variables: payload.variables.unwrap_or_default(), + extensions: payload.extensions, + }; + + // synthetic router http request for plugins - there's no real http request + // in the ws subscribe flow, so we assemble one from the ws path and merged headers. + // of course there is the http upgrade request, but that one is useless for the plugin system + let plugin_req_state = if let (Some(plugins), Some(ref plugin_context)) = ( + shared_state.plugins.as_ref(), + plugin_context, + ) { + Some(PluginRequestState { + plugins: plugins.clone(), + router_http_request: RouterHttpRequest { + uri: ws_uri, + method: &Method::POST, + version: http::Version::HTTP_11, + headers: &headers, + path: ws_uri.path(), + query_string: ws_uri.query().unwrap_or(""), + match_info: ws_path, + }, + context: plugin_context.clone(), + }) + } else { + None + }; + + let client_name = headers + .get( + &shared_state + .router_config + .telemetry + .client_identification + .name_header, + ) + .and_then(|v| v.to_str().ok()); + let client_version = headers + .get( + &shared_state + .router_config + .telemetry + .client_identification + .version_header, + ) + .and_then(|v| v.to_str().ok()); + + let parser_result = + match parse_operation_with_cache(shared_state, &payload, &plugin_req_state).await { + Ok(result) => result, + Err(err) => return Some(err.into_server_message(&id)), + }; + + let parser_payload = match parser_result { + crate::pipeline::parser::ParseResult::Payload(payload) => payload, + crate::pipeline::parser::ParseResult::EarlyResponse(_) => { + return Some(ServerMessage::error( + &id, + &[GraphQLError::from_message_and_code( + "Unexpected early response during parse", + "INTERNAL_SERVER_ERROR", + )], + )); + } + }; + + operation_span.record_details( + &parser_payload.minified_document, + (&parser_payload).into(), + client_name, + client_version, + &parser_payload.hive_operation_hash, + ); + + match validate_operation_with_cache( + supergraph, + schema_state, + shared_state, + &parser_payload, + &plugin_req_state, + ) + .await + { + Ok(Some(_)) => { + return Some(ServerMessage::error( + &id, + &[GraphQLError::from_message_and_code( + "Unexpected early response during validation", + "INTERNAL_SERVER_ERROR", + )], + )); + } + Ok(None) => {} + Err(err) => return Some(err.into_server_message(&id)), + } + + let normalize_payload = match normalize_request_with_cache( + supergraph, + schema_state, + &payload, + &parser_payload, + ) + .await + { + Ok(payload) => payload, + Err(err) => return Some(err.into_server_message(&id)), + }; + + let is_subscription = matches!( + normalize_payload.operation_for_plan.operation_kind, + Some(OperationKind::Subscription) + ); + + if is_subscription && !shared_state.router_config.subscriptions.enabled { + return Some(PipelineError::SubscriptionsNotSupported.into_server_message(&id)); + } + + let request_dedupe_enabled = + shared_state.router_config.traffic_shaping.router.dedupe.enabled; + + let fingerprint = if request_dedupe_enabled + && matches!( + normalize_payload.operation_for_plan.operation_kind, + // same deduplication applies for queries and subscriptions + None | Some(OperationKind::Query) | Some(OperationKind::Subscription) + ) { + let variables_hash = hash_graphql_variables(&payload.variables); + let extensions_hash = payload + .extensions + .as_ref() + .map_or(0, hash_graphql_extensions); + Some(inbound_request_fingerprint( + &Method::POST, + ws_uri.path(), + &headers, + &shared_state.in_flight_requests_header_policy, + supergraph.schema_checksum(), + normalize_payload.normalized_operation_hash, + variables_hash, + extensions_hash, + )) + } else { + None + }; + + // synthetic request details for plan executor + let response_mode = ResponseMode::Dual( + SingleContentType::default(), + StreamContentType::default(), + ); + let exec = |guard| execute_planned_request( + &Method::POST, + ws_uri, + &headers, + payload, + &normalize_payload, + supergraph, + shared_state, + schema_state, + operation_span, + plugin_req_state, + &response_mode, + guard, + ); + + let shared_response = if let Some(fp) = fingerprint { + let result = if is_subscription { + shared_state + .in_flight_requests + .claim(fp) + .get_or_try_init_with_guard(|guard| exec(Some(guard))) + .await + } else { + shared_state + .in_flight_requests + .claim(fp) + .get_or_try_init(|| exec(None)) + .await + }; + let (shared_response, _role) = match result { + Ok(result) => result, + Err(PipelineError::JwtError(err)) => { + let _ = sink.send(err.clone().into_server_message(&id)).await; + // we report error as graphql error, but we also close the + // connection since we're dealing with auth so let's be safe + return Some(err.into_close_message()); + }, + Err(err) => return Some(err.into_server_message(&id)), + }; + Arc::unwrap_or_clone(shared_response) + } else { + match exec(None).await { + Ok(result) => result, + Err(PipelineError::JwtError(err)) => { + let _ = sink.send(err.clone().into_server_message(&id)).await; + // we report error as graphql error, but we also close the + // connection since we're dealing with auth so let's be safe + return Some(err.into_close_message()); + }, + Err(err) => return Some(err.into_server_message(&id)), + } + }; + + if let Some(hive_usage_agent) = &shared_state.hive_usage_agent { + usage_reporting::collect_usage_report( + supergraph.supergraph_schema.clone(), + started_at.elapsed(), + client_name, + client_version, + normalize_payload.operation_for_plan.name.as_deref(), + &parser_payload.minified_document, + hive_usage_agent, + shared_state + .router_config + .telemetry + .hive + .as_ref() + .map(|c| &c.usage_reporting) + .expect("Expected Usage Reporting options to be present when Hive Usage Agent is initialized"), + shared_response.error_count(), + ) + .await; + } + + match shared_response { + SharedRouterResponse::Single(response) => { + let _ = sink.send(ServerMessage::next(&id, &response.body)).await; + Some(ServerMessage::complete(&id)) + } + SharedRouterResponse::Stream(response) => { + let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); + + state + .borrow_mut() + .subscriptions + .insert(id.clone(), cancel_tx); + + let guard = SubscriptionGuard { + state: state.clone(), + id: id.clone(), + }; + + let mut receiver = response + .receiver + .unwrap_or_else(|| response.body.subscribe()); + + trace!(id = %id, "Subscription started"); + + let sink = sink.clone(); + let id_for_loop = id.clone(); + // must be spawned - blocking the frame handler would prevent + // ClientMessage::Complete from being received and processed, + // making cancellation impossible + rt::spawn(async move { + let _guard = guard; + let mut cancelled = false; + + loop { + tokio::select! { + maybe_item = receiver.recv() => { + match maybe_item { + Ok(SubscriptionEvent::Raw(data)) => { + let _ = sink.send(ServerMessage::next(&id_for_loop, &data)).await; + } + Ok(SubscriptionEvent::Error(errors)) => { + let _ = sink.send(ServerMessage::error(&id_for_loop, &errors)).await; + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + trace!(id = %id_for_loop, lagged = n, "broadcast receiver lagged, skipping missed messages"); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + _ = cancel_rx.recv() => { + cancelled = true; + break; + } + } + } + + if cancelled { + trace!(id = %id_for_loop, "Subscription cancelled"); + } else { + trace!(id = %id_for_loop, "Subscription completed"); + let _ = sink.send(ServerMessage::complete(&id_for_loop)).await; + } + }); + + None + } + } + } + .instrument(span_clone) + .await; + + result + } + ClientMessage::Complete { id } => { + if let Some(msg) = state.borrow().check_acknowledged() { + return Some(msg); + } + + if let Some(cancel_tx) = state.borrow_mut().subscriptions.remove(&id) { + trace!(id = %id, "Client requested subscription cancellation"); + let _ = cancel_tx.try_send(()); + } + None + } + ClientMessage::Ping {} => { + // respond with pong always, regardless of acknowledged state + // the client should be able to use subprotocol pings/pongs to check liveness + Some(ServerMessage::pong()) + } + ClientMessage::Pong {} => None, + } +} + +/// Parses headers from a sonic_rs::Object into a HeaderMap. Only stringifiable +/// values are included; nulls, arrays, objects and other non-primitive values +/// are ignored. +fn parse_headers_from_object(headers_obj: &sonic_rs::Object) -> HeaderMap { + let mut header_map = HeaderMap::new(); + + for (key, value) in headers_obj.iter() { + let Ok(name) = HeaderName::try_from(key as &str) else { + continue; + }; + let header_value = if let Some(s) = value.as_str() { + HeaderValue::try_from(s).ok() + } else if let Some(b) = value.as_bool() { + Some(HeaderValue::from_static(if b { "true" } else { "false" })) + } else if let Some(n) = value.as_i64() { + Some(HeaderValue::from(n)) + } else if let Some(n) = value.as_u64() { + Some(HeaderValue::from(n)) + } else if let Some(f) = value.as_f64() { + // f64 has no HeaderValue::From impl, string alloc unavoidable + HeaderValue::try_from(f.to_string()).ok() + } else { + None // ignore nulls, arrays, objects + }; + if let Some(val) = header_value { + header_map.insert(name, val); + } + } + + header_map +} + +fn parse_headers_from_connection_init_payload( + payload: Option<&ConnectionInitPayload>, +) -> HeaderMap { + let mut header_map = HeaderMap::new(); + if let Some(payload) = payload { + // First check if there's a nested "headers" object + if let Some(headers_prop) = payload.fields.get("headers") { + if let Some(headers_obj) = headers_prop.as_object() { + header_map = parse_headers_from_object(headers_obj); + return header_map; + } + } + + // If no nested "headers" object, treat all top-level fields as potential headers + // Convert the entire fields HashMap to a sonic_rs::Object + let mut obj = sonic_rs::Object::new(); + for (k, v) in payload.fields.iter() { + obj.insert(k, v.clone()); + } + header_map = parse_headers_from_object(&obj); + } + header_map +} + +fn parse_headers_from_extensions(extensions: Option<&HashMap>) -> HeaderMap { + let mut header_map = HeaderMap::new(); + if let Some(ext) = extensions { + if let Some(headers_value) = ext.get("headers") { + if let Some(headers_obj) = headers_value.as_object() { + header_map = parse_headers_from_object(headers_obj); + } + } + } + header_map +} + +// NOTE: no `From` trait because it can into ws message and ws closecode but both are ws::Message +impl PipelineError { + fn into_server_message(self, id: &str) -> ws::Message { + let code = self.graphql_error_code(); + let message = self.graphql_error_message(); + + let graphql_error = GraphQLError::from_message_and_extensions( + message, + GraphQLErrorExtensions::new_from_code(code), + ); + + ServerMessage::error(id, &[graphql_error]) + } +} + +// NOTE: no `From` trait because it can into ws message and ws closecode but both are ws::Message +impl JwtError { + fn into_server_message(self, id: &str) -> ws::Message { + ServerMessage::error( + id, + &[GraphQLError::from_message_and_code( + self.to_string(), + self.error_code(), + )], + ) + } + fn into_close_message(self) -> ws::Message { + CloseCode::Forbidden(self.error_code().to_string()).into() + } +} + +#[cfg(test)] +mod tests { + use sonic_rs::json; + + use super::*; + + #[test] + fn should_parse_headers_from_object() { + let headers_json = json!({ + "authorization": "Bearer token123", + "x-custom-header": "custom-value", + "x-number": 42, + "x-bool": true, + "x-float": 3.14 + }); + + let headers_obj = headers_json.as_object().expect("Failed to get object"); + + let headers = parse_headers_from_object(&headers_obj); + + assert_eq!( + headers.get("authorization").unwrap().to_str().unwrap(), + "Bearer token123" + ); + assert_eq!( + headers.get("x-custom-header").unwrap().to_str().unwrap(), + "custom-value" + ); + assert_eq!(headers.get("x-number").unwrap().to_str().unwrap(), "42"); + assert_eq!(headers.get("x-bool").unwrap().to_str().unwrap(), "true"); + assert_eq!(headers.get("x-float").unwrap().to_str().unwrap(), "3.14"); + } + + // TODO: hella tests +} diff --git a/bin/router/src/schema_state.rs b/bin/router/src/schema_state.rs index b95f806b4..61c10ed38 100644 --- a/bin/router/src/schema_state.rs +++ b/bin/router/src/schema_state.rs @@ -1,6 +1,8 @@ +use crate::pipeline::active_subscriptions::ActiveSubscriptions; use crate::pipeline::authorization::metadata::AuthorizationMetadataExt; use arc_swap::{ArcSwap, Guard}; use async_trait::async_trait; +use dashmap::DashMap; use graphql_tools::static_graphql::schema::Document; use graphql_tools::validation::utils::ValidationError; use hive_router_config::{supergraph::SupergraphSource, HiveRouterConfig}; @@ -9,6 +11,10 @@ use hive_router_internal::{ authorization::metadata::AuthorizationMetadata, background_tasks::{BackgroundTask, BackgroundTasksManager}, }; +use hive_router_plan_executor::executors::http_callback::{ + CallbackMessage, CallbackSubscriptionsMap, +}; +use hive_router_plan_executor::response::graphql_error::GraphQLErrorExtensions; use hive_router_plan_executor::{ executors::error::SubgraphExecutorError, hooks::on_supergraph_load::{ @@ -16,6 +22,7 @@ use hive_router_plan_executor::{ }, introspection::schema::SchemaWithMetadata, plugin_trait::{EndControlFlow, RouterPluginBoxed, StartControlFlow}, + response::graphql_error::GraphQLError, SubgraphExecutorMap, }; use hive_router_query_planner::planner::plan_nodes::QueryPlan; @@ -25,6 +32,7 @@ use hive_router_query_planner::{ }; use moka::future::Cache; use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{debug, error, trace}; @@ -44,6 +52,7 @@ pub struct SchemaState { pub validate_cache: Cache>>, pub normalize_cache: Cache>, pub telemetry_context: Arc, + pub callback_subscriptions: CallbackSubscriptionsMap, } #[derive(Debug, thiserror::Error)] @@ -79,6 +88,7 @@ impl SchemaState { router_config: Arc, plugins: Option>>, cache_state: Arc, + active_subscriptions: ActiveSubscriptions, ) -> Result { let (tx, mut rx) = mpsc::channel::(1); let background_loader = SupergraphBackgroundLoader::new( @@ -93,12 +103,27 @@ impl SchemaState { let plan_cache = cache_state.plan_cache.clone(); let validate_cache = cache_state.validate_cache.clone(); let normalize_cache = cache_state.normalize_cache.clone(); + let callback_subscriptions: CallbackSubscriptionsMap = Arc::new(DashMap::new()); // This is cheap clone, as Cache is thread-safe and can be cloned without any performance penalty. let cache_state_for_invalidation = cache_state.clone(); + let callback_subscriptions_for_build_data = callback_subscriptions.clone(); + + // kick off subscriptions/subgraphs that are idling/timed out due to missed heartbeats + if let Some(ref callback_config) = router_config.subscriptions.callback { + if !callback_config.heartbeat_interval.is_zero() { + let enforcer_subs = callback_subscriptions.clone(); + let heartbeat_interval = callback_config.heartbeat_interval; + bg_tasks_manager.register_task(CallbackHeartbeatEnforcerTask { + callback_subscriptions: enforcer_subs, + heartbeat_interval, + }); + } + } let metrics = telemetry_context.metrics.clone(); let task_telemetry = telemetry_context.clone(); + let active_subscriptions_for_reload = active_subscriptions.clone(); bg_tasks_manager.register_handle(async move { let supergraph_metrics = &metrics.supergraph; while let Some(new_sdl) = rx.recv().await { @@ -141,7 +166,12 @@ impl SchemaState { } match new_supergraph_data.unwrap_or_else(|| { - Self::build_data(router_config.clone(), task_telemetry.clone(), new_ast) + Self::build_data( + router_config.clone(), + task_telemetry.clone(), + new_ast, + callback_subscriptions_for_build_data.clone(), + ) }) { Ok(mut new_supergraph_data) => { if !on_end_callbacks.is_empty() { @@ -180,6 +210,16 @@ impl SchemaState { new_supergraph_data = end_payload.new_supergraph_data; } + // close all active subscriptions before swapping supergraph data + active_subscriptions_for_reload.close_all_with_error(vec![ + // this is litearaly the same message apollo sends - reasoning is + // drop in replacement - is that oke? should we have our own? + GraphQLError::from_message_and_code( + "subscription has been closed due to a schema reload", + "SUBSCRIPTION_SCHEMA_RELOAD", + ), + ]); + swappable_data_spawn_clone.store(Arc::new(Some(new_supergraph_data))); debug!("Supergraph updated successfully"); @@ -201,6 +241,7 @@ impl SchemaState { validate_cache, normalize_cache, telemetry_context: telemetry_context.clone(), + callback_subscriptions, }) } @@ -208,15 +249,17 @@ impl SchemaState { router_config: Arc, telemetry_context: Arc, parsed_supergraph_sdl: Document, + callback_subscriptions: CallbackSubscriptionsMap, ) -> Result { let planner = Planner::new_from_supergraph(&parsed_supergraph_sdl)?; - let metadata = planner.consumer_schema.schema_metadata(); + let metadata = Arc::new(planner.consumer_schema.schema_metadata()); let authorization = AuthorizationMetadata::build(&planner.supergraph, &metadata)?; - let subgraph_executor_map = SubgraphExecutorMap::from_http_endpoint_map( + let subgraph_executor_map = Arc::new(SubgraphExecutorMap::from_http_endpoint_map( &planner.supergraph.subgraph_endpoint_map, router_config, telemetry_context, - )?; + callback_subscriptions, + )?); Ok(SupergraphData { supergraph_schema: Arc::new(parsed_supergraph_sdl), @@ -305,3 +348,68 @@ impl BackgroundTask for SupergraphBackgroundLoaderTask { } } } + +struct CallbackHeartbeatEnforcerTask { + callback_subscriptions: CallbackSubscriptionsMap, + heartbeat_interval: Duration, +} + +#[async_trait] +impl BackgroundTask for CallbackHeartbeatEnforcerTask { + fn id(&self) -> &str { + "http-callback-heartbeat-enforcer" + } + + async fn run(&self, token: CancellationToken) { + use std::time::Instant; + + loop { + tokio::select! { + _ = token.cancelled() => { + debug!("heartbeat enforcer cancelled, stopping"); + return; + } + _ = ntex::time::sleep(self.heartbeat_interval) => {} + } + + let mut timed_out = Vec::new(); + for entry in self.callback_subscriptions.iter() { + let last = *entry.value().last_heartbeat.lock().unwrap(); + // heartbeat interval and some grace period to account for potential network delays + #[cfg(not(feature = "testing"))] + let grace_period = std::time::Duration::from_millis(1000); + // when dealing with tests that run in parallel in the CI, we need to increase the + // grace period to avoid flaky tests due to timing issues with runner under pressure + #[cfg(feature = "testing")] + let grace_period = std::time::Duration::from_millis(2000); + let deadline = self.heartbeat_interval + grace_period; + let elapsed = match last { + // first check hasn't arrived yet, measure from creation time instead + None => Instant::now().duration_since(entry.value().created_at), + Some(last) => Instant::now().duration_since(last), + }; + if elapsed > deadline { + timed_out.push(entry.key().clone()); + } + } + + // separate iter so that we dont mess up the slice while looping + for id in timed_out { + debug!( + subscription_id = %id, + "terminating subscription due to http callback subgraph missed heartbeat" + ); + if let Some((_, sub)) = self.callback_subscriptions.remove(&id) { + // we dont care about the result of this send, if it fails it means the client + // is already gone or too slow, either way we just terminate the subscription + let _ = sub.sender.try_send(CallbackMessage::Complete { + errors: Some(vec![GraphQLError::from_message_and_extensions( + "Subgraph gone due to heartbeat timeout".to_string(), + GraphQLErrorExtensions::new_from_code("SUBGRAPH_GONE"), + )]), + }); + } + } + } + } +} diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index 22823db44..de96706d3 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -1,3 +1,4 @@ +use futures::Stream; use graphql_tools::validation::validate::ValidationPlan; use hive_console_sdk::agent::usage_agent::{AgentError, UsageAgent}; use hive_router_config::traffic_shaping::{ @@ -6,8 +7,9 @@ use hive_router_config::traffic_shaping::{ use hive_router_config::HiveRouterConfig; use hive_router_internal::expressions::values::boolean::BooleanOrProgram; use hive_router_internal::expressions::ExpressionCompileError; -use hive_router_internal::inflight::InFlightMap; +use hive_router_internal::inflight::{InFlightCleanupGuard, InFlightMap}; use hive_router_internal::telemetry::TelemetryContext; +use hive_router_plan_executor::execution::plan::FailedExecutionResult; use hive_router_plan_executor::headers::{ compile::compile_headers_plan, errors::HeaderRuleCompileError, plan::HeaderRulesPlan, }; @@ -17,16 +19,25 @@ use moka::future::Cache; use moka::Expiry; use ntex::web; use ntex::{http::HeaderMap, util::Bytes}; +use std::sync::atomic::AtomicUsize; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::{collections::HashSet, sync::Arc}; +use tracing::trace; use crate::cache_state::CacheState; use crate::jwt::context::JwtTokenPayload; use crate::jwt::JwtAuthRuntime; +use crate::pipeline::active_subscriptions::{ActiveSubscriptions, SubscriptionEvent}; use crate::pipeline::cors::{CORSConfigError, Cors}; +use crate::pipeline::error::PipelineError; +use crate::pipeline::header::{ResponseMode, StreamContentType}; use crate::pipeline::introspection_policy::compile_introspection_policy; +use crate::pipeline::multipart_subscribe::{ + self, APOLLO_MULTIPART_HTTP_CONTENT_TYPE, INCREMENTAL_DELIVERY_CONTENT_TYPE, +}; use crate::pipeline::parser::ParseCacheEntry; use crate::pipeline::progressive_override::{OverrideLabelsCompileError, OverrideLabelsEvaluator}; +use crate::pipeline::sse; pub type JwtClaimsCache = Cache>; pub type RouterInflightRequestsMap = InFlightMap; @@ -74,27 +85,148 @@ impl From<&TrafficShapingRouterDedupeHeadersConfig> for RouterRequestDedupeHeade } } +pub type SharedRouterResponseGuard = InFlightCleanupGuard; + +#[derive(Clone)] +pub enum SharedRouterResponse { + Single(SharedRouterSingleResponse), + Stream(SharedRouterStreamResponse), +} + +impl SharedRouterResponse { + pub fn error_count(&self) -> usize { + match self { + SharedRouterResponse::Single(resp) => resp.error_count, + SharedRouterResponse::Stream(resp) => resp.error_count, + } + } + pub fn into_response( + self, + response_mode: &ResponseMode, + ) -> Result { + match self { + SharedRouterResponse::Single(single) => Ok(single.into()), + SharedRouterResponse::Stream(stream) => { + let stream_content_type = response_mode + .stream_content_type() + .ok_or(PipelineError::SubscriptionsTransportNotSupported)?; + Ok(stream.into_response(stream_content_type)) + } + } + } +} + #[derive(Clone)] -pub struct SharedRouterResponse { +pub struct SharedRouterSingleResponse { pub body: Bytes, pub headers: Arc, pub status: StatusCode, pub error_count: usize, } -impl From for web::HttpResponse { - fn from(shared_response: SharedRouterResponse) -> Self { +impl From for web::HttpResponse { + fn from(shared_response: SharedRouterSingleResponse) -> Self { let mut response = web::HttpResponse::Ok(); response.status(shared_response.status); - for (header_name, header_value) in shared_response.headers.iter() { response.set_header(header_name, header_value); } - response.body(shared_response.body) } } +// status is always 200 for streaming responses, errors are sent through the stream. +// stream content type is not included because we can deduplicate subscriptions across +// different content types, the response format is decided when converting to response +pub struct SharedRouterStreamResponse { + pub body: tokio::sync::broadcast::Sender, + pub headers: Arc, + pub error_count: usize, + // the leader gets the receiver that was subscribed before the pump was spawned, so + // there is no window where the channel has zero receivers and events can be lost. + // joiners get None and subscribe via body.subscribe() when consumed. + pub receiver: Option>, +} + +impl Clone for SharedRouterStreamResponse { + fn clone(&self) -> Self { + Self { + body: self.body.clone(), + headers: self.headers.clone(), + error_count: self.error_count, + receiver: None, + } + } +} + +impl SharedRouterStreamResponse { + pub fn into_response(self, stream_content_type: &StreamContentType) -> web::HttpResponse { + // leader already has a pre-subscribed receiver to avoid missing + // any potential events emitted. joiners, on the other hand, subscribe + let mut receiver = self.receiver.unwrap_or_else(|| self.body.subscribe()); + + let stream = Box::pin(async_stream::stream! { + loop { + match receiver.recv().await { + Ok(SubscriptionEvent::Raw(data)) => { + yield data.to_vec(); + } + Ok(SubscriptionEvent::Error(errors)) => { + yield FailedExecutionResult { errors }.serialize(); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + trace!(lagged = n, "broadcast receiver lagged, skipping missed messages"); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + }); + + let content_type_header = match stream_content_type { + StreamContentType::IncrementalDelivery => { + http::HeaderValue::from_static(INCREMENTAL_DELIVERY_CONTENT_TYPE) + } + StreamContentType::SSE => http::HeaderValue::from_static("text/event-stream"), + StreamContentType::ApolloMultipartHTTP => { + http::HeaderValue::from_static(APOLLO_MULTIPART_HTTP_CONTENT_TYPE) + } + }; + + let body: std::pin::Pin< + Box> + Send>, + > = match stream_content_type { + StreamContentType::IncrementalDelivery => Box::pin( + multipart_subscribe::create_incremental_delivery_stream(stream), + ), + StreamContentType::SSE => Box::pin(sse::create_stream( + stream, + std::time::Duration::from_secs(10), + )), + StreamContentType::ApolloMultipartHTTP => { + Box::pin(multipart_subscribe::create_apollo_multipart_http_stream( + stream, + std::time::Duration::from_secs(10), + )) + } + }; + + let mut response = web::HttpResponse::Ok(); + + for (header_name, header_value) in self.headers.iter() { + response.set_header(header_name, header_value); + } + + // we set content type after so that we can override the shared header + response.content_type(content_type_header); + + response.streaming(body) + } +} + /// Default TTL for JWT claims cache entries (5 seconds) const DEFAULT_JWT_CACHE_TTL_SECS: u64 = 5; @@ -138,7 +270,7 @@ pub struct RouterSharedState { pub validation_plan: Arc, pub parse_cache: Cache, pub router_config: Arc, - pub headers_plan: HeaderRulesPlan, + pub headers_plan: Arc, pub override_labels_evaluator: OverrideLabelsEvaluator, pub cors_runtime: Option, /// Cache for validated JWT claims to avoid re-parsing on every request. @@ -153,9 +285,14 @@ pub struct RouterSharedState { pub plugins: Option>>, pub in_flight_requests: RouterInflightRequestsMap, pub in_flight_requests_header_policy: RouterRequestDedupeHeaderPolicy, + /// Tracks the number of active long-lived clients (websockets + http streams) + pub long_lived_client_count: Arc, + /// Tracks all active subscriptions from clients to the router. + pub active_subscriptions: ActiveSubscriptions, } impl RouterSharedState { + #[allow(clippy::too_many_arguments)] pub fn new( router_config: Arc, jwt_auth_runtime: Option, @@ -164,11 +301,12 @@ impl RouterSharedState { telemetry_context: Arc, plugins: Option>>, cache_state: Arc, + active_subscriptions: ActiveSubscriptions, ) -> Result { let parse_cache = cache_state.parse_cache.clone(); Ok(Self { validation_plan: Arc::new(validation_plan), - headers_plan: compile_headers_plan(&router_config.headers).map_err(Box::new)?, + headers_plan: Arc::new(compile_headers_plan(&router_config.headers).map_err(Box::new)?), parse_cache, cors_runtime: Cors::from_config(&router_config.cors).map_err(Box::new)?, jwt_claims_cache: Cache::builder() @@ -195,6 +333,8 @@ impl RouterSharedState { .dedupe .headers) .into(), + long_lived_client_count: Arc::new(AtomicUsize::new(0)), + active_subscriptions, }) } } diff --git a/bin/router/src/telemetry.rs b/bin/router/src/telemetry.rs index c26aa1f84..c3181216d 100644 --- a/bin/router/src/telemetry.rs +++ b/bin/router/src/telemetry.rs @@ -284,7 +284,7 @@ fn create_prometheus_runtime( }; let registry = prometheus_config.registry.clone(); - let router_port = config.http.port(); + let router_port = config.port(); let port = prometheus_config.port.unwrap_or(router_port); let same_listener = router_port == port; @@ -301,7 +301,7 @@ fn create_prometheus_runtime( let registry_for_result = registry.clone(); let path_for_result = path.clone(); - let listen_address = (config.http.host(), port); + let listen_address = (config.host(), port); let server = HttpServer::new(move || { let registry = registry.clone(); let path = path.clone(); diff --git a/docs/README.md b/docs/README.md index f8c4c205f..e4bf7fa64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,9 +18,11 @@ |[**override\_subgraph\_urls**](#override_subgraph_urls)|`object`|Configuration for overriding subgraph URLs.
Default: `{}`
|| |[**plugins**](#plugins)|`object`|Configuration for custom plugins
|| |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| +|[**subscriptions**](#subscriptions)|`object`|Configuration for subscriptions.
Default: `{"broadcast_capacity":0,"enabled":false}`
|| |[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
|| |[**telemetry**](#telemetry)|`object`|Default: `{"client_identification":{"name_header":"graphql-client-name","version_header":"graphql-client-version"},"hive":null,"metrics":{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}},"resource":{"attributes":{}},"tracing":{"collect":{"max_attributes_per_event":16,"max_attributes_per_link":32,"max_attributes_per_span":128,"max_events_per_span":128,"parent_based_sampler":false,"sampling":1},"exporters":[],"instrumentation":{"spans":{"mode":"spec_compliant"}},"propagation":{"b3":false,"baggage":false,"jaeger":false,"trace_context":true}}}`
|| -|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"request_timeout":"1m"}}`
|| +|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}}`
|| +|[**websocket**](#websocket)|`object`|Configuration of router's WebSocket server.
Default: `{"enabled":false,"headers":{"persist":false,"source":"connection"},"path":null}`
|| **Additional Properties:** not allowed **Example** @@ -119,6 +121,9 @@ plugins: {} query_planner: allow_expose: false timeout: 10s +subscriptions: + broadcast_capacity: 0 + enabled: false supergraph: {} telemetry: client_identification: @@ -198,7 +203,14 @@ traffic_shaping: dedupe: enabled: false headers: all + max_long_lived_clients: 128 request_timeout: 1m +websocket: + enabled: false + headers: + persist: false + source: connection + path: null ``` @@ -1929,6 +1941,119 @@ timeout: 10s ``` + +## subscriptions: object + +Configuration for subscriptions. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**broadcast\_capacity**|`integer`|The capacity of the broadcast channel used to fan out subscription events to all active listeners.

Each active subscription has its own broadcast channel. This value controls how many events
can be buffered in that channel before slow consumers start lagging. If a consumer falls too
far behind and the buffer is full, it will skip the missed messages and continue from the
latest available event.

Subscription events are typically low-frequency, so the default of 32 is sufficient for most
use cases. Increase this value if you expect bursts of events or have slow consumers that
need more headroom to catch up.

Defaults to 32.
Default: `32`
Format: `"uint"`
Minimum: `0`
|| +|[**callback**](#subscriptionscallback)|`object`, `null`|Configuration for subgraphs using the HTTP Callback protocol.
|yes| +|**enabled**|`boolean`|Enables/disables subscriptions. By default, the subscriptions are disabled.

You can override this setting by setting the `SUBSCRIPTIONS_ENABLED` environment variable to `true` or `false`.
Default: `false`
|| +|[**websocket**](#subscriptionswebsocket)|`object`, `null`|Configuration for subgraphs using WebSocket protocol.
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +broadcast_capacity: 0 +enabled: false + +``` + + +### subscriptions\.callback: object,null + +Configuration for subgraphs using the HTTP Callback protocol. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**heartbeat\_interval**|`string`|The interval at which the subgraph must send heartbeat messages.
If set to 0, heartbeats are disabled. Defaults to 5 seconds.
Default: `"5s"`
|no| +|**listen**|`string`, `null`|The IP address and port the router will listen on for subscription callbacks.
When set, the router will start a dedicated HTTP server bound to this address
for receiving callback messages from subgraphs, separate from the main GraphQL server.
When not set, the callback handler is registered on the main server.

Example: `0.0.0.0:4001`
|no| +|**path**|`string`|The path of the router's callback endpoint.
Must be an absolute path starting with `/`. Defaults to `/callback`.
Default: `"/callback"`
Pattern: `^/`
|no| +|**public\_url**|`string`|The public URL that subgraphs will use to send callback messages to this router.

Your public_url must match the server address combined with the router's path.
Meaning, if your server is `http://localhost:4000` and the path is `/callback`,
your `public_url` should be `http://localhost:4000/callback`.

Example: `https://example.com:4000/callback`
Format: `"uri"`
|yes| +|[**subgraphs**](#subscriptionscallbacksubgraphs)|`string[]`|The list of subgraph names that use the HTTP callback protocol.
Default:
|no| + +**Additional Properties:** not allowed + +#### subscriptions\.callback\.subgraphs\[\]: array + +The list of subgraph names that use the HTTP callback protocol. + + +**Items** + +**Item Type:** `string` +**Unique Items:** yes + +### subscriptions\.websocket: object,null + +Configuration for subgraphs using WebSocket protocol. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**all**](#subscriptionswebsocketall)|`object`, `null`|The default configuration that will be applied to all subgraphs using
|| +|[**subgraphs**](#subscriptionswebsocketsubgraphs)|`object`|Optional per-subgraph configurations that will override the default configuration for specific subgraphs.
|| + +**Additional Properties:** not allowed + +#### subscriptions\.websocket\.all: object,null + +The default configuration that will be applied to all subgraphs using +WebSocket protocol, unless overridden by a specific subgraph configuration. + +When specified, all subgraphs (not claimed by `callback`) will use the WebSocket protocol. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**path**|`string`, `null`|Determines the URL path to use for the subscription endpoint:

- For WebSocket connections, the URL will be `ws://`.
- If `path` is not set, the default subgraph URL is used, with the scheme adjusted to `ws`
for WebSocket connections where applicable.

Note to always provide the absolute path starting with a `/`, e.g., `/ws`.

For example, if the subgraph URL is `http://example.com/graphql` and the path is set to `/ws`,
the resulting WebSocket URL will be `ws://example.com/ws`.
Pattern: `^/`
|| + +**Additional Properties:** not allowed + +#### subscriptions\.websocket\.subgraphs: object + +Optional per-subgraph configurations that will override the default configuration for specific subgraphs. + + +**Additional Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**Additional Properties**](#subscriptionswebsocketsubgraphsadditionalproperties)|`object`|WebSocket configuration for a specific subgraph or the default for all subgraphs.
|| + + +##### subscriptions\.websocket\.subgraphs\.additionalProperties: object + +WebSocket configuration for a specific subgraph or the default for all subgraphs. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**path**|`string`, `null`|Determines the URL path to use for the subscription endpoint:

- For WebSocket connections, the URL will be `ws://`.
- If `path` is not set, the default subgraph URL is used, with the scheme adjusted to `ws`
for WebSocket connections where applicable.

Note to always provide the absolute path starting with a `/`, e.g., `/ws`.

For example, if the subgraph URL is `http://example.com/graphql` and the path is set to `/ws`,
the resulting WebSocket URL will be `ws://example.com/ws`.
Pattern: `^/`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +path: null + +``` + ## supergraph: object @@ -2917,7 +3042,7 @@ Configuration for the traffic-shaping of the executor. Use these configurations |----|----|-----------|--------| |[**all**](#traffic_shapingall)|`object`|The default configuration that will be applied to all subgraphs, unless overridden by a specific subgraph configuration.
Default: `{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"}`
|| |**max\_connections\_per\_host**|`integer`|Limits the concurrent amount of requests/connections per host/subgraph.
Default: `100`
Format: `"uint"`
Minimum: `0`
|| -|[**router**](#traffic_shapingrouter)|`object`|Configuration for the router itself, e.g., for handling incoming requests, or other router-level traffic shaping configurations.
Default: `{"dedupe":{"enabled":false,"headers":"all"},"request_timeout":"1m"}`
|| +|[**router**](#traffic_shapingrouter)|`object`|Configuration for the router itself, e.g., for handling incoming requests, or other router-level traffic shaping configurations.
Default: `{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}`
|| |[**subgraphs**](#traffic_shapingsubgraphs)|`object`|Optional per-subgraph configurations that will override the default configuration for specific subgraphs.
|| **Additional Properties:** not allowed @@ -2933,6 +3058,7 @@ router: dedupe: enabled: false headers: all + max_long_lived_clients: 128 request_timeout: 1m ``` @@ -2972,6 +3098,7 @@ Configuration for the router itself, e.g., for handling incoming requests, or ot |Name|Type|Description|Required| |----|----|-----------|--------| |[**dedupe**](#traffic_shapingrouterdedupe)|`object`|Default: `{"enabled":false,"headers":"all"}`
|| +|**max\_long\_lived\_clients**|`integer`|Maximum number of concurrent long-lived clients (WebSocket connections and HTTP streaming responses).
Regular non-streaming requests are not counted toward this limit.
When the limit is reached, new WebSocket and streaming HTTP requests are rejected with 503.
If both WebSockets and Subscriptions are disabled, this setting has no effect.
Default: `128`
Format: `"uint"`
Minimum: `0`
|| |**request\_timeout**|`string`|Optional timeout configuration for incoming requests to the router.
It starts from the moment the request is received by the router,
and includes the entire processing of the request (validation, execution, etc.) until a response is sent back to the client.
If a request takes longer than the specified duration, it will be aborted and a timeout error will be returned to the client.
Default: `"1m"`
|| **Additional Properties:** not allowed @@ -2981,6 +3108,7 @@ Configuration for the router itself, e.g., for handling incoming requests, or ot dedupe: enabled: false headers: all +max_long_lived_clients: 128 request_timeout: 1m ``` @@ -2992,7 +3120,7 @@ request_timeout: 1m |Name|Type|Description|Required| |----|----|-----------|--------| -|**enabled**|`boolean`|Enables/disables in-flight request deduplication at the router endpoint level.

When enabled, identical incoming GraphQL query requests that are processed at the same time
share the same in-flight execution result.
Default: `false`
|| +|**enabled**|`boolean`|Enables/disables in-flight request and active subscriptions deduplication at the router level.

When enabled, the router deduplicates both queries and subscriptions using the same
fingerprint key (method, path, selected headers, schema checksum, normalized operation
hash, variables, and extensions). The `headers` configuration below controls which
headers participate in that key for all operation types.

For queries, concurrent HTTP requests that produce the same fingerprint share a single
in-flight execution - only the first one runs, and the rest wait for and receive the
same result.

For subscriptions, the mechanism is broadcast-based rather than request-sharing. The
first client with a given fingerprint becomes the leader: it runs the upstream subscription
and its events are fanned out through a broadcast channel backed by an active subscriptions
registry. Any subsequent client that arrives with an identical fingerprint while that subscription
is still active joins as a listener on the same broadcast channel instead of starting a new upstream
connection. When all listeners have dropped and the leader finishes, the entry is removed from the
registry.

WebSocket connections participate in the same deduplication space as HTTP. Each
subscribe message is processed with a synthetic request assembled from the WebSocket
path and the headers derived from the `websocket.headers` config. The fingerprint is computed
from those synthetic headers using the same header policy, so a subscription started over HTTP
and an identical one started over WebSocket will deduplicate against each other.

The deduplication is transport agnostic. A query over WebSocket would get deduplicated with an
identical query over HTTP if they arrive at the same time and have the same fingerprint.

Note: `content-type` is part of the fingerprint when `headers` includes it (e.g. `all`).
Since HTTP streaming clients send different `accept` headers than WebSocket clients,
cross-transport deduplication for subscriptions only applies when `content-type` (and
transport-specific headers) are excluded from the key. Configure `headers: none` or
`headers: { include: [] }` (or exclude the relevant headers) to enable true cross-transport
deduplication, where a WebSocket subscription and an SSE subscription with the same operation
share a single upstream connection and the events are fanned out to both.
Default: `false`
|| |**headers**||Header configuration participating in the dedupe key.

Accepted forms:
- `all`
- `none`
- `{ include: ["authorization", "cookie"] }`

Header names are case-insensitive and validated as standard HTTP header names.
Default: `"all"`
|| **Additional Properties:** not allowed @@ -3028,4 +3156,52 @@ Optional per-subgraph configurations that will override the default configuratio |**request\_timeout**||Optional timeout configuration for requests to subgraphs.

Example with a fixed duration:
```yaml
timeout:
duration: 5s
```

Or with a VRL expression that can return a duration based on the operation kind:
```yaml
timeout:
expression: \|
if (.request.operation.type == "mutation") {
"10s"
} else {
"15s"
}
```
|| **Additional Properties:** not allowed + +## websocket: object + +Configuration of router's WebSocket server. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`|Enables/disables WebSocket connections.

By default, WebSockets are disabled.

You can override this setting by setting the `WEBSOCKET_ENABLED` environment variable to `true` or `false`.
Default: `false`
|| +|[**headers**](#websocketheaders)|`object`|Configuration for handling headers for WebSocket connections.
Default: `{"persist":false,"source":"connection"}`
|yes| +|**path**|`string`, `null`|The path to use for the WebSocket endpoint on the router.

Note to always provide the absolute path starting with a `/`, e.g., `/ws`.

By default, the WebSocket endpoint will be available at the `http.graphql_endpoint` (defaults to `/graphql`)
if no path is specified and the clients will connect using `ws:///`.
Pattern: `^/`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +enabled: false +headers: + persist: false + source: connection +path: null + +``` + + +### websocket\.headers: object + +Configuration for handling headers for WebSocket connections. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**persist**|`boolean`|Whether to persist merged headers for the duration of the WebSocket connection
when using the `both` source (headers are accepted from multiple sources).

Only has effect when `source` is set to `both`.

This is useful when dealing with authentication using tokens that expire, where the
initial connection might use one token, but subsequent operations might need to
provide updated tokens in the operation extensions and then use that for further authentication.

For example:

1. Client connects with connection init payload containing an Authorization header with a token.
2. Client sends a subscription operation with an updated Authorization header in the operation extensions.
3. If `persist` is enabled, the updated Authorization header will be stored and used for subsequent operations.
Default: `false`
|no| +|**source**||The source(s) from which to accept headers for WebSocket connections.
|yes| + +**Additional Properties:** not allowed +**Example** + +```yaml +persist: false +source: connection + +``` + diff --git a/e2e/configs/header_propagation.router.yaml b/e2e/configs/header_propagation.router.yaml index 662e49210..fef9d7238 100644 --- a/e2e/configs/header_propagation.router.yaml +++ b/e2e/configs/header_propagation.router.yaml @@ -6,4 +6,6 @@ headers: all: request: - propagate: - named: x-context \ No newline at end of file + named: x-context +subscriptions: + enabled: true diff --git a/e2e/src/file_supergraph.rs b/e2e/src/file_supergraph.rs index dc3daf0e3..c0322d209 100644 --- a/e2e/src/file_supergraph.rs +++ b/e2e/src/file_supergraph.rs @@ -46,7 +46,7 @@ mod file_supergraph_e2e_tests { .unwrap() .as_array() .unwrap(); - assert_eq!(types_arr.len(), 21); + assert_eq!(types_arr.len(), 22); } #[ntex::test] diff --git a/e2e/src/hive_cdn_supergraph.rs b/e2e/src/hive_cdn_supergraph.rs index 7f8b67f5a..5091b58ad 100644 --- a/e2e/src/hive_cdn_supergraph.rs +++ b/e2e/src/hive_cdn_supergraph.rs @@ -255,7 +255,7 @@ mod hive_cdn_supergraph_e2e_tests { .unwrap() .as_array() .unwrap(); - assert_eq!(types_arr.len(), 21); + assert_eq!(types_arr.len(), 22); } #[ntex::test] @@ -277,7 +277,7 @@ mod hive_cdn_supergraph_e2e_tests { .with_body("type Query { dummy: String }") .create(); - let router = TestRouter::builder() + let _router = TestRouter::builder() .inline_config(&format!( r#" supergraph: @@ -289,13 +289,10 @@ mod hive_cdn_supergraph_e2e_tests { max_retries: 10 "#, )) - .skip_wait_for_ready_on_start() // this one will time out after 3 seconds, we need more .build() .start() .await; - router.wait_for_ready(Some(Duration::from_secs(7))).await; - one.assert(); two.assert(); } diff --git a/e2e/src/http.rs b/e2e/src/http.rs index 488591c6f..86dc9bc7b 100644 --- a/e2e/src/http.rs +++ b/e2e/src/http.rs @@ -426,7 +426,7 @@ mod http_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 100ms + poll_interval: 500ms traffic_shaping: all: dedupe_enabled: false @@ -457,10 +457,10 @@ mod http_tests { .is_empty() { assert!( - started.elapsed() < Duration::from_secs(3), + started.elapsed() < Duration::from_secs(5), "first request did not reach products subgraph in time" ); - time::sleep(Duration::from_millis(25)).await; + time::sleep(Duration::from_millis(100)).await; } mock_initial.remove(); diff --git a/e2e/src/http_callback.rs b/e2e/src/http_callback.rs new file mode 100644 index 000000000..f1621d2c0 --- /dev/null +++ b/e2e/src/http_callback.rs @@ -0,0 +1,338 @@ +#[cfg(test)] +mod http_callback_e2e_tests { + use futures::StreamExt; + use ntex::http; + use sonic_rs::{json, JsonValueTrait}; + + use crate::testkit::{some_header_map, ClientResponseExt, TestRouter, TestSubgraphs}; + + #[ntex::test] + async fn listen_on_different_port() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + // on slow systems when running tests concurrently, the available + // port might become unavailable by the time the router starts and binds + // the callback handler to it, causing the test to fail. in order to avoid + // we use a fixed high port that is unlikely to be used by other processes + // and cause conflicts, or get allocated (OS starts with 50000) + let callback_port = 61000; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + // .with_port() router is on a different port than the callback listener anyways + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + callback: + listen: 0.0.0.0:{callback_port} + public_url: http://0.0.0.0:{callback_port}/callback + subgraphs: + - reviews + "# + )) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + http::header::ACCEPT => "text/event-stream", + ), + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let body = res.string_body().await; + + assert!( + body.contains( + r#"data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}"# + ), + "Expected at least one emitted event, got: {}", + body + ); + assert!(body.contains("event: complete")); + } + + #[ntex::test] + async fn complete_active_subscription_on_heartbeat_timeout() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let router_port = router_listener.local_addr().unwrap().port(); + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .with_listener(router_listener) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-disable-http-callback-heartbeats + subscriptions: + enabled: true + callback: + heartbeat_interval: 500ms + public_url: http://0.0.0.0:{router_port}/callback + subgraphs: + - reviews + "# + )) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded( + # emitted messages do not count as heartbeats + intervalInMs: 300 + ) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + http::header::ACCEPT => "text/event-stream", + http::header::HeaderName::from_static("x-disable-http-callback-heartbeats") => "true" + ), + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let body = res.string_body().await; + + // emitted at least one event + assert!( + body.contains( + r#"data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}"# + ), + "Expected at least one emitted event, got: {}", + body + ); + + // kicked off client, eventually + assert!(body.contains(r#"{"data":null,"errors":[{"message":"Subgraph gone due to heartbeat timeout","extensions":{"code":"SUBGRAPH_GONE"}}]}"#)); + + // completed stream + assert!(body.contains("event: complete")); + } + + #[ntex::test] + async fn client_disconnect_removes_subscription() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let router_port = router_listener.local_addr().unwrap().port(); + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .with_listener(router_listener) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + callback: + public_url: http://0.0.0.0:{router_port}/callback + subgraphs: + - reviews + "# + )) + .build() + .start() + .await; + + // Use a longer interval so we have time to cancel before the stream completes + let mut res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#, + None, + some_header_map!( + http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + // Read first chunk to ensure the subscription is active + let chunk_bytes = res.next().await.unwrap().unwrap(); + let chunk_str = std::str::from_utf8(&chunk_bytes).unwrap(); + assert!( + chunk_str.contains(r#"{"data":{"reviewAdded":{"id":"1"}}}"#), + "Expected first emission, got: {}", + chunk_str + ); + + // Extract subscriptionId and verifier from the request the router sent to the subgraph + let subgraph_requests = subgraphs + .get_requests_log("reviews") + .expect("expected requests sent to reviews subgraph"); + let body_bytes = subgraph_requests[0] + .body + .as_ref() + .expect("expected request body"); + let body_json: sonic_rs::Value = + sonic_rs::from_slice(body_bytes).expect("expected valid JSON body"); + let subscription_id = body_json["extensions"]["subscription"]["subscriptionId"] + .as_str() + .expect("expected subscriptionId in request extensions") + .to_string(); + let verifier = body_json["extensions"]["subscription"]["verifier"] + .as_str() + .expect("expected verifier in request extensions") + .to_string(); + + // Disconnect the client — this should propagate to the router and remove the subscription + drop(res); + + // Give the router a moment to process the disconnect and clean up + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // A check for the now-removed subscription should return 404 + let check_res = router + .serv() + .post(format!("/callback/{subscription_id}")) + .set_header("subscription-protocol", "callback/1.0") + .send_json(&json!({ + "kind": "subscription", + "action": "check", + "id": subscription_id, + "verifier": verifier, + })) + .await + .expect("failed to send callback check request"); + + assert_eq!( + check_res.status(), + 404, + "Expected 404 after client disconnect removed the subscription" + ); + } + + #[ntex::test] + async fn invalid_verifier_is_rejected() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let router_port = router_listener.local_addr().unwrap().port(); + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .with_listener(router_listener) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + callback: + public_url: http://0.0.0.0:{router_port}/callback + subgraphs: + - reviews + "# + )) + .build() + .start() + .await; + + let mut res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#, + None, + some_header_map!( + http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let chunk_bytes = res.next().await.unwrap().unwrap(); + let chunk_str = std::str::from_utf8(&chunk_bytes).unwrap(); + assert!( + chunk_str.contains(r#"{"data":{"reviewAdded":{"id":"1"}}}"#), + "Expected first emission, got: {}", + chunk_str + ); + + let subgraph_requests = subgraphs + .get_requests_log("reviews") + .expect("expected requests sent to reviews subgraph"); + let body_bytes = subgraph_requests[0] + .body + .as_ref() + .expect("expected request body"); + let body_json: sonic_rs::Value = + sonic_rs::from_slice(body_bytes).expect("expected valid JSON body"); + let subscription_id = body_json["extensions"]["subscription"]["subscriptionId"] + .as_str() + .expect("expected subscriptionId in request extensions") + .to_string(); + + // Send a check with a verifier that doesn't match the subscription's verifier + let wrong_verifier = "this-is-not-the-correct-verifier"; + let check_res = router + .serv() + .post(format!("/callback/{subscription_id}")) + .set_header("subscription-protocol", "callback/1.0") + .send_json(&json!({ + "kind": "subscription", + "action": "check", + "id": subscription_id, + "verifier": wrong_verifier, + })) + .await + .expect("failed to send callback check request"); + + assert_eq!( + check_res.status(), + 400, + "Expected 400 when using an invalid verifier" + ); + + // keep alive until the end of the test so the subscription stays active + drop(res); + } +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index a8cc50597..f42fcfd76 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -21,6 +21,8 @@ mod hive_cdn_supergraph; #[cfg(test)] mod http; #[cfg(test)] +mod http_callback; +#[cfg(test)] mod introspection; #[cfg(test)] mod issues; @@ -41,11 +43,15 @@ mod probes; #[cfg(test)] mod router_timeout; #[cfg(test)] +mod subscriptions; +#[cfg(test)] mod supergraph; #[cfg(test)] mod telemetry; #[cfg(test)] mod timeout_per_subgraph; +#[cfg(test)] +mod websocket; pub use insta; pub use mockito; diff --git a/e2e/src/subscriptions.rs b/e2e/src/subscriptions.rs new file mode 100644 index 000000000..f1268b401 --- /dev/null +++ b/e2e/src/subscriptions.rs @@ -0,0 +1,1829 @@ +#[cfg(test)] +mod subscriptions_e2e_tests { + + use insta::assert_snapshot; + use ntex::http; + use reqwest::StatusCode; + use sonic_rs::json; + + use crate::testkit::{ + some_header_map, ClientResponseExt, ResponseLike, TestRouter, TestSubgraphs, + }; + + #[ntex::test] + async fn subscription_not_allowed_when_disabled() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + # disabled by default + # subscriptions: + # enabled: false + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + product { + upc + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + // even though subscriptions are disabled, we accept the stream + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"errors":[{"message":"Subscriptions are not supported","extensions":{"code":"SUBSCRIPTIONS_NOT_SUPPORTED"}}]} + + event: complete + "#); + } + + #[ntex::test] + async fn subscription_no_entity_resolution_sse_subgraph() { + let subgraphs = TestSubgraphs::builder() + .with_http_streaming_subscriptions_protocol( + subgraphs::HTTPStreamingSubscriptionProtocol::SseOnly, + ) + .build() + .start() + .await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + product { + upc + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"3"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"4"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"4"}}}} + + event: complete + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, "text/event-stream", + "Expected Content-Type to be text/event-stream" + ); + } + + #[ntex::test] + async fn subscription_no_entity_resolution_multipart_subgraph() { + let subgraphs = TestSubgraphs::builder() + .with_http_streaming_subscriptions_protocol( + subgraphs::HTTPStreamingSubscriptionProtocol::MultipartOnly, + ) + .build() + .start() + .await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + product { + upc + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"1"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"2"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"3"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"4"}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"upc":"4"}}}} + + event: complete + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, "text/event-stream", + "Expected Content-Type to be text/event-stream" + ); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}} + + event: complete + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, "text/event-stream", + "Expected Content-Type to be text/event-stream" + ); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution_multipart_client_unquoted_spec() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => r#"multipart/mixed;subscriptionSpec=1.0"# + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}}} + --graphql-- + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, + "multipart/mixed;boundary=\"graphql\";subscriptionSpec=1.0", + ); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution_multipart_client_quoted_spec() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map! { + // exactly as per https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol#executing-a-subscription + http::header::ACCEPT => r#"multipart/mixed;subscriptionSpec="1.0", application/json"# + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}}} + --graphql + Content-Type: application/json + + {"payload":{"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}}} + --graphql-- + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, + "multipart/mixed;boundary=\"graphql\";subscriptionSpec=1.0", + ); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution_incremental_delivery_client() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => r#"multipart/mixed"# + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}} + --- + Content-Type: application/json + + {"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}} + ----- + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!(content_type_header, "multipart/mixed;boundary=\"-\"",); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution_websocket_subgraph() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + websocket: + subgraphs: + reviews: + path: /reviews/ws + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}} + + event: complete + "#); + } + + #[ntex::test] + async fn subscription_yes_entity_resolution_http_callback_subgraph() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let router_port = router_listener.local_addr().unwrap().port(); + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .with_listener(router_listener) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + callback: + public_url: http://0.0.0.0:{router_port}/callback + subgraphs: + - reviews + "# + )) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + assert_snapshot!(res.string_body().await, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"2","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"3","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"4","product":{"name":"Table"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"5","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"6","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"7","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"8","product":{"name":"Couch"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"9","product":{"name":"Glass"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"10","product":{"name":"Chair"}}}} + + event: next + data: {"data":{"reviewAdded":{"id":"11","product":{"name":"Chair"}}}} + + event: complete + "#); + } + + #[ntex::test] + async fn subscription_entity_resolution_with_requires() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + product { + name + shippingEstimate + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Table","shippingEstimate":50}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Table","shippingEstimate":50}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Table","shippingEstimate":50}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Table","shippingEstimate":50}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Couch","shippingEstimate":0}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Couch","shippingEstimate":0}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Couch","shippingEstimate":0}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Couch","shippingEstimate":0}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Glass","shippingEstimate":10}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Chair","shippingEstimate":50}}}} + + event: next + data: {"data":{"reviewAdded":{"product":{"name":"Chair","shippingEstimate":50}}}} + + event: complete + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, "text/event-stream", + "Expected Content-Type to be text/event-stream" + ); + } + + #[ntex::test] + async fn subscription_with_variable_forwarding() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription ($upc: String!) { + reviewAddedForProduct(productUpc: $upc, intervalInMs: 0) { + product { + upc + name + } + } + } + "#, + Some(json!({ + "upc": "2" + })), + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let content_type_header = res + .header("content-type") + .expect("must have content-type header"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"upc":"2","name":"Couch"}}}} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"upc":"2","name":"Couch"}}}} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"upc":"2","name":"Couch"}}}} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"upc":"2","name":"Couch"}}}} + + event: complete + "#); + + // we check this at the end because the body will hold clues to why the test fails + assert_eq!( + content_type_header, "text/event-stream", + "Expected Content-Type to be text/event-stream" + ); + } + + #[ntex::test] + async fn subscription_http_accept_multipart_and_sse() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription ($upc: String!) { + reviewAddedForProduct(productUpc: $upc, intervalInMs: 0) { + product { + upc + name + } + } + } + "#, + Some(json!({ + "upc": "2" + })), + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + let subgraph_request = subgraphs + .get_requests_log("reviews") + .expect("expected requests sent to reviews subgraph"); + + let Ok(accept_header) = subgraph_request + .get(0) + .expect("expected at least one request to reviews") + .headers + .get("accept") + .expect("expected accept header to be sent with the subgraph request") + .to_str() + else { + panic!("accept header could not be converted to string") + }; + + assert_snapshot!(accept_header, @r#"multipart/mixed;subscriptionSpec="1.0", text/event-stream"#); + } + + #[ntex::test] + async fn subscription_stream_failed_source_subgraph_requests() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|_req| { + Some(ResponseLike::new( + StatusCode::INTERNAL_SERVER_ERROR, + None, + None, + )) + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert!( + body_str.contains("SUBGRAPH_STREAM_STATUS_CODE_NOT_OK"), + "Expected '{}' to contain the subgraph stream response not-ok failure error code", + body_str + ); + } + + #[ntex::test] + async fn subscription_stream_failed_entity_resolution_requests() { + let subgraphs = TestSubgraphs::builder() + .with_on_request(|req| { + if req.path.contains("products") { + // entity resolution + Some(ResponseLike::new( + StatusCode::INTERNAL_SERVER_ERROR, + Some( + json!({ + "errors": [{"message": "Something Went Wrong!"}] + }) + .to_string(), + ), + some_header_map! { + http::header::CONTENT_TYPE => "application/json" + }, + )) + } else { + // subscription itself (on "reviews" subgraph) + None + } + }) + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription ($upc: String!) { + reviewAddedForProduct(productUpc: $upc, intervalInMs: 0) { + product { + name + } + } + } + "#, + Some(json!({ + "upc": "2" + })), + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"name":null}}},"errors":[{"message":"Something Went Wrong!","extensions":{"code":"DOWNSTREAM_SERVICE_ERROR","serviceName":"products","affectedPath":"reviewAddedForProduct.product"}}]} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"name":null}}},"errors":[{"message":"Something Went Wrong!","extensions":{"code":"DOWNSTREAM_SERVICE_ERROR","serviceName":"products","affectedPath":"reviewAddedForProduct.product"}}]} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"name":null}}},"errors":[{"message":"Something Went Wrong!","extensions":{"code":"DOWNSTREAM_SERVICE_ERROR","serviceName":"products","affectedPath":"reviewAddedForProduct.product"}}]} + + event: next + data: {"data":{"reviewAddedForProduct":{"product":{"name":null}}},"errors":[{"message":"Something Went Wrong!","extensions":{"code":"DOWNSTREAM_SERVICE_ERROR","serviceName":"products","affectedPath":"reviewAddedForProduct.product"}}]} + + event: complete + "#); + } + + #[ntex::test] + async fn subscription_stream_client_cancelled() { + use futures::StreamExt; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + // Use a longer interval so we have time to cancel + let mut res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream" + }, + ) + .await; + + assert!(res.status().is_success(), "Expected 200 OK"); + + // read first chunk + let chunk_bytes = res.next().await.unwrap().unwrap(); + let chunk_str = std::str::from_utf8(&chunk_bytes).unwrap(); + + assert_snapshot!(chunk_str, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"1"}}} + "#); + + // read second chunk to ensure stream is flowing + let chunk_bytes = res.next().await.unwrap().unwrap(); + let chunk_str = std::str::from_utf8(&chunk_bytes).unwrap(); + + assert_snapshot!(chunk_str, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"2"}}} + "#); + + // cancel + drop(res); + + // TODO: check if propagated? + } + + #[ntex::test] + async fn subscription_header_propagation_for_subscription() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/header_propagation.router.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream", + http::header::HeaderName::from_static("x-context") => "maybe-propagate" + }, + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + // we have to consume the body to ensure the subscription is fully processed + let body = res.body().await.unwrap(); + std::str::from_utf8(&body).unwrap(); + + let subgraph_requests = subgraphs + .get_requests_log("reviews") + .expect("expected requests sent to reviews subgraph"); + + let context_header = subgraph_requests[0] + .headers + .get("x-context") + .expect("expected x-context header to be present"); + + assert_eq!( + context_header, "maybe-propagate", + "expected x-context header to be propagated to subgraph" + ); + } + + #[ntex::test] + async fn subscription_header_propagation_for_entity_resolution() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .file_config("configs/header_propagation.router.yaml") + .build() + .start() + .await; + + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + product { + name + } + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream", + http::header::HeaderName::from_static("x-context") => "maybe-propagate" + }, + ) + .await; + + assert_eq!(res.status(), 200, "Expected 200 OK"); + + // we have to consume the body to ensure all entity resolutions were made + let body = res.body().await.unwrap(); + std::str::from_utf8(&body).unwrap(); + + let subgraph_requests = subgraphs + .get_requests_log("products") + .expect("expected requests sent to products subgraph"); + + // every entity resolution request must have the propagated header + for subgraph_request in subgraph_requests { + let context_header = subgraph_request + .headers + .get("x-context") + .expect("expected x-context header to be present"); + + assert_eq!( + context_header, "maybe-propagate", + "expected x-context header to be propagated to subgraph" + ); + } + } + + #[ntex::test] + async fn subscription_propagate_connection_termination_subgraph() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + headers: + all: + request: + - propagate: + named: x-break-after + "#, + ) + .build() + .start() + .await; + + // NOTE: we add a 100ms interval because providing 0 will end the connection while the buffer is still being written to leading to a different error + let res = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#, + None, + some_header_map! { + http::header::ACCEPT => "text/event-stream", + http::header::HeaderName::from_static("x-break-after") => "3" + }, + ) + .await; + + let body = res.body().await.unwrap(); + let body_str = std::str::from_utf8(&body).unwrap(); + + assert_snapshot!(body_str, @r#" + event: next + data: {"data":{"reviewAdded":{"id":"1"}}} + + event: next + data: {"data":{"reviewAdded":{"id":"2"}}} + + event: next + data: {"data":{"reviewAdded":{"id":"3"}}} + + event: next + data: {"errors":[{"message":"Error reading SSE subscription stream: Stream read error: error reading a body from connection","extensions":{"code":"SUBGRAPH_SUBSCRIPTION_SSE_STREAM_ERROR","serviceName":"reviews"}}]} + + event: complete + "#); + } + + #[ntex::test] + async fn active_subscriptions_deduplication() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + traffic_shaping: + router: + dedupe: + enabled: true + "#, + ) + .build() + .start() + .await; + + let query = r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + product { + name + } + } + } + "#; + let headers = some_header_map! { + http::header::ACCEPT => "text/event-stream" + }; + + let (sub1, sub2, sub3) = tokio::join!( + router.send_graphql_request(query, None, headers.clone()), + router.send_graphql_request(query, None, headers.clone()), + router.send_graphql_request(query, None, headers.clone()), + ); + + for sub in [&sub1, &sub2, &sub3] { + let body = sub.string_body().await; + assert!( + body.contains("event: next") && body.contains("event: complete"), + "Expected subscription to receive events and complete" + ); + } + + let reviews_requests = subgraphs.get_requests_log("reviews").unwrap_or_default(); + assert_eq!( + reviews_requests.len(), + 1, + "Expected requests to reviews subgraph to be deduplicated" + ); + } + + #[ntex::test] + async fn active_subscriptions_deduplication_promotion() { + use futures::StreamExt; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + traffic_shaping: + router: + dedupe: + enabled: true + "#, + ) + .build() + .start() + .await; + + let query = r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#; + let headers = some_header_map! { + http::header::ACCEPT => "text/event-stream" + }; + + let mut sub1 = router + .send_graphql_request(query, None, headers.clone()) + .await; + + assert!(sub1.status().is_success(), "Expected 200 OK"); + + // consume 2 events from sub1 to let the source stream advance + let chunk = sub1.next().await.unwrap().unwrap(); + assert!( + std::str::from_utf8(&chunk).unwrap().contains(r#""id":"1""#), + "Expected first event to be id=1" + ); + let chunk = sub1.next().await.unwrap().unwrap(); + assert!( + std::str::from_utf8(&chunk).unwrap().contains(r#""id":"2""#), + "Expected second event to be id=2" + ); + let chunk = sub1.next().await.unwrap().unwrap(); + assert!( + std::str::from_utf8(&chunk).unwrap().contains(r#""id":"3""#), + "Expected third event to be id=3" + ); + + // subscribe again with the same query - dedup promotes sub2 onto the live source + let sub2 = router + .send_graphql_request(query, None, headers.clone()) + .await; + + assert!(sub2.status().is_success(), "Expected 200 OK"); + + // drop sub1 now that sub2 is connected; sub2 must become the active subscriber + drop(sub1); + + // sub2 should receive the remainder of the stream from where the source left off + let body = sub2.string_body().await; + assert!( + body.contains("event: next") && body.contains("event: complete"), + "Expected sub2 to receive remaining events and complete, got: {body}" + ); + + // sub2 must not have received the first 3 events that were already consumed by sub1 + assert!( + !body.contains(r#""id":"1""#) + && !body.contains(r#""id":"2""#) + && !body.contains(r#""id":"3""#), + "Expected sub2 to not replay events already consumed by sub1, got: {body}" + ); + + // only one subgraph request should have been made + let reviews_requests = subgraphs.get_requests_log("reviews").unwrap_or_default(); + assert_eq!( + reviews_requests.len(), + 1, + "Expected requests to reviews subgraph to be deduplicated" + ); + } + + #[ntex::test] + async fn active_across_transports_subscriptions_deduplication() { + use futures::StreamExt; + use hive_router_plan_executor::executors::{ + graphql_transport_ws::SubscribePayload, websocket_client::WsClient, + }; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + websocket: + enabled: true + traffic_shaping: + router: + dedupe: + enabled: true + headers: none + "#, + ) + .build() + .start() + .await; + + let query = r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + product { + name + } + } + } + "#; + + let sse_headers = some_header_map! { + http::header::ACCEPT => "text/event-stream" + }; + let multipart_headers = some_header_map! { + http::header::ACCEPT => "multipart/mixed;subscriptionSpec=1.0" + }; + + let wsconn = router.ws().await; + let mut ws_client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + let ws_payload = SubscribePayload { + query: query.into(), + ..Default::default() + }; + let mut ws_stream = ws_client.subscribe(ws_payload).await; + + let (sub_sse, sub_multipart) = tokio::join!( + router.send_graphql_request(query, None, sse_headers), + router.send_graphql_request(query, None, multipart_headers), + ); + + let sse_body = sub_sse.string_body().await; + assert!( + sse_body.contains("event: next") && sse_body.contains("event: complete"), + "Expected SSE subscription to receive events and complete" + ); + + let multipart_body = sub_multipart.string_body().await; + assert!( + multipart_body.contains("--graphql") && multipart_body.contains("--graphql--"), + "Expected multipart subscription to receive events and complete" + ); + + let mut ws_received = 0; + while let Some(response) = ws_stream.next().await { + assert!( + response.errors.is_none(), + "Expected no errors from WS subscription" + ); + assert!( + !response.data.is_null(), + "Expected data from WS subscription" + ); + ws_received += 1; + } + assert!( + ws_received > 0, + "Expected WS subscription to receive at least one event" + ); + + let reviews_requests = subgraphs.get_requests_log("reviews").unwrap_or_default(); + assert_eq!( + reviews_requests.len(), + 1, + "Expected requests to reviews subgraph to be deduplicated across transports" + ); + } + + #[ntex::test] + async fn active_across_transports_subscriptions_deduplication_promotion() { + use futures::StreamExt; + use hive_router_plan_executor::executors::{ + graphql_transport_ws::SubscribePayload, websocket_client::WsClient, + }; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + websocket: + enabled: true + traffic_shaping: + router: + dedupe: + enabled: true + headers: none + "#, + ) + .build() + .start() + .await; + + let query = r#" + subscription { + reviewAdded(intervalInMs: 100) { + id + } + } + "#; + + let wsconn = router.ws().await; + let mut ws_client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + let ws_payload = SubscribePayload { + query: query.into(), + ..Default::default() + }; + let mut ws_stream = ws_client.subscribe(ws_payload).await; + + // consume 3 events from sub1 to let the source stream advance + let response = ws_stream.next().await.unwrap(); + assert!( + response.data.to_string().contains(r#""id": "1""#), + "Expected first event to be id=1" + ); + let response = ws_stream.next().await.unwrap(); + assert!( + response.data.to_string().contains(r#""id": "2""#), + "Expected second event to be id=2" + ); + let response = ws_stream.next().await.unwrap(); + assert!( + response.data.to_string().contains(r#""id": "3""#), + "Expected third event to be id=3" + ); + + // subscribe again with SSE - dedup promotes sub2 onto the live source + let sse_headers = some_header_map! { + http::header::ACCEPT => "text/event-stream" + }; + let sub2 = router.send_graphql_request(query, None, sse_headers).await; + + assert!(sub2.status().is_success(), "Expected 200 OK"); + + // drop the WS sub now that sub2 is connected; sub2 must become the active subscriber + drop(ws_stream); + drop(ws_client); + + // sub2 should receive the remainder of the stream from where the source left off + let body = sub2.string_body().await; + assert!( + body.contains("event: next") && body.contains("event: complete"), + "Expected sub2 to receive remaining events and complete, got: {body}" + ); + + // sub2 must not have received the first 3 events already consumed by the WS sub + assert!( + !body.contains(r#""id":"1""#) + && !body.contains(r#""id":"2""#) + && !body.contains(r#""id":"3""#), + "Expected sub2 to not replay events already consumed by the WS sub, got: {body}" + ); + + // only one subgraph request should have been made + let reviews_requests = subgraphs.get_requests_log("reviews").unwrap_or_default(); + assert_eq!( + reviews_requests.len(), + 1, + "Expected requests to reviews subgraph to be deduplicated across transports" + ); + } + + #[ntex::test] + async fn max_long_lived_clients_rejects_over_limit() { + use futures::StreamExt; + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + traffic_shaping: + router: + max_long_lived_clients: 2 + "#, + ) + .build() + .start() + .await; + + let query = r#" + subscription { + reviewAdded(intervalInMs: 200) { + id + } + } + "#; + let headers = some_header_map! { + http::header::ACCEPT => "text/event-stream" + }; + + // open two subscriptions and keep them alive by reading the first event + let mut sub1 = router + .send_graphql_request(query, None, headers.clone()) + .await; + assert!(sub1.status().is_success(), "sub1 should be accepted"); + let _ = sub1.next().await; + + let mut sub2 = router + .send_graphql_request(query, None, headers.clone()) + .await; + assert!(sub2.status().is_success(), "sub2 should be accepted"); + let _ = sub2.next().await; + + // the third subscriber exceeds the limit and must be rejected + let sub3 = router + .send_graphql_request(query, None, headers.clone()) + .await; + assert_eq!( + sub3.status(), + reqwest::StatusCode::SERVICE_UNAVAILABLE, + "sub3 should be rejected with 503 when the limit is reached" + ); + let retry_after = sub3.header("retry-after"); + assert!( + retry_after.is_some(), + "rejected response should include a Retry-After header" + ); + let body = sub3.string_body().await; + assert_eq!(body, "Too many long-lived clients"); + + // release the two held subscriptions + drop(sub1); + drop(sub2); + + // wait briefly for the slots to be freed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // a new subscriber should now be accepted again + let sub4 = router + .send_graphql_request(query, None, headers.clone()) + .await; + assert!( + sub4.status().is_success(), + "sub4 should be accepted after the previous slots were freed" + ); + } +} diff --git a/e2e/src/supergraph.rs b/e2e/src/supergraph.rs index 44598274a..cc4d7ca6d 100644 --- a/e2e/src/supergraph.rs +++ b/e2e/src/supergraph.rs @@ -21,25 +21,30 @@ mod supergraph_e2e_tests { let router = TestRouter::builder() .inline_config(format!( r#" - supergraph: - source: hive - endpoint: http://{host}/supergraph - key: dummy_key - poll_interval: 300ms - "#, + supergraph: + source: hive + endpoint: http://{host}/supergraph + key: dummy_key + poll_interval: 500ms + "#, )) .build() .start() .await; - let res = router - .send_graphql_request("{ __schema { types { name } } }", None, None) - .await; - assert!(res.status().is_success(), "Expected 200 OK"); + wait_until_mock_matched(&mock1) + .await + .expect("Expected mock1 to be matched"); // wait for caches to populate - let deadline = std::time::Instant::now() + Duration::from_secs(5); + let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { + // we keep making requests to ensure the cache because its flakey + let res = router + .send_graphql_request("{ __schema { types { name } } }", None, None) + .await; + assert!(res.status().is_success(), "Expected 200 OK"); + router .schema_state() .normalize_cache @@ -51,16 +56,18 @@ mod supergraph_e2e_tests { { break; } + assert!( std::time::Instant::now() < deadline, "timed out waiting for caches to populate: plan={}, normalize={}", router.schema_state().plan_cache.entry_count(), router.schema_state().normalize_cache.entry_count() ); - ntex::time::sleep(Duration::from_millis(100)).await; + ntex::time::sleep(Duration::from_millis(50)).await; } mock1.remove(); + let mock2 = server .mock("GET", "/supergraph") .expect_at_least(1) @@ -73,11 +80,8 @@ mod supergraph_e2e_tests { .await .expect("Expected mock2 to be matched"); - // wait for the router to finish rebuilding with the new supergraph - router.wait_for_ready(None).await; - - // wait for cache invalidation to be reflected (moka invalidate_all is lazy) - let deadline = std::time::Instant::now() + Duration::from_secs(5); + // wait for cache invalidation to be reflected + let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { router .schema_state() @@ -96,7 +100,7 @@ mod supergraph_e2e_tests { router.schema_state().plan_cache.entry_count(), router.schema_state().normalize_cache.entry_count() ); - ntex::time::sleep(Duration::from_millis(100)).await; + ntex::time::sleep(Duration::from_millis(50)).await; } } @@ -139,7 +143,7 @@ mod supergraph_e2e_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 100ms + poll_interval: 500ms "#, )) .build() @@ -290,7 +294,7 @@ mod supergraph_e2e_tests { source: hive endpoint: http://{host}/supergraph key: dummy_key - poll_interval: 200ms + poll_interval: 500ms "#, )) .build() diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index 2e83fbd8f..af2b06ac4 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -8,7 +8,9 @@ use lazy_static::lazy_static; use mockito::Mock; use ntex::{ client::ClientResponse, + io::Sealed, web::{self, test}, + ws::WsConnection, }; use reqwest::header::{ACCEPT, CONTENT_TYPE}; use sonic_rs::json; @@ -23,19 +25,23 @@ use std::{ }; use tempfile::{NamedTempFile, TempPath}; use tokio::{ - net::TcpListener, sync::{oneshot, Semaphore}, time, }; use tracing::{info, warn}; use hive_router::{ - background_tasks::BackgroundTasksManager, configure_app_from_config, configure_ntex_app, - init_rustls_crypto_provider, invoke_shutdown_hooks, plugins::plugins_service::PluginService, - telemetry::Telemetry, PluginRegistry, RouterPaths, RouterSharedState, SchemaState, + add_callback_handler, background_tasks::BackgroundTasksManager, configure_app_from_config, + configure_ntex_app, init_rustls_crypto_provider, invoke_shutdown_hooks, + pipeline::long_lived_client_limit::LongLivedClientLimitService, + plugins::plugins_service::PluginService, telemetry::Telemetry, PluginRegistry, RouterPaths, + RouterSharedState, SchemaState, }; -use hive_router_config::{load_config, parse_yaml_config, HiveRouterConfig}; -use subgraphs::subgraphs_app; +use hive_router_config::{ + load_config, parse_yaml_config, subscriptions::CallbackConfig, HiveRouterConfig, +}; +use hive_router_plan_executor::executors::websocket_client; +use subgraphs::{subgraphs_app, HTTPStreamingSubscriptionProtocol}; // utilities @@ -63,6 +69,17 @@ macro_rules! flakey { // #[macro_export] always hoists to the crate root so we re-export it here module level pub use flakey; +/// Binds a TCP listener to an OS-assigned port and returns that port number. +/// The listener is immediately dropped, so the port is free for the caller to use. +pub fn get_available_port() -> u16 { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind to get available port"); + listener + .local_addr() + .expect("failed to get local address") + .port() +} + /// Creates a Some(http::HeaderMap) from a list of key-value pairs, for use in test requests. #[macro_export] macro_rules! some_header_map { @@ -240,6 +257,7 @@ impl ResponseLike { type OnRequest = dyn Fn(RequestLike) -> Option + Send + Sync; pub struct TestSubgraphsBuilder { + subscriptions_protocol: HTTPStreamingSubscriptionProtocol, on_request: Option>, delay: Option, } @@ -249,10 +267,18 @@ impl TestSubgraphsBuilder { Self { on_request: None, delay: None, + subscriptions_protocol: HTTPStreamingSubscriptionProtocol::default(), } } - #[allow(unused)] + pub fn with_http_streaming_subscriptions_protocol( + mut self, + protocol: HTTPStreamingSubscriptionProtocol, + ) -> Self { + self.subscriptions_protocol = protocol; + self + } + pub fn with_on_request( mut self, on_request: impl Fn(RequestLike) -> Option + Send + Sync + 'static, @@ -275,6 +301,7 @@ impl TestSubgraphsBuilder { TestSubgraphs { on_request: self.on_request, delay: self.delay, + subscriptions_protocol: self.subscriptions_protocol, handle: None, _state: PhantomData, } @@ -294,6 +321,7 @@ struct TestSubgraphsHandle { } pub struct TestSubgraphs { + subscriptions_protocol: HTTPStreamingSubscriptionProtocol, on_request: Option>, delay: Option, handle: Option, @@ -372,12 +400,12 @@ impl TestSubgraphs { } pub async fn start(self) -> TestSubgraphs { - let listener = TcpListener::bind("127.0.0.1:0") + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind tcp listener"); let addr = listener.local_addr().expect("failed to get local address"); - let mut app = subgraphs_app(); + let mut app = subgraphs_app(self.subscriptions_protocol.clone()); let middleware_state = Arc::new(TestSubgraphsMiddlewareState { request_log: DashMap::new(), @@ -415,6 +443,7 @@ impl TestSubgraphs { TestSubgraphs { on_request: self.on_request, delay: self.delay, + subscriptions_protocol: self.subscriptions_protocol, handle: Some(TestSubgraphsHandle { shutdown_tx: Some(shutdown_tx), addr, @@ -471,6 +500,8 @@ pub struct TestRouterBuilder { config: Option, plugins: Vec PluginRegistry>>, subgraphs_addr: Option, + port: u16, + listener: Option, } impl TestRouterBuilder { @@ -481,6 +512,8 @@ impl TestRouterBuilder { config: None, plugins: vec![], subgraphs_addr: None, + port: 0, + listener: None, } } @@ -510,6 +543,16 @@ impl TestRouterBuilder { self } + pub fn with_port(mut self, port: u16) -> Self { + self.port = port; + self + } + + pub fn with_listener(mut self, listener: std::net::TcpListener) -> Self { + self.listener = Some(listener); + self + } + pub fn skip_wait_for_healthy_on_start(mut self) -> Self { self.wait_for_healthy_on_start = false; self @@ -529,6 +572,7 @@ impl TestRouterBuilder { pub fn build(self) -> TestRouter { let mut config = self.config.unwrap_or_default(); + config.http.port = self.port; // sync with config // TODO: what if testing custom port? let mut _hold_until_drop: Vec> = vec![]; // change the supergraph to use the test subgraphs address @@ -563,7 +607,11 @@ impl TestRouterBuilder { TestRouter { wait_for_healthy_on_start: self.wait_for_healthy_on_start, wait_for_ready_on_start: self.wait_for_ready_on_start, - graphql_path: config.http.graphql_endpoint().to_string(), + graphql_path: config.graphql_path().to_string(), + websocket_path: config.websocket_path().map(|s| s.to_string()), + callback_conf: config.callback_conf().cloned(), + port: self.port, + listener: self.listener, config: Some(config), plugins: self.plugins, handle: None, @@ -614,7 +662,8 @@ impl Drop for TestRouterHandle { tracing::info!( component = "telemetry", layer = "provider", - "shutdown scheduled" + layer = "provider", + "shutdown completed" ); let _ = provider.force_flush(); let _ = provider.shutdown(); @@ -650,6 +699,10 @@ pub struct TestRouter { wait_for_healthy_on_start: bool, wait_for_ready_on_start: bool, graphql_path: String, + websocket_path: Option, + callback_conf: Option, + port: u16, + listener: Option, config: Option, plugins: Vec PluginRegistry>>, handle: Option, @@ -694,18 +747,71 @@ impl TestRouter { let serv_shared_state = shared_state.clone(); let serv_schema_state = schema_state.clone(); - let paths = RouterPaths::new(self.graphql_path.clone()); + let serv_callback_subs = schema_state.callback_subscriptions.clone(); + let serv_graphql_path = self.graphql_path.clone(); + let serv_websocket_path = self.websocket_path.clone(); + + // when `listen` is set, the callback route lives on a dedicated server bound to that + // address as a background task; otherwise it is mounted on the main server + let serv_callback_path = match self.callback_conf { + Some(CallbackConfig { + listen: Some(listen), + ref path, + .. + }) => { + let cb_path = path.to_string(); + let cb_addr = listen.to_string(); + let cb_subs = schema_state.callback_subscriptions.clone(); + + let server = web::HttpServer::new(async move || { + let cb_subs = cb_subs.clone(); + let cb_path = cb_path.clone(); + web::App::new() + .state(cb_subs) + .configure(move |m| add_callback_handler(m, &cb_path)) + }) + .bind(&cb_addr) + .expect("failed to bind callback server") + .run(); + + bg_tasks_manager.register_handle(async move { + server.await.ok(); + }); + + None + } + Some(ref cb) => Some(cb.path.to_string()), + None => None, + }; + + let paths = RouterPaths::new( + serv_graphql_path, + serv_websocket_path, + serv_callback_path.clone(), + ); paths .detect_conflicts(&prometheus) .expect("failed to detect endpoint conflicts"); + let serv_listener = self.listener.unwrap_or( + std::net::TcpListener::bind(format!("127.0.0.1:{}", self.port)) + .expect("failed to bind tcp listener for test server"), + ); + let serv_port = serv_listener + .local_addr() + .expect("failed to get local address of test server") + .port(); let serv_paths = paths.clone(); let serv_prometheus = prometheus.clone(); - let serv = test::server(move || { + let long_lived_limit = LongLivedClientLimitService::new(&shared_state.router_config); + let serv = test::server_with(test::config().listener(serv_listener), move || { let shared_state = serv_shared_state.clone(); let schema_state = serv_schema_state.clone(); let paths = serv_paths.clone(); let prometheus = serv_prometheus.clone(); + let serv_callback_path = serv_callback_path.clone(); + let callback_subs = serv_callback_subs.clone(); + let long_lived_limit = long_lived_limit.clone(); // set the tracing dispatch on the server thread. the guard is // intentionally leaked: dropping it would restore the no-op default @@ -719,10 +825,17 @@ impl TestRouter { async move { web::App::new() + .middleware(long_lived_limit) .middleware(PluginService) .state(shared_state) .state(schema_state) + .state(callback_subs) .configure(|m| configure_ntex_app(m, &paths, prometheus)) + .configure(|m| { + if let Some(ref callback) = serv_callback_path { + add_callback_handler(m, callback); + } + }) } }) .await; @@ -730,9 +843,13 @@ impl TestRouter { let mut hold_until_drop = self._hold_until_drop; hold_until_drop.push(Box::new(subscription_guard)); let started = TestRouter { + port: serv_port, + listener: None, wait_for_healthy_on_start: self.wait_for_healthy_on_start, wait_for_ready_on_start: self.wait_for_ready_on_start, graphql_path: self.graphql_path, + websocket_path: self.websocket_path, + callback_conf: self.callback_conf, handle: Some(TestRouterHandle { schema_state, shared_state, @@ -775,7 +892,7 @@ impl TestRouter { /// Waits for the /health endpoint to return 200 OK, with an optional timeout (defaults to 5 seconds). pub async fn wait_for_healthy(&self, timeout: Option) { - tokio::time::timeout(timeout.unwrap_or(Duration::from_secs(5)), async { + tokio::time::timeout(timeout.unwrap_or(Duration::from_secs(10)), async { loop { match self.serv().get("/health").send().await { Ok(response) => { @@ -796,7 +913,7 @@ impl TestRouter { /// Waits for the /readiness endpoint to return 200 OK, with an optional timeout (defaults to 5 seconds). pub async fn wait_for_ready(&self, timeout: Option) { - tokio::time::timeout(timeout.unwrap_or(Duration::from_secs(5)), async { + tokio::time::timeout(timeout.unwrap_or(Duration::from_secs(10)), async { loop { match self.serv().get("/readiness").send().await { Ok(response) => { @@ -845,6 +962,19 @@ impl TestRouter { .await .expect("Failed to send graphql request") } + + pub async fn ws(&self) -> WsConnection { + let url = self.handle.as_ref().unwrap().serv.url( + self.websocket_path + .as_deref() + .expect("Websocket path not set"), + ); + let ws_url = url.as_str().replace("http://", "ws://"); + let ws_uri = ws_url.parse::().expect("Failed to parse ws url"); + websocket_client::connect(&ws_uri) + .await + .expect("Failed to connect to websocket") + } } pub trait ClientResponseExt { @@ -874,7 +1004,7 @@ impl ClientResponseExt for ClientResponse { pub async fn wait_until_mock_matched(mock: &Mock) -> Result<(), String> { let now = Instant::now(); - let timeout = Duration::from_secs(5); // always a sane default + let timeout = Duration::from_secs(10); // always a sane default loop { if mock.matched_async().await { return Ok(()); diff --git a/e2e/src/testkit/otel.rs b/e2e/src/testkit/otel.rs index 9903519cb..230d5f21c 100644 --- a/e2e/src/testkit/otel.rs +++ b/e2e/src/testkit/otel.rs @@ -699,7 +699,7 @@ impl OtlpCollector { /// Waits for at least `count` traces to be collected, returning all collected traces. pub async fn wait_for_traces_count(&self, count: usize) -> Vec { - tokio::time::timeout(Duration::from_secs(5), async { + tokio::time::timeout(Duration::from_secs(10), async { loop { let traces = self.traces().await; @@ -722,7 +722,7 @@ impl OtlpCollector { count: usize, hive_kind: &str, ) -> Vec { - tokio::time::timeout(Duration::from_secs(5), async { + tokio::time::timeout(Duration::from_secs(10), async { loop { let traces = self.traces().await; let matching = traces @@ -747,7 +747,7 @@ impl OtlpCollector { } pub async fn wait_for_span_by_hive_kind_one(&self, hive_kind: &str) -> CollectedSpan { - tokio::time::timeout(Duration::from_secs(5), async { + tokio::time::timeout(Duration::from_secs(10), async { loop { let traces = self.traces().await; diff --git a/e2e/src/websocket.rs b/e2e/src/websocket.rs new file mode 100644 index 000000000..a6590776b --- /dev/null +++ b/e2e/src/websocket.rs @@ -0,0 +1,478 @@ +#[cfg(test)] +mod websocket_e2e_tests { + use futures::StreamExt; + use sonic_rs::json; + use std::collections::HashMap; + + use crate::testkit::{TestRouter, TestSubgraphs}; + use hive_router_plan_executor::executors::{ + graphql_transport_ws::{ConnectionInitPayload, SubscribePayload}, + websocket_client::WsClient, + }; + + #[ntex::test] + async fn query_over_websocket() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + websocket: + enabled: true + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + + let execution_request = SubscribePayload { + query: r#" + query { + topProducts { + name + upc + } + } + "# + .into(), + ..Default::default() + }; + + let mut stream = client.subscribe(execution_request).await; + + let response = stream.next().await.expect("Expected a response"); + + assert!(response.errors.is_none(), "Expected no errors"); + assert!(!response.data.is_null(), "Expected data"); + + let next = stream.next().await; + assert!(next.is_none(), "Expected stream to complete after query"); + } + + #[ntex::test] + async fn subscription_over_websocket() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + websocket: + enabled: true + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + + let subscribe_payload = SubscribePayload { + query: r#" + subscription { + reviewAdded(step: 1, intervalInMs: 0) { + id + body + } + } + "# + .into(), + ..Default::default() + }; + + let mut stream = client.subscribe(subscribe_payload).await; + + let mut received_count = 0; + while let Some(response) = stream.next().await { + assert!(response.errors.is_none(), "Expected no errors"); + assert!(!response.data.is_null(), "Expected data"); + received_count += 1; + } + + assert_eq!( + received_count, 11, + "Expected to receive 11 subscription events" + ); + } + + #[ntex::test] + async fn multiple_subscriptions_in_parallel() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + websocket: + enabled: true + subscriptions: + enabled: true + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + + let subscribe_payload1 = SubscribePayload { + query: r#" + subscription { + reviewAdded(step: 1, intervalInMs: 0) { + id + } + } + "# + .into(), + ..Default::default() + }; + + let mut stream1 = client.subscribe(subscribe_payload1).await; + + let subscribe_payload = SubscribePayload { + query: r#" + subscription { + reviewAdded(step: 2, intervalInMs: 0) { + id + } + } + "# + .into(), + ..Default::default() + }; + + let mut stream2 = client.subscribe(subscribe_payload).await; + + let mut count1 = 0; + let mut count2 = 0; + + loop { + tokio::select! { + maybe_response = stream1.next() => { + match maybe_response { + Some(response) => { + assert!(response.errors.is_none(), "Expected no errors in stream1"); + count1 += 1; + } + None => { + if count2 > 0 { + break; + } + } + } + } + maybe_response = stream2.next() => { + match maybe_response { + Some(response) => { + assert!(response.errors.is_none(), "Expected no errors in stream2"); + count2 += 1; + } + None => { + if count1 > 0 { + break; + } + } + } + } + } + + if count1 > 0 && count2 > 0 { + break; + } + } + + assert!( + count1 > 0, + "Expected to receive at least one event from stream1" + ); + assert!( + count2 > 0, + "Expected to receive at least one event from stream2" + ); + } + + #[ntex::test] + async fn header_propagation_from_connection_init_payload() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-context + websocket: + enabled: true + # default headers.source: connection + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init( + wsconn, + Some(ConnectionInitPayload::new(HashMap::from([( + "x-context".to_string(), + json!("my-init_payload-value"), + )]))), + ) + .await + .expect("Failed to init WsClient"); + + let subscribe_payload = SubscribePayload { + query: r#" + query { + topProducts { + name + upc + } + } + "# + .into(), + ..Default::default() + }; + + let mut stream = client.subscribe(subscribe_payload).await; + + stream.next().await.expect("Expected a response"); + + let products_requests = subgraphs + .get_requests_log("products") + .expect("expected requests sent to products subgraph"); + let last_products_request = products_requests + .last() + .expect("expected at least one request to products subgraph"); + assert_eq!( + last_products_request + .headers + .get("x-context") + .expect("expected x-context header to be present"), + "my-init_payload-value", + ) + } + + #[ntex::test] + async fn header_propagation_from_operation_extensions() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-context + websocket: + enabled: true + headers: + source: operation + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init(wsconn, None) + .await + .expect("Failed to init WsClient"); + + let subscribe_payload = SubscribePayload { + query: r#" + query { + topProducts { + name + upc + } + } + "# + .into(), + extensions: Some(HashMap::from([( + "headers".to_string(), + json!({"x-context": "my-extensions-value"}), + )])), + ..Default::default() + }; + + let mut stream = client.subscribe(subscribe_payload).await; + + stream.next().await.expect("Expected a response"); + + let products_requests = subgraphs + .get_requests_log("products") + .expect("expected requests sent to products subgraph"); + let last_products_request = products_requests + .last() + .expect("expected at least one request to products subgraph"); + assert_eq!( + last_products_request + .headers + .get("x-context") + .expect("expected x-context header to be present"), + "my-extensions-value", + ) + } + + #[ntex::test] + async fn merged_header_propagation_from_both_connection_init_payload_and_operation_extensions() + { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-context + websocket: + enabled: true + headers: + source: both + persist: true + "#, + ) + .build() + .start() + .await; + + let wsconn = router.ws().await; + + let mut client = WsClient::init( + wsconn, + Some(ConnectionInitPayload::new(HashMap::from([( + "x-context".to_string(), + json!("my-init_payload-value"), + )]))), + ) + .await + .expect("Failed to init WsClient"); + + let subscribe_payload = SubscribePayload { + query: r#" + query { + topProducts { + name + upc + } + } + "# + .into(), + extensions: Some(HashMap::from([( + "headers".to_string(), + json!({"x-context": "my-extensions-value"}), + )])), + ..Default::default() + }; + + // merging headers + let mut stream = client.subscribe(subscribe_payload).await; + stream.next().await.expect("Expected a response"); + + let subscribe_payload = SubscribePayload { + query: r#" + query { + topProducts { + name + upc + } + } + "# + .into(), + ..Default::default() + }; + + // missing headers in extensions, should've been merged + let mut stream = client.subscribe(subscribe_payload).await; + stream.next().await.expect("Expected a response"); + + let products_requests = subgraphs + .get_requests_log("products") + .expect("expected requests sent to products subgraph"); + let last_products_request = products_requests + .last() + .expect("expected at least one request to products subgraph"); + assert_eq!( + last_products_request + .headers + .get("x-context") + .expect("expected x-context header to be present"), + "my-extensions-value", + ) + } + + #[ntex::test] + async fn should_not_steal_non_upgrade_get_requests_on_same_graphql_path() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + websocket: + enabled: true + "#, + ) + .build() + .start() + .await; + + let req = router + .serv() + .get(router.graphql_path()) // same path as the websocket upgrade endpoint + .header(http::header::ACCEPT, "application/graphql-response+json") + .query(&[("query", "{ __typename }")]) + .unwrap(); + + let res = req.send().await.unwrap(); + + assert_eq!(res.status(), http::StatusCode::OK); + assert_eq!( + res.header(http::header::CONTENT_TYPE) + .expect("expected content-type header to be present"), + "application/graphql-response+json" + ); + } +} diff --git a/e2e/supergraph.graphql b/e2e/supergraph.graphql index 655a18c1a..0f158458f 100644 --- a/e2e/supergraph.graphql +++ b/e2e/supergraph.graphql @@ -2,6 +2,7 @@ schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { query: Query + subscription: Subscription mutation: Mutation } @@ -83,6 +84,8 @@ type Product shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") name: String @join__field(graph: PRODUCTS) reviews: [Review] @join__field(graph: REVIEWS) + notes: String @join__field(graph: PRODUCTS) + internal: String @join__field(graph: PRODUCTS) } type Query @@ -96,6 +99,13 @@ type Query topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) } +type Subscription @join__type(graph: REVIEWS) { + reviewAdded(step: Int = 1, intervalInMs: Int = 1000): Review + @join__field(graph: REVIEWS) + reviewAddedForProduct(productUpc: String!, intervalInMs: Int = 1000): Review + @join__field(graph: REVIEWS) +} + type Review @join__type(graph: REVIEWS, key: "id") { id: ID! body: String diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index c74589fec..be8f22a45 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -40,6 +40,7 @@ strum = { workspace = true, features = ["derive"] } ahash = { workspace = true } ntex = { workspace = true } hyper-rustls = { workspace = true} +rustls = { workspace = true } hyper-util = { version = "0.1.16", features = [ "client", @@ -53,6 +54,9 @@ zmij = "1.0.21" indexmap = "2.10.0" bumpalo = "3.19.0" sonic-simd = "0.1.2" +async-stream = "0.3.6" +futures-util = "0.3.31" +ulid = "1.2.1" [dev-dependencies] subgraphs = { path = "../../bench/subgraphs" } diff --git a/lib/executor/src/execution/client_request_details.rs b/lib/executor/src/execution/client_request_details.rs index b6ebd89fb..aba579055 100644 --- a/lib/executor/src/execution/client_request_details.rs +++ b/lib/executor/src/execution/client_request_details.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc}; use bytes::Bytes; use hive_router_internal::expressions::{lib::ToVrlValue, vrl::core::Value}; @@ -16,7 +16,7 @@ pub struct ClientRequestDetails<'exec> { pub url: &'exec http::Uri, pub headers: &'exec NtexHeaderMap, pub operation: OperationDetails<'exec>, - pub jwt: JwtRequestDetails, + pub jwt: Arc, } pub enum JwtRequestDetails { @@ -65,7 +65,7 @@ impl From<&ClientRequestDetails<'_>> for Value { ])); // .request.jwt - let jwt_value = match &details.jwt { + let jwt_value = match details.jwt.as_ref() { JwtRequestDetails::Authenticated { token, prefix, diff --git a/lib/executor/src/execution/plan.rs b/lib/executor/src/execution/plan.rs index 57fc7f48c..540ff80e8 100644 --- a/lib/executor/src/execution/plan.rs +++ b/lib/executor/src/execution/plan.rs @@ -1,28 +1,36 @@ use std::collections::hash_map::Entry; use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; +use std::vec; use ahash::{HashMap as AHashMap, HashMapExt}; use bytes::BufMut; use futures::TryFutureExt; -use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, StreamExt}; +use futures::{ + future::BoxFuture, + stream::{BoxStream, FuturesUnordered}, + FutureExt, StreamExt, +}; use hive_router_internal::telemetry::metrics::graphql_metrics::GraphQLErrorMetricsRecorder; use hive_router_internal::telemetry::traces::spans::graphql::{ GraphQLOperationSpan, GraphQLSpanOperationIdentity, GraphQLSubgraphOperationSpan, }; use hive_router_query_planner::ast::operation::SubgraphFetchOperation; +use hive_router_query_planner::planner::query_plan::QUERY_PLAN_KIND; use hive_router_query_planner::{ ast::operation::OperationDefinition, planner::plan_nodes::{ ConditionNode, EntityBatch, EntityBatchAlias, FetchRewrite, FlattenNodePath, PlanNode, - QueryPlan, + QueryPlan, SequenceNode, }, state::supergraph_state::OperationKind, }; use http::{HeaderMap, StatusCode}; +use serde::Serialize; use sonic_rs::ValueRef; use tracing::Instrument; +use crate::execution::client_request_details::OperationDetails; use crate::{ context::ExecutionContext, execution::{ @@ -64,20 +72,31 @@ use crate::{ pub struct QueryPlanExecutionOpts<'exec> { pub query_plan: &'exec QueryPlan, - pub operation_for_plan: &'exec OperationDefinition, - pub projection_plan: &'exec Vec, - pub headers_plan: &'exec HeaderRulesPlan, - pub variable_values: &'exec Option>, + pub operation_for_plan: Arc, + pub projection_plan: Arc>, + pub headers_plan: Arc, + pub variable_values: Arc>>, pub extensions: HashMap, - pub client_request: &'exec ClientRequestDetails<'exec>, - pub introspection_context: &'exec IntrospectionContext<'exec>, - pub operation_type_name: &'exec str, - pub executors: &'exec SubgraphExecutorMap, - pub jwt_auth_forwarding: Option, + pub client_request: Arc>, + pub introspection_context: Arc, + pub operation_type_name: &'static str, + pub executors: Arc, + pub jwt_auth_forwarding: Option>, pub graphql_error_recorder: Option, pub initial_errors: Vec, - pub span: &'exec GraphQLOperationSpan, - pub plugin_req_state: &'exec Option>, + pub span: GraphQLOperationSpan, + pub plugin_req_state: Option>, +} + +pub struct PlanSubscriptionOutput { + pub body: BoxStream<'static, Vec>, + pub response_headers_aggregator: Option, + pub error_count: usize, +} + +pub enum QueryPlanExecutionResult { + Single(PlanExecutionOutput), + Stream(PlanSubscriptionOutput), } #[derive(Default)] @@ -88,23 +107,245 @@ pub struct PlanExecutionOutput { pub status_code: StatusCode, } +#[derive(Serialize)] +pub struct FailedExecutionResult { + pub errors: Vec, +} + +impl FailedExecutionResult { + pub fn serialize(&self) -> Vec { + sonic_rs::to_vec(&self).unwrap_or_else(|err| { + // should never happen. result should always serialize - but hey, no unwraps + tracing::error!("Failed to serialize pipeline error to response: {}", err); + sonic_rs::to_vec(&FailedExecutionResult { + errors: vec![GraphQLError::from_message_and_code( + "Failed to serialize error response", + "INTERNAL_SERVER_ERROR", + )], + }) + .unwrap() + }) + } +} + pub async fn execute_query_plan<'exec>( opts: QueryPlanExecutionOpts<'exec>, -) -> Result { - let mut data = if let Some(introspection_query) = opts.introspection_context.query { - resolve_introspection(introspection_query, opts.introspection_context) +) -> Result { + let (subscription_node, remaining_nodes) = match &opts.query_plan.node { + // a subscription to a subgraph that contains all data and doesn't need entity resolution + Some(PlanNode::Subscription(sub)) => (Some(sub), None), + // a subscription that needs entity resolution. after emitting, it needs to execute the + // remaining plan nodes in the sequence + Some(PlanNode::Sequence(seq)) => match seq.nodes.first() { + Some(PlanNode::Subscription(sub)) => { + let remaining = if seq.nodes.len() > 1 { + // TODO: why to_vec()? is it wasteful? it's actually a slice, we dont need it as Vec + Some(seq.nodes[1..].to_vec()) + } else { + None + }; + (Some(sub), remaining) + } + _ => (None, None), + }, + _ => (None, None), + }; + + // subscription + if let Some(sub) = subscription_node { + // subscription + + // the primary (fetch node) of the subscription is the + // subscription destination, we execute it first and it + // would give us back a stream of results + let fetch_node = &sub.primary; + + // we assemble a synthetic query plan for entity resolution from remaining nodes + // because we might need entity resolution after receiving each subscription event + let query_plan: Arc = Arc::new(QueryPlan { + kind: QUERY_PLAN_KIND, + node: remaining_nodes.map(|nodes| { + if nodes.len() == 1 { + nodes.into_iter().next().unwrap() + } else { + PlanNode::Sequence(SequenceNode { nodes }) + } + }), + }); + + // we perform a regular subgraph request to the subscription subgraph + // the only difference is that we get back a stream of results + let mut headers_map = HeaderMap::new(); + modify_subgraph_request_headers( + &opts.headers_plan, + &fetch_node.service_name, + &opts.client_request, + &mut headers_map, + ) + .with_plan_context(LazyPlanContext { + subgraph_name: || Some(fetch_node.service_name.to_string()), + affected_path: || None, + })?; + let variable_refs = + select_fetch_variables(&opts.variable_values, fetch_node.variable_usages.as_ref()); + + let mut subgraph_request = SubgraphExecutionRequest { + query: fetch_node.operation.document_str.as_str(), + dedupe: false, + operation_name: fetch_node.operation_name.as_deref(), + variables: variable_refs, + headers: headers_map, + raw_variable_values: None, + extensions: None, + }; + + // TODO: otel instrumentation and stuff + // let subgraph_operation_span = GraphQLSubgraphOperationSpan::new( + // fetch_node.service_name.as_str(), + // &fetch_node.operation.document_str, + // ); + // subgraph_operation_span.record_operation_identity(GraphQLSpanOperationIdentity { + // name: subgraph_request.operation_name, + // operation_type: match fetch_node.operation_kind { + // Some(OperationKind::Query) | None => "query", + // Some(OperationKind::Mutation) => "mutation", + // Some(OperationKind::Subscription) => "subscription", + // }, + // client_document_hash: fetch_node.operation.hash.to_string().as_str(), + // }); + + if let Some(jwt_forwarding_plan) = &opts.jwt_auth_forwarding { + subgraph_request.add_request_extensions_field( + jwt_forwarding_plan.extension_field_name.clone(), + jwt_forwarding_plan.extension_field_value.clone(), + ); + } + + let mut response_stream = opts + .executors + .subscribe( + &fetch_node.service_name, + subgraph_request, + &opts.client_request, + ) + .await + .with_plan_context(LazyPlanContext { + subgraph_name: || Some(fetch_node.service_name.to_string()), + affected_path: || None, + })?; + // clone all necessary data from the context for usage in the stream. + // the stream will move all of these values inside its closure + let subgraph_name: String = fetch_node.service_name.clone(); + let client_method = opts.client_request.method.clone(); + let client_url = opts.client_request.url.clone(); + let client_headers = opts.client_request.headers.clone(); + let client_operation_name = opts.client_request.operation.name.map(|s| s.to_string()); + let client_operation_query = opts.client_request.operation.query.to_string(); + let client_operation_kind = opts.client_request.operation.kind; + let client_jwt = opts.client_request.jwt.clone(); + + let body_stream = Box::pin(async_stream::stream! { + while let Some(stream_result) = response_stream.next().await { + let response = match stream_result.with_plan_context(LazyPlanContext { + subgraph_name: || Some(subgraph_name.to_string()), + affected_path: || None, + }) { + Ok(response) => response, + // NOTE: I thought about going one way up and having the + // PlanSubscriptionOutput.body be a `Result, SubgraphExecutorError>` + // but that would put the burden on the caller to handle errors that are + // internal to the execution of the query plan (subgraph executor errors). + // furthermore, we want to always act on those errors the same way (stream and stop, + // read below) and not allow the caller to decide and potentially decide wrong + Err(err) => { + // not a fatal error, but stream it and stop. + // it's not fatal because the subgraph might recover and send more + // events if the subgraph error is a network error. but we fail and stop + // just to be on the safe side and avoid infinite error streaming because + // we cannot guarantee that the subgraph will recover and clients might + // simply ignore errors wasting the router's resources + yield FailedExecutionResult { + errors: vec![err.into()], + }.serialize(); + return; + } + }; + let mut initial_errors = opts.initial_errors.clone(); + if let Some(new_errors) = response.errors { + initial_errors.extend(new_errors); + } + let opts = QueryPlanExecutionOpts { + query_plan: &query_plan, + operation_for_plan: opts.operation_for_plan.clone(), + projection_plan: opts.projection_plan.clone(), + headers_plan: opts.headers_plan.clone(), + variable_values: opts.variable_values.clone(), + extensions: opts.extensions.clone(), + client_request: ClientRequestDetails { + method: &client_method, + url: &client_url, + headers: &client_headers, + operation: OperationDetails { + query: &client_operation_query, + name: client_operation_name.as_deref(), + kind: client_operation_kind, + }, + jwt: client_jwt.clone(), + }.into(), + introspection_context: opts.introspection_context.clone(), + operation_type_name: opts.operation_type_name, + executors: opts.executors.clone(), + jwt_auth_forwarding: opts.jwt_auth_forwarding.clone(), + initial_errors, + span: GraphQLOperationSpan { span: opts.span.clone() }, + // TODO: plugins for subscriptions are not yet supported + plugin_req_state: None, + graphql_error_recorder: None, + }; + match execute_query_plan_with_data(response.data, opts).await { + Ok(result) => yield result.body, + Err(err) => { + // fatal error, stream it and stop + yield FailedExecutionResult { + errors: vec![err.into()], + }.serialize(); + return; + } + } + } + }); + + return Ok(QueryPlanExecutionResult::Stream(PlanSubscriptionOutput { + body: body_stream, + response_headers_aggregator: None, + error_count: 0, // NOTE: errors can only happen before streaming started + })); + } + + // query or mutation + + let introspection_context_clone = Arc::clone(&opts.introspection_context); + let data = if let Some(introspection_query) = &introspection_context_clone.query { + resolve_introspection(introspection_query, &introspection_context_clone) } else if opts.projection_plan.is_empty() { Value::Null } else { Value::Object(Vec::new()) }; + let output = execute_query_plan_with_data(data, opts).await?; + + Ok(QueryPlanExecutionResult::Single(output)) +} + +async fn execute_query_plan_with_data<'exec>( + mut data: Value<'exec>, + opts: QueryPlanExecutionOpts<'exec>, +) -> Result { let mut errors = opts.initial_errors; let mut extensions = opts.extensions; - let mut query_plan = opts.query_plan; - let dedupe_subgraph_requests = opts.operation_type_name == "Query"; let mut on_end_callbacks = vec![]; @@ -113,12 +354,12 @@ pub async fn execute_query_plan<'exec>( let mut start_payload = OnExecuteStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, - query_plan, - operation_for_plan: opts.operation_for_plan, + query_plan: opts.query_plan, + operation_for_plan: &opts.operation_for_plan, data, errors, extensions, - variable_values: opts.variable_values, + variable_values: &opts.variable_values, dedupe_subgraph_requests, }; @@ -137,7 +378,6 @@ pub async fn execute_query_plan<'exec>( } // Give the ownership back to variables - query_plan = start_payload.query_plan; data = start_payload.data; errors = start_payload.errors; extensions = start_payload.extensions; @@ -147,17 +387,17 @@ pub async fn execute_query_plan<'exec>( // No need for `new`, it has too many parameters // We can directly create `Executor` instance here let executor = Executor { - variable_values: opts.variable_values, - schema_metadata: opts.introspection_context.metadata, - executors: opts.executors, - client_request: opts.client_request, - headers_plan: opts.headers_plan, + variable_values: &opts.variable_values, + schema_metadata: &opts.introspection_context.metadata, + executors: &opts.executors, + client_request: &opts.client_request, + headers_plan: &opts.headers_plan, jwt_forwarding_plan: opts.jwt_auth_forwarding, dedupe_subgraph_requests, - plugin_req_state: opts.plugin_req_state, + plugin_req_state: opts.plugin_req_state.as_ref(), }; - if let Some(node) = &query_plan.node { + if let Some(node) = &opts.query_plan.node { executor.execute_plan_node(&mut exec_ctx, node).await; } @@ -228,10 +468,10 @@ pub async fn execute_query_plan<'exec>( errors, &extensions, opts.operation_type_name, - opts.projection_plan, - opts.variable_values, + &opts.projection_plan, + &opts.variable_values, response_size_estimate, - opts.introspection_context.metadata, + &opts.introspection_context.metadata, ) .with_plan_context(LazyPlanContext { subgraph_name: || None, @@ -252,9 +492,9 @@ pub struct Executor<'exec> { pub executors: &'exec SubgraphExecutorMap, pub client_request: &'exec ClientRequestDetails<'exec>, pub headers_plan: &'exec HeaderRulesPlan, - pub jwt_forwarding_plan: Option, + pub jwt_forwarding_plan: Option>, pub dedupe_subgraph_requests: bool, - pub plugin_req_state: &'exec Option>, + pub plugin_req_state: Option<&'exec PluginRequestState<'exec>>, } enum ExecutionJob<'exec> { @@ -1161,6 +1401,7 @@ mod tests { }; use super::select_fetch_variables; + use dashmap::DashMap; use graphql_tools::parser::query; use hive_router_config::HiveRouterConfig; use hive_router_internal::telemetry::TelemetryContext; @@ -1313,6 +1554,7 @@ mod tests { Arc::new(TelemetryContext::from_propagation_config( &Default::default(), )), + Arc::new(DashMap::new()), ) .unwrap(); @@ -1329,12 +1571,12 @@ mod tests { query: "{ products { upc } }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }, headers_plan: &HeaderRulesPlan::default(), jwt_forwarding_plan: None, dedupe_subgraph_requests: false, - plugin_req_state: &None, + plugin_req_state: None, }; let data: ResponseValue = sonic_rs::from_str( @@ -1429,6 +1671,7 @@ mod tests { Arc::new(TelemetryContext::from_propagation_config( &Default::default(), )), + Arc::new(DashMap::new()), ) .unwrap(), client_request: &ClientRequestDetails { @@ -1440,12 +1683,12 @@ mod tests { query: "{ from_a from_b }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }, headers_plan: &HeaderRulesPlan::default(), jwt_forwarding_plan: None, dedupe_subgraph_requests: false, - plugin_req_state: &None, + plugin_req_state: None, }; let mock_a = subgraph_a diff --git a/lib/executor/src/executors/common.rs b/lib/executor/src/executors/common.rs index 780094705..667515074 100644 --- a/lib/executor/src/executors/common.rs +++ b/lib/executor/src/executors/common.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use async_trait::async_trait; +use futures::stream::BoxStream; use http::{HeaderMap, Uri}; use sonic_rs::Value; @@ -12,13 +13,23 @@ use crate::{ #[async_trait] pub trait SubgraphExecutor { fn endpoint(&self) -> &Uri; + async fn execute<'a>( &self, execution_request: SubgraphExecutionRequest<'a>, timeout: Option, - plugin_req_state: &'a Option>, + plugin_req_state: Option<&'a PluginRequestState<'a>>, ) -> Result, SubgraphExecutorError>; + async fn subscribe<'a>( + &self, + execution_request: SubgraphExecutionRequest<'a>, + timeout: Option, + ) -> Result< + BoxStream<'static, Result, SubgraphExecutorError>>, + SubgraphExecutorError, + >; + fn to_boxed_arc<'a>(self) -> Arc> where Self: Sized + Send + Sync + 'a, diff --git a/lib/executor/src/executors/error.rs b/lib/executor/src/executors/error.rs index e85137d2d..c1f7cc46b 100644 --- a/lib/executor/src/executors/error.rs +++ b/lib/executor/src/executors/error.rs @@ -1,4 +1,4 @@ -use http::uri::InvalidUri; +use http::{uri::InvalidUri, StatusCode}; use strum::IntoStaticStr; #[derive(thiserror::Error, Debug, IntoStaticStr)] @@ -6,6 +6,9 @@ pub enum SubgraphExecutorError { #[error("Failed to parse endpoint \"{0}\" as URI: {1}")] #[strum(serialize = "SUBGRAPH_ENDPOINT_PARSE_FAILURE")] EndpointParseFailure(String, InvalidUri), + #[error("Failed to build WebSocket endpoint \"{0}\" as URI: {1}")] + #[strum(serialize = "SUBGRAPH_WEBSOCKET_ENDPOINT_BUILD_FAILURE")] + WebSocketEndpointBuildFailure(String, http::Error), #[error("Failed to compile VRL expression for subgraph '{0}'. Please check your VRL expression for syntax errors. Diagnostic: {1}")] #[strum(serialize = "SUBGRAPH_ENDPOINT_EXPRESSION_BUILD_FAILURE")] EndpointExpressionBuild(String, String), @@ -53,6 +56,42 @@ pub enum SubgraphExecutorError { #[error("Failed to initialize or load native TLS root certificates: {0}")] #[strum(serialize = "SUBGRAPH_HTTPS_CERTS_FAILURE")] NativeTlsCertificatesError(std::io::Error), + #[error("Unsupported content-type '{0}': expected 'multipart/mixed' or 'text/event-stream' for HTTP subscriptions")] + #[strum(serialize = "SUBGRAPH_SUBSCRIPTION_UNSUPPORTED_CONTENT_TYPE")] + UnsupportedContentTypeError(String), + #[error("Failed to connect WebSocket to '{0}': {1}")] + #[strum(serialize = "SUBGRAPH_WEBSOCKET_CONNECT_FAILURE")] + WebSocketConnectFailure(String, String), + #[error("WebSocket protocol handshake with '{0}' failed: {1}")] + #[strum(serialize = "SUBGRAPH_WEBSOCKET_HANDSHAKE_FAILURE")] + WebSocketHandshakeFailure(String, String), + #[error("WebSocket subscription stream at '{0}' closed before sending any response")] + #[strum(serialize = "SUBGRAPH_WEBSOCKET_STREAM_CLOSED_EMPTY")] + WebSocketStreamClosedEmpty(String), + #[error("WebSocket executor arbiter channel closed unexpectedly")] + #[strum(serialize = "SUBGRAPH_WEBSOCKET_ARBITER_CHANNEL_CLOSED")] + WebSocketArbiterChannelClosed, + #[error("Failed to parse multipart boundary from Content-Type header: {0}")] + #[strum(serialize = "SUBGRAPH_SUBSCRIPTION_MULTIPART_BOUNDARY_PARSE_FAILURE")] + MultipartBoundaryParseFailure(String), + #[error("Error reading multipart subscription stream: {0}")] + #[strum(serialize = "SUBGRAPH_SUBSCRIPTION_MULTIPART_STREAM_ERROR")] + MultipartStreamError(String), + #[error("Error reading SSE subscription stream: {0}")] + #[strum(serialize = "SUBGRAPH_SUBSCRIPTION_SSE_STREAM_ERROR")] + SseStreamError(String), + #[error("Subgraph stream responded with a not-OK status code '{0}'")] + #[strum(serialize = "SUBGRAPH_STREAM_STATUS_CODE_NOT_OK")] + StreamStatusCodeNotOk(StatusCode), + #[error("Subgraph HTTP callback responded with a not-OK status code '{0}'")] + #[strum(serialize = "SUBGRAPH_HTTP_CALLBACK_STATUS_CODE_NOT_OK")] + HttpCallbackStatusCodeNotOk(StatusCode), + #[error("HTTP Callback protocol does not support single-shot execution, use it only for subscriptions")] + #[strum(serialize = "SUBGRAPH_HTTP_CALLBACK_NO_SINGLE")] + HttpCallbackNoSingle, + #[error("HTTP Callback protocol configured for subgraph but no callback configuration provided for router")] + #[strum(serialize = "SUBGRAPH_HTTP_CALLBACK_NOT_CONFIGURED")] + HttpCallbackNotConfigured, } impl SubgraphExecutorError { diff --git a/lib/executor/src/executors/graphql_transport_ws.rs b/lib/executor/src/executors/graphql_transport_ws.rs new file mode 100644 index 000000000..e7565ebca --- /dev/null +++ b/lib/executor/src/executors/graphql_transport_ws.rs @@ -0,0 +1,433 @@ +/// Common types and messages for the GraphQL over WebSocket Transport Protocol +/// as per the spec: https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md +use ntex::ws; +use serde::{Deserialize, Serialize}; +use sonic_rs::{JsonContainerTrait, JsonValueTrait, Value}; +use std::collections::HashMap; +use strum::AsRefStr; +use tracing::error; + +use crate::{executors::common::SubgraphExecutionRequest, response::graphql_error::GraphQLError}; + +pub const WS_SUBPROTOCOL: &str = "graphql-transport-ws"; + +pub enum CloseCode { + SubprotocolNotAcceptable, + ConnectionInitTimeout, + ConnectionAcknowledgementTimeout, + TooManyInitialisationRequests, + Unauthorized, + Forbidden(String), + BadRequest(&'static str), + BadResponse(&'static str), + SubscriberAlreadyExists(String), + InternalServerError(Option), +} + +impl From for ws::Message { + fn from(msg: CloseCode) -> Self { + match msg { + CloseCode::SubprotocolNotAcceptable => ws::Message::Close(Some(ws::CloseReason { + code: ws::CloseCode::from(4406), + description: Some("Subprotocol not acceptable".into()), + })), + CloseCode::ConnectionInitTimeout => ws::Message::Close(Some(ws::CloseReason { + code: ws::CloseCode::from(4408), + description: Some("Connection initialisation timeout".into()), + })), + CloseCode::ConnectionAcknowledgementTimeout => { + ws::Message::Close(Some(ws::CloseReason { + code: ws::CloseCode::from(4504), + description: Some("Connection acknowledgement timeout".into()), + })) + } + CloseCode::TooManyInitialisationRequests => ws::Message::Close(Some(ws::CloseReason { + code: ws::CloseCode::from(4429), + description: Some("Too many initialisation requests".into()), + })), + CloseCode::Unauthorized => ws::Message::Close(Some(ws::CloseReason { + code: ntex::ws::CloseCode::from(4401), + description: Some("Unauthorized".into()), + })), + CloseCode::Forbidden(reason) => ws::Message::Close(Some(ws::CloseReason { + code: ntex::ws::CloseCode::from(4403), + description: Some(reason), + })), + CloseCode::BadRequest(reason) => ws::Message::Close(Some(ws::CloseReason { + code: ntex::ws::CloseCode::from(4400), + description: Some(reason.into()), + })), + CloseCode::BadResponse(reason) => ws::Message::Close(Some(ws::CloseReason { + code: ntex::ws::CloseCode::from(4004), + description: Some(reason.into()), + })), + CloseCode::SubscriberAlreadyExists(id) => ws::Message::Close(Some(ws::CloseReason { + code: ws::CloseCode::from(4409), + description: Some(format!("Subscriber for {id} already exists")), + })), + CloseCode::InternalServerError(reason) => ws::Message::Close(Some(ws::CloseReason { + code: ntex::ws::CloseCode::from(4500), + description: reason.or(Some("Internal Server Error".into())), + })), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SubscribePayload { + pub query: String, + pub operation_name: Option, + pub variables: Option>, + pub extensions: Option>, +} + +pub fn build_subscribe_payload( + execution_request: SubgraphExecutionRequest<'_>, +) -> (SubscribePayload, Option) { + let variables: Option> = match &execution_request.variables { + Some(variables) => { + if variables.is_empty() { + None + } else { + Some( + variables + .iter() + .map(|(k, v)| (k.to_string(), (*v).clone())) + .collect(), + ) + } + } + None => None, + }; + let subscribe_payload = SubscribePayload { + query: execution_request.query.to_string(), + operation_name: execution_request.operation_name.map(|s| s.to_string()), + variables, + extensions: execution_request.extensions, + }; + let init_payload = if execution_request.headers.is_empty() { + None + } else { + Some(execution_request.headers.into()) + }; + (subscribe_payload, init_payload) +} + +#[derive( + Serialize, + Debug, + AsRefStr, // for logging the enum variant type as a string without the fields +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + ConnectionInit { + payload: Option, + }, + Ping {}, + Pong {}, + Subscribe { + id: String, + payload: SubscribePayload, + }, + Complete { + id: String, + }, +} + +// using a custom deserializer due to compatibility issues +// with internally-tagged enum deserialization #[serde(tag = "type")] +impl<'de> Deserialize<'de> for ClientMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let obj = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("expected object"))?; + + let type_key = "type".to_string(); + let msg_type = obj + .get(&type_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + + match msg_type { + "connection_init" => { + let payload_key = "payload".to_string(); + let payload = obj + .get(&payload_key) + .filter(|v| !v.is_null()) + .map(|v| { + sonic_rs::from_str(&v.to_string()) + .map_err(|e| serde::de::Error::custom(e.to_string())) + }) + .transpose()?; + Ok(ClientMessage::ConnectionInit { payload }) + } + "ping" => Ok(ClientMessage::Ping {}), + "pong" => Ok(ClientMessage::Pong {}), + "subscribe" => { + let id_key = "id".to_string(); + let payload_key = "payload".to_string(); + let id = obj + .get(&id_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("id"))? + .to_string(); + let payload_value = obj + .get(&payload_key) + .ok_or_else(|| serde::de::Error::missing_field("payload"))?; + let payload: SubscribePayload = sonic_rs::from_str(&payload_value.to_string()) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; + Ok(ClientMessage::Subscribe { id, payload }) + } + "complete" => { + let id_key = "id".to_string(); + let id = obj + .get(&id_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("id"))? + .to_string(); + Ok(ClientMessage::Complete { id }) + } + other => Err(serde::de::Error::unknown_variant( + other, + &["connection_init", "ping", "pong", "subscribe", "complete"], + )), + } + } +} + +impl ClientMessage { + pub fn init(payload: Option) -> ws::Message { + ClientMessage::ConnectionInit { payload }.into() + } + + pub fn ping() -> ws::Message { + ServerMessage::Ping {}.into() + } + + pub fn pong() -> ws::Message { + ServerMessage::Pong {}.into() + } + + pub fn subscribe(id: String, payload: SubscribePayload) -> ws::Message { + ClientMessage::Subscribe { id, payload }.into() + } + + pub fn complete(id: String) -> ws::Message { + ClientMessage::Complete { id }.into() + } +} + +impl From for ws::Message { + fn from(msg: ClientMessage) -> Self { + match sonic_rs::to_string(&msg) { + Ok(text) => ws::Message::Text(text.into()), + Err(e) => { + error!("Failed to serialize client message to JSON: {}", e); + CloseCode::InternalServerError(None).into() + } + } + } +} + +/// The connection init message payload MUST be a map of string to arbitrary JSON +/// values as per the spec. We represent this as a HashMap. +#[derive(Serialize, Debug, Clone)] +pub struct ConnectionInitPayload { + #[serde(flatten)] + pub fields: HashMap, +} + +impl<'de> Deserialize<'de> for ConnectionInitPayload { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let obj = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("expected object"))?; + + let mut fields = HashMap::new(); + for (k, v) in obj.iter() { + fields.insert(k.to_string(), v.clone()); + } + + Ok(ConnectionInitPayload { fields }) + } +} + +impl ConnectionInitPayload { + pub fn new(fields: HashMap) -> Self { + Self { fields } + } +} + +impl From for ConnectionInitPayload { + fn from(headers: http::HeaderMap) -> Self { + let fields: HashMap = headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|v| (name.to_string(), Value::from(v))) + }) + .collect(); + Self::new(fields) + } +} + +#[derive( + Serialize, + Debug, + AsRefStr, // for logging the enum variant type as a string without the fields +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + ConnectionAck { + // NOTE: as per spec there is a "payload" field here, but we don't use it + }, + Ping {}, + Pong {}, + Next { + id: String, + payload: Value, + }, + Error { + id: String, + payload: Vec, + }, + Complete { + id: String, + }, +} + +// using a custom deserializer due to compatibility issues +// with internally-tagged enum deserialization #[serde(tag = "type")] +impl<'de> Deserialize<'de> for ServerMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let obj = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("expected object"))?; + + let type_key = "type".to_string(); + let msg_type = obj + .get(&type_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + + match msg_type { + "connection_ack" => Ok(ServerMessage::ConnectionAck {}), + "ping" => Ok(ServerMessage::Ping {}), + "pong" => Ok(ServerMessage::Pong {}), + "next" => { + let id_key = "id".to_string(); + let payload_key = "payload".to_string(); + let id = obj + .get(&id_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("id"))? + .to_string(); + let payload = obj.get(&payload_key).cloned().unwrap_or_else(Value::new); + Ok(ServerMessage::Next { id, payload }) + } + "error" => { + let id_key = "id".to_string(); + let payload_key = "payload".to_string(); + let id = obj + .get(&id_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("id"))? + .to_string(); + let payload_value = obj + .get(&payload_key) + .ok_or_else(|| serde::de::Error::missing_field("payload"))?; + let payload: Vec = sonic_rs::from_str(&payload_value.to_string()) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; + Ok(ServerMessage::Error { id, payload }) + } + "complete" => { + let id_key = "id".to_string(); + let id = obj + .get(&id_key) + .and_then(|v| v.as_str()) + .ok_or_else(|| serde::de::Error::missing_field("id"))? + .to_string(); + Ok(ServerMessage::Complete { id }) + } + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "connection_ack", + "ping", + "pong", + "next", + "error", + "complete", + ], + )), + } + } +} + +impl ServerMessage { + pub fn ack() -> ws::Message { + ServerMessage::ConnectionAck {}.into() + } + + pub fn ping() -> ws::Message { + ServerMessage::Ping {}.into() + } + + pub fn pong() -> ws::Message { + ServerMessage::Pong {}.into() + } + + pub fn next(id: &str, body: &[u8]) -> ws::Message { + let payload = match sonic_rs::from_slice(body) { + Ok(value) => value, + Err(err) => { + error!("Failed to serialize plan execution output body: {}", err); + return CloseCode::InternalServerError(None).into(); + } + }; + ServerMessage::Next { + id: id.to_string(), + payload, + } + .into() + } + + pub fn error(id: &str, errors: &[GraphQLError]) -> ws::Message { + ServerMessage::Error { + id: id.to_string(), + payload: errors.to_vec(), + } + .into() + } + + pub fn complete(id: &str) -> ws::Message { + ServerMessage::Complete { id: id.to_string() }.into() + } +} + +impl From for ws::Message { + fn from(msg: ServerMessage) -> Self { + match sonic_rs::to_string(&msg) { + Ok(text) => ws::Message::Text(text.into()), + Err(e) => { + error!("Failed to serialize server message to JSON: {}", e); + CloseCode::InternalServerError(None).into() + } + } + } +} diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index 6f92dbad0..5d659614c 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -3,12 +3,15 @@ use std::time::{Duration, Instant}; use crate::executors::dedupe::unique_leader_fingerprint; use crate::executors::map::InflightRequestsMap; +use crate::executors::multipart_subscribe; +use crate::executors::sse; use crate::hooks::on_subgraph_http_request::{ OnSubgraphHttpRequestHookPayload, OnSubgraphHttpResponseHookPayload, }; use crate::plugin_context::PluginRequestState; use crate::plugin_trait::{EndControlFlow, StartControlFlow}; use crate::response::subgraph_response::SubgraphResponse; +use futures::stream::BoxStream; use hive_router_config::HiveRouterConfig; use hive_router_internal::inflight::InFlightRole; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; @@ -27,7 +30,7 @@ use hyper::Version; use hyper_rustls::HttpsConnector; use hyper_util::client::legacy::{connect::HttpConnector, Client}; use tokio::sync::Semaphore; -use tracing::debug; +use tracing::{debug, trace}; use crate::executors::common::SubgraphExecutionRequest; use crate::executors::error::SubgraphExecutorError; @@ -71,6 +74,66 @@ struct HttpRequestTelemetryCapture<'a> { transport_duration: Duration, } +pub fn build_request_body( + execution_request: &SubgraphExecutionRequest<'_>, +) -> Result, SubgraphExecutorError> { + let mut body = Vec::with_capacity(4096); + body.put(FIRST_QUOTE_STR); + write_and_escape_string(&mut body, execution_request.query); + let mut first_variable = true; + if let Some(variables) = &execution_request.variables { + for (variable_name, variable_value) in variables { + if first_variable { + body.put(FIRST_VARIABLE_STR); + first_variable = false; + } else { + body.put(COMMA); + } + body.put(QUOTE); + body.put(variable_name.as_bytes()); + body.put(QUOTE); + body.put(COLON); + let value_str = sonic_rs::to_string(variable_value).map_err(|err| { + SubgraphExecutorError::VariablesSerializationFailure(variable_name.to_string(), err) + })?; + body.put(value_str.as_bytes()); + } + } + if let Some(raw_variable_values) = &execution_request.raw_variable_values { + for (variable_name, variable_value) in raw_variable_values { + if first_variable { + body.put(FIRST_VARIABLE_STR); + first_variable = false; + } else { + body.put(COMMA); + } + body.put(QUOTE); + body.put(variable_name.as_bytes()); + body.put(QUOTE); + body.put(COLON); + body.extend_from_slice(variable_value); + } + } + // "first_variable" should be still true if there are no variables + if !first_variable { + body.put(CLOSE_BRACE); + } + + if let Some(extensions) = &execution_request.extensions { + if !extensions.is_empty() { + let as_value = sonic_rs::to_value(extensions).unwrap(); + + body.put(COMMA); + body.put("\"extensions\":".as_bytes()); + body.extend_from_slice(as_value.to_string().as_bytes()); + } + } + + body.put(CLOSE_BRACE); + + Ok(body) +} + impl HTTPSubgraphExecutor { #[allow(clippy::too_many_arguments)] pub fn new( @@ -105,70 +168,6 @@ impl HTTPSubgraphExecutor { config, } } - - fn build_request_body<'a>( - &self, - execution_request: &SubgraphExecutionRequest<'a>, - ) -> Result, SubgraphExecutorError> { - let mut body = Vec::with_capacity(4096); - body.put(FIRST_QUOTE_STR); - write_and_escape_string(&mut body, execution_request.query); - let mut first_variable = true; - if let Some(variables) = &execution_request.variables { - for (variable_name, variable_value) in variables { - if first_variable { - body.put(FIRST_VARIABLE_STR); - first_variable = false; - } else { - body.put(COMMA); - } - body.put(QUOTE); - body.put(variable_name.as_bytes()); - body.put(QUOTE); - body.put(COLON); - let value_str = sonic_rs::to_string(variable_value).map_err(|err| { - SubgraphExecutorError::VariablesSerializationFailure( - variable_name.to_string(), - err, - ) - })?; - body.put(value_str.as_bytes()); - } - } - if let Some(raw_variable_values) = &execution_request.raw_variable_values { - for (variable_name, variable_value) in raw_variable_values { - if first_variable { - body.put(FIRST_VARIABLE_STR); - first_variable = false; - } else { - body.put(COMMA); - } - body.put(QUOTE); - body.put(variable_name.as_bytes()); - body.put(QUOTE); - body.put(COLON); - body.extend_from_slice(variable_value); - } - } - // "first_variable" should be still true if there are no variables - if !first_variable { - body.put(CLOSE_BRACE); - } - - if let Some(extensions) = &execution_request.extensions { - if !extensions.is_empty() { - let as_value = sonic_rs::to_value(extensions).unwrap(); - - body.put(COMMA); - body.put("\"extensions\":".as_bytes()); - body.extend_from_slice(as_value.to_string().as_bytes()); - } - } - - body.put(CLOSE_BRACE); - - Ok(body) - } } pub struct SendRequestOpts<'a> { @@ -293,13 +292,14 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { fn endpoint(&self) -> &http::Uri { &self.endpoint } + async fn execute<'a>( &self, mut execution_request: SubgraphExecutionRequest<'a>, timeout: Option, - plugin_req_state: &'a Option>, + plugin_req_state: Option<&'a PluginRequestState<'a>>, ) -> Result, SubgraphExecutorError> { - let mut body = self.build_request_body(&execution_request)?; + let mut body = build_request_body(&execution_request)?; self.header_map.iter().for_each(|(key, value)| { execution_request.headers.insert(key, value.clone()); @@ -474,6 +474,131 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { response_result } + + async fn subscribe<'a>( + &self, + execution_request: SubgraphExecutionRequest<'a>, + connection_timeout: Option, + ) -> Result< + BoxStream<'static, Result, SubgraphExecutorError>>, + SubgraphExecutorError, + > { + let body = build_request_body(&execution_request)?; + + let mut req = hyper::Request::builder() + .method(http::Method::POST) + .uri(&self.endpoint) + .version(Version::HTTP_11) + .body(Full::new(Bytes::from(body)))?; + + let mut headers = execution_request.headers; + self.header_map.iter().for_each(|(key, value)| { + headers.insert(key, value.clone()); + }); + + // Prefer multipart over SSE for subscriptions + // https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol + headers.insert( + http::header::ACCEPT, + HeaderValue::from_static( + r#"multipart/mixed;subscriptionSpec="1.0", text/event-stream"#, + ), + ); + *req.headers_mut() = headers; + + debug!( + "establishing subscription connection to subgraph {} at {}", + self.subgraph_name, + self.endpoint.to_string() + ); + + let res_fut = self.http_client.request(req); + + let res = if let Some(timeout_duration) = connection_timeout { + tokio::time::timeout(timeout_duration, res_fut).await? + } else { + res_fut.await + }?; + + debug!( + "subscription connection to subgraph {} at {} established, status: {}", + self.subgraph_name, + self.endpoint.to_string(), + res.status() + ); + + if !res.status().is_success() { + return Err(SubgraphExecutorError::StreamStatusCodeNotOk(res.status())); + } + + let (parts, body_stream) = res.into_parts(); + + let content_type = parts + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let is_multipart = content_type.starts_with("multipart/mixed"); + let is_sse = content_type.starts_with("text/event-stream"); + + if !is_multipart && !is_sse { + return Err(SubgraphExecutorError::UnsupportedContentTypeError( + content_type.to_string(), + )); + } + + if is_multipart { + debug!( + subgraph_name = self.subgraph_name, + "using multipart HTTP for subscription", + ); + + let boundary = multipart_subscribe::parse_boundary_from_header(content_type) + .map_err(|e| SubgraphExecutorError::MultipartBoundaryParseFailure(e.to_string()))?; + let stream = multipart_subscribe::parse_to_stream(boundary, body_stream); + + Ok(Box::pin(async_stream::stream! { + trace!("multipart subscription stream started"); + for await result in stream { + match result { + Ok(response) => { + trace!(response = ?response, "multipart subscription event received"); + yield Ok(response); + } + Err(e) => { + yield Err(SubgraphExecutorError::MultipartStreamError(e.to_string())); + return; + } + } + } + })) + } else { + debug!( + "using SSE for subscription connection to subgraph {} at {}", + self.subgraph_name, + self.endpoint.to_string(), + ); + + let stream = sse::parse_to_stream(body_stream); + + Ok(Box::pin(async_stream::stream! { + trace!("SSE subscription stream started"); + for await result in stream { + match result { + Ok(response) => { + trace!(response = ?response, "SSE subscription event received"); + yield Ok(response); + } + Err(e) => { + yield Err(SubgraphExecutorError::SseStreamError(e.to_string())); + return; + } + } + } + })) + } + } } fn finish_capture_from_subgraph_result( @@ -511,25 +636,13 @@ pub struct SubgraphHttpResponse { impl SubgraphHttpResponse { fn deserialize_http_response<'a>(self) -> Result, SubgraphExecutorError> { - let bytes_ref: &[u8] = &self.body; - - // SAFETY: The byte slice `bytes_ref` is transmuted to have lifetime `'a`. - // This is safe because the returned `SubgraphResponse` contains a clone of `self.body` - // in its `bytes` field. `Bytes` is a reference-counted buffer, so this ensures the - // underlying data remains alive as long as the `SubgraphResponse` does. - // The `data` field of `SubgraphResponse` contains values that borrow from this buffer, - // creating a self-referential struct, which is why `unsafe` is required. - let bytes_ref: &'a [u8] = unsafe { std::mem::transmute(bytes_ref) }; - - sonic_rs::from_slice(bytes_ref) - .map_err(SubgraphExecutorError::ResponseDeserializationFailure) - .map(|mut resp: SubgraphResponse<'a>| { - // This is Arc - resp.headers = Some(self.headers); - // Zero cost of cloning Bytes - resp.bytes = Some(self.body); + SubgraphResponse::deserialize_from_bytes(self.body.clone()).map( + |mut resp: SubgraphResponse<'a>| { + // headers are under arc, zero cost clone + resp.headers = Some(self.headers.clone()); resp - }) + }, + ) } } diff --git a/lib/executor/src/executors/http_callback.rs b/lib/executor/src/executors/http_callback.rs new file mode 100644 index 000000000..ff3907c24 --- /dev/null +++ b/lib/executor/src/executors/http_callback.rs @@ -0,0 +1,283 @@ +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use bytes::Bytes; +use dashmap::DashMap; +use futures::stream::BoxStream; +use http::{HeaderMap, HeaderValue}; +use http_body_util::BodyExt; +use http_body_util::Full; +use hyper::Version; +use tokio::sync::mpsc; +use tracing::{debug, error, trace}; +use ulid::Ulid; + +use crate::executors::common::{SubgraphExecutionRequest, SubgraphExecutor}; +use crate::executors::error::SubgraphExecutorError; +use crate::executors::http::{build_request_body, HttpClient}; +use crate::plugin_context::PluginRequestState; +use crate::response::graphql_error::GraphQLError; +use crate::response::subgraph_response::SubgraphResponse; + +pub const CALLBACK_PROTOCOL_VERSION: &str = "callback/1.0"; +pub const SUBSCRIPTION_PROTOCOL_HEADER: &str = "subscription-protocol"; + +#[derive(Clone)] +pub struct CallbackSubscription { + pub verifier: String, + pub sender: mpsc::Sender, + // the subgraph sends an initial check before responding to the subscription POST, but under + // load this can be arbitrarily delayed. we track created_at so the enforcer can still evict + // subscriptions whose first check never arrives, using it as the reference point instead of + // last_heartbeat until the first check is recorded. + pub created_at: Instant, + // None until the first check is received. the enforcer measures elapsed time from created_at + // while this is None, and switches to this once the first check arrives. + pub last_heartbeat: Arc>>, +} + +impl CallbackSubscription { + pub fn record_heartbeat(&self) { + *self.last_heartbeat.lock().unwrap() = Some(Instant::now()); + } +} + +#[derive(Debug)] +pub enum CallbackMessage { + Next { payload: Bytes }, + Complete { errors: Option> }, +} + +pub type CallbackSubscriptionsMap = Arc>; + +struct CallbackSubscriptionGuard { + subscription_id: String, + callback_subscriptions: CallbackSubscriptionsMap, +} + +impl Drop for CallbackSubscriptionGuard { + fn drop(&mut self) { + self.callback_subscriptions.remove(&self.subscription_id); + trace!(subscription_id = %self.subscription_id, "HTTP callback subscription entry removed from active subscriptions"); + } +} + +pub struct HttpCallbackSubgraphExecutor { + pub subgraph_name: String, + pub endpoint: http::Uri, + pub http_client: Arc, + pub header_map: HeaderMap, + pub callback_base_url: String, + pub heartbeat_interval_ms: u64, + pub active_subscriptions: CallbackSubscriptionsMap, +} + +impl HttpCallbackSubgraphExecutor { + pub fn new( + subgraph_name: String, + endpoint: http::Uri, + http_client: Arc, + callback_base_url: String, + heartbeat_interval_ms: u64, + active_subscriptions: CallbackSubscriptionsMap, + ) -> Self { + let mut header_map = HeaderMap::new(); + header_map.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + header_map.insert( + http::header::CONNECTION, + HeaderValue::from_static("keep-alive"), + ); + header_map.insert( + http::header::ACCEPT, + HeaderValue::from_static("application/json;callbackSpec=1.0"), + ); + + Self { + subgraph_name, + endpoint, + http_client, + header_map, + callback_base_url, + heartbeat_interval_ms, + active_subscriptions, + } + } + + fn build_request_body( + &self, + execution_request: &mut SubgraphExecutionRequest<'_>, + subscription_id: &str, + verifier: &str, + ) -> Result, SubgraphExecutorError> { + let callback_url = format!( + "{}/{}", + self.callback_base_url.trim_end_matches('/'), + subscription_id + ); + let extensions = execution_request.extensions.get_or_insert_default(); + + let subscription_ext = sonic_rs::json!({ + "callbackUrl": callback_url, + "subscriptionId": subscription_id, + "verifier": verifier, + "heartbeatIntervalMs": self.heartbeat_interval_ms + }); + extensions.insert("subscription".to_string(), subscription_ext); + + build_request_body(execution_request) + } +} + +#[async_trait] +impl SubgraphExecutor for HttpCallbackSubgraphExecutor { + fn endpoint(&self) -> &http::Uri { + &self.endpoint + } + + #[tracing::instrument(level = "trace", skip_all, fields(subgraph_name = %self.subgraph_name))] + async fn execute<'a>( + &self, + _execution_request: SubgraphExecutionRequest<'a>, + _timeout: Option, + _plugin_req_state: Option<&'a PluginRequestState<'a>>, + ) -> Result, SubgraphExecutorError> { + Err(SubgraphExecutorError::HttpCallbackNoSingle) + } + + #[tracing::instrument(level = "trace", skip_all, fields(subgraph_name = %self.subgraph_name))] + async fn subscribe<'a>( + &self, + mut execution_request: SubgraphExecutionRequest<'a>, + timeout: Option, + ) -> Result< + BoxStream<'static, Result, SubgraphExecutorError>>, + SubgraphExecutorError, + > { + let subscription_id = Ulid::new().to_string(); + let verifier = Ulid::new().to_string(); + + let body = self.build_request_body(&mut execution_request, &subscription_id, &verifier)?; + + // all subscriptions emit events into the shared active subscriptions broadcaster + // which itself handles back-pressure by dropping old events when the buffer is full, + // so we can use a small buffer here + // TODO: do we thererefore need to buffer at all? + let (tx, mut rx) = mpsc::channel::(16); + + self.active_subscriptions.insert( + subscription_id.clone(), + CallbackSubscription { + verifier, + sender: tx, + created_at: Instant::now(), + last_heartbeat: Arc::new(Mutex::new(None)), + }, + ); + + // guard removes the entry from `active_subscriptions` when dropped + let guard = CallbackSubscriptionGuard { + subscription_id: subscription_id.clone(), + callback_subscriptions: self.active_subscriptions.clone(), + }; + + let mut req = hyper::Request::builder() + .method(http::Method::POST) + .uri(&self.endpoint) + .version(Version::HTTP_11) + .body(Full::new(Bytes::from(body))) + .map_err(SubgraphExecutorError::RequestBuildFailure)?; + + let mut headers = execution_request.headers; + self.header_map.iter().for_each(|(key, value)| { + headers.insert(key, value.clone()); + }); + *req.headers_mut() = headers; + + debug!( + subscription_id = %subscription_id, + "sending HTTP callback subscription request to subgraph {} at {}", + self.subgraph_name, + self.endpoint.to_string() + ); + + let req_fut = self.http_client.request(req); + let res = if let Some(timeout_duration) = timeout { + tokio::time::timeout(timeout_duration, req_fut) + .await? + .map_err(SubgraphExecutorError::RequestFailure)? + } else { + req_fut + .await + .map_err(SubgraphExecutorError::RequestFailure)? + }; + + debug!( + subscription_id = %subscription_id, + "HTTP callback subscription request to {} completed, status: {}", + self.endpoint.to_string(), + res.status() + ); + + if !res.status().is_success() { + let status = res.status(); + let (_, body) = res.into_parts(); + let body_bytes = body.collect().await.ok().map(|b| b.to_bytes()); + let body_str = body_bytes + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or("(no body)"); + error!( + subscription_id = %subscription_id, + status = %status, + body = body_str, + "HTTP callback subscription request failed with non-success status" + ); + return Err(SubgraphExecutorError::HttpCallbackStatusCodeNotOk(status)); + } + + Ok(Box::pin(async_stream::stream! { + // `guard` is held here; dropping the stream drops `guard`, removing the map entry. + let _guard = guard; + + trace!(subscription_id = %subscription_id, "HTTP callback subscription stream started"); + + while let Some(msg) = rx.recv().await { + match msg { + CallbackMessage::Next { payload } => { + trace!(subscription_id = %subscription_id, "received next payload"); + match SubgraphResponse::deserialize_from_bytes(payload) { + Ok(response) => yield Ok(response), + Err(e) => { + error!( + subscription_id = %subscription_id, + error = %e, + "failed to deserialize callback payload" + ); + yield Err(e); + break; + } + } + } + CallbackMessage::Complete { errors } => { + trace!(subscription_id = %subscription_id, "received complete"); + if let Some(errors) = errors { + if !errors.is_empty() { + yield Ok(SubgraphResponse { + errors: Some(errors), + ..Default::default() + }); + } + } + break; + } + } + } + + trace!(subscription_id = %subscription_id, "HTTP callback subscription stream ended"); + })) + } +} diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index 80a2257bf..56eef932f 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -5,9 +5,10 @@ use std::{ }; use dashmap::DashMap; +use futures::stream::BoxStream; use hive_router_config::{ - override_subgraph_urls::UrlOrExpression, traffic_shaping::DurationOrExpression, - HiveRouterConfig, + override_subgraph_urls::UrlOrExpression, subscriptions::SubscriptionProtocol, + traffic_shaping::DurationOrExpression, HiveRouterConfig, }; use hive_router_internal::expressions::vrl::core::Value as VrlValue; use hive_router_internal::expressions::{CompileExpression, DurationOrProgram, ExecutableProgram}; @@ -29,6 +30,8 @@ use crate::{ common::{SubgraphExecutionRequest, SubgraphExecutor, SubgraphExecutorBoxedArc}, error::SubgraphExecutorError, http::{HTTPSubgraphExecutor, HttpClient, SubgraphHttpResponse}, + http_callback::{CallbackSubscriptionsMap, HttpCallbackSubgraphExecutor}, + websocket::WsSubgraphExecutor, }, hooks::on_subgraph_execute::{ OnSubgraphExecuteEndHookPayload, OnSubgraphExecuteStartHookPayload, @@ -55,7 +58,8 @@ struct ResolvedSubgraphConfig<'a> { pub type InflightRequestsMap = InFlightMap; pub struct SubgraphExecutorMap { - executors_by_subgraph: ExecutorsBySubgraphMap, + http_executors_by_subgraph: ExecutorsBySubgraphMap, + subscription_executors_by_subgraph: ExecutorsBySubgraphMap, /// Mapping from subgraph name to static endpoint for quick lookup /// based on subgraph SDL and static overrides from router's config. static_endpoints_by_subgraph: StaticEndpointsBySubgraphMap, @@ -70,6 +74,8 @@ pub struct SubgraphExecutorMap { max_connections_per_host: usize, in_flight_requests: InflightRequestsMap, telemetry_context: Arc, + /// Shared map of active HTTP callback subscriptions + callback_subscriptions: CallbackSubscriptionsMap, } fn build_https_executor() -> Result, SubgraphExecutorError> { @@ -94,7 +100,8 @@ impl SubgraphExecutorMap { let max_connections_per_host = config.traffic_shaping.max_connections_per_host; Ok(SubgraphExecutorMap { - executors_by_subgraph: Default::default(), + http_executors_by_subgraph: Default::default(), + subscription_executors_by_subgraph: Default::default(), static_endpoints_by_subgraph: Default::default(), expression_endpoints_by_subgraph: Default::default(), config, @@ -105,6 +112,7 @@ impl SubgraphExecutorMap { timeouts_by_subgraph: Default::default(), global_timeout, telemetry_context, + callback_subscriptions: Arc::new(DashMap::new()), }) } @@ -112,6 +120,7 @@ impl SubgraphExecutorMap { subgraph_endpoint_map: &HashMap, config: Arc, telemetry_context: Arc, + active_callback_subscriptions: CallbackSubscriptionsMap, ) -> Result { let global_timeout = DurationOrProgram::compile( &config.traffic_shaping.all.request_timeout, @@ -122,6 +131,7 @@ impl SubgraphExecutorMap { })?; let mut subgraph_executor_map = SubgraphExecutorMap::new(config.clone(), global_timeout, telemetry_context)?; + subgraph_executor_map.callback_subscriptions = active_callback_subscriptions; for (subgraph_name, original_endpoint_str) in subgraph_endpoint_map.iter() { let endpoint_config = config @@ -139,31 +149,28 @@ impl SubgraphExecutorMap { }; subgraph_executor_map.register_static_endpoint(subgraph_name, &endpoint_str); - subgraph_executor_map.register_executor(subgraph_name, &endpoint_str)?; + subgraph_executor_map.register_executor(subgraph_name, &endpoint_str, false)?; subgraph_executor_map.register_subgraph_timeout(subgraph_name)?; } Ok(subgraph_executor_map) } + /// Returns the shared active callback subscriptions map for use by callback handlers. + pub fn callback_subscriptions(&self) -> CallbackSubscriptionsMap { + self.callback_subscriptions.clone() + } + pub async fn execute<'exec>( &self, subgraph_name: &'exec str, mut execution_request: SubgraphExecutionRequest<'exec>, client_request: &ClientRequestDetails<'exec>, - plugin_req_state: &'exec Option>, + plugin_req_state: Option<&'exec PluginRequestState<'exec>>, ) -> Result, SubgraphExecutorError> { - let mut executor = self.get_or_create_executor(subgraph_name, client_request)?; + let mut executor = self.get_or_create_http_executor(subgraph_name, client_request)?; - let timeout = self - .timeouts_by_subgraph - .get(subgraph_name) - .map(|t| { - let global_timeout_duration = - resolve_timeout(&self.global_timeout, client_request, None)?; - resolve_timeout(t.value(), client_request, Some(global_timeout_duration)) - }) - .transpose()?; + let timeout = self.resolve_subgraph_timeout(subgraph_name, client_request)?; let mut on_end_callbacks = vec![]; @@ -234,91 +241,106 @@ impl SubgraphExecutorMap { Ok(execution_result) } - /// Looks up a subgraph executor based on the subgraph name. - /// Looks for an expression first, falling back to a static endpoint. - /// If nothing is found, returns an error. - fn get_or_create_executor( + pub async fn subscribe<'exec>( + &self, + subgraph_name: &str, + execution_request: SubgraphExecutionRequest<'exec>, + client_request: &ClientRequestDetails<'exec>, + ) -> Result< + BoxStream<'static, Result, SubgraphExecutorError>>, + SubgraphExecutorError, + > { + let executor = self.get_or_create_subscription_executor(subgraph_name, client_request)?; + + let timeout = self.resolve_subgraph_timeout(subgraph_name, client_request)?; + + executor.subscribe(execution_request, timeout).await + } + + fn resolve_subgraph_timeout( &self, subgraph_name: &str, client_request: &ClientRequestDetails<'_>, - ) -> Result { - self.expression_endpoints_by_subgraph + ) -> Result, SubgraphExecutorError> { + self.timeouts_by_subgraph .get(subgraph_name) - .map(|expression| { - self.get_or_create_executor_from_expression( - subgraph_name, - expression, - client_request, - ) - }) - .unwrap_or_else(|| { - self.get_executor_from_static_endpoint(subgraph_name) - .ok_or(SubgraphExecutorError::StaticEndpointNotFound) + .map(|t| { + let global_timeout_duration = + resolve_timeout(&self.global_timeout, client_request, None)?; + resolve_timeout(t.value(), client_request, Some(global_timeout_duration)) }) + .transpose() } - /// Looks up a subgraph executor, - /// or creates one if a VRL expression is defined for the subgraph. - /// The expression is resolved to get the endpoint URL, - /// and a new executor is created and stored for future requests. - fn get_or_create_executor_from_expression( + fn resolve_endpoint( &self, subgraph_name: &str, - expression: &VrlProgram, client_request: &ClientRequestDetails<'_>, - ) -> Result { - let original_url_value = VrlValue::Bytes( + ) -> Result { + if let Some(expression) = self.expression_endpoints_by_subgraph.get(subgraph_name) { + let original_url_value = VrlValue::Bytes( + self.static_endpoints_by_subgraph + .get(subgraph_name) + .map(|endpoint| endpoint.value().clone()) + .ok_or_else(|| SubgraphExecutorError::StaticEndpointNotFound)? + .into(), + ); + + let value = VrlValue::Object(BTreeMap::from([ + ("request".into(), client_request.into()), + ("default".into(), original_url_value), + ])); + + let endpoint_result = expression.execute(value).map_err(|err| { + SubgraphExecutorError::EndpointExpressionResolutionFailure(err.to_string()) + })?; + + match endpoint_result.as_str() { + Some(s) => Ok(s.to_string()), + None => Err(SubgraphExecutorError::EndpointExpressionWrongType), + } + } else { self.static_endpoints_by_subgraph .get(subgraph_name) - .map(|endpoint| endpoint.value().clone()) - .ok_or_else(|| SubgraphExecutorError::StaticEndpointNotFound)? - .into(), - ); - - let value = VrlValue::Object(BTreeMap::from([ - ("request".into(), client_request.into()), - ("default".into(), original_url_value), - ])); - - // Resolve the expression to get an endpoint URL. - let endpoint_result = expression.execute(value).map_err(|err| { - SubgraphExecutorError::EndpointExpressionResolutionFailure(err.to_string()) - })?; + .map(|e| e.value().clone()) + .ok_or_else(|| SubgraphExecutorError::StaticEndpointNotFound) + } + } - let endpoint_str = match endpoint_result.as_str() { - Some(s) => Ok(s.to_string()), - None => Err(SubgraphExecutorError::EndpointExpressionWrongType), - }?; + fn get_or_create_http_executor( + &self, + subgraph_name: &str, + client_request: &ClientRequestDetails<'_>, + ) -> Result { + let endpoint_str = self.resolve_endpoint(subgraph_name, client_request)?; - // Check if an executor for this endpoint already exists. - if let Some(executor) = self.get_executor_from_endpoint(subgraph_name, &endpoint_str) { + if let Some(executor) = self + .http_executors_by_subgraph + .get(subgraph_name) + .and_then(|endpoints| endpoints.get(&endpoint_str).map(|e| e.clone())) + { return Ok(executor); } - // If not, create and register a new one. - self.register_executor(subgraph_name, &endpoint_str) + self.register_executor(subgraph_name, &endpoint_str, false) } - /// Looks up a subgraph executor based on a static endpoint URL. - fn get_executor_from_static_endpoint( + fn get_or_create_subscription_executor( &self, subgraph_name: &str, - ) -> Option { - let endpoint_ref = self.static_endpoints_by_subgraph.get(subgraph_name)?; - let endpoint_str = endpoint_ref.value(); - self.get_executor_from_endpoint(subgraph_name, endpoint_str) - } + client_request: &ClientRequestDetails<'_>, + ) -> Result { + let endpoint_str = self.resolve_endpoint(subgraph_name, client_request)?; - /// Looks up a subgraph executor for a given endpoint URL. - #[inline] - fn get_executor_from_endpoint( - &self, - subgraph_name: &str, - endpoint_str: &str, - ) -> Option { - self.executors_by_subgraph + if let Some(executor) = self + .subscription_executors_by_subgraph .get(subgraph_name) - .and_then(|endpoints| endpoints.get(endpoint_str).map(|e| e.clone())) + .and_then(|endpoints| endpoints.get(&endpoint_str).map(|e| e.clone())) + { + return Ok(executor); + } + + self.register_executor(subgraph_name, &endpoint_str, true) } /// Registers a new HTTP subgraph executor for the given subgraph name and endpoint URL. @@ -348,12 +370,15 @@ impl SubgraphExecutorMap { .insert(subgraph_name.to_string(), endpoint_str.to_string()); } - /// Registers a new HTTP subgraph executor for the given subgraph name and endpoint URL. - /// It makes it available for future requests. + /// Registers a subgraph executor for the given subgraph name and endpoint URL. + /// If `subscription_protocol` is Some, creates the appropriate executor for that protocol + /// and stores it in `subscription_executors_by_subgraph`. + /// If `subscription_protocol` is None, creates an HTTP executor and stores it in `http_executors_by_subgraph`. fn register_executor( &self, subgraph_name: &str, endpoint_str: &str, + for_subscription: bool, ) -> Result { let endpoint_uri = endpoint_str.parse::().map_err(|e| { SubgraphExecutorError::EndpointParseFailure(endpoint_str.to_string(), e) @@ -364,10 +389,9 @@ impl SubgraphExecutorMap { endpoint_uri.scheme_str().unwrap_or("http"), endpoint_uri.host().unwrap_or(""), endpoint_uri.port_u16().unwrap_or_else(|| { - if endpoint_uri.scheme_str() == Some("https") { - 443 - } else { - 80 + match endpoint_uri.scheme_str() { + Some("https") | Some("wss") => 443, + _ => 80, } }) ); @@ -378,27 +402,121 @@ impl SubgraphExecutorMap { .or_insert_with(|| Arc::new(Semaphore::new(self.max_connections_per_host))) .clone(); - let subgraph_config = self.resolve_subgraph_config(subgraph_name)?; - - let executor = HTTPSubgraphExecutor::new( - subgraph_name.to_string(), - endpoint_uri, - subgraph_config.client, - semaphore, - subgraph_config.dedupe_enabled, - self.in_flight_requests.clone(), - self.telemetry_context.clone(), - self.config.clone(), - ); + let protocol = if for_subscription { + self.config + .subscriptions + .get_protocol_for_subgraph(subgraph_name) + } else { + SubscriptionProtocol::HTTP + }; + + match protocol { + SubscriptionProtocol::HTTP => { + let subgraph_config = self.resolve_subgraph_config(subgraph_name)?; + + let http_executor = HTTPSubgraphExecutor::new( + subgraph_name.to_string(), + endpoint_uri, + subgraph_config.client, + semaphore, + subgraph_config.dedupe_enabled, + self.in_flight_requests.clone(), + self.telemetry_context.clone(), + self.config.clone(), + ) + .to_boxed_arc(); - let executor_arc = executor.to_boxed_arc(); + self.http_executors_by_subgraph + .entry(subgraph_name.to_string()) + .or_default() + .insert(endpoint_str.to_string(), http_executor.clone()); - self.executors_by_subgraph - .entry(subgraph_name.to_string()) - .or_default() - .insert(endpoint_str.to_string(), executor_arc.clone()); + Ok(http_executor) + } + SubscriptionProtocol::WebSocket => { + let ws_scheme = match endpoint_uri.scheme_str() { + Some("https") => "wss", + _ => "ws", + }; + + // take the path from the subscription config or use the one from the endpoint + let path_and_query = self + .config + .subscriptions + .get_websocket_path(subgraph_name) + .or_else(|| endpoint_uri.path_and_query().map(|pq| pq.as_str())) + // fallback to default if neither is set, but this should never happen + .unwrap_or_default(); + + // build the final WebSocket URI + let ws_endpoint_uri = Uri::builder() + .scheme(ws_scheme) + .authority( + endpoint_uri + .authority() + .map(|a| a.as_str()) + .unwrap_or_default(), + ) + .path_and_query(path_and_query) + .build() + .map_err(|e| { + SubgraphExecutorError::WebSocketEndpointBuildFailure( + format!( + "{}://{}{}", + ws_scheme, + endpoint_uri + .authority() + .map(|a| a.as_str()) + .unwrap_or_default(), + path_and_query + ), + e, + ) + })?; + + let ws_executor = WsSubgraphExecutor::new( + subgraph_name.to_string(), + // we use the new constructed ws_endpoint_uri here + ws_endpoint_uri, + ) + .to_boxed_arc(); - Ok(executor_arc) + self.subscription_executors_by_subgraph + .entry(subgraph_name.to_string()) + .or_default() + // we store the original endpoint_str as the key for faster lookups + .insert(endpoint_str.to_string(), ws_executor.clone()); + + Ok(ws_executor) + } + SubscriptionProtocol::HTTPCallback => { + let callback_config = self + .config + .subscriptions + .callback + .as_ref() + .ok_or_else(|| SubgraphExecutorError::HttpCallbackNotConfigured)?; + + let heartbeat_interval_ms = callback_config.heartbeat_interval.as_millis() as u64; + + let callback_executor = HttpCallbackSubgraphExecutor::new( + subgraph_name.to_string(), + endpoint_uri, + self.client.clone(), + callback_config.public_url.to_string(), + heartbeat_interval_ms, + self.callback_subscriptions.clone(), + ) + .to_boxed_arc(); + + self.subscription_executors_by_subgraph + .entry(subgraph_name.to_string()) + .or_default() + .insert(endpoint_str.to_string(), callback_executor.clone()); + + Ok(callback_executor) + } + } } /// Resolves traffic shaping configuration for a specific subgraph, applying subgraph-specific diff --git a/lib/executor/src/executors/mod.rs b/lib/executor/src/executors/mod.rs index 520ff5f94..283e331e3 100644 --- a/lib/executor/src/executors/mod.rs +++ b/lib/executor/src/executors/mod.rs @@ -1,5 +1,12 @@ pub mod common; pub mod dedupe; pub mod error; +pub mod graphql_transport_ws; pub mod http; +pub mod http_callback; pub mod map; +pub mod multipart_subscribe; +pub mod sse; +pub mod websocket; +pub mod websocket_client; +pub mod websocket_common; diff --git a/lib/executor/src/executors/multipart_subscribe.rs b/lib/executor/src/executors/multipart_subscribe.rs new file mode 100644 index 000000000..2d635ee7f --- /dev/null +++ b/lib/executor/src/executors/multipart_subscribe.rs @@ -0,0 +1,627 @@ +use std::str::Utf8Error; + +use bytes::{Buf, Bytes}; +use futures::stream::BoxStream; +use http_body_util::BodyExt; +use hyper::body::Body; + +use crate::{ + executors::error::SubgraphExecutorError, response::subgraph_response::SubgraphResponse, +}; + +// subgraphs are internal and generally safe, but this limit exists as defense-in-depth +// to prevent a misbehaving subgraph from growing the buffer unboundedly until OOM +const MAX_BUFFER_SIZE: usize = 10 * 1024 * 1024; // 10MB + +#[derive(thiserror::Error, Debug)] +pub enum ParseError { + #[error("Invalid UTF-8 sequence: {0}")] + InvalidUtf8(#[from] Utf8Error), + #[error("Stream read error: {0}")] + StreamReadError(String), + #[error("Invalid subgraph response: {0}")] + InvalidSubgraphResponse(SubgraphExecutorError), + #[error("Missing boundary parameter in Content-Type header")] + MissingBoundary, + #[error("Invalid boundary parameter: {0}")] + InvalidBoundary(String), + #[error("Buffer size limit exceeded: stream sent more than {MAX_BUFFER_SIZE} bytes without a part boundary")] + BufferSizeLimitExceeded, +} + +/// Parse the boundary parameter from a Content-Type header value. +/// +/// Example: `multipart/mixed; boundary=graphql` returns `Ok("graphql")` +/// +/// Returns an error if: +/// - The boundary parameter is missing (boundary is required by the spec) +/// - The boundary value is empty (boundary is required by the spec) +/// - The quoted boundary is not properly closed +pub fn parse_boundary_from_header(content_type: &str) -> Result<&str, ParseError> { + let content_type = content_type.trim(); + + let content_type_lower = content_type.to_lowercase(); + let boundary_param_start = content_type_lower + .find("boundary=") + .ok_or(ParseError::MissingBoundary)?; + let value_start = boundary_param_start + "boundary=".len(); + + let remaining = &content_type[value_start..]; + + let boundary = if let Some(inside) = remaining.strip_prefix('"') { + // quoted boundary: find the closing quote + let quote_end = inside + .find('"') + .ok_or_else(|| ParseError::InvalidBoundary("Unclosed quoted boundary".to_string()))?; + &remaining[1..quote_end + 1] + } else { + // unquoted boundary: value extends until semicolon, whitespace, or end of string + let end = remaining + .find(|c: char| c == ';' || c.is_whitespace()) + .unwrap_or(remaining.len()); + &remaining[..end] + }; + + // boundary most not empty + if boundary.is_empty() { + return Err(ParseError::InvalidBoundary( + "Empty boundary value".to_string(), + )); + } + + Ok(boundary) +} + +pub fn parse_to_stream( + boundary: &str, + body_stream: B, +) -> BoxStream<'static, Result, ParseError>> +where + B: Body + Send + Unpin + 'static, + B::Data: Buf + Send, + B::Error: std::fmt::Display + Send, +{ + let delimiter = format!("--{}", boundary); + let end_marker = format!("--{}--", boundary); + + let stream = async_stream::stream! { + let mut body = body_stream; + let mut buffer = Vec::::new(); + let mut started = false; + + loop { + while let Some((part_end, skip_len, is_end)) = find_next_part(&buffer, &delimiter, &end_marker, started) { + if !started { + buffer.drain(..skip_len); + started = true; + continue; + } + + let part_bytes: Vec = buffer.drain(..part_end).collect(); + buffer.drain(..skip_len); + + if !part_bytes.is_empty() { + match parse_part(&part_bytes) { + Ok(Some(response)) => { + yield Ok(response); + } + Ok(None) => {} + Err(e) => { + yield Err(e); + return; + } + } + } + + if is_end { + return; + } + } + + match body.frame().await { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + buffer.extend_from_slice(data.chunk()); + if buffer.len() > MAX_BUFFER_SIZE { + yield Err(ParseError::BufferSizeLimitExceeded); + return; + } + } + } + Some(Err(e)) => { + yield Err(ParseError::StreamReadError(e.to_string())); + return; + } + None => { + return; + } + } + } + }; + + Box::pin(stream) +} + +fn find_next_part( + buffer: &[u8], + delimiter: &str, + end_marker: &str, + started: bool, +) -> Option<(usize, usize, bool)> { + let delimiter_b = delimiter.as_bytes(); + let end_marker_b = end_marker.as_bytes(); + + let find_bytes = |haystack: &[u8], needle: &[u8]| -> Option { + haystack.windows(needle.len()).position(|w| w == needle) + }; + + if !started { + let pos = find_bytes(buffer, delimiter_b)?; + let after_delimiter = pos + delimiter_b.len(); + let newline_pos = buffer[after_delimiter..].iter().position(|&b| b == b'\n')?; + let skip_len = after_delimiter + newline_pos + 1; + return Some((0, skip_len, false)); + } + + let next_delimiter_pos = find_bytes(buffer, delimiter_b)?; + let is_end = buffer[next_delimiter_pos..].starts_with(end_marker_b); + + let skip_len = if is_end { + let after_end = next_delimiter_pos + end_marker_b.len(); + if let Some(newline) = buffer[after_end..].iter().position(|&b| b == b'\n') { + end_marker_b.len() + newline + 1 + } else { + end_marker_b.len() + } + } else { + let after_delimiter = next_delimiter_pos + delimiter_b.len(); + if let Some(newline) = buffer[after_delimiter..].iter().position(|&b| b == b'\n') { + delimiter_b.len() + newline + 1 + } else { + delimiter_b.len() + } + }; + + Some((next_delimiter_pos, skip_len, is_end)) +} + +fn parse_part(raw: &[u8]) -> Result>, ParseError> { + let text = std::str::from_utf8(raw)?; + let body = extract_body_after_headers(text); + + if body.is_empty() { + return Ok(None); + } + + extract_payload(body) +} + +fn extract_body_after_headers(content: &str) -> &str { + if let Some(pos) = content.find("\r\n\r\n") { + content[pos + 4..].trim() + } else if let Some(pos) = content.find("\n\n") { + content[pos + 2..].trim() + } else { + content.trim() + } +} + +fn extract_payload(body: &str) -> Result>, ParseError> { + // cheap heartbeat check: subgraphs send `{}` as a keep-alive ping + if body == "{}" { + return Ok(None); + } + + let payload_lv = sonic_rs::get_from_str(body, &["payload"]); + + match payload_lv { + Ok(lv) => { + let raw = lv.as_raw_str(); + + if raw == "null" { + // transport error: payload is null, check for top-level errors + if let Ok(errors_lv) = sonic_rs::get_from_str(body, &["errors"]) { + let transport_err = format!(r#"{{"errors":{}}}"#, errors_lv.as_raw_str()); + return SubgraphResponse::deserialize_from_bytes(Bytes::from(transport_err)) + .map_err(ParseError::InvalidSubgraphResponse) + .map(Some); + } + return Ok(None); + } + + // happy path: deserialize the raw payload substring directly - no re-serialization + SubgraphResponse::deserialize_from_bytes(Bytes::copy_from_slice(raw.as_bytes())) + .map_err(ParseError::InvalidSubgraphResponse) + .map(Some) + } + Err(e) if e.is_not_found() => { + // no payload wrapper, treat the whole body as a subgraph response + SubgraphResponse::deserialize_from_bytes(Bytes::from(body.to_owned())) + .map_err(ParseError::InvalidSubgraphResponse) + .map(Some) + } + Err(e) => Err(ParseError::InvalidSubgraphResponse( + SubgraphExecutorError::ResponseDeserializationFailure(e), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + + #[test] + fn test_parse_boundary_from_header_simple() { + let content_type = "multipart/mixed; boundary=graphql"; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql"); + } + + #[test] + fn test_parse_boundary_from_header_quoted() { + let content_type = r#"multipart/mixed; boundary="my-boundary""#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "my-boundary"); + } + + #[test] + fn test_parse_boundary_from_header_with_spaces() { + let content_type = "multipart/mixed; boundary=graphql"; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql"); + } + + #[test] + fn test_parse_boundary_from_header_with_additional_params() { + let content_type = r#"multipart/mixed; boundary=graphql; charset=utf-8"#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql"); + } + + #[test] + fn test_parse_boundary_from_header_quoted_with_additional_params() { + let content_type = r#"multipart/mixed; boundary="my-boundary"; charset=utf-8"#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "my-boundary"); + } + + #[test] + fn test_parse_boundary_from_header_complex_boundary() { + let content_type = "multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW"); + } + + #[test] + fn test_parse_boundary_from_header_with_subscription_spec() { + let content_type = r#"multipart/mixed; boundary=graphql; subscriptionSpec="1.0""#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql"); + } + + #[test] + fn test_parse_boundary_from_header_quoted_special_chars() { + let content_type = + r#"multipart/mixed; boundary="boundary-with-dashes_and_underscores.123""#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "boundary-with-dashes_and_underscores.123"); + } + + #[test] + fn test_parse_boundary_from_header_no_boundary() { + let content_type = "multipart/mixed"; + let boundary = parse_boundary_from_header(content_type); + assert!(matches!(boundary, Err(ParseError::MissingBoundary))); + } + + #[test] + fn test_parse_boundary_from_header_empty_string() { + let content_type = ""; + let boundary = parse_boundary_from_header(content_type); + assert!(matches!(boundary, Err(ParseError::MissingBoundary))); + } + + #[test] + fn test_parse_boundary_from_header_case_insensitive() { + let cases = [ + "multipart/mixed; Boundary=graphql", + "multipart/mixed; BOUNDARY=graphql", + "multipart/mixed; boundary=graphql", + "multipart/mixed; bOuNdArY=graphql", + ]; + for content_type in cases { + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql", "failed for: {content_type}"); + } + } + + #[test] + fn test_parse_boundary_from_header_whitespace_in_boundary() { + let content_type = "multipart/mixed; boundary=my boundary"; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "my"); + } + + #[test] + fn test_parse_boundary_from_header_quoted_with_whitespace() { + let content_type = r#"multipart/mixed; boundary="my boundary""#; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "my boundary"); + } + + #[test] + fn test_parse_boundary_from_header_dash_boundary() { + let content_type = "multipart/mixed; boundary=-"; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "-"); + } + + #[test] + fn test_parse_boundary_from_header_trailing_whitespace() { + let content_type = "multipart/mixed; boundary=graphql "; + let boundary = parse_boundary_from_header(content_type).unwrap(); + assert_eq!(boundary, "graphql"); + } + + #[test] + fn test_parse_boundary_from_header_empty_value() { + let content_type = "multipart/mixed; boundary="; + let boundary = parse_boundary_from_header(content_type); + assert!(matches!(boundary, Err(ParseError::InvalidBoundary(_)))); + } + + #[test] + fn test_parse_boundary_from_header_empty_quoted_value() { + let content_type = r#"multipart/mixed; boundary="""#; + let boundary = parse_boundary_from_header(content_type); + assert!(matches!(boundary, Err(ParseError::InvalidBoundary(_)))); + } + + #[test] + fn test_parse_boundary_from_header_unclosed_quote() { + let content_type = r#"multipart/mixed; boundary="graphql"#; + let boundary = parse_boundary_from_header(content_type); + assert!(matches!(boundary, Err(ParseError::InvalidBoundary(_)))); + } + + #[test] + fn test_parse_part_with_headers() { + let part_data = + b"Content-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"reviewAdded\":{\"id\":\"1\"}}}}"; + + let response = parse_part(part_data) + .expect("Should parse valid part") + .expect("Should have response"); + + assert!(!response.data.is_null()); + } + + #[test] + fn test_parse_part_without_headers() { + let part_data = b"\r\n{\"payload\":{\"data\":{\"value\":1}}}"; + + let response = parse_part(part_data) + .expect("Should parse part without headers") + .expect("Should have response"); + + assert!(!response.data.is_null()); + } + + #[test] + fn test_extract_payload_with_payload_property() { + let body = r#"{"payload":{"data":{"reviewAdded":{"id":"1"}}}}"#; + + let response = extract_payload(body) + .expect("Should extract payload") + .expect("Should have response"); + + assert!(!response.data.is_null()); + } + + #[test] + fn test_extract_payload_without_payload_property() { + let body = r#"{"data":{"user":{"name":"Alice"}}}"#; + + let response = extract_payload(body) + .expect("Should extract payload") + .expect("Should have response"); + + assert!(!response.data.is_null()); + } + + #[test] + fn test_extract_payload_heartbeat() { + let body = "{}"; + + let payload = extract_payload(body).expect("Should parse heartbeat"); + + assert!(payload.is_none(), "Heartbeat should return None"); + } + + #[test] + fn test_extract_payload_transport_error() { + let body = r#"{"payload":null,"errors":[{"message":"Connection lost"}]}"#; + + let response = extract_payload(body) + .expect("Should extract error") + .expect("Should have error response"); + + assert!(response.errors.is_some()); + let errors = response.errors.unwrap(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Connection lost"); + } + + #[tokio::test] + async fn test_parse_to_stream_single_event() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![Ok(Frame::data( + Bytes::from( + "--graphql\r\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"test\":1}}}\r\n--graphql--\r\n", + ), + ))]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("graphql", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_chunked_events() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![ + Ok(Frame::data(Bytes::from( + "--graphql\r\nContent-Type: application/json\r\n\r\n{\"pay", + ))), + Ok(Frame::data(Bytes::from( + "load\":{\"data\":{\"value\":1}}}\r\n--graphql\r\n", + ))), + Ok(Frame::data(Bytes::from( + "Content-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"value\":2}}}\r\n--graphql--\r\n", + ))), + ]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("graphql", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_some()); + let second_result = second.unwrap(); + assert!(second_result.is_ok()); + let response = second_result.unwrap(); + assert!(!response.data.is_null()); + + let third = stream.next().await; + assert!(third.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_with_heartbeat() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![Ok(Frame::data( + Bytes::from( + "--graphql\r\nContent-Type: application/json\r\n\r\n{}\r\n--graphql\r\nContent-Type: application/json\r\n\r\n{\"payload\":{\"data\":{\"test\":1}}}\r\n--graphql--\r\n", + ), + ))]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("graphql", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_transport_error() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![Ok(Frame::data( + Bytes::from( + "--graphql\r\nContent-Type: application/json\r\n\r\n{\"payload\":null,\"errors\":[{\"message\":\"Connection lost\"}]}\r\n--graphql--\r\n", + ), + ))]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("graphql", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(response.errors.is_some()); + let errors = response.errors.unwrap(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Connection lost"); + + let second = stream.next().await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_custom_boundary() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![Ok(Frame::data( + Bytes::from( + "--myboundary\r\nContent-Type: application/json\r\n\r\n{\"data\":{\"test\":1}}\r\n--myboundary--\r\n", + ), + ))]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("myboundary", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_without_payload_wrapper() { + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![Ok(Frame::data( + Bytes::from( + "--boundary\r\nContent-Type: application/json\r\n\r\n{\"data\":{\"user\":\"Alice\"}}\r\n--boundary--\r\n", + ), + ))]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream("boundary", body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } +} diff --git a/lib/executor/src/executors/sse.rs b/lib/executor/src/executors/sse.rs new file mode 100644 index 000000000..0454e9efc --- /dev/null +++ b/lib/executor/src/executors/sse.rs @@ -0,0 +1,381 @@ +use bytes::{Buf, Bytes}; +use futures::stream::BoxStream; +use http_body_util::BodyExt; +use hyper::body::Body; + +use crate::{ + executors::error::SubgraphExecutorError, response::subgraph_response::SubgraphResponse, +}; + +// subgraphs are internal and generally safe, but this limit exists as defense-in-depth +// to prevent a misbehaving subgraph from growing the buffer unboundedly until OOM +const MAX_BUFFER_SIZE: usize = 10 * 1024 * 1024; // 10MB + +#[derive(thiserror::Error, Debug)] +pub enum ParseError { + #[error("Invalid UTF-8 sequence: {0}")] + InvalidUtf8(String), + #[error("Stream read error: {0}")] + StreamReadError(String), + #[error("Invalid subgraph response: {0}")] + InvalidSubgraphResponse(SubgraphExecutorError), + #[error("Buffer size limit exceeded: stream sent more than {MAX_BUFFER_SIZE} bytes without an event boundary")] + BufferSizeLimitExceeded, +} + +pub fn parse_to_stream( + body_stream: B, +) -> BoxStream<'static, Result, ParseError>> +where + B: Body + Send + Unpin + 'static, + B::Data: Buf + Send, + B::Error: std::fmt::Display + Send, +{ + let stream = async_stream::stream! { + let mut body = body_stream; + let mut buffer = Vec::::new(); + loop { + while let Some(boundary) = find_sse_event_boundary(&buffer) { + let event_bytes: Vec = buffer.drain(..boundary).collect(); + + match parse(&event_bytes) { + Ok(Some(sse_event)) => { + match sse_event.event.as_deref() { + Some("next") if !sse_event.data.is_empty() => { + match SubgraphResponse::deserialize_from_bytes(Bytes::from(sse_event.data.clone())) { + Ok(response) => { + yield Ok(response); + } + Err(e) => { + yield Err(ParseError::InvalidSubgraphResponse(e)); + return; + } + } + } + Some("complete") => { + return; + } + _ => { + // ping + } + } + } + Err(e) => { + yield Err(e); + return; + } + _ => {} + } + } + + match body.frame().await { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + buffer.extend_from_slice(data.chunk()); + if buffer.len() > MAX_BUFFER_SIZE { + yield Err(ParseError::BufferSizeLimitExceeded); + return; + } + } + } + Some(Err(e)) => { + yield Err(ParseError::StreamReadError(e.to_string())); + return; + } + None => { + return; + } + } + } + }; + + Box::pin(stream) +} + +#[derive(Debug)] +struct SubgraphSseEvent { + pub event: Option, + pub data: String, +} + +/// find the boundary of a complete event in the stream, the '\n\n' or '\r\n\r\n' +fn find_sse_event_boundary(buffer: &[u8]) -> Option { + for i in 0..buffer.len().saturating_sub(1) { + if buffer[i] == b'\r' + && i + 3 < buffer.len() + && buffer[i + 1] == b'\n' + && buffer[i + 2] == b'\r' + && buffer[i + 3] == b'\n' + { + return Some(i + 4); + } + if buffer[i] == b'\n' && buffer[i + 1] == b'\n' { + return Some(i + 2); + } + } + None +} + +// returns at most one event because the caller always passes exactly one event's bytes +// (drained up to the \n\n boundary by find_sse_event_boundary) +fn parse(raw: &[u8]) -> Result, ParseError> { + let text = std::str::from_utf8(raw).map_err(|e| ParseError::InvalidUtf8(e.to_string()))?; + + let mut current_event: Option = None; + let mut current_data_lines: Vec = Vec::new(); + + for line in text.lines() { + if line.is_empty() { + if current_event.is_some() || !current_data_lines.is_empty() { + return Ok(Some(SubgraphSseEvent { + event: current_event, + data: current_data_lines.join("\n"), + })); + } + continue; + } + + if line.starts_with(':') { + // heartbeat + continue; + } + + if let Some(colon_pos) = line.find(':') { + let field = &line[..colon_pos]; + let value = &line[colon_pos + 1..]; + + let value = value.trim(); + + match field { + "event" => { + current_event = Some(value.to_string()); + } + "data" => { + current_data_lines.push(value.to_string()); + } + _ => { + // ignore unknown fields as per SSE spec + } + } + } + } + + // handle any remaining event that wasn't terminated with empty line(s) + if current_event.is_some() || !current_data_lines.is_empty() { + return Ok(Some(SubgraphSseEvent { + event: current_event, + data: current_data_lines.join("\n"), + })); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_single_event_with_data() { + let sse_data = br#"event: next +data: some data + +"#; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "some data"); + } + + #[test] + fn test_parse_event_without_explicit_type() { + let sse_data = b"data: some data\n\n"; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, None); + assert_eq!(event.data, "some data"); + } + + #[test] + fn test_parse_just_event() { + let sse_data = b"event: complete\n\n"; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("complete".to_string())); + assert_eq!(event.data, ""); + } + + #[test] + fn test_parse_multiple_events() { + let sse_data = br#"event: next +data: value 1 + +event: next +data: value 2 + +event: complete + +"#; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "value 1"); + } + + #[test] + fn test_parse_multiline_data() { + // SSE spec allows data to be split across multiple "data:" lines + // even though we'll not often see this in practice, good to cover + let sse_data = b"event: next\ndata: line1\ndata: line2\n\n"; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "line1\nline2"); + } + + #[test] + fn test_parse_no_double_newline() { + // even though we'll never see this in practice + let sse_data = b"event: next\ndata: line0"; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "line0"); + } + + #[test] + fn test_parse_heartbeat() { + let sse_data = b":\n\nevent: next\ndata: payload\n\n:\n\n"; + + let event = parse(sse_data).expect("Should parse valid SSE"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "payload"); + } + + #[test] + fn test_parse_empty_input() { + let sse_data = b""; + + let event = parse(sse_data).expect("Should handle empty input"); + + assert!(event.is_none()); + } + + #[test] + fn test_find_sse_event_boundary_crlf() { + let buffer = b"event: next\r\ndata: hello\r\n\r\n"; + let boundary = find_sse_event_boundary(buffer); + assert_eq!(boundary, Some(buffer.len())); + } + + #[test] + fn test_find_sse_event_boundary_lf() { + let buffer = b"event: next\ndata: hello\n\n"; + let boundary = find_sse_event_boundary(buffer); + assert_eq!(boundary, Some(buffer.len())); + } + + #[test] + fn test_parse_single_event_crlf() { + let sse_data = b"event: next\r\ndata: some data\r\n\r\n"; + + let event = parse(sse_data).expect("Should parse valid SSE with CRLF"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("next".to_string())); + assert_eq!(event.data, "some data"); + } + + #[test] + fn test_parse_just_event_crlf() { + let sse_data = b"event: complete\r\n\r\n"; + + let event = parse(sse_data).expect("Should parse valid SSE with CRLF"); + + assert!(event.is_some()); + let event = event.unwrap(); + assert_eq!(event.event, Some("complete".to_string())); + assert_eq!(event.data, ""); + } + + #[tokio::test] + async fn test_parse_to_stream_chunked_events_crlf() { + use bytes::Bytes; + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + let chunks: Vec, std::convert::Infallible>> = vec![ + Ok(Frame::data(Bytes::from( + "event: next\r\ndata: {\"data\":{\"hello\":\"wor", + ))), + Ok(Frame::data(Bytes::from("ld\"}}\r\n\r\neve"))), + Ok(Frame::data(Bytes::from("nt: complete\r\n\r\n"))), + ]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream(body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_parse_to_stream_chunked_events() { + use bytes::Bytes; + use futures::StreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + // chunked delivery where events are split across multiple chunks + let chunks: Vec, std::convert::Infallible>> = vec![ + Ok(Frame::data(Bytes::from( + "event: next\ndata: {\"data\":{\"hello\":\"wor", + ))), + Ok(Frame::data(Bytes::from("ld\"}}\n\neve"))), + Ok(Frame::data(Bytes::from("nt: complete\n\n"))), + ]; + + let body = StreamBody::new(futures::stream::iter(chunks)); + let mut stream = parse_to_stream(body); + + let first = stream.next().await; + assert!(first.is_some()); + let first_result = first.unwrap(); + assert!(first_result.is_ok()); + let response = first_result.unwrap(); + assert!(!response.data.is_null()); + + let second = stream.next().await; + assert!(second.is_none()); + } +} diff --git a/lib/executor/src/executors/websocket.rs b/lib/executor/src/executors/websocket.rs new file mode 100644 index 000000000..f7fcef09b --- /dev/null +++ b/lib/executor/src/executors/websocket.rs @@ -0,0 +1,196 @@ +use std::time::Duration; + +use async_trait::async_trait; +use futures::channel::oneshot; +use futures::stream::BoxStream; +use futures_util::StreamExt; +use ntex::rt; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use crate::executors::common::{SubgraphExecutionRequest, SubgraphExecutor}; +use crate::executors::error::SubgraphExecutorError; +use crate::executors::graphql_transport_ws::build_subscribe_payload; +use crate::executors::websocket_client::{connect, WsClient}; +use crate::response::subgraph_response::SubgraphResponse; + +pub struct WsSubgraphExecutor { + subgraph_name: String, + endpoint: http::Uri, +} + +impl WsSubgraphExecutor { + pub fn new(subgraph_name: String, endpoint: http::Uri) -> Self { + Self { + subgraph_name, + endpoint, + } + } +} + +#[async_trait] +impl SubgraphExecutor for WsSubgraphExecutor { + fn endpoint(&self) -> &http::Uri { + &self.endpoint + } + + async fn execute<'a>( + &self, + execution_request: SubgraphExecutionRequest<'a>, + _timeout: Option, + _plugin_req_state: Option<&'a crate::plugin_context::PluginRequestState<'a>>, + ) -> Result, SubgraphExecutorError> { + let endpoint = self.endpoint.clone(); + let subgraph_name = self.subgraph_name.clone(); + debug!( + "establishing WebSocket connection to subgraph {} at {}", + subgraph_name, endpoint + ); + + let (subscribe_payload, init_payload) = build_subscribe_payload(execution_request); + + let (tx, rx) = oneshot::channel(); + + // run this on ntex runtime instead of Handle::spawn because the websocket path builds + // and awaits futures that capture ntex local types like Rc and RefCell via WsClient. + // those futures are not Send, so they cannot cross a tokio multi-threaded spawn boundary. + // ntex::rt::spawn keeps the whole websocket flow on the local ntex runtime, while this + // async_trait method still stays Send by awaiting only the futures oneshot receiver here. + // this task ends after the first websocket response is forwarded through the oneshot, + // or earlier if connect/init fails. + rt::spawn(async move { + let result = async { + let connection = match connect(&endpoint).await { + Ok(conn) => conn, + Err(e) => { + return Err(SubgraphExecutorError::WebSocketConnectFailure( + endpoint.to_string(), + e.to_string(), + )); + } + }; + + let mut client = match WsClient::init(connection, init_payload).await { + Ok(client) => client, + Err(e) => { + return Err(SubgraphExecutorError::WebSocketHandshakeFailure( + endpoint.to_string(), + e.to_string(), + )); + } + }; + + debug!( + "WebSocket connection to subgraph {} at {} established", + subgraph_name, endpoint + ); + + let mut stream = client.subscribe(subscribe_payload).await; + + match stream.next().await { + Some(response) => Ok(response), + None => Err(SubgraphExecutorError::WebSocketStreamClosedEmpty( + endpoint.to_string(), + )), + } + } + .await; + + let _ = tx.send(result); + }); + + rx.await + .map_err(|_| SubgraphExecutorError::WebSocketArbiterChannelClosed)? + } + + async fn subscribe<'a>( + &self, + execution_request: SubgraphExecutionRequest<'a>, + _timeout: Option, + ) -> Result< + BoxStream<'static, Result, SubgraphExecutorError>>, + SubgraphExecutorError, + > { + // all subscriptions emit events into the shared active subscriptions broadcaster + // which itself handles back-pressure by dropping old events when the buffer is full, + // so we can use a small buffer here + // TODO: do we thererefore need to buffer at all? + let (tx, mut rx) = + mpsc::channel::, SubgraphExecutorError>>(16); + + let endpoint = self.endpoint.clone(); + let subgraph_name = self.subgraph_name.clone(); + + let (subscribe_payload, init_payload) = build_subscribe_payload(execution_request); + + debug!( + "establishing WebSocket subscription connection to subgraph {} at {}", + self.subgraph_name, self.endpoint + ); + + // no await intentionally. the task runs the subscription in the background + // and sends responses through the channel. The spawned future itself stays local + // to ntex runtime, so it can hold non-Send websocket client state. + // this task ends when the websocket stream completes, the client drops the receiver, + // or back-pressure fills the channel and we terminate the subscription. + drop(rt::spawn(async move { + let connection = match connect(&endpoint).await { + Ok(conn) => conn, + Err(e) => { + let _ = tx.try_send(Err(SubgraphExecutorError::WebSocketConnectFailure( + endpoint.to_string(), + e.to_string(), + ))); + return; + } + }; + + let mut client = match WsClient::init(connection, init_payload).await { + Ok(client) => client, + Err(e) => { + let _ = tx.try_send(Err(SubgraphExecutorError::WebSocketHandshakeFailure( + endpoint.to_string(), + e.to_string(), + ))); + return; + } + }; + + debug!( + "WebSocket subscription connection to subgraph {} at {} established", + subgraph_name, endpoint + ); + + let mut stream = client.subscribe(subscribe_payload).await; + + while let Some(response) = stream.next().await { + match tx.try_send(Ok(response)) { + Ok(()) => (), + Err(mpsc::error::TrySendError::Full(_)) => { + // if the channel is full it means the consuming client is too slow and unable to keep + // up. we terminate the subscription without an error message because it anyways cant + // go through + warn!( + "Client for subgraph {} at {} subscriptions is too slow", + subgraph_name, endpoint + ); + break; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!( + "Client for subgraph {} at {} dropped the receiver", + subgraph_name, endpoint + ); + break; + } + } + } + })); + + Ok(Box::pin(async_stream::stream! { + while let Some(response) = rx.recv().await { + yield response; + } + })) + } +} diff --git a/lib/executor/src/executors/websocket_client.rs b/lib/executor/src/executors/websocket_client.rs new file mode 100644 index 000000000..d17b376d3 --- /dev/null +++ b/lib/executor/src/executors/websocket_client.rs @@ -0,0 +1,480 @@ +use bytes::Bytes; +use hyper_rustls::ConfigBuilderExt; +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +use futures::{stream::LocalBoxStream, StreamExt}; +use ntex::{ + channel::{mpsc, oneshot}, + io::Sealed, + rt, + ws::{self, error::WsError, WsClient as NtexWsClient, WsConnection, WsSink}, + SharedCfg, +}; +use tracing::{debug, error, trace}; + +use crate::{ + executors::graphql_transport_ws::{SubscribePayload, WS_SUBPROTOCOL}, + response::subgraph_response::SubgraphResponse, +}; +use crate::{ + executors::{ + graphql_transport_ws::{ClientMessage, CloseCode, ConnectionInitPayload, ServerMessage}, + websocket_common::{ + handshake_timeout, heartbeat, parse_frame_to_text, FrameNotParsedToText, WsState, + }, + }, + response::graphql_error::GraphQLError, +}; + +#[derive(Debug, thiserror::Error)] +pub enum WsConnectError { + #[error("Missing schema from WebSocket URI: {0}")] + MissingUriSchema(String), + #[error("Wrong WebSocket URI schema: {0}")] + WrongUriSchema(String), + #[error("WebSocket client error: {0}")] + Client(#[from] ws::error::WsClientError), + #[error("WebSocket client builder error: {0}")] + BuilderError(String), + #[error("Failed to load native TLS certificates: {0}")] + NativeTlsCertificatesError(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum WsInitError { + #[error("Connection acknowledgement receiver failed")] + ConnectionAckReceiverError, + #[error("Connection acknowledgement receiver closed")] + ConnectionAckReceiverClosed, + #[error("Connection closed before acknowledgement")] + ConnectionClosedBeforeAck, + #[error("Invalid message received during acknowledgement")] + InvalidMessage, + #[error("Wrong message received before connection acknowledgement")] + WrongMessageBeforeAck, +} + +#[derive(Debug, thiserror::Error)] +pub enum WsClientError { + #[error("Connection closed")] + ConnectionClosed, + #[error("Message dispatcher closed")] + MessageDispatcherClosed, + #[error("Failed to deserialize payload")] + FailedToDeserializePayload, +} + +impl WsClientError { + pub fn error_code(&self) -> &'static str { + match self { + WsClientError::ConnectionClosed => "WS_CONNECTION_CLOSED", + WsClientError::MessageDispatcherClosed => "WS_MESSAGE_DISPATCHER_CLOSED", + WsClientError::FailedToDeserializePayload => "WS_FAILED_TO_DESERIALIZE_PAYLOAD", + } + } +} + +impl From for SubgraphResponse<'static> { + fn from(err: WsClientError) -> Self { + SubgraphResponse { + errors: Some(vec![GraphQLError::from_message_and_code( + err.to_string(), + err.error_code(), + )]), + ..Default::default() + } + } +} + +pub async fn connect(uri: &http::Uri) -> Result, WsConnectError> { + let scheme = uri + .scheme_str() + .ok_or_else(|| WsConnectError::MissingUriSchema(uri.to_string()))?; + if scheme == "wss" { + let tls_config = Arc::new( + rustls::ClientConfig::builder() + .with_native_roots() + .map_err(|e| WsConnectError::NativeTlsCertificatesError(e.to_string()))? + .with_no_client_auth(), + ); + + let ws_client = NtexWsClient::builder(uri) + .protocols([WS_SUBPROTOCOL]) + .timeout(ntex::time::Seconds(60)) + .rustls(tls_config) + .take() + .build(SharedCfg::default()) + .await + .map_err(|e| WsConnectError::BuilderError(e.to_string()))?; + + Ok(ws_client.connect().await?.seal()) + } else if scheme == "ws" { + let ws_client = NtexWsClient::builder(uri) + .protocols([WS_SUBPROTOCOL]) + .timeout(ntex::time::Seconds(60)) + .build(SharedCfg::default()) + .await + .map_err(|e| WsConnectError::BuilderError(e.to_string()))?; + + Ok(ws_client.connect().await?.seal()) + } else { + Err(WsConnectError::WrongUriSchema(uri.to_string())) + } +} + +/// The client's WebSocket state. Its subscriptions map subscription IDs to their response senders. +type WsStateRef = Rc>>>>; + +/// GraphQL over WebSocket client implementing the graphql-transport-ws protocol. +/// +/// This client is designed for single-threaded use with ntex's runtime. +/// It is not Send/Sync due to ntex's Rc-based internal types. +/// +/// Supports multiplexing multiple subscriptions over a single WebSocket connection, +/// it does so by spawning a background task to handle incoming messages and dispatch them +/// to the appropriate subscription streams as well as handling connection-level messages. +pub struct WsClient { + sink: ws::WsSink, + state: WsStateRef, + next_subscription_id: u64, + _heartbeat_stop_tx: Option>, +} + +impl WsClient { + /// Initialize a new GraphQL over WebSocket client. + /// + /// This sends the connection init message and waits for the server to acknowledge. + /// After acknowledgement, spawns background tasks for: + /// - Message dispatching + /// - Heartbeat pings + /// + /// Returns an error if the connection is closed before acknowledgement. + pub async fn init( + connection: WsConnection, + payload: Option, + ) -> Result { + debug!("Initialising WebSocket client connection"); + + let sink = connection.sink(); + let mut receiver = connection.receiver(); + + let (acknowledged_tx, acknowledged_rx) = oneshot::channel(); + + let state: WsStateRef = Rc::new(RefCell::new(WsState::new(acknowledged_tx))); + + // heartbeats + let (heartbeat_stop_tx, heartbeat_stop_rx) = oneshot::channel(); + rt::spawn(heartbeat(state.clone(), sink.clone(), heartbeat_stop_rx)); + + // handshake timeout monitor will close connection if no ack received in time + rt::spawn(handshake_timeout( + state.clone(), + sink.clone(), + acknowledged_rx, + CloseCode::ConnectionAcknowledgementTimeout, + )); + + // send init and wait for ack or connection close + let _ = sink.send(ClientMessage::init(payload)).await; + loop { + match receiver.next().await { + Some(Ok(frame)) => { + match parse_frame_to_text(frame, &state) { + Ok(text) => { + let server_msg = match text_to_server_message(&text) { + Ok(msg) => msg, + Err(msg) => { + let _ = sink.send(msg).await; + return Err(WsInitError::InvalidMessage); + } + }; + + match server_msg { + ServerMessage::ConnectionAck {} => { + state.borrow_mut().handshake_received = true; + state.borrow_mut().complete_handshake(); + debug!("Connection acknowledged"); + break; + } + ServerMessage::Ping {} => { + let _ = sink.send(ClientMessage::pong()).await; + } + ServerMessage::Pong {} => {} + _ => { + // any other message before ack is an error + error!( + "Wrong message received before ConnectionAck: {:?}", + server_msg + ); + let _ = sink.send(CloseCode::Unauthorized.into()).await; + return Err(WsInitError::WrongMessageBeforeAck); + } + } + } + Err(FrameNotParsedToText::Message(msg)) => { + // this is safe to send indenependently of ack, it could be a ping/pong + // or a close frame due to parsing issues + let _ = sink.send(msg).await; + } + Err(FrameNotParsedToText::Closed) => { + debug!("Connection closed before acknowledgement"); + return Err(WsInitError::ConnectionClosedBeforeAck); + } + Err(FrameNotParsedToText::None) => {} + } + } + Some(Err(e)) => { + error!("WebSocket receiver error during init: {:?}", e); + return Err(WsInitError::ConnectionAckReceiverError); + } + None => { + debug!("WebSocket receiver closed during init"); + return Err(WsInitError::ConnectionAckReceiverClosed); + } + } + } + + let dispatcher_state = state.clone(); + let dispatcher_sink = sink.clone(); + rt::spawn(async move { + let _guard = DispatcherGuard { + state: dispatcher_state.clone(), + }; + dispatch_loop(receiver, dispatcher_sink, dispatcher_state).await; + }); + + Ok(Self { + sink, + state, + next_subscription_id: 1, + _heartbeat_stop_tx: Some(heartbeat_stop_tx), + }) + } + + fn next_subscription_id(&mut self) -> String { + let id = self.next_subscription_id; + self.next_subscription_id += 1; + id.to_string() + } + + /// Execute a GraphQL operation (query, mutation, or subscription) over WebSocket. + /// + /// Returns a stream of responses. The stream completes when the server sends + /// a Complete message, or can be cancelled by dropping the stream. + /// + /// Multiple subscriptions can be active simultaneously on the same connection. + pub async fn subscribe( + &mut self, + subscribe_payload: SubscribePayload, + ) -> LocalBoxStream<'static, SubgraphResponse<'static>> { + let subscribe_id = self.next_subscription_id(); + + let (tx, rx) = mpsc::channel(); + + self.state + .borrow_mut() + .subscriptions + .insert(subscribe_id.clone(), tx); + + let _ = self + .sink + .send(ClientMessage::subscribe( + subscribe_id.clone(), + subscribe_payload, + )) + .await; + + trace!(id = %subscribe_id, "Subscribe message sent"); + + let state = self.state.clone(); + let sink = self.sink.clone(); + + Box::pin(async_stream::stream! { + let mut rx = rx; + let _guard = SubscriptionGuard { + state, + sink, + id: subscribe_id, + }; + + while let Some(response) = rx.next().await { + // the response specific to THIS subscription (matching by id) + yield response; + } + }) + } +} + +impl Drop for WsClient { + fn drop(&mut self) { + // heartbeat_stop_tx will be dropped automatically, stopping the heartbeat task + + // sending is async, so spawn a task to do it + let sink = self.sink.clone(); + rt::spawn(async move { + // TODO: client can be dropped but already closed by server, should be ok though + let _ = sink + .send(ws::Message::Close(Some(ws::CloseCode::Normal.into()))) + .await; + }); + } +} + +/// Ensures a subscription is cleaned up when dropped. +struct SubscriptionGuard { + state: WsStateRef, + sink: WsSink, + id: String, +} + +impl Drop for SubscriptionGuard { + fn drop(&mut self) { + // only send complete message if the subscription is still active - client cancelled. + // if the server sent the complete/error message, the subscription would've been removed + // by the dispatcher so no complete message would be sent from the client back to the server + if self + .state + .borrow_mut() + .subscriptions + .remove(&self.id) + .is_some() + { + let id = self.id.clone(); + let sink = self.sink.clone(); + + // sending is async, so spawn a task to do it + rt::spawn(async move { + let _ = sink.send(ClientMessage::complete(id.clone())).await; + }); + } + } +} + +/// Dispatch loop handling WebSocket messages and distributing them accordingly across subscriptions. +async fn dispatch_loop( + mut receiver: mpsc::Receiver>>, + sink: WsSink, + state: WsStateRef, +) { + loop { + match receiver.next().await { + Some(Ok(frame)) => { + match parse_frame_to_text(frame, &state) { + Ok(text) => { + if let Some(msg) = handle_text_frame(text, &state) { + if send_and_is_closed(sink.clone(), msg).await { + return; + } + } + } + Err(FrameNotParsedToText::Message(msg)) => { + if send_and_is_closed(sink.clone(), msg).await { + return; + } + } + Err(FrameNotParsedToText::Closed) => { + // notify all subscriptions that the connection was closed + for (_, tx) in state.borrow_mut().subscriptions.drain() { + let _ = tx.send(WsClientError::ConnectionClosed.into()); + tx.close(); + } + return; + } + Err(FrameNotParsedToText::None) => {} + } + } + Some(Err(e)) => { + error!("Dispatch loop WebSocket receiver error: {:?}", e); + return; + } + None => { + return; + } + } + } +} + +/// Guard that cleans up all subscriptions when the message dispatcher is dropped (client-side). +struct DispatcherGuard { + state: WsStateRef, +} + +impl Drop for DispatcherGuard { + fn drop(&mut self) { + for (_, tx) in self.state.borrow_mut().subscriptions.drain() { + let _ = tx.send(WsClientError::ConnectionClosed.into()); + tx.close(); + } + } +} + +async fn send_and_is_closed(sink: WsSink, msg: ws::Message) -> bool { + let is_close = matches!(msg, ws::Message::Close(_)); + let _ = sink.send(msg).await; + is_close +} + +fn handle_text_frame(text: String, state: &WsStateRef) -> Option { + let server_msg = match text_to_server_message(&text) { + Ok(msg) => msg, + Err(msg) => return Some(msg), + }; + + trace!("type" = server_msg.as_ref(), "Received server message"); + + match server_msg { + ServerMessage::ConnectionAck {} => { + // already received during init, ignore duplicate + // TODO: consider closing the connection with error, + // but it's not that big of a deal since ack is + // just a handshake confirmation + None + } + ServerMessage::Next { id, payload } => { + if let Some(tx) = state.borrow().subscriptions.get(&id) { + let payload_bytes = Bytes::from(sonic_rs::to_vec(&payload).unwrap_or_default()); + let response = match SubgraphResponse::deserialize_from_bytes(payload_bytes) { + Ok(response) => response, + Err(e) => { + tracing::warn!("Failed to deserialize payload: {}", e); + WsClientError::FailedToDeserializePayload.into() + } + }; + // TODO: should we be strict and close the connection if id did not match any subscription? + let _ = tx.send(response); + } + None + } + ServerMessage::Error { id, payload } => { + if let Some(tx) = state.borrow_mut().subscriptions.remove(&id) { + let _ = tx.send(SubgraphResponse { + errors: Some(payload), + ..Default::default() + }); + tx.close(); + } + None + } + ServerMessage::Complete { id } => { + if let Some(tx) = state.borrow_mut().subscriptions.remove(&id) { + tx.close(); + } + None + } + ServerMessage::Ping {} => Some(ClientMessage::pong()), + ServerMessage::Pong {} => None, + } +} + +fn text_to_server_message(text: &str) -> Result { + let server_msg: ServerMessage = match sonic_rs::from_str(text) { + Ok(msg) => msg, + Err(e) => { + error!("Failed to parse server message to JSON: {}", e); + return Err(CloseCode::BadResponse("Invalid message received from server").into()); + } + }; + Ok(server_msg) +} + +// TODO: hella tests diff --git a/lib/executor/src/executors/websocket_common.rs b/lib/executor/src/executors/websocket_common.rs new file mode 100644 index 000000000..2f0129293 --- /dev/null +++ b/lib/executor/src/executors/websocket_common.rs @@ -0,0 +1,200 @@ +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration, time::Instant}; + +use ntex::{ + channel::oneshot, + util::Bytes, + ws::{self, WsSink}, +}; +use tracing::{debug, error, warn}; + +use super::graphql_transport_ws::{CloseCode, ConnectionInitPayload}; + +/// Shared WebSocket state for both client and server implementations. +pub struct WsState { + /// The moment of the last heartbeat received from the peer. This is used + /// to detect stale connections and drop them on timeout. + pub last_heartbeat: Instant, + /// Indicates whether the handshake message has been received. + /// + /// - Server received ConnectionInit + /// - Client received ConnectionAck + /// + /// This flag is used to enforce the peer cant send multiple handshake messages. + /// + /// Do not confuse it with acknowledged_tx. + pub handshake_received: bool, + /// Sender to indicate that the handshake has been completed and that + /// the timeout task should cancel. + /// + /// When `None`, the handshake has been completed. + pub acknowledged_tx: Option>, + /// Payload from the connection init message. + pub init_payload: Option, + /// Active subscriptions in the WebSocket connection. + /// + /// - Server subscriptions map subscription IDs to their cancellation sender. + /// - Client subscriptions map subscription IDs to their response senders. + pub subscriptions: HashMap, +} + +impl WsState { + pub fn new(acknowledged_tx: oneshot::Sender<()>) -> Self { + Self { + last_heartbeat: Instant::now(), + handshake_received: false, + acknowledged_tx: Some(acknowledged_tx), + init_payload: None, + subscriptions: HashMap::new(), + } + } + + pub fn is_acknowledged(&self) -> bool { + self.acknowledged_tx.is_none() + } + + /// Checks if the connection has been acknowledged; if not, returns a close + /// frame for the peer. + pub fn check_acknowledged(&self) -> Option { + if self.is_acknowledged() { + None + } else { + Some(CloseCode::Unauthorized.into()) + } + } + + pub fn complete_handshake(&mut self) { + if let Some(tx) = self.acknowledged_tx.take() { + let _ = tx.send(()); + } + } +} + +/// Heartbeat ping interval. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +/// Peer response to heartbeat timeout. +pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10); +/// Handshake message received timeout. +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Ping peer every heartbeat interval. +pub async fn heartbeat( + state: Rc>>, + sink: WsSink, + mut stop_rx: oneshot::Receiver<()>, +) { + loop { + tokio::select! { + _ = tokio::time::sleep(HEARTBEAT_INTERVAL) => { + if Instant::now().duration_since(state.borrow().last_heartbeat) > HEARTBEAT_TIMEOUT + { + debug!("WebSocket heartbeat timeout, closing connection"); + let _ = sink + .send(ws::Message::Close(Some( + // client is violating the WebSocket protocol by not responding + // to the PING frames with PONG frames as required by the spec, + // so we use the "Protocol Error" to close the connection + ws::CloseCode::Protocol.into(), + ))) + .await; + return; + } + if sink + .send(ws::Message::Ping(Bytes::default())) + .await + .is_err() + { + warn!("Failed to send WebSocket heartbeat ping, stopping heartbeat task"); + return; + } + } + _ = &mut stop_rx => return, + } + } +} + +/// Monitor handshake timeout and close connection if handshake not completed in time. Uses the provided +/// close code to close the connection on timeout. +pub async fn handshake_timeout( + state: Rc>>, + sink: WsSink, + mut stop_rx: oneshot::Receiver<()>, + timeout_close_code: CloseCode, +) { + tokio::select! { + _ = tokio::time::sleep(HANDSHAKE_TIMEOUT) => { + // handshake_received should always be here false, but double check + // just to avoid any potential race conditions + if !state.borrow().handshake_received { + debug!("WebSocket handshake timeout, closing connection"); + let _ = sink.send(timeout_close_code.into()).await; + } + } + _ = &mut stop_rx => { + // cancelled, handshake was completed + } + } +} + +/// The frame was handled but not parsed to text. In this case the +pub enum FrameNotParsedToText { + /// Not parsed to text but there is a message to send back to the peer. + Message(ws::Message), + /// Not parsed to text and nothing to send back to the peer. This happens + /// when receiving a pong or other non-text frames that don't require a response. + None, + /// Connection was closed (Close frame received), nothing to send back to the peer. + Closed, +} + +/// Parse WebSocket frame to text, returning messages to send back on non-parsed-text frames. +pub fn parse_frame_to_text( + frame: ws::Frame, + state: &Rc>>, +) -> Result { + match frame { + ws::Frame::Text(text) => { + match String::from_utf8(text.to_vec()) { + Ok(s) => Ok(s), + Err(e) => { + error!("Invalid UTF-8 in WebSocket message: {}", e); + Err(FrameNotParsedToText::Message(ws::Message::Close(Some( + // this one is not in the CloseCode enum because it's an internal WebSocket + // transport error that has nothing to do with GraphQL over WebSockets + ws::CloseReason { + code: ws::CloseCode::Unsupported, + description: Some("Invalid UTF-8 in message".into()), + }, + )))) + } + } + } + ws::Frame::Ping(data) => { + state.borrow_mut().last_heartbeat = Instant::now(); + Err(FrameNotParsedToText::Message(ws::Message::Pong(data))) + } + ws::Frame::Pong(_) => { + state.borrow_mut().last_heartbeat = Instant::now(); + Err(FrameNotParsedToText::None) + } + // we don't support binary frames + ws::Frame::Binary(_) => Err(FrameNotParsedToText::Message(ws::Message::Close(Some( + ws::CloseReason { + // this one is not in the CloseCode enum because it's an internal WebSocket + // transport error that has nothing to do with GraphQL over WebSockets + code: ws::CloseCode::Unsupported, + description: Some("Unsupported message type".into()), + }, + )))), + ws::Frame::Close(reason) => { + if let Some(close_reason) = reason { + debug!( + code = ?close_reason.code, + description = ?close_reason.description, + "WebSocket connection closed", + ); + } + Err(FrameNotParsedToText::Closed) + } + _ => Err(FrameNotParsedToText::None), + } +} diff --git a/lib/executor/src/headers/mod.rs b/lib/executor/src/headers/mod.rs index ef9cedacc..425419c6d 100644 --- a/lib/executor/src/headers/mod.rs +++ b/lib/executor/src/headers/mod.rs @@ -96,7 +96,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); @@ -130,7 +130,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); modify_subgraph_request_headers(&plan, "any", &client_details, &mut out).unwrap(); @@ -177,7 +177,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); @@ -215,7 +215,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); @@ -249,7 +249,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); @@ -289,7 +289,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; // For "accounts" subgraph, the specific rule should apply. @@ -333,7 +333,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut accumulator = ResponseHeaderAggregator::default(); @@ -402,7 +402,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut accumulator = ResponseHeaderAggregator::default(); @@ -470,7 +470,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut accumulator = ResponseHeaderAggregator::default(); @@ -531,7 +531,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut accumulator = ResponseHeaderAggregator::default(); @@ -593,7 +593,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut accumulator = ResponseHeaderAggregator::default(); @@ -656,7 +656,7 @@ mod tests { query: "{ __typename }", kind: "query", }, - jwt: JwtRequestDetails::Unauthenticated, + jwt: JwtRequestDetails::Unauthenticated.into(), }; let mut out = HeaderMap::new(); diff --git a/lib/executor/src/introspection/resolve.rs b/lib/executor/src/introspection/resolve.rs index b1df566d7..73b3b1779 100644 --- a/lib/executor/src/introspection/resolve.rs +++ b/lib/executor/src/introspection/resolve.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::sync::Arc; use graphql_tools::parser::query::Value as QueryValue; use graphql_tools::static_graphql::schema::{ @@ -17,10 +18,10 @@ use hive_router_query_planner::state::supergraph_state::OperationKind; use crate::introspection::schema::SchemaMetadata; use crate::response::value::Value; -pub struct IntrospectionContext<'exec> { - pub query: Option<&'exec OperationDefinition>, - pub schema: &'exec Document, - pub metadata: &'exec SchemaMetadata, +pub struct IntrospectionContext { + pub query: Option>, + pub schema: Arc, + pub metadata: Arc, } fn get_deprecation_reason(directives: &[Directive]) -> Option<&str> { @@ -82,7 +83,7 @@ fn kind_to_str<'exec>(type_def: &'exec TypeDefinition) -> Cow<'exec, str> { fn resolve_input_value<'exec>( iv: &'exec InputValue, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut iv_data = resolve_input_value_selections(iv, &selections.items, ctx); iv_data.sort_by_key(|(k, _)| *k); @@ -92,7 +93,7 @@ fn resolve_input_value<'exec>( fn resolve_input_value_selections<'exec>( iv: &'exec InputValue, selection_items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut iv_data: Vec<(&str, Value<'_>)> = Vec::with_capacity(selection_items.len()); for item in selection_items { @@ -129,7 +130,7 @@ fn resolve_input_value_selections<'exec>( fn resolve_field<'exec>( f: &'exec Field, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut field_data = resolve_field_selections(f, &selections.items, ctx); field_data.sort_by_key(|(k, _)| *k); @@ -139,7 +140,7 @@ fn resolve_field<'exec>( fn resolve_field_selections<'exec>( f: &'exec Field, selection_items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut field_data = Vec::with_capacity(selection_items.len()); for item in selection_items { @@ -220,7 +221,7 @@ fn resolve_enum_value_selections<'exec>( fn resolve_type_definition<'exec>( type_def: &'exec TypeDefinition, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut type_data = resolve_type_definition_selections(type_def, &selections.items, ctx); type_data.sort_by_key(|(k, _)| *k); @@ -230,7 +231,7 @@ fn resolve_type_definition<'exec>( fn resolve_type_definition_selections<'exec>( type_def: &'exec TypeDefinition, selection_items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut type_data = Vec::with_capacity(selection_items.len()); @@ -390,7 +391,7 @@ fn resolve_wrapper_type<'exec>( kind: &'exec str, inner_type: &'exec Type, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut type_data = resolve_wrapper_type_selections(kind, inner_type, &selections.items, ctx); type_data.sort_by_key(|(k, _)| *k); @@ -401,7 +402,7 @@ fn resolve_wrapper_type_selections<'exec>( kind: &'exec str, inner_type: &'exec Type, selection_items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut type_data = Vec::with_capacity(selection_items.len()); for item in selection_items { @@ -429,7 +430,7 @@ fn resolve_wrapper_type_selections<'exec>( fn resolve_type<'exec>( t: &'exec Type, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { match t { Type::NamedType(name) => { @@ -449,7 +450,7 @@ fn resolve_type<'exec>( fn resolve_directive<'exec>( d: &'exec DirectiveDefinition, selections: &'exec SelectionSet, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut directive_data = resolve_directive_selections(d, &selections.items, ctx); directive_data.sort_by_key(|(k, _)| *k); @@ -459,7 +460,7 @@ fn resolve_directive<'exec>( fn resolve_directive_selections<'exec>( d: &'exec DirectiveDefinition, selection_items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut directive_data = Vec::with_capacity(selection_items.len()); for item in selection_items { @@ -504,7 +505,7 @@ fn resolve_directive_selections<'exec>( fn resolve_schema_field<'exec>( field: &'exec FieldSelection, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let mut schema_data = resolve_schema_selections(&field.selections.items, ctx); @@ -514,7 +515,7 @@ fn resolve_schema_field<'exec>( fn resolve_schema_selections<'exec>( items: &'exec Vec, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Vec<(&'exec str, Value<'exec>)> { let mut schema_data = Vec::with_capacity(items.len()); @@ -582,7 +583,7 @@ fn resolve_schema_selections<'exec>( pub fn resolve_introspection<'exec>( operation_definition: &'exec OperationDefinition, - ctx: &'exec IntrospectionContext<'exec>, + ctx: &'exec IntrospectionContext, ) -> Value<'exec> { let root_selection_set = &operation_definition.selection_set; diff --git a/lib/executor/src/plugins/hooks/on_supergraph_load.rs b/lib/executor/src/plugins/hooks/on_supergraph_load.rs index 5449e1e23..4d951b04f 100644 --- a/lib/executor/src/plugins/hooks/on_supergraph_load.rs +++ b/lib/executor/src/plugins/hooks/on_supergraph_load.rs @@ -14,13 +14,13 @@ use crate::{ pub struct SupergraphData { /// The metadata of the supergraph schema, /// which includes the list of subgraphs, their relationships, and other relevant information about the supergraph. - pub metadata: SchemaMetadata, + pub metadata: Arc, /// The query planner instance that will be used to generate the query plan for the incoming GraphQL requests based on the supergraph schema. pub planner: Planner, /// The authorization metadata that will be used to authorize the incoming GraphQL requests based on the supergraph schema and the authorization rules defined in the router. pub authorization: AuthorizationMetadata, /// The map of subgraph executors that will be used to execute the query plan for the incoming GraphQL requests based on the supergraph schema. - pub subgraph_executor_map: SubgraphExecutorMap, + pub subgraph_executor_map: Arc, /// The AST of the supergraph schema document that was loaded and parsed by the router. pub supergraph_schema: Arc, } diff --git a/lib/executor/src/response/subgraph_response.rs b/lib/executor/src/response/subgraph_response.rs index d468c7410..5bbd81821 100644 --- a/lib/executor/src/response/subgraph_response.rs +++ b/lib/executor/src/response/subgraph_response.rs @@ -1,7 +1,10 @@ use core::fmt; use std::sync::Arc; -use crate::response::{graphql_error::GraphQLError, value::Value}; +use crate::{ + executors::error::SubgraphExecutorError, + response::{graphql_error::GraphQLError, value::Value}, +}; use bytes::Bytes; use http::HeaderMap; use serde::de::{self, Deserializer, MapAccess, Visitor}; @@ -89,6 +92,30 @@ impl<'de> de::Deserialize<'de> for SubgraphResponse<'de> { } } +impl<'a> SubgraphResponse<'a> { + pub fn deserialize_from_bytes( + bytes: Bytes, + ) -> Result, SubgraphExecutorError> { + let bytes_ref: &[u8] = &bytes; + + // SAFETY: The byte slice `bytes_ref` is transmuted to `'static`. + // This is safe because the returned `SubgraphResponse` stores the `bytes` (Arc-backed + // reference-counted buffer) in its `bytes` field, keeping the underlying data alive as + // long as the `SubgraphResponse` does. The `data` field of `SubgraphResponse` contains + // values that borrow from this buffer, creating a self-referential struct, which is why + // `unsafe` is required. + let bytes_ref: &'static [u8] = unsafe { std::mem::transmute(bytes_ref) }; + + sonic_rs::from_slice(bytes_ref) + .map_err(SubgraphExecutorError::ResponseDeserializationFailure) + .map(move |mut resp: SubgraphResponse<'static>| { + // Zero cost of cloning Bytes + resp.bytes = Some(bytes); + resp + }) + } +} + #[cfg(test)] mod tests { // When subgraph returns an error with custom extensions but without `data` field diff --git a/lib/internal/src/inflight.rs b/lib/internal/src/inflight.rs index 7ac3c2237..d9fb5ce3e 100644 --- a/lib/internal/src/inflight.rs +++ b/lib/internal/src/inflight.rs @@ -99,11 +99,9 @@ where Fut: Future>, { let mut did_initialize = false; - let key = self.key.clone(); - let map = self.map.clone(); + let InFlightClaim { key, map, cell } = self; - let value = self - .cell + let value = cell .get_or_try_init(|| { did_initialize = true; async { @@ -122,9 +120,45 @@ where Ok((value, role)) } + + /// Like [`get_or_try_init`](Self::get_or_try_init), but passes the cleanup guard to the + /// caller. The caller is responsible for dropping the guard when deduplication should end. + /// This is useful for long-lived operations like subscriptions where the deduplication + /// window extends beyond the init future. + #[inline] + pub async fn get_or_try_init_with_guard( + self, + init: F, + ) -> Result<(Arc, InFlightRole), E> + where + F: FnOnce(InFlightCleanupGuard) -> Fut, + Fut: Future>, + { + let mut did_initialize = false; + let InFlightClaim { key, map, cell } = self; + + let value = cell + .get_or_try_init(|| { + did_initialize = true; + async { + let guard = InFlightCleanupGuard { key, map }; + init(guard).await.map(Arc::new) + } + }) + .await? + .clone(); + + let role = if did_initialize { + InFlightRole::Leader + } else { + InFlightRole::Joiner + }; + + Ok((value, role)) + } } -struct InFlightCleanupGuard +pub struct InFlightCleanupGuard where K: Eq + Hash, S: BuildHasher + Clone, diff --git a/lib/query-planner/src/planner/plan_nodes.rs b/lib/query-planner/src/planner/plan_nodes.rs index aa0e1b735..fafe9862e 100644 --- a/lib/query-planner/src/planner/plan_nodes.rs +++ b/lib/query-planner/src/planner/plan_nodes.rs @@ -359,7 +359,8 @@ pub struct ValueSetter { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SubscriptionNode { - pub primary: Box, // Use Box to prevent size issues + // A subscription node can only really have a primary fetch node. + pub primary: FetchNode, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -576,15 +577,17 @@ impl PlanNode { step: &FetchStepData, supergraph: &SupergraphState, ) -> Self { - let node = if step.response_path.is_empty() { - PlanNode::Fetch(FetchNode::from_fetch_step(step, supergraph)) - } else { + let fetch = FetchNode::from_fetch_step(step, supergraph); + + let node = if !step.response_path.is_empty() { PlanNode::Flatten(FlattenNode { path: step.response_path.clone().into(), - node: Box::new(PlanNode::Fetch(FetchNode::from_fetch_step( - step, supergraph, - ))), + node: Box::new(PlanNode::Fetch(fetch)), }) + } else if matches!(fetch.operation_kind, Some(OperationKind::Subscription)) { + PlanNode::Subscription(SubscriptionNode { primary: fetch }) + } else { + PlanNode::Fetch(fetch) }; match step.condition.as_ref() { @@ -635,6 +638,12 @@ impl Display for FlattenNode { } } +impl Display for SubscriptionNode { + fn fmt(&self, f: &mut FmtFormatter<'_>) -> FmtResult { + self.pretty_fmt(f, 0) + } +} + impl PrettyDisplay for QueryPlan { fn pretty_fmt(&self, f: &mut FmtFormatter<'_>, depth: usize) -> FmtResult { let indent = get_indent(depth); @@ -754,6 +763,16 @@ impl PrettyDisplay for ConditionNode { } } +impl PrettyDisplay for SubscriptionNode { + fn pretty_fmt(&self, f: &mut FmtFormatter<'_>, depth: usize) -> FmtResult { + let indent = get_indent(depth); + writeln!(f, "{indent}Subscription {{")?; + self.primary.pretty_fmt(f, depth + 1)?; + writeln!(f, "{indent}}},")?; + Ok(()) + } +} + impl PrettyDisplay for PlanNode { fn pretty_fmt(&self, f: &mut FmtFormatter<'_>, depth: usize) -> FmtResult { match self { @@ -763,6 +782,7 @@ impl PrettyDisplay for PlanNode { PlanNode::Sequence(node) => node.pretty_fmt(f, depth), PlanNode::Parallel(node) => node.pretty_fmt(f, depth), PlanNode::Condition(node) => node.pretty_fmt(f, depth), + PlanNode::Subscription(node) => node.pretty_fmt(f, depth), _ => Ok(()), } } diff --git a/lib/query-planner/src/planner/query_plan/optimize.rs b/lib/query-planner/src/planner/query_plan/optimize.rs index e292f8fb8..180ef13e4 100644 --- a/lib/query-planner/src/planner/query_plan/optimize.rs +++ b/lib/query-planner/src/planner/query_plan/optimize.rs @@ -479,9 +479,8 @@ impl PlanOptimizer<'_> { self.optimize_optional_child(&mut condition_node.else_clause)?; Ok(PlanNode::Condition(condition_node)) } - PlanNode::Subscription(mut subscription_node) => { - subscription_node.primary = - Box::new(self.optimize_node(*subscription_node.primary)?); + PlanNode::Subscription(subscription_node) => { + // self.optimize_node on a FetchNode returns itself, no need to optimize Ok(PlanNode::Subscription(subscription_node)) } PlanNode::Defer(mut defer_node) => { diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 5139a0bf1..1a25658da 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -27,7 +27,8 @@ tracing = { workspace = true } regex-automata = { workspace = true } once_cell = "1.21.3" -schemars = "1.0.4" +schemars = { version = "1.0.4", features = ["url2"] } +url = { version = "2.5.8", features = ["serde"] } humantime-serde = "1.1.1" humantime = { workspace = true } human-size = { version = "0.4.3" ,features = ["serde"] } diff --git a/lib/router-config/src/env_overrides.rs b/lib/router-config/src/env_overrides.rs index 4bb3aaeb3..8b725d531 100644 --- a/lib/router-config/src/env_overrides.rs +++ b/lib/router-config/src/env_overrides.rs @@ -18,6 +18,14 @@ pub struct EnvVarOverrides { #[envconfig(from = "LABORATORY_ENABLED")] pub laboratory_enabled: Option, + // WebSocket overrides + #[envconfig(from = "WEBSOCKET_ENABLED")] + pub websocket_enabled: Option, + + // Subscriptions overrides + #[envconfig(from = "SUBSCRIPTIONS_ENABLED")] + pub subscriptions_enabled: Option, + // HTTP overrides #[envconfig(from = "PORT")] pub http_port: Option, @@ -137,6 +145,14 @@ impl EnvVarOverrides { config = config.set_override("laboratory.enabled", laboratory_enabled)?; } + if let Some(websocket_enabled) = self.websocket_enabled.take() { + config = config.set_override("websocket.enabled", websocket_enabled)?; + } + + if let Some(subscriptions_enabled) = self.subscriptions_enabled.take() { + config = config.set_override("subscriptions.enabled", subscriptions_enabled)?; + } + Ok(config) } } diff --git a/lib/router-config/src/http_server.rs b/lib/router-config/src/http_server.rs index 8558e86fa..496c65705 100644 --- a/lib/router-config/src/http_server.rs +++ b/lib/router-config/src/http_server.rs @@ -6,13 +6,13 @@ use serde::{Deserialize, Serialize}; pub struct HttpServerConfig { /// The endpoint to serve GraphQL requests. By default, `/graphql` is used. #[serde(default = "graphql_endpoint_default")] - graphql_endpoint: String, + pub graphql_endpoint: String, /// The host address to bind the HTTP server to. /// /// Can also be set via the `HOST` environment variable. #[serde(default = "http_server_host_default")] - host: String, + pub host: String, /// The port to bind the HTTP server to. /// @@ -20,7 +20,7 @@ pub struct HttpServerConfig { /// /// If you are running the router inside a Docker container, please ensure that the port is exposed correctly using `-p :` flag. #[serde(default = "http_server_port_default")] - port: u16, + pub port: u16, } impl Default for HttpServerConfig { @@ -44,21 +44,3 @@ fn graphql_endpoint_default() -> String { fn http_server_port_default() -> u16 { 4000 } - -impl HttpServerConfig { - pub fn address(&self) -> String { - format!("{}:{}", self.host, self.port) - } - - pub fn host(&self) -> &str { - &self.host - } - - pub fn port(&self) -> u16 { - self.port - } - - pub fn graphql_endpoint(&self) -> &str { - &self.graphql_endpoint - } -} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index 26e5da516..afa00d4d2 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -13,10 +13,12 @@ pub mod override_labels; pub mod override_subgraph_urls; pub mod primitives; pub mod query_planner; +pub mod subscriptions; pub mod supergraph; pub mod telemetry; pub mod traffic_shaping; pub mod usage_reporting; +pub mod websocket; use config::{Config, File, FileFormat, FileSourceFile}; use envconfig::Envconfig; @@ -117,6 +119,14 @@ pub struct HiveRouterConfig { /// Configuration for custom plugins #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub plugins: HashMap, + + /// Configuration for subscriptions. + #[serde(default)] + pub subscriptions: subscriptions::SubscriptionsConfig, + + /// Configuration of router's WebSocket server. + #[serde(default)] + pub websocket: websocket::WebSocketConfig, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -152,6 +162,38 @@ pub fn default_plugin_warn_on_error() -> bool { false } +impl HiveRouterConfig { + pub fn address(&self) -> String { + format!("{}:{}", self.http.host, self.http.port) + } + + pub fn host(&self) -> String { + self.http.host.clone() + } + + pub fn port(&self) -> u16 { + self.http.port + } + + pub fn graphql_path(&self) -> &str { + &self.http.graphql_endpoint + } + + pub fn websocket_path(&self) -> Option<&str> { + self.websocket.enabled.then(|| { + self.websocket + .path + .as_ref() + .map(|p| p.as_str()) + .unwrap_or_else(|| self.graphql_path()) + }) + } + + pub fn callback_conf(&self) -> Option<&subscriptions::CallbackConfig> { + self.subscriptions.callback.as_ref() + } +} + #[derive(Debug, thiserror::Error)] pub enum RouterConfigError { #[error("Failed to load configuration: {0}")] diff --git a/lib/router-config/src/primitives/absolute_path.rs b/lib/router-config/src/primitives/absolute_path.rs new file mode 100644 index 000000000..0bb1954e5 --- /dev/null +++ b/lib/router-config/src/primitives/absolute_path.rs @@ -0,0 +1,107 @@ +use std::fmt; + +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AbsolutePath(String); + +impl AbsolutePath { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AbsolutePath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for AbsolutePath { + type Error = String; + + fn try_from(path: String) -> Result { + if !path.starts_with('/') { + return Err(format!( + "path must be absolute (start with /), got: {path:?}" + )); + } + Ok(AbsolutePath(path)) + } +} + +impl TryFrom<&str> for AbsolutePath { + type Error = String; + + fn try_from(path: &str) -> Result { + AbsolutePath::try_from(path.to_string()) + } +} + +impl Serialize for AbsolutePath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl JsonSchema for AbsolutePath { + fn schema_name() -> std::borrow::Cow<'static, str> { + "AbsolutePath".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string", + "description": "An absolute path starting with /.", + "pattern": "^/" + }) + } + + fn inline_schema() -> bool { + true + } +} + +struct AbsolutePathVisitor; + +impl<'de> serde::de::Visitor<'de> for AbsolutePathVisitor { + type Value = AbsolutePath; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an absolute path starting with / (e.g., \"/callback\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + AbsolutePath::try_from(value).map_err(serde::de::Error::custom) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: serde::de::Error, + { + self.visit_str(value) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + self.visit_str(&value) + } +} + +impl<'de> Deserialize<'de> for AbsolutePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(AbsolutePathVisitor) + } +} diff --git a/lib/router-config/src/primitives/mod.rs b/lib/router-config/src/primitives/mod.rs index a72478941..47f123c52 100644 --- a/lib/router-config/src/primitives/mod.rs +++ b/lib/router-config/src/primitives/mod.rs @@ -1,3 +1,4 @@ +pub mod absolute_path; pub mod file_path; pub mod http_header; pub mod retry_policy; diff --git a/lib/router-config/src/subscriptions.rs b/lib/router-config/src/subscriptions.rs new file mode 100644 index 000000000..c922780fd --- /dev/null +++ b/lib/router-config/src/subscriptions.rs @@ -0,0 +1,206 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::time::Duration; +use url::Url; + +use crate::primitives::absolute_path::AbsolutePath; + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +pub struct SubscriptionsConfig { + /// Enables/disables subscriptions. By default, the subscriptions are disabled. + /// + /// You can override this setting by setting the `SUBSCRIPTIONS_ENABLED` environment variable to `true` or `false`. + #[serde(default)] + pub enabled: bool, + /// The capacity of the broadcast channel used to fan out subscription events to all active listeners. + /// + /// Each active subscription has its own broadcast channel. This value controls how many events + /// can be buffered in that channel before slow consumers start lagging. If a consumer falls too + /// far behind and the buffer is full, it will skip the missed messages and continue from the + /// latest available event. + /// + /// Subscription events are typically low-frequency, so the default of 32 is sufficient for most + /// use cases. Increase this value if you expect bursts of events or have slow consumers that + /// need more headroom to catch up. + /// + /// Defaults to 32. + #[serde(default = "default_broadcast_capacity")] + pub broadcast_capacity: usize, + /// Configuration for subgraphs using the HTTP Callback protocol. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback: Option, + /// Configuration for subgraphs using WebSocket protocol. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub websocket: Option, +} + +/// Configuration for the HTTP Callback subscription mode. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct CallbackConfig { + /// The public URL that subgraphs will use to send callback messages to this router. + /// + /// Your public_url must match the server address combined with the router's path. + /// Meaning, if your server is `http://localhost:4000` and the path is `/callback`, + /// your `public_url` should be `http://localhost:4000/callback`. + /// + /// Example: `https://example.com:4000/callback` + pub public_url: Url, + /// The path of the router's callback endpoint. + /// Must be an absolute path starting with `/`. Defaults to `/callback`. + #[serde(default = "default_callback_path")] + pub path: AbsolutePath, + /// The interval at which the subgraph must send heartbeat messages. + /// If set to 0, heartbeats are disabled. Defaults to 5 seconds. + #[serde( + default = "default_heartbeat_interval", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub heartbeat_interval: Duration, + /// The IP address and port the router will listen on for subscription callbacks. + /// When set, the router will start a dedicated HTTP server bound to this address + /// for receiving callback messages from subgraphs, separate from the main GraphQL server. + /// When not set, the callback handler is registered on the main server. + /// + /// Example: `0.0.0.0:4001` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + /// The list of subgraph names that use the HTTP callback protocol. + #[serde(default)] + pub subgraphs: HashSet, +} + +fn default_broadcast_capacity() -> usize { + 32 +} + +fn default_callback_path() -> AbsolutePath { + AbsolutePath::try_from("/callback").expect("default callback path is valid") +} + +fn default_heartbeat_interval() -> Duration { + Duration::from_secs(5) +} + +/// Configuration for the WebSocket subscription mode. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct WebSocketConfig { + /// The default configuration that will be applied to all subgraphs using + /// WebSocket protocol, unless overridden by a specific subgraph configuration. + /// + /// When specified, all subgraphs (not claimed by `callback`) will use the WebSocket protocol. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub all: Option, + /// Optional per-subgraph configurations that will override the default configuration for specific subgraphs. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub subgraphs: HashMap, +} + +/// WebSocket configuration for a specific subgraph or the default for all subgraphs. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct WebSocketSubgraphConfig { + /// Determines the URL path to use for the subscription endpoint: + /// + /// - For WebSocket connections, the URL will be `ws://`. + /// - If `path` is not set, the default subgraph URL is used, with the scheme adjusted to `ws` + /// for WebSocket connections where applicable. + /// + /// Note to always provide the absolute path starting with a `/`, e.g., `/ws`. + /// + /// For example, if the subgraph URL is `http://example.com/graphql` and the path is set to `/ws`, + /// the resulting WebSocket URL will be `ws://example.com/ws`. + #[serde(default)] + pub path: Option, +} + +impl SubscriptionsConfig { + /// Returns the subscription protocol for the given subgraph. + /// Returns HTTP (streaming) as the default if no specific mode is configured. + pub fn get_protocol_for_subgraph(&self, subgraph_name: &str) -> SubscriptionProtocol { + if let Some(ref callback) = self.callback { + if callback.subgraphs.contains(subgraph_name) { + return SubscriptionProtocol::HTTPCallback; + } + } + if let Some(ref websocket) = self.websocket { + if websocket.all.is_some() || websocket.subgraphs.contains_key(subgraph_name) { + return SubscriptionProtocol::WebSocket; + } + } + SubscriptionProtocol::HTTP + } + + /// Returns the WebSocket path for the given subgraph, if configured. + /// Checks the subgraph-specific configuration first, then falls back to the `all` default. + pub fn get_websocket_path(&self, subgraph_name: &str) -> Option<&str> { + self.websocket.as_ref().and_then(|ws| { + ws.subgraphs + .get(subgraph_name) + .and_then(|s| s.path.as_ref().map(|p| p.as_str())) + .or_else(|| { + ws.all + .as_ref() + .and_then(|a| a.path.as_ref().map(|p| p.as_str())) + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn callback_path_must_be_absolute() { + let err = serde_json::from_str::( + r#"{"public_url": "http://localhost:4000/callback", "path": "callback"}"#, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("path must be absolute (start with /)"), + "unexpected error: {err}" + ); + } + + #[test] + fn callback_path_absolute_is_accepted() { + let config = serde_json::from_str::( + r#"{"public_url": "http://localhost:4000/callback", "path": "/callback"}"#, + ) + .unwrap(); + assert_eq!(config.path.as_str(), "/callback"); + } + + #[test] + fn callback_path_defaults_to_absolute() { + let config = serde_json::from_str::( + r#"{"public_url": "http://localhost:4000/callback"}"#, + ) + .unwrap(); + assert_eq!(config.path.as_str(), "/callback"); + } +} + +/// The selected protocol for the subscriptions towards subgraphs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SubscriptionProtocol { + /// Uses any HTTP streaming protocol that the subgraph accepts. Supported protocols are: + /// - Server-Sent Events (SSE). Respecting only the "distinct connection mode" of the GraphQL over SSE specification. See: https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md#distinct-connections-mode. + /// - Apollo Multipart HTTP. Implements the Apollo's Multipart HTTP specification. See: https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol. + /// - GraphQL Incremental Delivery. Implements the official GraphQL Incremental Delivery specification. See: https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md. + #[default] + HTTP, + /// Uses GraphQL over WebSocket (graphql-transport-ws subprotocol). + WebSocket, + /// Uses the HTTP Callback protocol for subscriptions. + /// See: https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/callback-protocol + HTTPCallback, +} diff --git a/lib/router-config/src/traffic_shaping.rs b/lib/router-config/src/traffic_shaping.rs index 8b362b6c6..7dc22492d 100644 --- a/lib/router-config/src/traffic_shaping.rs +++ b/lib/router-config/src/traffic_shaping.rs @@ -180,15 +180,53 @@ pub struct TrafficShapingRouterConfig { )] #[schemars(with = "String")] pub request_timeout: Duration, + + /// Maximum number of concurrent long-lived clients (WebSocket connections and HTTP streaming responses). + /// Regular non-streaming requests are not counted toward this limit. + /// When the limit is reached, new WebSocket and streaming HTTP requests are rejected with 503. + /// If both WebSockets and Subscriptions are disabled, this setting has no effect. + #[serde(default = "default_max_long_lived_clients")] + pub max_long_lived_clients: usize, } #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] pub struct TrafficShapingRouterDedupeConfig { - /// Enables/disables in-flight request deduplication at the router endpoint level. + /// Enables/disables in-flight request and active subscriptions deduplication at the router level. + /// + /// When enabled, the router deduplicates both queries and subscriptions using the same + /// fingerprint key (method, path, selected headers, schema checksum, normalized operation + /// hash, variables, and extensions). The `headers` configuration below controls which + /// headers participate in that key for all operation types. + /// + /// For queries, concurrent HTTP requests that produce the same fingerprint share a single + /// in-flight execution - only the first one runs, and the rest wait for and receive the + /// same result. + /// + /// For subscriptions, the mechanism is broadcast-based rather than request-sharing. The + /// first client with a given fingerprint becomes the leader: it runs the upstream subscription + /// and its events are fanned out through a broadcast channel backed by an active subscriptions + /// registry. Any subsequent client that arrives with an identical fingerprint while that subscription + /// is still active joins as a listener on the same broadcast channel instead of starting a new upstream + /// connection. When all listeners have dropped and the leader finishes, the entry is removed from the + /// registry. /// - /// When enabled, identical incoming GraphQL query requests that are processed at the same time - /// share the same in-flight execution result. + /// WebSocket connections participate in the same deduplication space as HTTP. Each + /// subscribe message is processed with a synthetic request assembled from the WebSocket + /// path and the headers derived from the `websocket.headers` config. The fingerprint is computed + /// from those synthetic headers using the same header policy, so a subscription started over HTTP + /// and an identical one started over WebSocket will deduplicate against each other. + /// + /// The deduplication is transport agnostic. A query over WebSocket would get deduplicated with an + /// identical query over HTTP if they arrive at the same time and have the same fingerprint. + /// + /// Note: `content-type` is part of the fingerprint when `headers` includes it (e.g. `all`). + /// Since HTTP streaming clients send different `accept` headers than WebSocket clients, + /// cross-transport deduplication for subscriptions only applies when `content-type` (and + /// transport-specific headers) are excluded from the key. Configure `headers: none` or + /// `headers: { include: [] }` (or exclude the relevant headers) to enable true cross-transport + /// deduplication, where a WebSocket subscription and an SSE subscription with the same operation + /// share a single upstream connection and the events are fanned out to both. #[serde(default = "default_router_dedupe_enabled")] pub enabled: bool, @@ -238,11 +276,16 @@ fn default_router_request_timeout() -> Duration { Duration::from_secs(60) } +fn default_max_long_lived_clients() -> usize { + 128 +} + impl Default for TrafficShapingRouterConfig { fn default() -> Self { Self { dedupe: Default::default(), request_timeout: default_router_request_timeout(), + max_long_lived_clients: default_max_long_lived_clients(), } } } diff --git a/lib/router-config/src/websocket.rs b/lib/router-config/src/websocket.rs new file mode 100644 index 000000000..cdc12aa65 --- /dev/null +++ b/lib/router-config/src/websocket.rs @@ -0,0 +1,118 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::primitives::absolute_path::AbsolutePath; + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +pub struct WebSocketConfig { + /// Enables/disables WebSocket connections. + /// + /// By default, WebSockets are disabled. + /// + /// You can override this setting by setting the `WEBSOCKET_ENABLED` environment variable to `true` or `false`. + #[serde(default)] + pub enabled: bool, + + /// The path to use for the WebSocket endpoint on the router. + /// + /// Note to always provide the absolute path starting with a `/`, e.g., `/ws`. + /// + /// By default, the WebSocket endpoint will be available at the `http.graphql_endpoint` (defaults to `/graphql`) + /// if no path is specified and the clients will connect using `ws:///`. + #[serde(default)] + pub path: Option, + + /// Configuration for handling headers for WebSocket connections. + #[serde(default)] + pub headers: WebSocketHeadersConfig, +} + +#[derive(Default, Deserialize, Serialize, JsonSchema, Debug)] +#[serde(rename_all = "lowercase")] +pub enum WebSocketHeadersSource { + /// Do not accept headers from any source inside WebSocket connections. + None, + /// Accept headers from the connection init payload. This is the default. + /// + /// For example, if the client sends a connection init message like: + /// + /// ```json + /// { + /// "type": "connection_init", + /// "payload": { + /// "Authorization": "Bearer abc123" + /// } + /// } + /// ``` + /// + /// The headers will be extracted and considered for the WebSocket connection to the subgraph + /// respecting the header propagation rules as well as validating against any + /// JWT rules defined in the configuration. + /// + /// Note that there is no `headers` field in the payload, so all fields in the payload + /// will be treated as headers when this option is enabled. + #[default] + Connection, + /// Accept headers from the `headers` field from the GraphQL operation extensions. + /// + /// For example, if the connected client sends a GraphQL operation like: + /// + /// ```json + /// { + /// "query": "{ topProducts { name } }", + /// "extensions": { + /// "headers": { + /// "Authorization": "Bearer abc123" + /// } + /// } + /// } + /// ``` + /// + /// The headers will be extracted and considered for the subgraph respecting the header + /// propagation rules as well as validating against any JWT rules defined in the configuration. + Operation, + /// Accept headers from both the connection init payload and the operation extensions. + /// + /// Headers from the operation extensions will take precedence over those from the connection init + /// payload when both are provided. + Both, +} + +#[derive(Default, Deserialize, Serialize, JsonSchema, Debug)] +#[serde(deny_unknown_fields)] +pub struct WebSocketHeadersConfig { + /// The source(s) from which to accept headers for WebSocket connections. + pub source: WebSocketHeadersSource, + /// Whether to persist merged headers for the duration of the WebSocket connection + /// when using the `both` source (headers are accepted from multiple sources). + /// + /// Only has effect when `source` is set to `both`. + /// + /// This is useful when dealing with authentication using tokens that expire, where the + /// initial connection might use one token, but subsequent operations might need to + /// provide updated tokens in the operation extensions and then use that for further authentication. + /// + /// For example: + /// + /// 1. Client connects with connection init payload containing an Authorization header with a token. + /// 2. Client sends a subscription operation with an updated Authorization header in the operation extensions. + /// 3. If `persist` is enabled, the updated Authorization header will be stored and used for subsequent operations. + #[serde(default)] + pub persist: bool, +} + +impl WebSocketHeadersConfig { + pub fn accepts_connection_headers(&self) -> bool { + matches!( + self.source, + WebSocketHeadersSource::Connection | WebSocketHeadersSource::Both + ) + } + pub fn accepts_operation_headers(&self) -> bool { + matches!( + self.source, + WebSocketHeadersSource::Operation | WebSocketHeadersSource::Both + ) + } +} From e4a80804354783bcbfc8b551c906f3f4814cc9de Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 14 Apr 2026 19:11:00 +0200 Subject: [PATCH 32/76] Prevent hang in multiple_subscriptions_in_parallel test (#912) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- e2e/src/websocket.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/e2e/src/websocket.rs b/e2e/src/websocket.rs index a6590776b..9ff063579 100644 --- a/e2e/src/websocket.rs +++ b/e2e/src/websocket.rs @@ -168,32 +168,34 @@ mod websocket_e2e_tests { let mut count1 = 0; let mut count2 = 0; + let mut done1 = false; + let mut done2 = false; loop { + if done1 && done2 { + break; + } + tokio::select! { - maybe_response = stream1.next() => { + maybe_response = stream1.next(), if !done1 => { match maybe_response { Some(response) => { assert!(response.errors.is_none(), "Expected no errors in stream1"); count1 += 1; } None => { - if count2 > 0 { - break; - } + done1 = true; } } } - maybe_response = stream2.next() => { + maybe_response = stream2.next(), if !done2 => { match maybe_response { Some(response) => { assert!(response.errors.is_none(), "Expected no errors in stream2"); count2 += 1; } None => { - if count1 > 0 { - break; - } + done2 = true; } } } From d71e02b4d34e8e5ee1b5c9c2eb03cbc9bd22a982 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 15 Apr 2026 08:42:35 +0300 Subject: [PATCH 33/76] enhance(query-planner): use less Into and From traits, to have a cleaner error handling (#731) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- lib/query-planner/src/graph/error.rs | 8 +--- lib/query-planner/src/planner/best.rs | 6 +-- lib/query-planner/src/planner/error.rs | 16 +------ lib/query-planner/src/planner/fetch/error.rs | 10 +--- .../src/planner/fetch/fetch_graph.rs | 3 +- lib/query-planner/src/planner/mod.rs | 47 +++---------------- 6 files changed, 15 insertions(+), 75 deletions(-) diff --git a/lib/query-planner/src/graph/error.rs b/lib/query-planner/src/graph/error.rs index cfa0d0666..4c582ddbc 100644 --- a/lib/query-planner/src/graph/error.rs +++ b/lib/query-planner/src/graph/error.rs @@ -14,11 +14,5 @@ pub enum GraphError { #[error("Field named '{0}' was not found in definition name '{1}'")] FieldDefinitionNotFound(String, String), #[error("Supergraph state error: {0}")] - SupergraphStateError(Box), -} - -impl From for GraphError { - fn from(err: SupergraphStateError) -> Self { - GraphError::SupergraphStateError(Box::new(err)) - } + SupergraphStateError(#[from] SupergraphStateError), } diff --git a/lib/query-planner/src/planner/best.rs b/lib/query-planner/src/planner/best.rs index dc3943e59..1db7d028c 100644 --- a/lib/query-planner/src/planner/best.rs +++ b/lib/query-planner/src/planner/best.rs @@ -95,10 +95,10 @@ impl Candidate { #[inline] fn get_tree(&self, graph: &Graph) -> Result { - self.tree + Ok(self + .tree .get_or_create(|(p, mp)| QueryTree::from_path(graph, &p, mp)) - .clone() - .map_err(Into::into) + .clone()?) } #[inline] diff --git a/lib/query-planner/src/planner/error.rs b/lib/query-planner/src/planner/error.rs index 4037c775d..d6f571773 100644 --- a/lib/query-planner/src/planner/error.rs +++ b/lib/query-planner/src/planner/error.rs @@ -8,9 +8,9 @@ use super::fetch::error::FetchGraphError; #[derive(Debug, Clone, thiserror::Error)] pub enum QueryPlanError { #[error("FetchGraph error: {0}")] - FetchGraphFailure(Box), + FetchGraphFailure(#[from] FetchGraphError), #[error("Graph error: {0}")] - GraphFailure(Box), + GraphFailure(#[from] GraphError), #[error("Root fetch is missing")] NoRoot, #[error("Failed to build a plan")] @@ -24,15 +24,3 @@ pub enum QueryPlanError { #[error(transparent)] CancellationError(#[from] CancellationError), } - -impl From for QueryPlanError { - fn from(error: FetchGraphError) -> Self { - QueryPlanError::FetchGraphFailure(Box::new(error)) - } -} - -impl From for QueryPlanError { - fn from(error: GraphError) -> Self { - QueryPlanError::GraphFailure(Box::new(error)) - } -} diff --git a/lib/query-planner/src/planner/fetch/error.rs b/lib/query-planner/src/planner/fetch/error.rs index 46065b7b9..052f290ca 100644 --- a/lib/query-planner/src/planner/fetch/error.rs +++ b/lib/query-planner/src/planner/fetch/error.rs @@ -10,7 +10,7 @@ pub enum FetchGraphError { #[error("Internal Error: {0}")] Internal(String), #[error("Graph error: {0}")] - GraphFailure(Box), + GraphFailure(#[from] GraphError), #[error("Missing FetchStep: {0} {1}")] MissingStep(usize, String), #[error("Missing parent of FetchStep: {0}")] @@ -30,7 +30,7 @@ pub enum FetchGraphError { #[error("Expected different indexes: {0}")] SameNodeIndex(usize), #[error("Failed ot find satisfiable key for @requires: {0}")] - SatisfiableKeyFailure(Box), + SatisfiableKeyFailure(#[from] WalkOperationError), #[error("Expected a FetchStep with Mutation to have its order defined")] MutationStepWithNoOrder, #[error("Index mapping got lost")] @@ -52,9 +52,3 @@ pub enum FetchGraphError { #[error(transparent)] CancellationError(#[from] CancellationError), } - -impl From for FetchGraphError { - fn from(error: GraphError) -> Self { - FetchGraphError::GraphFailure(Box::new(error)) - } -} diff --git a/lib/query-planner/src/planner/fetch/fetch_graph.rs b/lib/query-planner/src/planner/fetch/fetch_graph.rs index 5cb0a74ca..ec223fd71 100644 --- a/lib/query-planner/src/planner/fetch/fetch_graph.rs +++ b/lib/query-planner/src/planner/fetch/fetch_graph.rs @@ -1630,8 +1630,7 @@ fn find_satisfiable_key<'a>( // as the result of this function is guaranteed to be successful, // and fast. &CancellationToken::new(), - ) - .map_err(|err| FetchGraphError::SatisfiableKeyFailure(Box::new(err)))? + )? .is_some() { return edge_ref diff --git a/lib/query-planner/src/planner/mod.rs b/lib/query-planner/src/planner/mod.rs index f9e524e42..1b96dd73a 100644 --- a/lib/query-planner/src/planner/mod.rs +++ b/lib/query-planner/src/planner/mod.rs @@ -37,52 +37,17 @@ pub struct Planner { #[derive(Debug, Clone, thiserror::Error)] pub enum PlannerError { #[error("failed to initalize relations graph: {0}")] - GraphInitError(Box), + GraphInitError(#[from] GraphError), #[error("failed to locate operation to execute")] MissingOperationToExecute, #[error("walker failed to locate path: {0}")] - PathLocatorError(Box), + PathLocatorError(#[from] WalkOperationError), #[error("failed to build fetch graph: {0}")] - FailedToConstructFetchGraph(Box), + FailedToConstructFetchGraph(#[from] FetchGraphError), #[error("failed to build plan: {0}")] - QueryPlanBuildFailed(Box), - #[error("cancelled")] - Cancelled, - #[error("timedout")] - Timedout, -} - -impl From for PlannerError { - fn from(value: GraphError) -> Self { - PlannerError::GraphInitError(Box::new(value)) - } -} - -impl From for PlannerError { - fn from(value: WalkOperationError) -> Self { - PlannerError::PathLocatorError(Box::new(value)) - } -} - -impl From for PlannerError { - fn from(value: FetchGraphError) -> Self { - PlannerError::FailedToConstructFetchGraph(Box::new(value)) - } -} - -impl From for PlannerError { - fn from(value: QueryPlanError) -> Self { - PlannerError::QueryPlanBuildFailed(Box::new(value)) - } -} - -impl From for PlannerError { - fn from(value: CancellationError) -> Self { - match value { - CancellationError::Cancelled => PlannerError::Cancelled, - CancellationError::TimedOut => PlannerError::Timedout, - } - } + QueryPlanBuildFailed(#[from] QueryPlanError), + #[error(transparent)] + CancellationError(#[from] CancellationError), } impl Planner { From dd2a6403bf141496b5d88ae5cbe89c4e89b97fbd Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:45:11 +0300 Subject: [PATCH 34/76] chore(release): router crates and artifacts (#911) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/federated_graphql_subscriptions.md | 17 ----------------- .changeset/query_plan_subscriptions_node.md | 8 -------- Cargo.lock | 10 +++++----- bin/router/CHANGELOG.md | 16 ++++++++++++++++ bin/router/Cargo.toml | 8 ++++---- lib/executor/CHANGELOG.md | 16 ++++++++++++++++ lib/executor/Cargo.toml | 6 +++--- lib/internal/Cargo.toml | 2 +- lib/node-addon/CHANGELOG.md | 8 ++++++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- lib/query-planner/CHANGELOG.md | 8 ++++++++ lib/query-planner/Cargo.toml | 2 +- lib/router-config/CHANGELOG.md | 16 ++++++++++++++++ lib/router-config/Cargo.toml | 2 +- 15 files changed, 81 insertions(+), 42 deletions(-) delete mode 100644 .changeset/federated_graphql_subscriptions.md delete mode 100644 .changeset/query_plan_subscriptions_node.md diff --git a/.changeset/federated_graphql_subscriptions.md b/.changeset/federated_graphql_subscriptions.md deleted file mode 100644 index f298883d9..000000000 --- a/.changeset/federated_graphql_subscriptions.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -hive-router: minor -hive-router-config: minor -hive-router-plan-executor: minor ---- - -# Federated GraphQL Subscriptions - -Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. - -- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) -- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) -- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) -- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) -- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) -- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) -- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) diff --git a/.changeset/query_plan_subscriptions_node.md b/.changeset/query_plan_subscriptions_node.md deleted file mode 100644 index 0d05d59b2..000000000 --- a/.changeset/query_plan_subscriptions_node.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -hive-router-query-planner: minor -node-addon: minor ---- - -# Query Plan Subscriptions Node - -The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. diff --git a/Cargo.lock b/Cargo.lock index 7dc7ddf40..f45060ce0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2356,7 +2356,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.47" +version = "0.0.48" dependencies = [ "ahash", "anyhow", @@ -2417,7 +2417,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.28" +version = "0.0.29" dependencies = [ "config", "envconfig", @@ -2480,7 +2480,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.9.3" +version = "6.10.0" dependencies = [ "ahash", "async-stream", @@ -2523,7 +2523,7 @@ dependencies = [ [[package]] name = "hive-router-query-planner" -version = "2.5.2" +version = "2.6.0" dependencies = [ "bitflags 2.11.0", "criterion", @@ -3542,7 +3542,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.18" +version = "0.0.19" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 365fd1424..ea2ce5549 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.48 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + ## 0.0.47 (2026-04-13) ### Fixes diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 2b488abdb..9ccd4314e 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.47" +version = "0.0.48" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -22,9 +22,9 @@ noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] [dependencies] -hive-router-query-planner = { path = "../../lib/query-planner", version = "2.5.2" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.9.3" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.28" } +hive-router-query-planner = { path = "../../lib/query-planner", version = "2.6.0" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.10.0" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.29" } hive-router-internal = { path = "../../lib/internal", version = "0.0.16" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index 399858458..14b67d649 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.10.0 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + ## 6.9.3 (2026-04-13) ### Fixes diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index be8f22a45..d2a5631cc 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.9.3" +version = "6.10.0" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -15,8 +15,8 @@ authors = ["The Guild"] doctest = false [dependencies] -hive-router-query-planner = { path = "../query-planner", version = "2.5.2" } -hive-router-config = { path = "../router-config", version = "0.0.28" } +hive-router-query-planner = { path = "../query-planner", version = "2.6.0" } +hive-router-config = { path = "../router-config", version = "0.0.29" } hive-router-internal = { path = "../internal", version = "0.0.16" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 3f33f3b9c..a18de9189 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.28" } +hive-router-config = { path = "../router-config", version = "0.0.29" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index db0a4bd81..4480b8417 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,12 @@ # @graphql-hive/router-query-planner changelog +## 0.0.19 (2026-04-15) + +### Features + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 0.0.18 (2026-04-13) ### Fixes diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 3957552e5..19eedfeee 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.18" +version = "0.0.19" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index c88baf827..7552a582b 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.18", + "version": "0.0.19", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/lib/query-planner/CHANGELOG.md b/lib/query-planner/CHANGELOG.md index 22e4b3155..93cb04266 100644 --- a/lib/query-planner/CHANGELOG.md +++ b/lib/query-planner/CHANGELOG.md @@ -30,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 2.6.0 (2026-04-15) + +### Features + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 2.5.2 (2026-04-13) ### Fixes diff --git a/lib/query-planner/Cargo.toml b/lib/query-planner/Cargo.toml index 24e1d4ae2..ac09f209b 100644 --- a/lib/query-planner/Cargo.toml +++ b/lib/query-planner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-query-planner" -version = "2.5.2" +version = "2.6.0" edition = "2021" description = "GraphQL query planner for Federation specification" license = "MIT" diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index 59c0b2471..24cf3f765 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.29 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + ## 0.0.28 (2026-04-13) ### Fixes diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 1a25658da..bb306db1e 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.28" +version = "0.0.29" edition = "2021" publish = true license = "MIT" From 6fbbcc1f6ea9a225570665b69790390aa25bca55 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Wed, 15 Apr 2026 09:09:07 +0300 Subject: [PATCH 35/76] fix(release): correct changesets Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/federated_graphql_subscriptions.md | 18 ++++++++++++++++++ .changeset/query_plan_subscriptions_node.md | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/federated_graphql_subscriptions.md create mode 100644 .changeset/query_plan_subscriptions_node.md diff --git a/.changeset/federated_graphql_subscriptions.md b/.changeset/federated_graphql_subscriptions.md new file mode 100644 index 000000000..c579d8f9a --- /dev/null +++ b/.changeset/federated_graphql_subscriptions.md @@ -0,0 +1,18 @@ +--- +hive-router-plan-executor: minor +hive-router: minor +hive-router-config: minor +hive-router-internal: patch +--- + +# Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) diff --git a/.changeset/query_plan_subscriptions_node.md b/.changeset/query_plan_subscriptions_node.md new file mode 100644 index 000000000..56bf782c0 --- /dev/null +++ b/.changeset/query_plan_subscriptions_node.md @@ -0,0 +1,10 @@ +--- +hive-router-query-planner: minor +node-addon: minor +hive-router-plan-executor: patch +hive-router: patch +--- + +# Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. From 33f545ffc5c548f8704cedc9a392dff205bb70ef Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:16:13 +0300 Subject: [PATCH 36/76] chore(release): router crates and artifacts (#914) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/federated_graphql_subscriptions.md | 18 --------------- .changeset/query_plan_subscriptions_node.md | 10 --------- Cargo.lock | 12 +++++----- bin/router/CHANGELOG.md | 22 +++++++++++++++++++ bin/router/Cargo.toml | 10 ++++----- lib/executor/CHANGELOG.md | 22 +++++++++++++++++++ lib/executor/Cargo.toml | 8 +++---- lib/internal/CHANGELOG.md | 16 ++++++++++++++ lib/internal/Cargo.toml | 4 ++-- lib/node-addon/CHANGELOG.md | 8 +++++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- lib/query-planner/CHANGELOG.md | 8 +++++++ lib/query-planner/Cargo.toml | 2 +- lib/router-config/CHANGELOG.md | 16 ++++++++++++++ lib/router-config/Cargo.toml | 2 +- 16 files changed, 113 insertions(+), 49 deletions(-) delete mode 100644 .changeset/federated_graphql_subscriptions.md delete mode 100644 .changeset/query_plan_subscriptions_node.md diff --git a/.changeset/federated_graphql_subscriptions.md b/.changeset/federated_graphql_subscriptions.md deleted file mode 100644 index c579d8f9a..000000000 --- a/.changeset/federated_graphql_subscriptions.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -hive-router-plan-executor: minor -hive-router: minor -hive-router-config: minor -hive-router-internal: patch ---- - -# Federated GraphQL Subscriptions - -Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. - -- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) -- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) -- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) -- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) -- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) -- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) -- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) diff --git a/.changeset/query_plan_subscriptions_node.md b/.changeset/query_plan_subscriptions_node.md deleted file mode 100644 index 56bf782c0..000000000 --- a/.changeset/query_plan_subscriptions_node.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -hive-router-query-planner: minor -node-addon: minor -hive-router-plan-executor: patch -hive-router: patch ---- - -# Query Plan Subscriptions Node - -The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. diff --git a/Cargo.lock b/Cargo.lock index f45060ce0..e4948e382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2356,7 +2356,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.48" +version = "0.0.49" dependencies = [ "ahash", "anyhow", @@ -2417,7 +2417,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.29" +version = "0.0.30" dependencies = [ "config", "envconfig", @@ -2440,7 +2440,7 @@ dependencies = [ [[package]] name = "hive-router-internal" -version = "0.0.16" +version = "0.0.17" dependencies = [ "ahash", "async-trait", @@ -2480,7 +2480,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.10.0" +version = "6.11.0" dependencies = [ "ahash", "async-stream", @@ -2523,7 +2523,7 @@ dependencies = [ [[package]] name = "hive-router-query-planner" -version = "2.6.0" +version = "2.7.0" dependencies = [ "bitflags 2.11.0", "criterion", @@ -3542,7 +3542,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.19" +version = "0.0.20" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index ea2ce5549..246956037 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.49 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + +### Fixes + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 0.0.48 (2026-04-15) ### Features diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 9ccd4314e..a9df18ee1 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.48" +version = "0.0.49" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -22,10 +22,10 @@ noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] [dependencies] -hive-router-query-planner = { path = "../../lib/query-planner", version = "2.6.0" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.10.0" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.29" } -hive-router-internal = { path = "../../lib/internal", version = "0.0.16" } +hive-router-query-planner = { path = "../../lib/query-planner", version = "2.7.0" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.11.0" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.30" } +hive-router-internal = { path = "../../lib/internal", version = "0.0.17" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index 14b67d649..bc59adad9 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.11.0 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + +### Fixes + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 6.10.0 (2026-04-15) ### Features diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index d2a5631cc..a2e79e5cb 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.10.0" +version = "6.11.0" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -15,9 +15,9 @@ authors = ["The Guild"] doctest = false [dependencies] -hive-router-query-planner = { path = "../query-planner", version = "2.6.0" } -hive-router-config = { path = "../router-config", version = "0.0.29" } -hive-router-internal = { path = "../internal", version = "0.0.16" } +hive-router-query-planner = { path = "../query-planner", version = "2.7.0" } +hive-router-config = { path = "../router-config", version = "0.0.30" } +hive-router-internal = { path = "../internal", version = "0.0.17" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } async-trait = { workspace = true } diff --git a/lib/internal/CHANGELOG.md b/lib/internal/CHANGELOG.md index 0a6ae79fc..97f59ffb5 100644 --- a/lib/internal/CHANGELOG.md +++ b/lib/internal/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.0.17 (2026-04-15) + +### Fixes + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + ## 0.0.16 (2026-04-13) ### Fixes diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index a18de9189..58a5bad25 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-internal" -version = "0.0.16" +version = "0.0.17" edition = "2021" description = "GraphQL Hive Router internal crate" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.29" } +hive-router-config = { path = "../router-config", version = "0.0.30" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 4480b8417..595e43280 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,12 @@ # @graphql-hive/router-query-planner changelog +## 0.0.20 (2026-04-15) + +### Features + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 0.0.19 (2026-04-15) ### Features diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 19eedfeee..1137140d3 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.19" +version = "0.0.20" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index 7552a582b..c46c7f3cd 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.19", + "version": "0.0.20", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/lib/query-planner/CHANGELOG.md b/lib/query-planner/CHANGELOG.md index 93cb04266..88a75fbc3 100644 --- a/lib/query-planner/CHANGELOG.md +++ b/lib/query-planner/CHANGELOG.md @@ -30,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 2.7.0 (2026-04-15) + +### Features + +#### Query Plan Subscriptions Node + +The query planner now emits a `Subscription` node when planning a subscription operation. The `Subscription` node contains a `primary` fetch that is sent to the subgraph owning the subscription field. + ## 2.6.0 (2026-04-15) ### Features diff --git a/lib/query-planner/Cargo.toml b/lib/query-planner/Cargo.toml index ac09f209b..a7225a60c 100644 --- a/lib/query-planner/Cargo.toml +++ b/lib/query-planner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-query-planner" -version = "2.6.0" +version = "2.7.0" edition = "2021" description = "GraphQL query planner for Federation specification" license = "MIT" diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index 24cf3f765..c6226191a 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.30 (2026-04-15) + +### Features + +#### Federated GraphQL Subscriptions + +Hive Router now supports federated GraphQL subscriptions with full protocol coverage across [SSE](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse), [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets), [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http), [Incremental Delivery](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery), and [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) - for both client-to-router and router-to-subgraph communication. Subscription events spanning multiple subgraphs are resolved automatically: when a subscription field lives in one subgraph but the response includes entity fields owned by others, the router fetches those on every event with no extra configuration. + +- [Read the product update](https://the-guild.dev/graphql/hive/product-updates/2026-04-14-hive-router-subscriptions) +- [Subscriptions overview](https://the-guild.dev/graphql/hive/docs/router/subscriptions) +- [Server-Sent Events](https://the-guild.dev/graphql/hive/docs/router/subscriptions/sse) +- [Incremental Delivery over HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/incremental-delivery) +- [Multipart HTTP](https://the-guild.dev/graphql/hive/docs/router/subscriptions/multipart-http) +- [WebSockets](https://the-guild.dev/graphql/hive/docs/router/subscriptions/websockets) +- [HTTP Callback](https://the-guild.dev/graphql/hive/docs/router/subscriptions/http-callback) + ## 0.0.29 (2026-04-15) ### Features diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index bb306db1e..d64ef19d0 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.29" +version = "0.0.30" edition = "2021" publish = true license = "MIT" From e8d206f33e932cb3f2ccd4d31d30cb81681c3ec8 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 15 Apr 2026 10:39:08 +0300 Subject: [PATCH 37/76] chore: move apollo-router-hive-fork to router repo (#723) Co-authored-by: Dotan Simha Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/release-sync.sh | 68 +- .github/workflows/apollo-router-release.yaml | 406 + .github/workflows/apollo-router-updater.yaml | 61 + apollo-router-workspace/.cargo/config.toml | 3 + apollo-router-workspace/.dockerignore | 1 + apollo-router-workspace/.gitignore | 123 + apollo-router-workspace/Cargo.lock | 7588 +++++++++++++++++ apollo-router-workspace/Cargo.toml | 3 + .../bin/router/CHANGELOG.md | 350 + apollo-router-workspace/bin/router/Cargo.toml | 42 + apollo-router-workspace/bin/router/README.md | 86 + .../bin/router/router.yaml | 10 + .../apollo-router-updater/package-lock.json | 94 + .../apollo-router-updater/package.json | 12 + .../apollo-router-updater/src/index.ts | 94 + .../apollo-router-updater/tsconfig.json | 27 + .../bin/router/src/consts.rs | 1 + apollo-router-workspace/bin/router/src/lib.rs | 5 + .../bin/router/src/main.rs | 32 + .../bin/router/src/persisted_documents.rs | 744 ++ .../bin/router/src/registry.rs | 211 + .../bin/router/src/registry_logger.rs | 94 + .../bin/router/src/usage.rs | 558 ++ apollo-router-workspace/docker/docker.hcl | 113 + .../docker/router.dockerfile | 63 + apollo-router-workspace/rust-toolchain.toml | 4 + knope.toml | 5 +- 27 files changed, 10771 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/apollo-router-release.yaml create mode 100644 .github/workflows/apollo-router-updater.yaml create mode 100644 apollo-router-workspace/.cargo/config.toml create mode 100644 apollo-router-workspace/.dockerignore create mode 100644 apollo-router-workspace/.gitignore create mode 100644 apollo-router-workspace/Cargo.lock create mode 100644 apollo-router-workspace/Cargo.toml create mode 100644 apollo-router-workspace/bin/router/CHANGELOG.md create mode 100644 apollo-router-workspace/bin/router/Cargo.toml create mode 100644 apollo-router-workspace/bin/router/README.md create mode 100644 apollo-router-workspace/bin/router/router.yaml create mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json create mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json create mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts create mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json create mode 100644 apollo-router-workspace/bin/router/src/consts.rs create mode 100644 apollo-router-workspace/bin/router/src/lib.rs create mode 100644 apollo-router-workspace/bin/router/src/main.rs create mode 100644 apollo-router-workspace/bin/router/src/persisted_documents.rs create mode 100644 apollo-router-workspace/bin/router/src/registry.rs create mode 100644 apollo-router-workspace/bin/router/src/registry_logger.rs create mode 100644 apollo-router-workspace/bin/router/src/usage.rs create mode 100644 apollo-router-workspace/docker/docker.hcl create mode 100644 apollo-router-workspace/docker/router.dockerfile create mode 100644 apollo-router-workspace/rust-toolchain.toml diff --git a/.changeset/release-sync.sh b/.changeset/release-sync.sh index 134c6831a..209cf8623 100755 --- a/.changeset/release-sync.sh +++ b/.changeset/release-sync.sh @@ -3,6 +3,10 @@ set -e CHANGESET_DIR=".changeset" +# Define your list of target directories here (space-separated) +# You can also override this by passing arguments to the script: TARGET_DIRS="${@:-.}" +TARGET_DIRS=". ./apollo-router-workspace" + # Function to extract package names and their corresponding filenames get_pkg_file_pairs() { grep -rE "^[^:]+: (patch|minor|major)" "$CHANGESET_DIR"/*.md 2>/dev/null | \ @@ -11,40 +15,52 @@ get_pkg_file_pairs() { echo "🔍 Cascading changes (scoped per file)..." -METADATA=$(cargo metadata --format-version 1 --no-deps) +for DIR in $TARGET_DIRS; do + echo "📂 Processing workspace: $DIR" + + MANIFEST_PATH="$DIR/Cargo.toml" + + if [ ! -f "$MANIFEST_PATH" ]; then + echo "⚠️ No Cargo.toml found at $MANIFEST_PATH. Skipping..." + continue + fi + + # Fetch metadata for the specific directory + METADATA=$(cargo metadata --manifest-path "$MANIFEST_PATH" --format-version 1 --no-deps) -while true; do - PAIRS=$(get_pkg_file_pairs) - NEW_CHANGE_FOUND=false + while true; do + PAIRS=$(get_pkg_file_pairs) + NEW_CHANGE_FOUND=false - for PAIR in $PAIRS; do - PKG=$(echo "$PAIR" | cut -d: -f1) - FILE=$(echo "$PAIR" | cut -d: -f2) + for PAIR in $PAIRS; do + PKG=$(echo "$PAIR" | cut -d: -f1) + FILE=$(echo "$PAIR" | cut -d: -f2) - # Find publishable packages that depend on $PKG - DEPENDENTS=$(echo "$METADATA" | jq -r ".packages[] | - select(.dependencies[].name == \"$PKG\") | - select(.publish == null or (.publish | length > 0)) | - .name") + # Find publishable packages that depend on $PKG + DEPENDENTS=$(echo "$METADATA" | jq -r ".packages[] | + select(.dependencies[].name == \"$PKG\") | + select(.publish == null or (.publish | length > 0)) | + .name") - for DEP in $DEPENDENTS; do - # SCOPED CHECK: Only check if the dependency is missing FROM THIS SPECIFIC FILE - if ! grep -qE "^$DEP:" "$FILE" 2>/dev/null; then - echo "🔗 In $FILE: $DEP depends on $PKG. Adding..." + for DEP in $DEPENDENTS; do + # SCOPED CHECK: Only check if the dependency is missing FROM THIS SPECIFIC FILE + if ! grep -qE "^$DEP:" "$FILE" 2>/dev/null; then + echo "🔗 In $FILE: $DEP depends on $PKG. Adding..." - # macOS/BSD compatible sed insertion - sed -i '' "2,\$s/^---$/$DEP: patch\\ + # macOS/BSD compatible sed insertion + sed -i '' "2,\$s/^---$/$DEP: patch\\ ---/" "$FILE" - NEW_CHANGE_FOUND=true - fi + NEW_CHANGE_FOUND=true + fi + done done - done - # If no files were modified in this pass, we are done - if [ "$NEW_CHANGE_FOUND" = false ]; then - break - fi + # If no files were modified in this pass for this directory, break the while loop + if [ "$NEW_CHANGE_FOUND" = false ]; then + break + fi + done done -echo "✅ All changesets internally consistent." +echo "✅ All changesets internally consistent across all directories." diff --git a/.github/workflows/apollo-router-release.yaml b/.github/workflows/apollo-router-release.yaml new file mode 100644 index 000000000..2c0041b2d --- /dev/null +++ b/.github/workflows/apollo-router-release.yaml @@ -0,0 +1,406 @@ +name: apollo-router plugin +on: + # For PRs, this pipeline will use the commit ID as Docker image tag and R2 artifact prefix. + pull_request: + branches: + - main + paths: + - "apollo-router-workspace/**" + # For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and + # publish them only if changes are found and image does not exists in GH Packages. + push: + paths: + - "apollo-router-workspace/**" + branches: + - main + +defaults: + run: + working-directory: ./apollo-router-workspace + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # This script is doing the following: + # 1. Get the version of the apollo-router and the plugin from the Cargo.toml file + # 2. Check if there are changes in the Cargo.toml file in the current commit + # 3. If there are changes, check if the image tag exists in the GitHub Container Registry + find-changes: + name: find changes for apollo-router release + runs-on: ubuntu-22.04 + if: ${{ !github.event.pull_request.head.repo.fork }} + outputs: + should_release: ${{ steps.find_changes.outputs.should_release }} + release_version: ${{ steps.find_changes.outputs.release_version }} + release_latest: ${{ steps.find_changes.outputs.release_latest }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + + - name: find changes in versions + id: find_changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then + echo "Running in a PR, using commit ID as tag" + echo "should_release=true" >> $GITHUB_OUTPUT + echo "release_latest=false" >> $GITHUB_OUTPUT + echo "release_version=$GITHUB_SHA" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Running on push event, looking for changes in Rust crates or plugin versioning" + + image_name="apollo-router" + github_org="graphql-hive" + router_version=$(cargo tree -i apollo-router --quiet | head -n 1 | awk -F" v" '{print $2}') + plugin_version=$(grep '^version' Cargo.toml | sed 's/version *= *"\(.*\)"/\1/') + has_changes=$(git diff HEAD~ HEAD --name-only -- 'Cargo.toml' 'Cargo.lock') + + if [ "$has_changes" ]; then + image_tag_version="router${router_version}-plugin${plugin_version}" + + response=$(curl -L \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -s \ + https://api.github.com/orgs/${github_org}/packages/container/${image_name}/versions) + tag_exists=$(echo "$response" | jq -r ".[] | .metadata.container.tags[] | select(. | contains(\"${image_tag_version}\"))") + + if [ ! "$tag_exists" ]; then + echo "Found changes in version $version_to_publish" + echo "release_version=$image_tag_version" >> $GITHUB_OUTPUT + echo "should_release=true" >> $GITHUB_OUTPUT + echo "release_latest=true" >> $GITHUB_OUTPUT + else + echo "No changes found in version $image_tag_version" + fi + fi + + # Builds Rust crates, and creates Docker images + dockerize: + name: image build and dockerize apollo-router (${{ matrix.platform }}) + needs: find-changes + if: ${{ needs.find-changes.outputs.should_release == 'true' }} + strategy: + fail-fast: false + matrix: + include: + - builder: ubuntu-22.04 + platform: linux/amd64 + suffix: "-amd64" + cache_key: Linux + rust_target: x86_64-unknown-linux-gnu + - builder: hive-linux-arm64-ubuntu2204 + platform: linux/arm64 + suffix: "-arm64" + rust_target: aarch64-unknown-linux-gnu + runs-on: ${{ matrix.builder }} + permissions: + contents: read + packages: write + pull-requests: write + steps: + - name: checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 # v1 + with: + target: ${{ matrix.rust_target }} + rust-src-dir: ./apollo-router-workspace + + - name: Cache Rust + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + + - name: Cache target + uses: actions/cache@v3 + with: + path: apollo-router-workspace/target + key: apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} + apollo-router-target-${{ matrix.cache_key }}-build- + apollo-router-target-${{ matrix.cache_key }}- + apollo-router-target- + + - name: Build + run: cargo build --release --target ${{ matrix.rust_target }} + + - name: Strip binary from debug symbols + if: ${{ runner.os == 'Linux' }} + run: strip ./target/${{ matrix.rust_target }}/release/router + + - name: configure docker buildx + uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0 + + - name: login to docker registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: frabert/replace-string-action@b6828c5a4cb6371753ff873b0d1c4c4fbd9a63cb # v2.5 + id: branch_name_fix + name: sanitize branch name + with: + pattern: '[+\/><@|-]' + flags: "g" + string: ${{ github.head_ref || github.ref_name }} + replace-with: "_" + + - uses: frabert/replace-string-action@b6828c5a4cb6371753ff873b0d1c4c4fbd9a63cb # v2.5 + id: docker_cache_key + name: build cache key + with: + pattern: '[\/,]' + flags: "g" + string: ${{ github.ref }}-apollo-router-hive-build-${{ matrix.platform }} + replace-with: "_" + + - name: build docker images + timeout-minutes: 60 + id: docker-bake + uses: docker/bake-action@5ca506d06f70338a4968df87fd8bfee5cbfb84c7 # v6.0.0 + env: + DOCKER_REGISTRY: ghcr.io/${{ github.repository_owner }}/ + COMMIT_SHA: ${{ needs.find-changes.outputs.release_version }} + RELEASE: ${{ needs.find-changes.outputs.release_version }} + BRANCH_NAME: ${{ steps.branch_name_fix.outputs.replaced }} + BUILD_TYPE: "publish" + PWD: ${{ github.workspace }}/apollo-router-workspace + BUILD_STABLE: ${{ needs.find-changes.outputs.release_latest == 'true' && '1' || '' }} + BUILD_PLATFORM: ${{ matrix.platform }} + IMAGE_SUFFIX: ${{ matrix.suffix }} + ROUTER_BINARY_DIR: ${{ github.workspace }}/apollo-router-workspace/target/${{ matrix.rust_target }}/release + with: + # See https://github.com/docker/buildx/issues/1533 + provenance: false + push: true + files: ./apollo-router-workspace/docker/docker.hcl + targets: apollo-router-hive-build + source: . + set: | + *.cache-from=type=gha,ignore-error=true,scope=${{ steps.docker_cache_key.outputs.replaced }} + *.cache-from=type=gha,ignore-error=true,scope=refs_heads_main-apollo-router-hive-build-${{ matrix.platform == 'linux/amd64' && 'linux_amd64' || 'linux_arm64' }} + *.cache-to=type=gha,mode=max,ignore-error=true,scope=${{ steps.docker_cache_key.outputs.replaced }} + + - name: docker details pr comment + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2 + if: ${{ github.event_name == 'pull_request' }} + with: + header: ${{ github.workflow }} + message: | + 🐋 This PR was built and pushed to the following [Docker images](https://github.com/graphql-hive?ecosystem=container&tab=packages&visibility=public): + + **Targets**: `apollo-router-hive-build` + + **Platform**: `${{ matrix.platform }}` + + **Image Tag**: `${{ needs.find-changes.outputs.release_version }}` + + # Test the Docker image, if it was published + test-image: + name: test apollo-router docker image + needs: + - find-changes + - dockerize + runs-on: ubuntu-22.04 + env: + HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }} + steps: + - name: checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + - name: Run Docker image + run: | + # Create router.yaml + cat << EOF > router.yaml + supergraph: + listen: 0.0.0.0:4000 + health_check: + listen: 0.0.0.0:8088 + enabled: true + path: /health + plugins: + hive.usage: + enabled: false + EOF + + # Download supergraph + curl -sSL https://supergraph.demo.starstuff.dev/ > ./supergraph.graphql + + # Run Docker image + docker run -p 4000:4000 -p 8088:8088 --name apollo_router_test -d \ + --env HIVE_TOKEN="fake" \ + --mount "type=bind,source=/$(pwd)/router.yaml,target=/dist/config/router.yaml" \ + --mount "type=bind,source=/$(pwd)/supergraph.graphql,target=/dist/config/supergraph.graphql" \ + ghcr.io/graphql-hive/apollo-router:${{ needs.find-changes.outputs.release_version }}-amd64 \ + --log debug \ + --supergraph /dist/config/supergraph.graphql \ + --config /dist/config/router.yaml + + # Wait for the container to be ready + echo "Waiting for the container to be ready..." + sleep 20 + HTTP_RESPONSE=$(curl --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --silent --output /dev/null "http://127.0.0.1:8088/health") + + # Check if the HTTP response code is 200 (OK) + if [ $HTTP_RESPONSE -eq 200 ]; then + echo "Health check successful." + docker stop apollo_router_test + docker rm apollo_router_test + exit 0 + else + echo "Health check failed with HTTP status code $HTTP_RESPONSE." + docker stop apollo_router_test + docker rm apollo_router_test + exit 1 + fi + + # Build and publish Rust crates and binaries + test-rust: + name: test apollo-router plugin + needs: find-changes + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 # v1 + with: + rust-src-dir: ./apollo-router-workspace + + - name: Cache Rust + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + + - name: Cache target + uses: actions/cache@v3 + with: + path: apollo-router-workspace/target + key: apollo-router-target-${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + apollo-router-target-${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} + apollo-router-target-${{ runner.os }}-test- + apollo-router-target-${{ runner.os }}- + apollo-router-target- + + - name: Run tests + run: cargo test + + publish-rust: + needs: find-changes + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + file: router + target: x86_64-unknown-linux-gnu + binary_name: linux + cache_key: Linux + - os: windows-latest + file: router.exe + target: x86_64-pc-windows-msvc + binary_name: win + cache_key: Windows + - os: macos-latest + file: router + target: x86_64-apple-darwin + binary_name: macos + cache_key: macOS + name: publish apollo-router binary (${{ matrix.cache_key }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 # v1 + with: + target: ${{ matrix.target }} + rust-src-dir: ./apollo-router-workspace + + - name: Cache Rust + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + + - name: Cache target + uses: actions/cache@v3 + with: + path: apollo-router-workspace/target + key: apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} + apollo-router-target-${{ matrix.cache_key }}-build- + apollo-router-target-${{ matrix.cache_key }}- + apollo-router-target- + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Strip binary from debug symbols + if: ${{ runner.os == 'Linux' }} + run: strip ./target/${{ matrix.target }}/release/${{ matrix.file }} + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + name: upload router artifact + with: + name: apollo_router_${{ matrix.binary_name }} + path: | + ./apollo-router-workspace/target/${{ matrix.target }}/release/${{ matrix.file }} + if-no-files-found: error + + - name: Compress + run: tar -czvf ./router.tar.gz -C ./target/${{ matrix.target }}/release ${{ matrix.file }} + + - name: Upload to R2 (${{ needs.find-changes.outputs.release_version }}) + uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2 + with: + endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com + accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }} + secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }} + bucket: apollo-router + file: ./apollo-router-workspace/router.tar.gz + destination: ${{ needs.find-changes.outputs.release_version }}/${{ matrix.binary_name }}/router.tar.gz + + - name: Upload to R2 (latest) + if: ${{ needs.find-changes.outputs.release_latest == 'true' }} + uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2 + with: + endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com + accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }} + secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }} + bucket: apollo-router + file: ./apollo-router-workspace/router.tar.gz + destination: latest/${{ matrix.binary_name }}/router.tar.gz diff --git a/.github/workflows/apollo-router-updater.yaml b/.github/workflows/apollo-router-updater.yaml new file mode 100644 index 000000000..6b53b4ff4 --- /dev/null +++ b/.github/workflows/apollo-router-updater.yaml @@ -0,0 +1,61 @@ +name: Apollo Router Updater +on: + schedule: + # Every 2 hours + - cron: '0 */2 * * *' + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: {} +defaults: + run: + working-directory: ./apollo-router-workspace + +jobs: + update: + runs-on: ubuntu-22.04 + permissions: + issues: write + pull-requests: write + contents: write + steps: + - name: checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 1 + token: ${{ secrets.BOT_GITHUB_TOKEN }} + + - name: Install Rust + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 + with: + toolchain: '1.91.1' + default: true + override: true + + - name: setup node + uses: the-guild-org/shared-config/setup@v1 + with: + node-version-file: .node-version + + - name: Check for updates + id: check + run: | + cd scripts/apollo-router-updater + npm install + npm start + + - name: Run updates + if: steps.check.outputs.update == 'true' + run: cargo update -p apollo-router --precise ${{ steps.check.outputs.version }} + + - name: Create Pull Request + if: steps.check.outputs.update == 'true' + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 + with: + token: ${{ secrets.BOT_GITHUB_TOKEN }} + commit-message: Update apollo-router to version ${{ steps.check.outputs.version }} + branch: apollo-router-update-${{ steps.check.outputs.version }} + delete-branch: true + title: ${{ steps.check.outputs.title }} + body: | + Automatic update of apollo-router to version ${{ steps.check.outputs.version }}. + assignees: kamilkisiela,dotansimha + reviewers: kamilkisiela,dotansimha diff --git a/apollo-router-workspace/.cargo/config.toml b/apollo-router-workspace/.cargo/config.toml new file mode 100644 index 000000000..4eb0d0d6d --- /dev/null +++ b/apollo-router-workspace/.cargo/config.toml @@ -0,0 +1,3 @@ +# The following are aliases you can use with "cargo command_name" +[alias] +"clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged" diff --git a/apollo-router-workspace/.dockerignore b/apollo-router-workspace/.dockerignore new file mode 100644 index 000000000..90f043127 --- /dev/null +++ b/apollo-router-workspace/.dockerignore @@ -0,0 +1 @@ +target/** diff --git a/apollo-router-workspace/.gitignore b/apollo-router-workspace/.gitignore new file mode 100644 index 000000000..bbaa851c3 --- /dev/null +++ b/apollo-router-workspace/.gitignore @@ -0,0 +1,123 @@ +# Logs +logs +*.log +npm-debug.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next/ + +# Nuxt.js build / generate output +.nuxt +dist/ + +deploy-tmp/ + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +temp + +.DS_STORE + +__generated__/ + +integration-tests/testkit/gql/ +.turbo/ + +# IntelliJ's project specific settings files +.idea/ + +*.pem + +npm-shrinkwrap.json + +# Rust +/target + +# Docker temp volumes +.volumes + +_redirects + +docker/docker-compose.override.yml + +test-results/ diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock new file mode 100644 index 000000000..9015cdc3e --- /dev/null +++ b/apollo-router-workspace/Cargo.lock @@ -0,0 +1,7588 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "cpp_demangle", + "fallible-iterator", + "gimli", + "memmap2", + "object", + "rustc-demangle", + "smallvec", + "typed-arena", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "apollo-compiler" +version = "1.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66f8eefe0ed21932dcc31eeca614cc8337bdc9ec7d4b2fb200b2a1539ba5c92" +dependencies = [ + "ahash", + "apollo-parser", + "ariadne", + "futures", + "indexmap 2.12.0", + "rowan", + "serde", + "serde_json_bytes", + "thiserror 2.0.18", + "triomphe", + "typed-arena", +] + +[[package]] +name = "apollo-environment-detector" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c628346f10c7615f1dd9e3f486d55bcad9edb667f4444dcbcb9cb5943815583a" +dependencies = [ + "libc", + "serde", + "wmi", +] + +[[package]] +name = "apollo-federation" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6cda42a8c302a0b26fcb50096e6932eb8bd31bec3a4e506e2b6825c584bc1c" +dependencies = [ + "apollo-compiler", + "derive_more", + "either", + "encoding_rs", + "form_urlencoded", + "hashbrown 0.16.0", + "http 1.3.1", + "indexmap 2.12.0", + "itertools 0.14.0", + "levenshtein", + "line-col", + "mime", + "multi_try", + "multimap 0.10.1", + "nom", + "nom_locate", + "parking_lot", + "percent-encoding", + "petgraph 0.8.3", + "regex", + "serde", + "serde_json", + "serde_json_bytes", + "shape", + "strum", + "strum_macros", + "thiserror 2.0.18", + "time", + "tracing", + "url", +] + +[[package]] +name = "apollo-parser" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f05cbc7da3c2e3bb2f86e985aad5f72571d2e2cd26faf8caa7782131576f84" +dependencies = [ + "memchr", + "rowan", + "thiserror 1.0.69", +] + +[[package]] +name = "apollo-router" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f69bbb0cb147c2224f6302b2d5caa81de144a81bb930e02af74246030659ebf1" +dependencies = [ + "addr2line", + "ahash", + "anyhow", + "apollo-compiler", + "apollo-environment-detector", + "apollo-federation", + "astral-tokio-tar", + "async-compression", + "async-trait", + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http-client", + "aws-smithy-runtime-api", + "aws-types", + "axum", + "base64 0.22.1", + "blake3", + "bloomfilter", + "brotli", + "buildstructor", + "bytes", + "bytesize", + "ci_info", + "clap", + "console", + "cookie", + "crossbeam-channel", + "dashmap", + "derivative", + "derive_more", + "diff", + "displaydoc", + "docker_credential", + "flate2", + "fred", + "futures", + "graphql_client", + "h2", + "heck 0.5.0", + "hex", + "hickory-resolver", + "hmac", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "http-serde", + "humantime", + "humantime-serde", + "hyper 1.7.0", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "indexmap 2.12.0", + "itertools 0.14.0", + "itoa", + "jsonpath-rust", + "jsonpath_lib", + "jsonschema 0.36.0", + "jsonwebtoken", + "libc", + "linkme", + "log", + "lru", + "mediatype", + "memchr", + "mime", + "mockall", + "multer", + "multimap 0.9.1", + "notify", + "nu-ansi-term", + "num-traits", + "oci-client", + "once_cell", + "opentelemetry", + "opentelemetry-aws", + "opentelemetry-http", + "opentelemetry-jaeger-propagator", + "opentelemetry-otlp", + "opentelemetry-prometheus", + "opentelemetry-semantic-conventions", + "opentelemetry-zipkin", + "opentelemetry_sdk", + "parking_lot", + "paste", + "pin-project-lite", + "prometheus", + "prost", + "prost-types", + "proteus", + "rand 0.9.2", + "regex", + "reqwest", + "rhai", + "rmp", + "rust-embed", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "ryu", + "schemars 1.0.4", + "scopeguard", + "semver", + "serde", + "serde_derive_default", + "serde_json", + "serde_json_bytes", + "serde_regex", + "serde_urlencoded", + "serde_yaml", + "sha1", + "sha2", + "shellexpand", + "similar", + "socket2 0.5.10", + "static_assertions", + "strum", + "sys-info", + "sysinfo", + "thiserror 2.0.18", + "tikv-jemalloc-ctl", + "tikv-jemalloc-sys", + "tikv-jemallocator", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tonic", + "tonic-prost-build", + "tower", + "tower-http", + "tower-service", + "tracing", + "tracing-core", + "tracing-futures", + "tracing-serde", + "tracing-subscriber", + "uname", + "url", + "urlencoding", + "uuid", + "wiremock", + "wsl", + "yaml-rust", + "zstd", + "zstd-safe", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "ariadne" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f" +dependencies = [ + "concolor", + "unicode-width 0.1.14", + "yansi", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash 2.1.1", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "futures-io", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-dropper-simple" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c4748dfe8cd3d625ec68fc424fa80c134319881185866f9e173af9e5d8add8" +dependencies = [ + "async-scoped", + "async-trait", + "futures", + "rustc_version", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-scoped" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4042078ea593edffc452eef14e99fdb2b120caa4ad9618bcdeabc4a023b98740" +dependencies = [ + "futures", + "pin-project", + "tokio", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.3.1", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.3.1", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.3.1", + "hyper 1.7.0", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0d43d899f9e508300e587bf582ba54c27a452dd0a9ea294690669138ae14a2" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.3.1", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905cb13a9895626d49cf2ced759b062d913834c7482c38e49557eac4e6193f01" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bloomfilter" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6d7f06817e48ea4e17532fa61bc4e8b9a101437f0623f69d2ea54284f3a817" +dependencies = [ + "getrandom 0.2.16", + "siphasher", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "buildstructor" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caabaaee17b2a78d7aa349a33edc9090c6bb47e6dfb25b0da281df57628bba68" +dependencies = [ + "proc-macro2", + "quote", + "str_inflector", + "syn 2.0.117", + "try_match", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "ci_info" +version = "0.14.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840dbb7bdd1f2c4d434d6b08420ef204e0bfad0ab31a07a80a1248d24cc6e38b" +dependencies = [ + "envmnt", + "serde", + "serde_derive", +] + +[[package]] +name = "clap" +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + +[[package]] +name = "concolor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b946244a988c390a94667ae0e3958411fa40cc46ea496a929b263d883f5f9c3" +dependencies = [ + "bitflags 1.3.2", + "concolor-query", + "is-terminal", +] + +[[package]] +name = "concolor-query" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf" +dependencies = [ + "windows-sys 0.45.0", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "envmnt" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73999a2b8871e74c8b8bc23759ee9f3d85011b24fafc91a4b3b5c8cc8185501" +dependencies = [ + "fsio", + "indexmap 1.9.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.5", + "redis-protocol", + "rustls", + "rustls-native-certs", + "semver", + "serde_json", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fsio" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4944f16eb6a05b4b2b79986b4786867bb275f52882adea798f17cc2588f25b2" +dependencies = [ + "dunce", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ghost" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1323e4e10ffd5d48a21ea37f8d4e3b15dd841121d1301a86122fa0984bedf0a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "stable_deref_trait", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql-tools" +version = "0.5.3" +dependencies = [ + "combine", + "itoa", + "lazy_static", + "ryu", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "xxhash-rust", +] + +[[package]] +name = "graphql_client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" +dependencies = [ + "graphql_query_derive", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hive-apollo-router-plugin" +version = "3.0.3" +dependencies = [ + "anyhow", + "apollo-router", + "async-trait", + "futures", + "hive-console-sdk", + "http 1.3.1", + "http-body-util", + "httpmock", + "jsonschema 0.29.1", + "lazy_static", + "rand 0.9.2", + "schemars 1.0.4", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-util", + "tower", + "tracing", +] + +[[package]] +name = "hive-console-sdk" +version = "0.3.8" +dependencies = [ + "anyhow", + "async-dropper-simple", + "async-trait", + "futures-util", + "graphql-tools", + "lazy_static", + "md5", + "moka", + "recloser", + "regex-automata", + "regress 0.11.1", + "reqwest", + "reqwest-middleware", + "reqwest-retry", + "retry-policies", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "typify", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http 1.3.1", + "serde", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper 1.7.0", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.7.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "inventory" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84344c6e0b90a9e2b6f3f9abe5cc74402684e348df7b32adca28747e0cef091a" +dependencies = [ + "ctor", + "ghost", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonpath-rust" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06cc127b7c3d270be504572364f9569761a180b981919dd0d87693a7f5fb7829" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath_lib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa63191d68230cccb81c5aa23abd53ed64d83337cacbb25a7b8c7979523774f" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "jsonschema" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161c33c3ec738cfea3288c5c53dfcdb32fd4fc2954de86ea06f71b5a1a40bfcd" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex 0.14.0", + "fraction", + "idna", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing 0.29.1", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "jsonschema" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd94c1d7bfa9d30b5d4268df9fe8c5ed13fa600a6bd0dae02b04db86d575fc8a" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex 0.16.2", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing 0.36.0", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph 0.6.5", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "line-col" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e69cdf6b85b5c8dce514f694089a2cf8b1a702f6cd28607bcb3cf296c9778db" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linkme" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", + "serde", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.0", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moka" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener 5.4.1", + "futures-util", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.3.1", + "httparse", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + +[[package]] +name = "multi_try" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42256e8ab5f19108cf42e2762786052ae4660635f6fe76134d2cab37068ee8a" + +[[package]] +name = "multimap" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a5d38b9b352dbd913288736af36af41c48d61b1a8cd34bcecd727561b7d511" +dependencies = [ + "serde", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + +[[package]] +name = "oci-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b74df13319e08bc386d333d3dc289c774c88cc543cae31f5347db07b5ec2172" +dependencies = [ + "bytes", + "chrono", + "futures-util", + "http 1.3.1", + "http-auth", + "jwt", + "lazy_static", + "oci-spec", + "olpc-cjson", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "oci-spec" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb4684653aeaba48dea019caa17b2773e1212e281d50b6fa759f36fe032239d" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-aws" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fbe9af6b9403e7fe43c11cc341d320d7cf5e779c6708b41415228af1921045" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http 1.3.1", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-jaeger-propagator" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3bbd907f151104a112f749f3b8387ef669b7264e0bb80546ea0700a3b307b7" +dependencies = [ + "opentelemetry", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +dependencies = [ + "http 1.3.1", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror 2.0.18", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-prometheus" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14095eb06b569eb5d538fa4555969f7e8a410ed7910c903bfd295f9e1a50d7ea" +dependencies = [ + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "prometheus", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" + +[[package]] +name = "opentelemetry-zipkin" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fcd074586dab55936b003c6a499acaabd6debbd539c3f36356bca2ef2fce2" +dependencies = [ + "http 1.3.1", + "once_cell", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.12.0", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "serde", + "serde_derive", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 2.0.18", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap 0.10.1", + "petgraph 0.8.3", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "proteus" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279396105537894fdecabfba63493bc93192c94a97951bef640d2feac3cfc362" +dependencies = [ + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "typetag", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags 2.10.0", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "recloser" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ac0d06281c3556fea72cef9e5372d9ac172335be0d71c3b4f3db900483e0eb" +dependencies = [ + "crossbeam-epoch", + "pin-project", +] + +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "referencing" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a64b3a635fad9000648b4d8a59c8710c523ab61a23d392a7d91d47683f5adc" +dependencies = [ + "ahash", + "fluent-uri 0.3.2", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "referencing" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1cb02ef237bd757aba02cd648a4ffa628cd8e5852e2b9bb89aabf93dc5dcc7" +dependencies = [ + "ahash", + "fluent-uri 0.4.1", + "getrandom 0.3.4", + "hashbrown 0.16.0", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.0", + "memchr", +] + +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.0", + "memchr", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http 1.3.1", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[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", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtimer", +] + +[[package]] +name = "resolv-conf" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" + +[[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 = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rhai" +version = "1.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "instant", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb44e1917075637ee8c7bcb865cf8830e3a92b5b1189e44e3a0ab5a0d5be314b" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382499b49db77a7c19abd2a574f85ada7e9dbe125d5d1160fa5cad7c4cf71fc9" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ruzstd" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640bec8aad418d7d03c72ea2de10d5c646a598f9883c7babc160d91e3c1b26c" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive 1.0.4", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_default" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb2522c2a87137bf6c2b3493127fed12877ef1b9476f074d6664edc98acd8a7" +dependencies = [ + "quote", + "regex", + "syn 2.0.117", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_bytes" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a27c10711f94d1042b4c96d483556ec84371864e25d0e1cf3dc1024b0880b1" +dependencies = [ + "ahash", + "bytes", + "indexmap 2.12.0", + "jsonpath-rust", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + +[[package]] +name = "serde_tokenstream" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap 1.9.3", + "ryu", + "serde", + "yaml-rust", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shape" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68f64918904ec44a5574808ffa986a2a1abf14738ebfc8479eed64d2882b0b1" +dependencies = [ + "apollo-compiler", + "indexmap 2.12.0", + "serde_json", + "serde_json_bytes", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_inflector" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0b848d5a7695b33ad1be00f84a3c079fe85c9278a325ff9159e6c99cef4ef7" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +dependencies = [ + "serde", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-ctl" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "661f1f6a57b3a36dc9174a2c10f19513b4866816e13425d3e418b11cc37bc24c" +dependencies = [ + "libc", + "paste", + "tikv-jemalloc-sys", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "flate2", + "h2", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2 0.6.1", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 2.12.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "try_match" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b065c869a3f832418e279aa4c1d7088f9d5d323bde15a60a08e20c2cd4549082" +dependencies = [ + "try_match_inner", +] + +[[package]] +name = "try_match_inner" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c81686f7ab4065ccac3df7a910c4249f8c0f3fb70421d6ddec19b9311f63f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typetag" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4080564c5b2241b5bff53ab610082234e0c57b0417f4bd10596f183001505b8a" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e60147782cc30833c05fba3bab1d9b5771b2685a2557672ac96fa5d154099c0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "typify" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b715573a376585888b742ead9be5f4826105e622169180662e2c81bed4a149c3" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fd0d27608a466d063d23b97cf2d26c25d838f01b4f7d5ff406a7446f16b6e3" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress 0.10.5", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd04bb1207cd4e250941cc1641f4c4815f7eaa2145f45c09dd49cb0a3691710a" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.117", + "typify-impl", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "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", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" +dependencies = [ + "windows-core 0.59.0", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface", + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.3.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wmi" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7787dacdd8e71cbc104658aade4009300777f9b5fda6a75f19145fedb8a18e71" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.59.0", + "windows-core 0.59.0", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wsl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/apollo-router-workspace/Cargo.toml b/apollo-router-workspace/Cargo.toml new file mode 100644 index 000000000..47ed66caf --- /dev/null +++ b/apollo-router-workspace/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["bin/router"] \ No newline at end of file diff --git a/apollo-router-workspace/bin/router/CHANGELOG.md b/apollo-router-workspace/bin/router/CHANGELOG.md new file mode 100644 index 000000000..05282a063 --- /dev/null +++ b/apollo-router-workspace/bin/router/CHANGELOG.md @@ -0,0 +1,350 @@ +## 3.0.3 + +### Patch Changes + +- [#7815](https://github.com/graphql-hive/console/pull/7815) + [`078e661`](https://github.com/graphql-hive/console/commit/078e6611cbbd94b2ba325dc35bfbf636d2458f24) + Thanks [@ardatan](https://github.com/ardatan)! - Bump `hive-console-sdk` to `0.3.7` to pin + `graphql-tools` to a compatible version. The previous `hive-console-sdk@0.3.5` allowed + `graphql-tools@^0.5` which resolves to `0.5.2`, a version that removes public API traits + (`SchemaDocumentExtension`, `FieldByNameExtension`, etc.) that `hive-console-sdk` depends on. + +## 3.0.2 + +### Patch Changes + +- [#7585](https://github.com/graphql-hive/console/pull/7585) + [`9a6e8a9`](https://github.com/graphql-hive/console/commit/9a6e8a9fe7f337c4a2ee6b7375281f5ae42a38e3) + Thanks [@dotansimha](https://github.com/dotansimha)! - Upgrade to latest `hive-console-sdk` and + drop direct dependency on `graphql-tools` + +## 3.0.1 + +### Patch Changes + +- [#7476](https://github.com/graphql-hive/console/pull/7476) + [`f4d5f7e`](https://github.com/graphql-hive/console/commit/f4d5f7ee5bf50bc8b621b011696d43757de2e071) + Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - 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. + +## 3.0.0 + +### Major 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)! - - Multiple endpoints support for `HiveRegistry` + and `PersistedOperationsPlugin` + + Breaking Changes: + + - Now there is no `endpoint` field in the configuration, it has been replaced with `endpoints`, + which is an array of strings. You are not affected if you use environment variables to set the + endpoint. + + ```diff + HiveRegistry::new( + Some( + HiveRegistryConfig { + - endpoint: String::from("CDN_ENDPOINT"), + + endpoints: vec![String::from("CDN_ENDPOINT1"), String::from("CDN_ENDPOINT2")], + ) + ) + ``` + +### Patch Changes + +- [#7479](https://github.com/graphql-hive/console/pull/7479) + [`382b481`](https://github.com/graphql-hive/console/commit/382b481e980e588e3e6cf7831558b2d0811253f5) + Thanks [@ardatan](https://github.com/ardatan)! - Update dependencies + +- Updated dependencies + [[`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37), + [`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37)]: + - hive-console-sdk-rs@0.3.0 + +## 2.3.6 + +### Patch Changes + +- Updated dependencies + [[`0ac2e06`](https://github.com/graphql-hive/console/commit/0ac2e06fd6eb94c9d9817f78faf6337118f945eb), + [`4b796f9`](https://github.com/graphql-hive/console/commit/4b796f95bbc0fc37aac2c3a108a6165858b42b49), + [`a9905ec`](https://github.com/graphql-hive/console/commit/a9905ec7198cf1bec977a281c5021e0ef93c2c34)]: + - hive-console-sdk-rs@0.2.3 + +## 2.3.5 + +### Patch Changes + +- Updated dependencies + [[`24c0998`](https://github.com/graphql-hive/console/commit/24c099818e4dfec43feea7775e8189d0f305a10c)]: + - hive-console-sdk-rs@0.2.2 + +## 2.3.4 + +### Patch Changes + +- Updated dependencies + [[`69e2f74`](https://github.com/graphql-hive/console/commit/69e2f74ab867ee5e97bbcfcf6a1b69bb23ccc7b2)]: + - hive-console-sdk-rs@0.2.1 + +## 2.3.3 + +### Patch Changes + +- Updated dependencies + [[`cc6cd28`](https://github.com/graphql-hive/console/commit/cc6cd28eb52d774683c088ce456812d3541d977d)]: + - hive-console-sdk-rs@0.2.0 + +## 2.3.2 + +### Patch Changes + +- Updated dependencies + [[`d8f6e25`](https://github.com/graphql-hive/console/commit/d8f6e252ee3cd22948eb0d64b9d25c9b04dba47c)]: + - hive-console-sdk-rs@0.1.1 + +## 2.3.1 + +### Patch 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. + +- Updated dependencies + [[`7878736`](https://github.com/graphql-hive/console/commit/7878736643578ab23d95412b893c091e32691e60)]: + - hive-console-sdk-rs@0.1.0 + +## 2.3.0 + +### Minor 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. + +### 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)! - Fixes a bug when Persisted Operations are enabled + by default which should be explicitly enabled + +- Updated dependencies + [[`b80e896`](https://github.com/graphql-hive/console/commit/b80e8960f492e3bcfe1012caab294d9066d86fe3)]: + - hive-console-sdk-rs@0.0.1 + +## 2.2.0 + +### Minor Changes + +- [#6906](https://github.com/graphql-hive/console/pull/6906) + [`7fe1c27`](https://github.com/graphql-hive/console/commit/7fe1c271a596353d23ad770ce667f7781be6cc13) + Thanks [@egoodwinx](https://github.com/egoodwinx)! - Advanced breaking change detection for inputs + and arguments. + + With this change, inputs and arguments will now be collected from the GraphQL operations executed + by the router, and will be reported to Hive Console. + + Additional references: + + - https://github.com/graphql-hive/console/pull/6764 + - https://github.com/graphql-hive/console/issues/6649 + +### Patch Changes + +- [#7173](https://github.com/graphql-hive/console/pull/7173) + [`eba62e1`](https://github.com/graphql-hive/console/commit/eba62e13f658f00a4a8f6db6b4d8501070fbed45) + Thanks [@dotansimha](https://github.com/dotansimha)! - Use the correct plugin version in the + User-Agent header used for Console requests + +- [#6906](https://github.com/graphql-hive/console/pull/6906) + [`7fe1c27`](https://github.com/graphql-hive/console/commit/7fe1c271a596353d23ad770ce667f7781be6cc13) + Thanks [@egoodwinx](https://github.com/egoodwinx)! - Update Rust version to 1.90 + +## 2.1.3 + +### Patch Changes + +- [#6753](https://github.com/graphql-hive/console/pull/6753) + [`7ef800e`](https://github.com/graphql-hive/console/commit/7ef800e8401a4e3fda4e8d1208b940ad6743449e) + Thanks [@Intellicode](https://github.com/Intellicode)! - fix tmp dir filename + +## 2.1.2 + +### Patch Changes + +- [#6788](https://github.com/graphql-hive/console/pull/6788) + [`6f0af0e`](https://github.com/graphql-hive/console/commit/6f0af0eb712ce358b212b335f11d4a86ede08931) + Thanks [@dotansimha](https://github.com/dotansimha)! - Bump version to trigger release, fix + lockfile + +## 2.1.1 + +### Patch Changes + +- [#6714](https://github.com/graphql-hive/console/pull/6714) + [`3f823c9`](https://github.com/graphql-hive/console/commit/3f823c9e1f3bd5fd8fde4e375a15f54a9d5b4b4e) + Thanks [@github-actions](https://github.com/apps/github-actions)! - Updated internal Apollo crates + to get downstream fix for advisories. See + https://github.com/apollographql/router/releases/tag/v2.1.1 + +## 2.1.0 + +### Minor Changes + +- [#6577](https://github.com/graphql-hive/console/pull/6577) + [`c5d7822`](https://github.com/graphql-hive/console/commit/c5d78221b6c088f2377e6491b5bd3c7799d53e94) + Thanks [@dotansimha](https://github.com/dotansimha)! - Add support for providing a target for + usage reporting with organization access tokens. + + 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`). + + ```yaml + # ... other apollo-router configuration + plugins: + hive.usage: + enabled: true + registry_token: 'ORGANIZATION_ACCESS_TOKEN' + target: 'my-org/my-project/my-target' + ``` + +## 2.0.0 + +### Major Changes + +- [#6549](https://github.com/graphql-hive/console/pull/6549) + [`158b63b`](https://github.com/graphql-hive/console/commit/158b63b4f217bf08f59dbef1fa14553106074cc9) + Thanks [@dotansimha](https://github.com/dotansimha)! - Updated core dependnecies (body, http) to + match apollo-router v2 + +### Patch Changes + +- [#6549](https://github.com/graphql-hive/console/pull/6549) + [`158b63b`](https://github.com/graphql-hive/console/commit/158b63b4f217bf08f59dbef1fa14553106074cc9) + Thanks [@dotansimha](https://github.com/dotansimha)! - Updated thiserror, jsonschema, lru, rand to + latest and adjust the code + +## 1.1.1 + +### Patch Changes + +- [#6383](https://github.com/graphql-hive/console/pull/6383) + [`ec356a7`](https://github.com/graphql-hive/console/commit/ec356a7784d1f59722f80a69f501f1f250b2f6b2) + Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - Collect custom scalars from arguments + and input object fields + +## 1.1.0 + +### Minor Changes + +- [#5732](https://github.com/graphql-hive/console/pull/5732) + [`1d3c566`](https://github.com/graphql-hive/console/commit/1d3c566ddcf5eb31c68545931da32bcdf4b8a047) + Thanks [@dotansimha](https://github.com/dotansimha)! - Updated Apollo-Router custom plugin for + Hive to use Usage reporting spec v2. + [Learn more](https://the-guild.dev/graphql/hive/docs/specs/usage-reports) + +- [#5732](https://github.com/graphql-hive/console/pull/5732) + [`1d3c566`](https://github.com/graphql-hive/console/commit/1d3c566ddcf5eb31c68545931da32bcdf4b8a047) + Thanks [@dotansimha](https://github.com/dotansimha)! - Add support for persisted documents using + Hive App Deployments. + [Learn more](https://the-guild.dev/graphql/hive/product-updates/2024-07-30-persisted-documents-app-deployments-preview) + +## 1.0.1 + +### Patch Changes + +- [#6057](https://github.com/graphql-hive/console/pull/6057) + [`e4f8b0a`](https://github.com/graphql-hive/console/commit/e4f8b0a51d1158da966a719f321bc13e5af39ea0) + Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - Explain what Hive is in README + +## 1.0.0 + +### Major Changes + +- [#5941](https://github.com/graphql-hive/console/pull/5941) + [`762bcd8`](https://github.com/graphql-hive/console/commit/762bcd83941d7854873f6670580ae109c4901dea) + Thanks [@dotansimha](https://github.com/dotansimha)! - Release v1 of Hive plugin for apollo-router + +## 0.1.2 + +### Patch Changes + +- [#5991](https://github.com/graphql-hive/console/pull/5991) + [`1ea4df9`](https://github.com/graphql-hive/console/commit/1ea4df95b5fcef85f19caf682a827baf1849a28d) + Thanks [@dotansimha](https://github.com/dotansimha)! - Improvements to release pipeline and added + missing metadata to Cargo file + +## 0.1.1 + +### Patch Changes + +- [#5930](https://github.com/graphql-hive/console/pull/5930) + [`1b7acd6`](https://github.com/graphql-hive/console/commit/1b7acd6978391e402fe04cc752b5e61ec05d0f03) + Thanks [@dotansimha](https://github.com/dotansimha)! - Fixes for Crate publishing flow + +## 0.1.0 + +### Minor Changes + +- [#5922](https://github.com/graphql-hive/console/pull/5922) + [`28c6da8`](https://github.com/graphql-hive/console/commit/28c6da8b446d62dcc4460be946fe3aecdbed858d) + Thanks [@dotansimha](https://github.com/dotansimha)! - Initial release of Hive plugin for + Apollo-Router + +## 0.0.1 + +### Patch Changes + +- [#5898](https://github.com/graphql-hive/console/pull/5898) + [`1a92d7d`](https://github.com/graphql-hive/console/commit/1a92d7decf9d0593450e81b394d12c92f40c2b3d) + Thanks [@dotansimha](https://github.com/dotansimha)! - Initial release of + hive-apollo-router-plugin crate + +- Report enum values when an enum is used as an output type and align with JS implementation + +# 19.07.2024 + +- Writes `supergraph-schema.graphql` file to a temporary directory (the path depends on OS), and + this is now the default of `HIVE_CDN_SCHEMA_FILE_PATH`. + +# 10.04.2024 + +- `HIVE_CDN_ENDPOINT` and `endpoint` accept an URL with and without the `/supergraph` part + +# 09.01.2024 + +- Introduce `HIVE_CDN_SCHEMA_FILE_PATH` environment variable to specify where to download the + supergraph schema (default is `./supergraph-schema.graphql`) + +# 11.07.2023 + +- Use debug level when logging dropped operations + +# 07.06.2023 + +- Introduce `enabled` flag (Usage Plugin) + +# 23.08.2022 + +- Don't panic on scalars used as variable types +- Introduce `buffer_size` +- Ignore operations including `__schema` or `__type` diff --git a/apollo-router-workspace/bin/router/Cargo.toml b/apollo-router-workspace/bin/router/Cargo.toml new file mode 100644 index 000000000..096b8ef0f --- /dev/null +++ b/apollo-router-workspace/bin/router/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "hive-apollo-router-plugin" +authors = ["The Guild"] +repository = "https://github.com/graphql-hive/router" +edition = "2021" +license = "MIT" +publish = true +version = "3.0.3" +description = "Apollo-Router Plugin for integrating with Hive Console" + +[[bin]] +name = "router" +path = "src/main.rs" + +[lib] +name = "hive_apollo_router_plugin" +path = "src/lib.rs" + +[dependencies] +apollo-router = { version = "^2.0.0" } +hive-console-sdk = { version = "=0.3.8", path = "../../../lib/hive-console-sdk"} +sha2 = { version = "0.10.8", features = ["std"] } +anyhow = "1" +tracing = "0.1" +async-trait = "0.1.77" +futures = { version = "0.3.30", features = ["thread-pool"] } +schemars = { version = "1.0.4", features = ["url2"] } +serde = "1" +serde_json = "1" +tokio = { version = "1.36.0", features = ["full"] } +tower = { version = "0.5", features = ["full"] } +http = "1" +http-body-util = "0.1" +rand = "0.9.0" +tokio-util = "0.7.16" + +[dev-dependencies] +httpmock = "0.7.0" +jsonschema = { version = "0.29.0", default-features = false, features = [ + "resolve-file", +] } +lazy_static = "1.5.0" diff --git a/apollo-router-workspace/bin/router/README.md b/apollo-router-workspace/bin/router/README.md new file mode 100644 index 000000000..c4719f9a1 --- /dev/null +++ b/apollo-router-workspace/bin/router/README.md @@ -0,0 +1,86 @@ +# Hive plugin for Apollo-Router + +[Hive](https://the-guild.dev/graphql/hive) is a fully open-source schema registry, analytics, +metrics and gateway for [GraphQL federation](https://the-guild.dev/graphql/hive/federation) and +other GraphQL APIs. + +--- + +This project includes a Hive integration plugin for Apollo-Router. + +At the moment, the following are implemented: + +- [Fetching Supergraph from Hive CDN](https://the-guild.dev/graphql/hive/docs/high-availability-cdn) +- [Sending usage information](https://the-guild.dev/graphql/hive/docs/schema-registry/usage-reporting) + from a running Apollo Router instance to Hive +- Persisted Operations using Hive's + [App Deployments](https://the-guild.dev/graphql/hive/docs/schema-registry/app-deployments) + +This project is constructed as a Rust project that implements Apollo-Router plugin interface. + +This build of this project creates an artifact identical to Apollo-Router releases, with additional +features provided by Hive. + +## Getting Started + +### Binary/Docker + +We provide a custom build of Apollo-Router that acts as a drop-in replacement, and adds Hive +integration to Apollo-Router. + +[Please follow this guide and documentation for integrating Hive with Apollo Router](https://the-guild.dev/graphql/hive/docs/other-integrations/apollo-router) + +### As a Library + +If you are +[building a custom Apollo-Router with your own native plugins](https://www.apollographql.com/docs/graphos/routing/customization/native-plugins), +you can use the Hive plugin as a dependency from Crates.io: + +```toml +[dependencies] +hive-apollo-router-plugin = "..." +``` + +And then in your codebase, make sure to import and register the Hive plugin: + +```rs +use apollo_router::register_plugin; +// import the registry instance and the plugin registration function +use hive_apollo_router_plugin::registry::HiveRegistry; +// Import the usage plugin +use hive_apollo_router_plugin::usage::UsagePlugin; +// Import persisted documents plugin, if needed +use hive_apollo_router_plugin::persisted_documents::PersistedDocumentsPlugin; + + +// In your main function, make sure to register the plugin before you create or initialize Apollo-Router +fn main() { + // Register the Hive usage_reporting plugin + register_plugin!("hive", "usage", UsagePlugin); + // Register the persisted documents plugin, if needed + register_plugin!("hive", "persisted_documents", PersistedDocumentsPlugin); + + // Initialize the Hive Registry instance and start the Apollo Router + match HiveRegistry::new(None).and(apollo_router::main()) { + Ok(_) => {} + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } +} +``` + +## Development + +0. Install latest version of Rust +1. To get started with development, it is recommended to ensure Rust-analyzer extension is enabled + on your VSCode instance. +2. Validate project status by running `cargo check` +3. To start the server with the demo config file (`./router.yaml`), use + `cargo run -- --config router.yaml`. Make sure to set environment variables required for your + setup and development process + ([docs](https://the-guild.dev/graphql/hive/docs/other-integrations/apollo-router#configuration)). +4. You can also just run + `cargo run -- --config router.yaml --log debug --dev --supergraph some.supergraph.graphql` for + running it with a test supergraph file. diff --git a/apollo-router-workspace/bin/router/router.yaml b/apollo-router-workspace/bin/router/router.yaml new file mode 100644 index 000000000..615c01d51 --- /dev/null +++ b/apollo-router-workspace/bin/router/router.yaml @@ -0,0 +1,10 @@ +sandbox: + enabled: true +homepage: + enabled: false +supergraph: + listen: 0.0.0.0:4000 + introspection: true +plugins: + hive.usage: {} + hive.persisted_documents: {} diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json new file mode 100644 index 000000000..a92ac08ff --- /dev/null +++ b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "apollo-router-updater", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "apollo-router-updater", + "dependencies": { + "@actions/core": "1.11.1", + "@types/node": "25.0.10" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + } + } +} diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json new file mode 100644 index 000000000..d43e7acb8 --- /dev/null +++ b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json @@ -0,0 +1,12 @@ +{ + "name": "apollo-router-updater", + "type": "module", + "private": true, + "scripts": { + "start": "node src/index.ts" + }, + "dependencies": { + "@types/node": "25.0.10", + "@actions/core": "1.11.1" + } +} \ No newline at end of file diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts new file mode 100644 index 000000000..557cd828f --- /dev/null +++ b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { setOutput } from '@actions/core'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const [localVersion, latestStableVersion] = await Promise.all([ + fetchLocalVersion(), + fetchLatestVersion(), +]); + +console.log(`Latest stable version: ${latestStableVersion}`); +console.log(`Local version: ${localVersion}`); + +if (localVersion === latestStableVersion) { + console.log('Local version is up to date'); + setOutput('update', 'false'); + process.exit(0); +} + +console.log('Local version is out of date'); + +if (await isPullRequestOpen(latestStableVersion)) { + console.log(`PR already exists`); + setOutput('update', 'false'); +} else { + console.log('PR does not exist.'); + console.log(`Run: cargo update -p apollo-router --precise ${latestStableVersion}`); + console.log('Then commit and push the changes.'); + setOutput('update', 'true'); + setOutput('version', latestStableVersion); +} + +async function fetchLatestVersion() { + const latestResponse = await fetch( + 'https://api.github.com/repos/apollographql/router/releases/latest', + { + method: 'GET', + }, + ); + + if (!latestResponse.ok) { + throw new Error('Failed to fetch versions'); + } + + const latest = await latestResponse.json(); + const latestStableVersion = latest.tag_name.replace('v', ''); + + if (!latestStableVersion) { + throw new Error('Failed to find latest stable version'); + } + + return latestStableVersion; +} +async function fetchLocalVersion() { + const lockFile = await readFile(join(__dirname, '../../../Cargo.toml'), 'utf-8'); + + const apolloRouterPackage = lockFile + .split('[[package]]') + .find(pkg => pkg.includes('name = "apollo-router"')); + + if (!apolloRouterPackage) { + throw new Error('Failed to find apollo-router package in Cargo.lock'); + } + + const versionMatch = apolloRouterPackage.match(/version = "(.*)"/); + + if (!versionMatch) { + throw new Error('Failed to find version of apollo-router package in Cargo.lock'); + } + + return versionMatch[1]; +} + +async function isPullRequestOpen(latestStableVersion: string) { + const prTitle = `Update apollo-router to ${latestStableVersion}`; + + setOutput('title', prTitle); + + const prResponse = await fetch(`https://api.github.com/repos/apollographql/router/pulls`); + + if (!prResponse.ok) { + throw new Error('Failed to fetch PRs'); + } + + const prs: Array<{ + title: string; + html_url: string; + }> = await prResponse.json(); + + return prs.some(pr => pr.title === prTitle); +} diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json new file mode 100644 index 000000000..6165d2b34 --- /dev/null +++ b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["esnext", "dom"], + "baseUrl": ".", + "outDir": "dist", + "rootDir": ".", + + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "allowJs": true, + "skipLibCheck": true, + + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + "sourceMap": true, + "declaration": false, + "declarationMap": false, + "resolveJsonModule": false, + + "moduleResolution": "node", + "strict": true + } +} diff --git a/apollo-router-workspace/bin/router/src/consts.rs b/apollo-router-workspace/bin/router/src/consts.rs new file mode 100644 index 000000000..9de035966 --- /dev/null +++ b/apollo-router-workspace/bin/router/src/consts.rs @@ -0,0 +1 @@ +pub const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/apollo-router-workspace/bin/router/src/lib.rs b/apollo-router-workspace/bin/router/src/lib.rs new file mode 100644 index 000000000..5dc5145da --- /dev/null +++ b/apollo-router-workspace/bin/router/src/lib.rs @@ -0,0 +1,5 @@ +pub mod consts; +pub mod persisted_documents; +pub mod registry; +pub mod registry_logger; +pub mod usage; diff --git a/apollo-router-workspace/bin/router/src/main.rs b/apollo-router-workspace/bin/router/src/main.rs new file mode 100644 index 000000000..910c4cf5f --- /dev/null +++ b/apollo-router-workspace/bin/router/src/main.rs @@ -0,0 +1,32 @@ +// Specify the modules our binary should include -- https://twitter.com/YassinEldeeb7/status/1468680104243077128 +mod consts; +mod persisted_documents; +mod registry; +mod registry_logger; +mod usage; + +use apollo_router::register_plugin; +use persisted_documents::PersistedDocumentsPlugin; +use registry::HiveRegistry; +use usage::UsagePlugin; + +// Register the Hive plugin +pub fn register_plugins() { + register_plugin!("hive", "usage", UsagePlugin); + register_plugin!("hive", "persisted_documents", PersistedDocumentsPlugin); +} + +fn main() { + // Register the Hive plugins in Apollo Router + register_plugins(); + + // Initialize the Hive Registry and start the Apollo Router + // TODO: Look at builder pattern in Executable::builder().start() + match HiveRegistry::new(None).and(apollo_router::main()) { + Ok(_) => {} + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } +} diff --git a/apollo-router-workspace/bin/router/src/persisted_documents.rs b/apollo-router-workspace/bin/router/src/persisted_documents.rs new file mode 100644 index 000000000..db7302e32 --- /dev/null +++ b/apollo-router-workspace/bin/router/src/persisted_documents.rs @@ -0,0 +1,744 @@ +use apollo_router::graphql; +use apollo_router::graphql::Error; +use apollo_router::layers::ServiceBuilderExt; +use apollo_router::plugin::Plugin; +use apollo_router::plugin::PluginInit; +use apollo_router::services::router; +use apollo_router::services::router::Body; +use apollo_router::Context; +use apollo_router::services::router::body::from_bytes; +use core::ops::Drop; +use futures::FutureExt; +use hive_console_sdk::persisted_documents::PersistedDocumentsError; +use hive_console_sdk::persisted_documents::PersistedDocumentsManager; +use http::StatusCode; +use http_body_util::BodyExt; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::env; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::time::Duration; +use tower::{BoxError, ServiceBuilder, ServiceExt}; +use tracing::{debug, info, warn}; + +use crate::consts::PLUGIN_VERSION; + +pub static PERSISTED_DOCUMENT_HASH_KEY: &str = "hive::persisted_document_hash"; + +#[derive(Clone, Debug, Deserialize, JsonSchema, Default)] +pub struct Config { + pub enabled: Option, + /// GraphQL Hive persisted documents CDN endpoint URL. + pub endpoint: Option, + /// GraphQL Hive persisted documents CDN access token. + pub key: Option, + /// Whether arbitrary documents should be allowed along-side persisted documents. + /// default: false + pub allow_arbitrary_documents: Option, + /// A timeout for only the connect phase of a request to GraphQL Hive + /// Unit: seconds + /// Default: 5 + pub connect_timeout: Option, + /// Retry count for the request to CDN request + /// Default: 3 + pub retry_count: Option, + /// A timeout for the entire request to GraphQL Hive + /// Unit: seconds + /// Default: 15 + pub request_timeout: Option, + /// Accept invalid SSL certificates + /// default: false + pub accept_invalid_certs: Option, + /// Configuration for the size of the in-memory caching of persisted documents. + /// Default: 1000 + pub cache_size: Option, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum EndpointConfig { + Single(String), + Multiple(Vec), +} + +impl From<&str> for EndpointConfig { + fn from(value: &str) -> Self { + EndpointConfig::Single(value.into()) + } +} + +impl From<&[&str]> for EndpointConfig { + fn from(value: &[&str]) -> Self { + EndpointConfig::Multiple(value.iter().map(|s| s.to_string()).collect()) + } +} + +pub struct PersistedDocumentsPlugin { + persisted_documents_manager: Option>, + allow_arbitrary_documents: bool, +} + +impl PersistedDocumentsPlugin { + fn from_config(config: Config) -> Result { + let enabled = config.enabled.unwrap_or(true); + let allow_arbitrary_documents = config.allow_arbitrary_documents.unwrap_or(false); + if !enabled { + return Ok(PersistedDocumentsPlugin { + persisted_documents_manager: None, + allow_arbitrary_documents, + }); + } + let endpoints = match &config.endpoint { + Some(ep) => match ep { + EndpointConfig::Single(url) => vec![url.clone()], + EndpointConfig::Multiple(urls) => urls.clone(), + }, + None => { + if let Ok(ep) = env::var("HIVE_CDN_ENDPOINT") { + vec![ep] + } else { + return Err( + "Endpoint for persisted documents CDN is not configured. Please set it via the plugin configuration or HIVE_CDN_ENDPOINT environment variable." + .into(), + ); + } + } + }; + + let key = match &config.key { + Some(k) => k.clone(), + None => { + if let Ok(key) = env::var("HIVE_CDN_KEY") { + key + } else { + return Err( + "Access token for persisted documents CDN is not configured. Please set it via the plugin configuration or HIVE_CDN_KEY environment variable." + .into(), + ); + } + } + }; + + let mut persisted_documents_manager = PersistedDocumentsManager::builder() + .key(key) + .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)); + + for endpoint in endpoints { + persisted_documents_manager = persisted_documents_manager.add_endpoint(endpoint); + } + + if let Some(connect_timeout) = config.connect_timeout { + persisted_documents_manager = + persisted_documents_manager.connect_timeout(Duration::from_secs(connect_timeout)); + } + + if let Some(request_timeout) = config.request_timeout { + persisted_documents_manager = + persisted_documents_manager.request_timeout(Duration::from_secs(request_timeout)); + } + + if let Some(retry_count) = config.retry_count { + persisted_documents_manager = persisted_documents_manager.max_retries(retry_count); + } + + if let Some(accept_invalid_certs) = config.accept_invalid_certs { + persisted_documents_manager = + persisted_documents_manager.accept_invalid_certs(accept_invalid_certs); + } + + if let Some(cache_size) = config.cache_size { + persisted_documents_manager = persisted_documents_manager.cache_size(cache_size); + } + + let persisted_documents_manager = persisted_documents_manager.build()?; + + Ok(PersistedDocumentsPlugin { + persisted_documents_manager: Some(Arc::new(persisted_documents_manager)), + allow_arbitrary_documents, + }) + } +} + +#[async_trait::async_trait] +impl Plugin for PersistedDocumentsPlugin { + type Config = Config; + + async fn new(init: PluginInit) -> Result { + PersistedDocumentsPlugin::from_config(init.config) + } + + fn router_service(&self, service: router::BoxService) -> router::BoxService { + if let Some(mgr) = &self.persisted_documents_manager { + let mgr = mgr.clone(); + let allow_arbitrary_documents = self.allow_arbitrary_documents; + ServiceBuilder::new() + .checkpoint_async(move |req: router::Request| { + let mgr = mgr.clone(); + async move { + let (parts, body) = req.router_request.into_parts(); + let bytes = body + .collect() + .await + .map_err(|err| PersistedDocumentsError::FailedToReadBody(err.to_string()))? + .to_bytes(); + + let payload = extract_document_id(&bytes); + + let mut payload = match payload { + Ok(payload) => payload, + Err(e) => { + return Ok(ControlFlow::Break( + to_router_response(e, req.context)?, + )); + } + }; + + if payload.original_req.query.is_some() { + if allow_arbitrary_documents { + let roll_req: router::Request = ( + http::Request::::from_parts( + parts, + from_bytes(bytes), + ), + req.context, + ) + .into(); + + return Ok(ControlFlow::Continue(roll_req)); + } else { + return Ok(ControlFlow::Break( + to_router_response(PersistedDocumentsError::PersistedDocumentRequired, req.context)? + )); + } + } + + if payload.document_id.is_none() { + return Ok(ControlFlow::Break( + to_router_response(PersistedDocumentsError::KeyNotFound, req.context)? + )); + } + + match payload.document_id.as_ref() { + None => { + Ok(ControlFlow::Break( + to_router_response(PersistedDocumentsError::PersistedDocumentRequired, req.context)? + )) + } + Some(document_id) => { + if document_id.contains("..") { + return Ok(ControlFlow::Break( + to_router_response(PersistedDocumentsError::KeyNotFound, req.context)?, + )); + } + match mgr.resolve_document(document_id).await { + Ok(document) => { + info!("Document found in persisted documents: {}", document); + + if req + .context + .insert(PERSISTED_DOCUMENT_HASH_KEY, document_id.clone()) + .is_err() + { + warn!("failed to extend router context with persisted document hash key"); + } + + payload.original_req.query = Some(document); + + let bytes = serde_json::to_vec(&payload.original_req)?; + + let roll_req: router::Request = ( + http::Request::::from_parts(parts, from_bytes(bytes)), + req.context, + ) + .into(); + + Ok(ControlFlow::Continue(roll_req)) + } + Err(e) => { + Ok(ControlFlow::Break( + to_router_response(e, req.context)?, + )) + } + } + }, + } + } + .boxed() + }) + .buffered() + .service(service) + .boxed() + } else { + service + } + } +} + +impl Drop for PersistedDocumentsPlugin { + fn drop(&mut self) { + debug!("PersistedDocumentsPlugin has been dropped!"); + } +} + +fn to_router_response( + err: PersistedDocumentsError, + ctx: Context, +) -> Result { + let errors = vec![Error::builder() + .message(err.message()) + .extension_code(err.code()) + .build()]; + + router::Response::error_builder() + .errors(errors) + .status_code(StatusCode::OK) + .context(ctx) + .build() +} + +/// Expected body structure for the router incoming requests +/// This is used to extract the document id and the original request as-is (see `flatten` attribute) +#[derive(Debug, Serialize, Deserialize, Clone)] +struct ExpectedBodyStructure { + /// This field is set to optional in order to prevent parsing errors + /// At runtime later, the plugin will double check the value. + #[serde(rename = "documentId")] + #[serde(skip_serializing)] + document_id: Option, + /// The rest of the GraphQL request, flattened to keep the original structure. + #[serde(flatten)] + original_req: graphql::Request, +} + +fn extract_document_id(body: &[u8]) -> Result { + serde_json::from_slice::(body) + .map_err(PersistedDocumentsError::FailedToParseBody) +} + +/// To test this plugin, we do the following: +/// 1. Create the plugin instance +/// 2. Link it to a mocked router service that reflects +/// back the body (to validate that the plugin is working and passes the body correctly) +/// 3. Run HTTP mock to create a mock Hive CDN server +#[cfg(test)] +mod hive_persisted_documents_tests { + use apollo_router::plugin::test::MockRouterService; + use futures::executor::block_on; + use http::Method; + use httpmock::{Method::GET, Mock, MockServer}; + use serde_json::json; + + use super::*; + + /// Creates a regular GraphQL request with a very simple GraphQL query: + /// { "query": "query { __typename }" } + fn create_regular_request() -> router::Request { + let mut r = graphql::Request::default(); + + r.query = Some("query { __typename }".into()); + + router::Request::fake_builder() + .method(Method::POST) + .body(serde_json::to_string(&r).unwrap()) + .header("content-type", "application/json") + .build() + .unwrap() + } + + /// Creates a persisted document request with a document id and optional variables. + /// The document id is used to fetch the persisted document from the CDN. + /// { "documentId": "123", "variables": { ... } } + fn create_persisted_request( + document_id: &str, + variables: Option, + ) -> router::Request { + let body = json!({ + "documentId": document_id, + "variables": variables, + }); + + let body_str = serde_json::to_string(&body).unwrap(); + + router::Request::fake_builder() + .body(body_str) + .header("content-type", "application/json") + .build() + .unwrap() + } + + /// Creates an "invalid" persisted request with an empty JSON object body. + fn create_invalid_req() -> router::Request { + router::Request::fake_builder() + .method(Method::POST) + .body(serde_json::to_string(&json!({})).unwrap()) + .header("content-type", "application/json") + .build() + .unwrap() + } + + struct PersistedDocumentsCDNMock { + server: MockServer, + } + + impl PersistedDocumentsCDNMock { + fn new() -> Self { + let server = MockServer::start(); + + Self { server } + } + + fn endpoint(&self) -> EndpointConfig { + EndpointConfig::Single(self.server.url("")) + } + + /// Registers a valid artifact URL with an actual GraphQL document + fn add_valid(&'_ self, document_id: &str) -> Mock<'_> { + let valid_artifact_url = format!("/apps/{}", str::replace(document_id, "~", "/")); + let document = "query { __typename }"; + let mock = self.server.mock(|when, then| { + when.method(GET).path(valid_artifact_url); + then.status(200) + .header("content-type", "text/plain") + .body(document); + }); + + mock + } + } + + async fn get_body(router_req: router::Request) -> String { + let (_parts, body) = router_req.router_request.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + String::from_utf8(body.to_vec()).unwrap() + } + + /// Creates a mocked router service that reflects the incoming body + /// back to the client. + /// We are using this mocked router in order to make sure that the Persisted Documents layer + /// is able to resolve, fetch and pass the document to the next layer. + fn create_reflecting_mocked_router() -> MockRouterService { + let mut mocked_execution: MockRouterService = MockRouterService::new(); + + mocked_execution + .expect_call() + .times(1) + .returning(move |req| { + let incoming_body = block_on(get_body(req)); + Ok(router::Response::fake_builder() + .data(json!({ + "incomingBody": incoming_body, + })) + .build() + .unwrap()) + }); + + mocked_execution + } + + /// Creates a mocked router service that returns a fake GraphQL response. + fn create_dummy_mocked_router() -> MockRouterService { + let mut mocked_execution = MockRouterService::new(); + + mocked_execution.expect_call().times(1).returning(move |_| { + Ok(router::Response::fake_builder() + .data(json!({ + "__typename": "Query" + })) + .build() + .unwrap()) + }); + + mocked_execution + } + + #[tokio::test] + async fn should_allow_arbitrary_when_regular_req_is_sent() { + let service = create_reflecting_mocked_router(); + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some("https://cdn.example.com".into()), + key: Some("123".into()), + allow_arbitrary_documents: Some(true), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(service.boxed()); + + let request = create_regular_request(); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "data": { + "incomingBody": "{\"query\":\"query { __typename }\"}" + } + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn should_disallow_arbitrary_when_regular_req_sent() { + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some("https://cdn.example.com".into()), + key: Some("123".into()), + allow_arbitrary_documents: Some(false), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(MockRouterService::new().boxed()); + + let request = create_regular_request(); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "errors": [ + { + "message": "No persisted document provided, or document id cannot be resolved.", + "extensions": { + "code": "PERSISTED_DOCUMENT_REQUIRED" + } + } + ] + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn returns_not_found_error_for_missing_persisted_query() { + let cdn_mock = PersistedDocumentsCDNMock::new(); + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some(cdn_mock.endpoint()), + key: Some("123".into()), + allow_arbitrary_documents: Some(true), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(MockRouterService::new().boxed()); + + let request = create_persisted_request("123", None); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "errors": [ + { + "message": "Persisted document not found.", + "extensions": { + "code": "PERSISTED_DOCUMENT_NOT_FOUND" + } + } + ] + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn returns_key_not_found_error_for_missing_input() { + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some("https://cdn.example.com".into()), + key: Some("123".into()), + allow_arbitrary_documents: Some(true), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(MockRouterService::new().boxed()); + + let request = create_invalid_req(); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "errors": [ + { + "message": "Failed to locate the persisted document key in request.", + "extensions": { + "code": "PERSISTED_DOCUMENT_KEY_NOT_FOUND" + } + } + ] + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn rejects_req_when_cdn_not_available() { + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some("https://127.0.0.1:9999".into()), // Invalid endpoint + key: Some("123".into()), + allow_arbitrary_documents: Some(false), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(MockRouterService::new().boxed()); + + let request = create_persisted_request("123", None); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "errors": [ + { + "message": "Failed to validate persisted document", + "extensions": { + "code": "FAILED_TO_FETCH_FROM_CDN" + } + } + ] + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn should_return_valid_response() { + let cdn_mock = PersistedDocumentsCDNMock::new(); + cdn_mock.add_valid("my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f"); + let upstream = create_dummy_mocked_router(); + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some(cdn_mock.endpoint()), + key: Some("123".into()), + allow_arbitrary_documents: Some(false), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(upstream.boxed()); + + let request = create_persisted_request( + "my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f", + None, + ); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "data": { + "__typename": "Query" + } + }) + .to_string() + .as_bytes() + ); + } + + #[tokio::test] + async fn should_passthrough_additional_req_params() { + let cdn_mock = PersistedDocumentsCDNMock::new(); + cdn_mock.add_valid("my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f"); + let upstream = create_reflecting_mocked_router(); + let service_stack = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some(cdn_mock.endpoint()), + key: Some("123".into()), + allow_arbitrary_documents: Some(false), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin") + .router_service(upstream.boxed()); + + let request = create_persisted_request( + "my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f", + Some(json!({"var": "value"})), + ); + let mut response = service_stack.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + "{\"data\":{\"incomingBody\":\"{\\\"query\\\":\\\"query { __typename }\\\",\\\"variables\\\":{\\\"var\\\":\\\"value\\\"}}\"}}" + ); + } + + #[tokio::test] + async fn should_use_caching_for_documents() { + let cdn_mock = PersistedDocumentsCDNMock::new(); + let cdn_req_mock = cdn_mock.add_valid("my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f"); + + let p = PersistedDocumentsPlugin::from_config(Config { + enabled: Some(true), + endpoint: Some(cdn_mock.endpoint()), + key: Some("123".into()), + allow_arbitrary_documents: Some(false), + ..Default::default() + }) + .expect("Failed to create PersistedDocumentsPlugin"); + let s1 = p.router_service(create_dummy_mocked_router().boxed()); + let s2 = p.router_service(create_dummy_mocked_router().boxed()); + + // first call + let request = create_persisted_request( + "my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f", + None, + ); + + let mut response = s1.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "data": { + "__typename": "Query" + } + }) + .to_string() + .as_bytes() + ); + + // second call + let request = create_persisted_request( + "my-app~cacb95c69ba4684aec972777a38cd106740c6453~04bfa72dfb83b297dd8a5b6fed9bafac2b395a0f", + None, + ); + let mut response = s2.oneshot(request).await.unwrap(); + let response_inner = response.next_response().await.unwrap().unwrap(); + assert_eq!(response.response.status(), StatusCode::OK); + assert_eq!( + response_inner, + json!({ + "data": { + "__typename": "Query" + } + }) + .to_string() + .as_bytes() + ); + + // makes sure cdn called only once. If called more than once, it will fail with 404 -> leading to error (and the above assertion will fail...) + cdn_req_mock.assert(); + } +} diff --git a/apollo-router-workspace/bin/router/src/registry.rs b/apollo-router-workspace/bin/router/src/registry.rs new file mode 100644 index 000000000..8d284633a --- /dev/null +++ b/apollo-router-workspace/bin/router/src/registry.rs @@ -0,0 +1,211 @@ +use crate::consts::PLUGIN_VERSION; +use crate::registry_logger::Logger; +use anyhow::{anyhow, Result}; +use hive_console_sdk::supergraph_fetcher::sync_fetcher::SupergraphFetcherSyncState; +use hive_console_sdk::supergraph_fetcher::SupergraphFetcher; +use sha2::Digest; +use sha2::Sha256; +use std::env; +use std::io::Write; +use std::thread; + +#[derive(Debug)] +pub struct HiveRegistry { + file_name: String, + fetcher: SupergraphFetcher, + pub logger: Logger, +} + +pub struct HiveRegistryConfig { + endpoints: Vec, + key: Option, + poll_interval: Option, + accept_invalid_certs: Option, + schema_file_path: Option, +} + +impl HiveRegistry { + #[allow(clippy::new_ret_no_self)] + pub fn new(user_config: Option) -> Result<()> { + let mut config = HiveRegistryConfig { + endpoints: vec![], + key: None, + poll_interval: None, + accept_invalid_certs: Some(true), + schema_file_path: None, + }; + + // Pass values from user's config + if let Some(user_config) = user_config { + config.endpoints = user_config.endpoints; + config.key = user_config.key; + config.poll_interval = user_config.poll_interval; + config.accept_invalid_certs = user_config.accept_invalid_certs; + config.schema_file_path = user_config.schema_file_path; + } + + // Pass values from environment variables if they are not set in the user's config + + if config.endpoints.is_empty() { + if let Ok(endpoint) = env::var("HIVE_CDN_ENDPOINT") { + config.endpoints.push(endpoint); + } + } + + if config.key.is_none() { + if let Ok(key) = env::var("HIVE_CDN_KEY") { + config.key = Some(key); + } + } + + if config.poll_interval.is_none() { + if let Ok(poll_interval) = env::var("HIVE_CDN_POLL_INTERVAL") { + config.poll_interval = Some( + poll_interval + .parse() + .expect("failed to parse HIVE_CDN_POLL_INTERVAL"), + ); + } + } + + if config.accept_invalid_certs.is_none() { + if let Ok(accept_invalid_certs) = env::var("HIVE_CDN_ACCEPT_INVALID_CERTS") { + config.accept_invalid_certs = Some( + accept_invalid_certs.eq("1") + || accept_invalid_certs.to_lowercase().eq("true") + || accept_invalid_certs.to_lowercase().eq("on"), + ); + } + } + + if config.schema_file_path.is_none() { + if let Ok(schema_file_path) = env::var("HIVE_CDN_SCHEMA_FILE_PATH") { + config.schema_file_path = Some(schema_file_path); + } + } + + // Resolve values + let endpoint = config.endpoints; + let key = config.key.unwrap_or_default(); + let poll_interval: u64 = config.poll_interval.unwrap_or(10); + let accept_invalid_certs = config.accept_invalid_certs.unwrap_or(false); + let logger = Logger::new(); + + // In case of an endpoint and an key being empty, we don't start the polling and skip the registry + if endpoint.is_empty() && key.is_empty() { + logger.info("You're not using GraphQL Hive as the source of schema."); + logger.info( + "Reason: could not find HIVE_CDN_KEY and HIVE_CDN_ENDPOINT environment variables.", + ); + return Ok(()); + } + + // Throw if endpoint is empty + if endpoint.is_empty() { + return Err(anyhow!("environment variable HIVE_CDN_ENDPOINT not found",)); + } + + // Throw if key is empty + if key.is_empty() { + return Err(anyhow!("environment variable HIVE_CDN_KEY not found")); + } + + // A hacky way to force the router to use GraphQL Hive CDN as the source of schema. + // Our plugin does the polling and saves the supergraph to a file. + // It also enables hot-reloading to makes sure Apollo Router watches the file. + let file_name = config.schema_file_path.unwrap_or( + env::temp_dir() + .join("supergraph-schema.graphql") + .to_string_lossy() + .to_string(), + ); + + env::set_var("APOLLO_ROUTER_SUPERGRAPH_PATH", file_name.clone()); + env::set_var("APOLLO_ROUTER_HOT_RELOAD", "true"); + + let mut fetcher = SupergraphFetcher::builder() + .key(key) + .user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)) + .accept_invalid_certs(accept_invalid_certs); + + for ep in endpoint { + fetcher = fetcher.add_endpoint(ep); + } + + let fetcher = fetcher + .build_sync() + .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; + + let registry = HiveRegistry { + fetcher, + file_name, + logger, + }; + + match registry.initial_supergraph() { + Ok(_) => { + registry + .logger + .info("Successfully fetched and saved supergraph from GraphQL Hive"); + } + Err(e) => { + registry.logger.error(&e); + std::process::exit(1); + } + } + + thread::spawn(move || loop { + thread::sleep(std::time::Duration::from_secs(poll_interval)); + registry.poll() + }); + + Ok(()) + } + + fn initial_supergraph(&self) -> Result<(), String> { + let mut file = std::fs::File::create(self.file_name.clone()).map_err(|e| e.to_string())?; + let resp = self + .fetcher + .fetch_supergraph() + .map_err(|err| err.to_string())?; + + match resp { + Some(supergraph) => { + file.write_all(supergraph.as_bytes()) + .map_err(|e| e.to_string())?; + } + None => { + return Err("Failed to fetch supergraph".to_string()); + } + } + + Ok(()) + } + + fn poll(&self) { + match self.fetcher.fetch_supergraph() { + Ok(new_supergraph) => { + if let Some(new_supergraph) = new_supergraph { + let current_file = std::fs::read_to_string(self.file_name.clone()) + .expect("Could not read file"); + let current_supergraph_hash = hash(current_file.as_bytes()); + + let new_supergraph_hash = hash(new_supergraph.as_bytes()); + + if current_supergraph_hash != new_supergraph_hash { + self.logger.info("New supergraph detected!"); + std::fs::write(self.file_name.clone(), new_supergraph) + .expect("Could not write file"); + } + } + } + Err(e) => self.logger.error(&e.to_string()), + } + } +} + +fn hash(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:X}", hasher.finalize()) +} diff --git a/apollo-router-workspace/bin/router/src/registry_logger.rs b/apollo-router-workspace/bin/router/src/registry_logger.rs new file mode 100644 index 000000000..642fe3c76 --- /dev/null +++ b/apollo-router-workspace/bin/router/src/registry_logger.rs @@ -0,0 +1,94 @@ +use std::env; + +static LOG_LEVEL_NAMES: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]; + +#[repr(usize)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Debug)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + fn from_usize(u: usize) -> Option { + match u { + 0 => Some(LogLevel::Error), + 1 => Some(LogLevel::Warn), + 2 => Some(LogLevel::Info), + 3 => Some(LogLevel::Debug), + 4 => Some(LogLevel::Trace), + _ => None, + } + } + + fn from_str(s: &str) -> LogLevel { + LOG_LEVEL_NAMES + .iter() + .position(|&name| name.eq_ignore_ascii_case(s)) + .map(|p| LogLevel::from_usize(p).expect("Hive failed to read the log level")) + .expect("Hive failed to parse the log level filter") + } +} + +#[derive(Clone, Debug)] +pub struct Logger { + max_level: LogLevel, +} + +impl Default for Logger { + fn default() -> Self { + Self::new() + } +} + +impl Logger { + pub fn new() -> Logger { + Self { + max_level: LogLevel::from_str( + env::var("HIVE_REGISTRY_LOG") + .unwrap_or_else(|_| "info".to_string()) + .as_str(), + ), + } + } + + fn should_log(&self, level: LogLevel) -> bool { + self.max_level >= level + } + + #[allow(dead_code)] + pub fn trace(&self, message: &str) { + if self.should_log(LogLevel::Trace) { + println!("TRACE: {}", message); + } + } + + #[allow(dead_code)] + pub fn debug(&self, message: &str) { + if self.should_log(LogLevel::Debug) { + println!("DEBUG: {}", message); + } + } + + pub fn info(&self, message: &str) { + if self.should_log(LogLevel::Info) { + println!("INFO: {}", message); + } + } + + #[allow(dead_code)] + pub fn warn(&self, message: &str) { + if self.should_log(LogLevel::Warn) { + println!("WARNING: {}", message); + } + } + + pub fn error(&self, message: &str) { + if self.should_log(LogLevel::Error) { + println!("ERROR: {}", message); + } + } +} diff --git a/apollo-router-workspace/bin/router/src/usage.rs b/apollo-router-workspace/bin/router/src/usage.rs new file mode 100644 index 000000000..5af384947 --- /dev/null +++ b/apollo-router-workspace/bin/router/src/usage.rs @@ -0,0 +1,558 @@ +use crate::consts::PLUGIN_VERSION; +use apollo_router::layers::ServiceBuilderExt; +use apollo_router::plugin::Plugin; +use apollo_router::plugin::PluginInit; +use apollo_router::services::*; +use apollo_router::Context; +use core::ops::Drop; +use futures::StreamExt; +use hive_console_sdk::agent::usage_agent::UsageAgentExt; +use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent}; +use hive_console_sdk::graphql_tools::parser::parse_schema; +use hive_console_sdk::graphql_tools::parser::schema::Document; +use http::HeaderValue; +use rand::Rng; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::env; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio_util::sync::CancellationToken; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; + +use crate::persisted_documents::PERSISTED_DOCUMENT_HASH_KEY; + +pub(crate) static OPERATION_CONTEXT: &str = "hive::operation_context"; + +#[derive(Serialize, Deserialize, Debug)] +struct OperationContext { + pub(crate) client_name: Option, + pub(crate) client_version: Option, + pub(crate) timestamp: u64, + pub(crate) operation_body: String, + pub(crate) operation_name: Option, + pub(crate) dropped: bool, +} + +#[derive(Clone, Debug)] +struct OperationConfig { + sample_rate: f64, + exclude: Option>, + client_name_header: String, + client_version_header: String, +} + +pub struct UsagePlugin { + config: OperationConfig, + agent: Option, + schema: Arc>, + cancellation_token: Arc, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Default)] +pub struct Config { + /// Default: true + enabled: Option, + /// Hive token, can also be set using the HIVE_TOKEN environment variable. + /// The token can be a registry access token, or a organization access token. + registry_token: Option, + /// Hive registry token. Set to your `/usage` endpoint if you are self-hosting. + /// Default: https://app.graphql-hive.com/usage + /// When `target` is set and organization access token is in use, the target ID is appended to the endpoint, + /// so usage endpoint becomes `https://app.graphql-hive.com/usage/` + registry_usage_endpoint: Option, + /// The target to which the usage data should be reported to. + /// 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"). + target: Option, + /// Sample rate to determine sampling. + /// 0.0 = 0% chance of being sent + /// 1.0 = 100% chance of being sent. + /// Default: 1.0 + sample_rate: Option, + /// A list of operations (by name) to be ignored by GraphQL Hive. + exclude: Option>, + client_name_header: Option, + client_version_header: Option, + /// A maximum number of operations to hold in a buffer before sending to GraphQL Hive + /// Default: 1000 + buffer_size: Option, + /// A timeout for only the connect phase of a request to GraphQL Hive + /// Unit: seconds + /// Default: 5 (s) + connect_timeout: Option, + /// A timeout for the entire request to GraphQL Hive + /// Unit: seconds + /// Default: 15 (s) + request_timeout: Option, + /// Accept invalid SSL certificates + /// Default: false + accept_invalid_certs: Option, + /// Frequency of flushing the buffer to the server + /// Default: 5 seconds + flush_interval: Option, +} + +impl UsagePlugin { + fn populate_context(config: OperationConfig, req: &supergraph::Request) { + let context = &req.context; + let http_request = &req.supergraph_request; + let headers = http_request.headers(); + + let get_header_value = |key: &str| { + headers + .get(key) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("")) + .to_str() + .ok() + .map(|v| v.to_string()) + }; + + let client_name = get_header_value(&config.client_name_header); + let client_version = get_header_value(&config.client_version_header); + + let operation_name = req.supergraph_request.body().operation_name.clone(); + let operation_body = req + .supergraph_request + .body() + .query + .clone() + .unwrap_or_default(); + + let excluded_operation_names: HashSet = config + .exclude + .unwrap_or_default() + .clone() + .into_iter() + .collect(); + + let mut rng = rand::rng(); + let sampled = rng.random::() < config.sample_rate; + let mut dropped = !sampled; + + if !dropped { + if let Some(name) = &operation_name { + if excluded_operation_names.contains(name) { + dropped = true; + } + } + } + + let _ = context.insert( + OPERATION_CONTEXT, + OperationContext { + dropped, + client_name, + client_version, + operation_name, + operation_body, + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + * 1000, + }, + ); + } +} + +#[async_trait::async_trait] +impl Plugin for UsagePlugin { + type Config = Config; + + async fn new(init: PluginInit) -> Result { + let user_config = init.config; + + let enabled = user_config.enabled.unwrap_or(true); + + if enabled { + tracing::info!("Starting GraphQL Hive Usage plugin"); + } + + let cancellation_token = Arc::new(CancellationToken::new()); + + let agent = if enabled { + let mut agent = + UsageAgent::builder().user_agent(format!("hive-apollo-router/{}", PLUGIN_VERSION)); + + if let Some(endpoint) = user_config.registry_usage_endpoint { + agent = agent.endpoint(endpoint); + } else if let Ok(env_endpoint) = env::var("HIVE_ENDPOINT") { + agent = agent.endpoint(env_endpoint); + } + + if let Some(token) = user_config.registry_token { + agent = agent.token(token); + } else if let Ok(env_token) = env::var("HIVE_TOKEN") { + agent = agent.token(env_token); + } + + if let Some(target_id) = user_config.target { + agent = agent.target_id(target_id); + } else if let Ok(env_target) = env::var("HIVE_TARGET_ID") { + agent = agent.target_id(env_target); + } + + if let Some(buffer_size) = user_config.buffer_size { + agent = agent.buffer_size(buffer_size); + } + + if let Some(connect_timeout) = user_config.connect_timeout { + agent = agent.connect_timeout(Duration::from_secs(connect_timeout)); + } + + if let Some(request_timeout) = user_config.request_timeout { + agent = agent.request_timeout(Duration::from_secs(request_timeout)); + } + + if let Some(accept_invalid_certs) = user_config.accept_invalid_certs { + agent = agent.accept_invalid_certs(accept_invalid_certs); + } + + if let Some(flush_interval) = user_config.flush_interval { + agent = agent.flush_interval(Duration::from_secs(flush_interval)); + } + + let agent = agent.build().map_err(Box::new)?; + + let cancellation_token_for_interval = cancellation_token.clone(); + let agent_for_interval = agent.clone(); + tokio::task::spawn(async move { + agent_for_interval + .start_flush_interval(&cancellation_token_for_interval) + .await; + }); + Some(agent) + } else { + None + }; + + let schema = parse_schema(&init.supergraph_sdl) + .expect("Failed to parse schema") + .into_static(); + + Ok(UsagePlugin { + schema: Arc::new(schema), + config: OperationConfig { + sample_rate: user_config.sample_rate.unwrap_or(1.0), + exclude: user_config.exclude, + client_name_header: user_config + .client_name_header + .unwrap_or("graphql-client-name".to_string()), + client_version_header: user_config + .client_version_header + .unwrap_or("graphql-client-version".to_string()), + }, + agent, + cancellation_token, + }) + } + + fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService { + let config = self.config.clone(); + let schema = self.schema.clone(); + match self.agent.clone() { + None => ServiceBuilder::new().service(service).boxed(), + Some(agent) => { + ServiceBuilder::new() + .map_future_with_request_data( + move |req: &supergraph::Request| { + Self::populate_context(config.clone(), req); + req.context.clone() + }, + move |ctx: Context, fut| { + let agent = agent.clone(); + let schema = schema.clone(); + async move { + let start: Instant = Instant::now(); + + let result: supergraph::ServiceResult = fut.await; + + // nested async block, bc async is unstable with closures that receive arguments + let Some(operation_context) = ctx + .get::<_, OperationContext>(OPERATION_CONTEXT) + .unwrap_or_default() else { + tracing::debug!("Operation context not found in request context, skipping usage reporting"); + return result; + }; + + // Injected by the persisted document plugin, if it was activated + // and discovered document id + let persisted_document_hash = ctx + .get::<_, String>(PERSISTED_DOCUMENT_HASH_KEY) + .unwrap_or_default(); + + if operation_context.dropped { + tracing::debug!( + "Dropping operation (phase: SAMPLING): {}", + operation_context + .operation_name + .clone() + .unwrap_or_else(|| "anonymous".to_string()) + ); + return result; + } + + let OperationContext { + client_name, + client_version, + operation_name, + timestamp, + operation_body, + .. + } = operation_context; + + let duration = start.elapsed(); + + match result { + Err(e) => { + tokio::spawn(async move { + let res = agent + .add_report(ExecutionReport { + schema, + client_name, + client_version, + timestamp, + duration, + ok: false, + errors: 1, + operation_body, + operation_name, + persisted_document_hash, + }) + .await; + if let Err(e) = res { + tracing::error!("Error adding report: {}", e); + } + }); + Err(e) + } + Ok(router_response) => { + let is_failure = + !router_response.response.status().is_success(); + Ok(router_response.map(move |response_stream| { + let res = response_stream + .map(move |response| { + // make sure we send a single report, not for each chunk + let response_has_errors = + !response.errors.is_empty(); + let agent = agent.clone(); + let execution_report = ExecutionReport { + schema: schema.clone(), + client_name: client_name.clone(), + client_version: client_version.clone(), + timestamp, + duration, + ok: !is_failure && !response_has_errors, + errors: response.errors.len(), + operation_body: operation_body.clone(), + operation_name: operation_name.clone(), + persisted_document_hash: + persisted_document_hash.clone(), + }; + tokio::spawn(async move { + let res = agent + .add_report(execution_report) + .await; + if let Err(e) = res { + tracing::error!( + "Error adding report: {}", + e + ); + } + }); + + response + }) + .boxed(); + + res + })) + } + } + } + }, + ) + .service(service) + .boxed() + } + } + } +} + +impl Drop for UsagePlugin { + fn drop(&mut self) { + self.cancellation_token.cancel(); + // Flush already done by UsageAgent's Drop impl + } +} + +#[cfg(test)] +mod hive_usage_tests { + use apollo_router::{ + 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! { + static ref SCHEMA_VALIDATOR: Validator = + jsonschema::validator_for(&serde_json::from_str(&std::fs::read_to_string("../../../lib/hive-console-sdk/usage-report-v2.schema.json").expect("can't load json schema file")).expect("failed to parse json schema")).expect("failed to parse schema"); + } + + struct UsageTestHelper { + mocked_upstream: MockServer, + plugin: UsagePlugin, + } + + impl UsageTestHelper { + async fn new() -> Self { + let server: MockServer = MockServer::start(); + let usage_endpoint = server.url("/usage"); + let mut config = Config::default(); + config.enabled = Some(true); + config.registry_usage_endpoint = Some(usage_endpoint.to_string()); + config.registry_token = Some("123".into()); + config.buffer_size = Some(1); + config.flush_interval = Some(1); + + let plugin_service = UsagePlugin::new( + PluginInit::fake_builder() + .config(config) + .supergraph_sdl("type Query { dummy: String! }".to_string().into()) + .build(), + ) + .await + .expect("failed to init plugin"); + + UsageTestHelper { + mocked_upstream: server, + plugin: plugin_service, + } + } + + fn wait_for_processing(&self) -> tokio::time::Sleep { + tokio::time::sleep(tokio::time::Duration::from_secs(2)) + } + + fn activate_usage_mock(&'_ self) -> Mock<'_> { + self.mocked_upstream.mock(|when, then| { + 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) + }); + then.status(200); + }) + } + + async fn execute_operation(&self, req: supergraph::Request) -> supergraph::Response { + let mut supergraph_service_mock = MockSupergraphService::new(); + + supergraph_service_mock + .expect_call() + .times(1) + .returning(move |_| { + Ok(supergraph::Response::fake_builder() + .data(json!({ + "data": { "hello": "world" }, + })) + .build() + .unwrap()) + }); + + let tower_service = self + .plugin + .supergraph_service(supergraph_service_mock.boxed()); + + let response = tower_service + .oneshot(req) + .await + .expect("failed to execute operation"); + + response + } + } + + #[tokio::test] + async fn should_work_correctly_for_simple_query() { + let instance = UsageTestHelper::new().await; + let req = supergraph::Request::fake_builder() + .query("query test { hello }") + .operation_name("test") + .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); + } + + #[tokio::test] + async fn without_operation_name() { + let instance = UsageTestHelper::new().await; + let req = supergraph::Request::fake_builder() + .query("query { hello }") + .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); + } + + #[tokio::test] + async fn multiple_operations() { + let instance = UsageTestHelper::new().await; + let req = supergraph::Request::fake_builder() + .query("query test { hello } query test2 { hello }") + .operation_name("test") + .build() + .unwrap(); + let mock = instance.activate_usage_mock(); + + instance.execute_operation(req).await.next_response().await; + + instance.wait_for_processing().await; + println!("Waiting done"); + + mock.assert(); + mock.assert_hits(1); + } +} diff --git a/apollo-router-workspace/docker/docker.hcl b/apollo-router-workspace/docker/docker.hcl new file mode 100644 index 000000000..8ea39daa3 --- /dev/null +++ b/apollo-router-workspace/docker/docker.hcl @@ -0,0 +1,113 @@ +variable "RELEASE" { + default = "dev" +} + +variable "PWD" { + default = "." +} + +variable "DOCKER_REGISTRY" { + default = "" +} + +variable "COMMIT_SHA" { + default = "" +} + +variable "BRANCH_NAME" { + default = "" +} + +variable "BUILD_TYPE" { + # Can be "", "ci" or "publish" + default = "" +} + +variable "BUILD_STABLE" { + # Can be "" or "1" + default = "" +} + +variable "IMAGE_SUFFIX" { + default = "" +} + +variable "BUILD_PLATFORM" { + default = "linux/amd64,linux/arm64" +} + +variable "ROUTER_BINARY_DIR" { + default = "" +} + +function "get_target" { + params = [] + result = notequal("", BUILD_TYPE) ? notequal("ci", BUILD_TYPE) ? "target-publish" : "target-ci" : "target-dev" +} + +function "get_platform" { + params = [] + result = "${BUILD_PLATFORM}" +} + +function "local_image_tag" { + params = [name] + result = equal("", BUILD_TYPE) ? "${DOCKER_REGISTRY}${name}:latest${IMAGE_SUFFIX}" : "" +} + +function "stable_image_tag" { + params = [name] + result = equal("1", BUILD_STABLE) ? "${DOCKER_REGISTRY}${name}:latest${IMAGE_SUFFIX}" : "" +} + +function "image_tag" { + params = [name, tag] + result = notequal("", tag) ? "${DOCKER_REGISTRY}${name}:${tag}${IMAGE_SUFFIX}" : "" +} + +target "router-base" { + dockerfile = "${PWD}/docker/router.dockerfile" + args = { + RELEASE = "${RELEASE}" + } +} + +target "target-dev" {} + +target "target-ci" { + cache-from = ["type=gha,ignore-error=true"] + cache-to = ["type=gha,mode=max,ignore-error=true"] +} + +target "target-publish" { + platforms = [get_platform()] + cache-from = ["type=gha,ignore-error=true"] + cache-to = ["type=gha,mode=max,ignore-error=true"] +} + +target "apollo-router" { + inherits = ["router-base", get_target()] + contexts = { + router_pkg = "${PWD}/bin/router" + config = "${PWD}" + root_dir = "${PWD}/.." + router_binary_dir = "${ROUTER_BINARY_DIR}" + } + args = { + IMAGE_TITLE = "graphql-hive/apollo-router" + PORT = "4000" + IMAGE_DESCRIPTION = "Apollo Router for GraphQL Hive." + } + tags = [ + local_image_tag("apollo-router"), + stable_image_tag("apollo-router"), + image_tag("apollo-router", COMMIT_SHA), + image_tag("apollo-router", BRANCH_NAME) + ] +} + +group "apollo-router-hive-build" { + targets = [ + "apollo-router" + ] +} \ No newline at end of file diff --git a/apollo-router-workspace/docker/router.dockerfile b/apollo-router-workspace/docker/router.dockerfile new file mode 100644 index 000000000..4c45cbc96 --- /dev/null +++ b/apollo-router-workspace/docker/router.dockerfile @@ -0,0 +1,63 @@ +# Based on https://github.com/apollographql/router/blob/dev/dockerfiles/Dockerfile.router#L23 +FROM debian:bookworm-slim AS runtime +ARG DEBUG_IMAGE=false +ARG REPO_URL=https://github.com/graphql-hive/router/blob/main/apollo-router-workspace +ARG BASE_VERSION + +# Add a user to run the router as +RUN useradd -m router + +WORKDIR /dist + +COPY --from=router_binary_dir --chown=root:root --chmod=755 ./router /dist + +# Update apt and install ca-certificates +RUN \ + apt-get update -y \ + && apt-get install -y \ + ca-certificates + +# If debug image, install heaptrack and make a data directory +RUN \ + if [ "${DEBUG_IMAGE}" = "true" ]; then \ + apt-get install -y heaptrack && \ + mkdir data && \ + chown router data; \ + fi + +# Clean up apt lists +RUN rm -rf /var/lib/apt/lists/* + +# Make directories for config and schema +RUN mkdir config schema + +# Copy configuration for docker image +COPY --from=router_pkg router.yaml /dist/config/router.yaml + +LABEL org.opencontainers.image.title="graphql-hive/apollo-router" +LABEL org.opencontainers.image.description="Apollo Router for GraphQL Hive." +LABEL org.opencontainers.image.authors="The Guild ${REPO_URL}" +LABEL org.opencontainers.image.source="${REPO_URL}" +LABEL org.opencontainers.image.version="${BASE_VERSION}" + +ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml" + +# Create a wrapper script to run the router, use exec to ensure signals are handled correctly +RUN \ + echo '#!/bin/bash \ +\nset -e \ +\n \ +\nif [ -f "/usr/bin/heaptrack" ]; then \ +\n exec heaptrack -o /dist/data/$(hostname)/router_heaptrack /dist/router "$@" \ +\nelse \ +\n exec /dist/router "$@" \ +\nfi \ +' > /dist/router_wrapper.sh + +# Make sure we can run our wrapper +RUN chmod 755 /dist/router_wrapper.sh + +USER router + +# Default executable is the wrapper script +ENTRYPOINT ["/dist/router_wrapper.sh"] \ No newline at end of file diff --git a/apollo-router-workspace/rust-toolchain.toml b/apollo-router-workspace/rust-toolchain.toml new file mode 100644 index 000000000..ef450a16d --- /dev/null +++ b/apollo-router-workspace/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/knope.toml b/knope.toml index ae4c981f7..1e68ed043 100644 --- a/knope.toml +++ b/knope.toml @@ -33,7 +33,7 @@ versioned_files = ["lib/graphql-tools/Cargo.toml", { path = "lib/executor/Cargo. changelog = "lib/graphql-tools/CHANGELOG.md" [packages.hive-console-sdk] -versioned_files = ["lib/hive-console-sdk/Cargo.toml", { path = "bin/router/Cargo.toml", dependency = "hive-console-sdk" }, "Cargo.lock"] +versioned_files = ["lib/hive-console-sdk/Cargo.toml", { path = "bin/router/Cargo.toml", dependency = "hive-console-sdk" }, "Cargo.lock", { path = "apollo-router-workspace/bin/router/Cargo.toml", dependency = "hive-console-sdk" }, { path = "apollo-router-workspace/Cargo.lock", dependency = "hive-console-sdk" }] changelog = "lib/hive-console-sdk/CHANGELOG.md" [packages.hive-router] @@ -44,6 +44,9 @@ changelog = "bin/router/CHANGELOG.md" versioned_files = ["lib/node-addon/package.json", "lib/node-addon/Cargo.toml", "Cargo.lock"] changelog = "lib/node-addon/CHANGELOG.md" +[packages.apollo-router-hive-fork] +versioned_files = ["apollo-router-workspace/bin/router/Cargo.toml", "apollo-router-workspace/Cargo.lock"] + # "release" pipeline that prepares the release and pushes a release PR [[workflows]] name = "release" From e610471a8d78ab090be7f165f151f233a191ecec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:38:07 +0300 Subject: [PATCH 38/76] chore(deps): bump the cargo group across 1 directory with 5 updates (#915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the cargo group with 5 updates in the /apollo-router-workspace directory: | Package | From | To | | --- | --- | --- | | [rand](https://github.com/rust-random/rand) | `0.9.2` | `0.9.3` | | [bytes](https://github.com/tokio-rs/bytes) | `1.11.0` | `1.11.1` | | [quinn-proto](https://github.com/quinn-rs/quinn) | `0.11.13` | `0.11.14` | | [rustls-webpki](https://github.com/rustls/webpki) | `0.103.8` | `0.103.12` | | [time](https://github.com/time-rs/time) | `0.3.44` | `0.3.47` | Updates `rand` from 0.9.2 to 0.9.3
Changelog

Sourced from rand's changelog.

[0.9.3] — 2026-02-11

This release back-ports a fix from v0.10. See also #1763.

Changes

  • Deprecate feature log (#1764)
  • Replace usages of doc_auto_cfg (#1764)

#1763: rust-random/rand#1763

Commits

Updates `bytes` from 1.11.0 to 1.11.1
Release notes

Sourced from bytes's releases.

Bytes v1.11.1

1.11.1 (February 3rd, 2026)

  • Fix integer overflow in BytesMut::reserve
Changelog

Sourced from bytes's changelog.

1.11.1 (February 3rd, 2026)

  • Fix integer overflow in BytesMut::reserve
Commits

Updates `quinn-proto` from 0.11.13 to 0.11.14
Release notes

Sourced from quinn-proto's releases.

quinn-proto 0.11.14

@​jxs reported a denial of service issue in quinn-proto 5 days ago:

We coordinated with them to release this version to patch the issue. Unfortunately the maintainers missed these issues during code review and we did not have enough fuzzing coverage -- we regret the oversight and have added an additional fuzzing target.

Organizations that want to participate in coordinated disclosure can contact us privately to discuss terms.

What's Changed

Commits
  • 2c315aa proto: bump version to 0.11.14
  • 8ad47f4 Use newer rustls-pki-types PEM parser API
  • c81c028 ci: fix workflow syntax
  • 0050172 ci: pin wasm-bindgen-cli version
  • 8a6f82c Take semver-compatible dependency updates
  • e52db4a Apply suggestions from clippy 1.91
  • 6df7275 chore: Fix unnecessary_unwrap clippy
  • c8eefa0 proto: avoid unwrapping varint decoding during parameters parsing
  • 9723a97 fuzz: add fuzzing target for parsing transport parameters
  • eaf0ef3 Fix over-permissive proto dependency edge (#2385)
  • Additional commits viewable in compare view

Updates `rustls-webpki` from 0.103.8 to 0.103.12
Release notes

Sourced from rustls-webpki's releases.

0.103.12

This release fixes two bugs in name constraint enforcement:

  • GHSA-965h-392x-2mh5: name constraints for URI names were ignored and therefore accepted. URI name constraints are now rejected unconditionally. Note this library does not provide an API for asserting URI names, and URI name constraints are otherwise not implemented.
  • GHSA-xgp8-3hg3-c2mh: permitted subtree name constraints for DNS names were accepted for certificates asserting a wildcard name. This was incorrect because, given a name constraint of accept.example.com, *.example.com could feasibly allow a name of reject.example.com which is outside the constraint. This is very similar to CVE-2025-61727.

Since name constraints are restrictions on otherwise properly-issued certificates, these bugs are reachable only after signature verification and require misissuance to exploit.

What's Changed

Full Changelog: https://github.com/rustls/webpki/compare/v/0.103.11...v/0.103.12

0.103.11

In response to #464, we've slightly relaxed requirements for anchor_from_trust_cert() to ignore unknown extensions even if they're marked as critical. This only affects parsing a TrustAnchor from DER, for which most extensions are ignored anyway.

What's Changed

0.103.10

Correct selection of candidate CRLs by Distribution Point and Issuing Distribution Point. If a certificate had more than one distributionPoint, then only the first distributionPoint would be considered against each CRL's IssuingDistributionPoint distributionPoint, and then the certificate's subsequent distributionPoints would be ignored.

The impact was that correctly provided CRLs would not be consulted to check revocation. With UnknownStatusPolicy::Deny (the default) this would lead to incorrect but safe Error::UnknownRevocationStatus. With UnknownStatusPolicy::Allow this would lead to inappropriate acceptance of revoked certificates.

This vulnerability is thought to be of limited impact. This is because both the certificate and CRL are signed -- an attacker would need to compromise a trusted issuing authority to trigger this bug. An attacker with such capabilities could likely bypass revocation checking through other more impactful means (such as publishing a valid, empty CRL.)

More likely, this bug would be latent in normal use, and an attacker could leverage faulty revocation checking to continue using a revoked credential.

This vulnerability is identified by GHSA-pwjx-qhcg-rvj4. Thank you to @​1seal for the report.

What's Changed

Full Changelog: https://github.com/rustls/webpki/compare/v/0.103.9...v/0.103.10

0.103.9

What's Changed

Commits
  • 27131d4 Bump version to 0.103.12
  • 6ecb876 Clean up stuttery enum variant names
  • 318b3e6 Ignore wildcard labels when matching name constraints
  • 1219622 Rewrite constraint matching to avoid permissive catch-all branch
  • 57bc62c Bump version to 0.103.11
  • d0fa01e Allow parsing trust anchors with unknown criticial extensions
  • 348ce01 Prepare 0.103.10
  • dbde592 crl: fix authoritative_for() support for multiple URIs
  • 9c4838e avoid std::prelude imports
  • 009ef66 fix rust 1.94 ambiguous panic macro warnings
  • Additional commits viewable in compare view

Updates `time` from 0.3.44 to 0.3.47
Release notes

Sourced from time's releases.

v0.3.47

See the changelog for details.

v0.3.46

See the changelog for details.

v0.3.45

See the changelog for details.

Changelog

Sourced from time's changelog.

0.3.47 [2026-02-05]

Security

  • The possibility of a stack exhaustion denial of service attack when parsing RFC 2822 has been eliminated. Previously, it was possible to craft input that would cause unbounded recursion. Now, the depth of the recursion is tracked, causing an error to be returned if it exceeds a reasonable limit.

    This attack vector requires parsing user-provided input, with any type, using the RFC 2822 format.

Compatibility

  • Attempting to format a value with a well-known format (i.e. RFC 3339, RFC 2822, or ISO 8601) will error at compile time if the type being formatted does not provide sufficient information. This would previously fail at runtime. Similarly, attempting to format a value with ISO 8601 that is only configured for parsing (i.e. Iso8601::PARSING) will error at compile time.

Added

  • Builder methods for format description modifiers, eliminating the need for verbose initialization when done manually.
  • date!(2026-W01-2) is now supported. Previously, a space was required between W and 01.
  • [end] now has a trailing_input modifier which can either be prohibit (the default) or discard. When it is discard, all remaining input is ignored. Note that if there are components after [end], they will still attempt to be parsed, likely resulting in an error.

Changed

  • More performance gains when parsing.

Fixed

  • If manually formatting a value, the number of bytes written was one short for some components. This has been fixed such that the number of bytes written is always correct.
  • The possibility of integer overflow when parsing an owned format description has been effectively eliminated. This would previously wrap when overflow checks were disabled. Instead of storing the depth as u8, it is stored as u32. This would require multiple gigabytes of nested input to overflow, at which point we've got other problems and trivial mitigations are available by downstream users.

0.3.46 [2026-01-23]

Added

  • All possible panics are now documented for the relevant methods.
  • The need to use #[serde(default)] when using custom serde formats is documented. This applies only when deserializing an Option<T>.
  • Duration::nanoseconds_i128 has been made public, mirroring std::time::Duration::from_nanos_u128.

... (truncated)

Commits
  • d5144cd v0.3.47 release
  • f6206b0 Guard against integer overflow in release mode
  • 1c63dc7 Avoid denial of service when parsing Rfc2822
  • 5940df6 Add builder methods to avoid verbose construction
  • 00881a4 Manually format macros everywhere
  • bb723b6 Add trailing_input modifier to end
  • 31c4f8e Permit W12 in date! macro
  • 490a17b Mark error paths in well-known formats as cold
  • 6cb1896 Optimize Rfc2822 parsing
  • 6d264d5 Remove erroneous #[inline(never)] attributes
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/graphql-hive/router/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- Cargo.lock | 6 +-- Cargo.toml | 2 +- apollo-router-workspace/Cargo.lock | 50 +++++++++---------- apollo-router-workspace/bin/router/Cargo.toml | 2 +- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4948e382..29fd728f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5597,9 +5597,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -6142,7 +6142,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.116", diff --git a/Cargo.toml b/Cargo.toml index 21a626928..05b8cd21a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,7 @@ rustls = { version = "0.23.37", default-features = false } retry-policies = "0.5.1" vrl = { version = "0.30.0", features = ["compiler", "parser", "value", "diagnostic", "stdlib", "core"] } regex-automata = "0.4.10" -bytes = "1.10.1" +bytes = "1.11.1" ahash = "0.8.12" arc-swap = "1.7.1" anyhow = "1.0.100" diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock index 9015cdc3e..44801a971 100644 --- a/apollo-router-workspace/Cargo.lock +++ b/apollo-router-workspace/Cargo.lock @@ -267,7 +267,7 @@ dependencies = [ "prost", "prost-types", "proteus", - "rand 0.9.2", + "rand 0.9.3", "regex", "reqwest", "rhai", @@ -1159,9 +1159,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytes-utils" @@ -2635,7 +2635,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", + "rand 0.9.3", "ring", "thiserror 2.0.18", "tinyvec", @@ -2657,7 +2657,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.9.3", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -2679,7 +2679,7 @@ dependencies = [ "httpmock", "jsonschema 0.29.1", "lazy_static", - "rand 0.9.2", + "rand 0.9.3", "schemars 1.0.4", "serde", "serde_json", @@ -3864,9 +3864,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -4154,7 +4154,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.2", + "rand 0.9.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4677,14 +4677,14 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash 2.1.1", "rustls", @@ -4738,9 +4738,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -5036,7 +5036,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -5259,9 +5259,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", @@ -6034,9 +6034,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -6044,22 +6044,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -6448,7 +6448,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.2", + "rand 0.9.3", "rustls", "rustls-pki-types", "sha1", diff --git a/apollo-router-workspace/bin/router/Cargo.toml b/apollo-router-workspace/bin/router/Cargo.toml index 096b8ef0f..969ec1f7e 100644 --- a/apollo-router-workspace/bin/router/Cargo.toml +++ b/apollo-router-workspace/bin/router/Cargo.toml @@ -31,7 +31,7 @@ tokio = { version = "1.36.0", features = ["full"] } tower = { version = "0.5", features = ["full"] } http = "1" http-body-util = "0.1" -rand = "0.9.0" +rand = "0.9.3" tokio-util = "0.7.16" [dev-dependencies] From 20fe85030970c13e176f799953993d0cd040fc79 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 15 Apr 2026 13:06:13 +0300 Subject: [PATCH 39/76] fix(node-addon): TypeScript types for `Subscription.primary` (#916) `Subscription` node's `primary` is `FetchNode` instead of `PlanNode` now, but the types were not compatible. This change updates the type of `Subscription.primary` to be `FetchNode` instead of `PlanNode`. Fixes https://github.com/graphql-hive/gateway/pull/2253/changes#diff-24a70983ec784fbc421e000030dbdec64676595d820e131c5edcb8255431d42dR1309 Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/subscription-node.md | 6 ++++++ lib/node-addon/src/query-plan.d.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/subscription-node.md diff --git a/.changeset/subscription-node.md b/.changeset/subscription-node.md new file mode 100644 index 000000000..7c94eb303 --- /dev/null +++ b/.changeset/subscription-node.md @@ -0,0 +1,6 @@ +--- +node-addon: patch +--- + +`Subscription` node's `primary` is `FetchNode` instead of `PlanNode` now, but the types were not compatible. +This change updates the type of `Subscription.primary` to be `FetchNode` instead of `PlanNode`. \ No newline at end of file diff --git a/lib/node-addon/src/query-plan.d.ts b/lib/node-addon/src/query-plan.d.ts index 2f2efa2ea..06aa9ed83 100644 --- a/lib/node-addon/src/query-plan.d.ts +++ b/lib/node-addon/src/query-plan.d.ts @@ -50,7 +50,7 @@ export interface EntityBatchAlias { export interface SubscriptionNode { kind: "Subscription"; - primary: PlanNode; + primary: FetchNode; } export type FetchNodePathSegment = From 06e5e23b4395bfea09a148eb6cacdf3d706fa138 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:14:30 +0300 Subject: [PATCH 40/76] chore(release): router crates and artifacts (#917) > [!IMPORTANT] > Merging this pull request will create these releases # node-addon 0.0.21 (2026-04-15) ## Fixes ### `Subscription` node's `primary` is `FetchNode` instead of `PlanNode` now, but the types were not compatible. This change updates the type of `Subscription.primary` to be `FetchNode` instead of `PlanNode`. Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/subscription-node.md | 6 ------ Cargo.lock | 2 +- lib/node-addon/CHANGELOG.md | 8 ++++++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) delete mode 100644 .changeset/subscription-node.md diff --git a/.changeset/subscription-node.md b/.changeset/subscription-node.md deleted file mode 100644 index 7c94eb303..000000000 --- a/.changeset/subscription-node.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -node-addon: patch ---- - -`Subscription` node's `primary` is `FetchNode` instead of `PlanNode` now, but the types were not compatible. -This change updates the type of `Subscription.primary` to be `FetchNode` instead of `PlanNode`. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 29fd728f2..7f18d40fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3542,7 +3542,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.20" +version = "0.0.21" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 595e43280..2d85ea362 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,12 @@ # @graphql-hive/router-query-planner changelog +## 0.0.21 (2026-04-15) + +### Fixes + +#### `Subscription` node's `primary` is `FetchNode` instead of `PlanNode` now, but the types were not compatible. + +This change updates the type of `Subscription.primary` to be `FetchNode` instead of `PlanNode`. + ## 0.0.20 (2026-04-15) ### Features diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 1137140d3..f656f1a02 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.20" +version = "0.0.21" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index c46c7f3cd..22d1d6340 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.20", + "version": "0.0.21", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", From dce211259b118bf1ed7ca079e4f69e6faa65b983 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 15 Apr 2026 13:37:55 +0300 Subject: [PATCH 41/76] fix(node-addon): `Subscription.primary` type is `FetchNode` (#918) Fix `Subscription.primary` type to `FetchNode` instead of `PlanNode` in the distributed `index.d.ts` file. Because changing `query-plan.d.ts` wasn't enough :) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/subscription-node.md | 5 +++++ lib/node-addon/dist/index.d.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/subscription-node.md diff --git a/.changeset/subscription-node.md b/.changeset/subscription-node.md new file mode 100644 index 000000000..0baafc2f0 --- /dev/null +++ b/.changeset/subscription-node.md @@ -0,0 +1,5 @@ +--- +node-addon: patch +--- + +Fix `Subscription.primary` type to `FetchNode` instead of `PlanNode` in the distributed `index.d.ts` file. \ No newline at end of file diff --git a/lib/node-addon/dist/index.d.ts b/lib/node-addon/dist/index.d.ts index 4e8896c07..fe4010358 100644 --- a/lib/node-addon/dist/index.d.ts +++ b/lib/node-addon/dist/index.d.ts @@ -61,7 +61,7 @@ export interface EntityBatchAlias { export interface SubscriptionNode { kind: "Subscription"; - primary: PlanNode; + primary: FetchNode; } export type FetchNodePathSegment = From 2223fc3a3d859da3e87db709ed6014ba9736d802 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:38:56 +0300 Subject: [PATCH 42/76] chore(release): router crates and artifacts (#919) > [!IMPORTANT] > Merging this pull request will create these releases # node-addon 0.0.22 (2026-04-15) ## Fixes - Fix `Subscription.primary` type to `FetchNode` instead of `PlanNode` in the distributed `index.d.ts` file. Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/subscription-node.md | 5 ----- Cargo.lock | 2 +- lib/node-addon/CHANGELOG.md | 6 ++++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/subscription-node.md diff --git a/.changeset/subscription-node.md b/.changeset/subscription-node.md deleted file mode 100644 index 0baafc2f0..000000000 --- a/.changeset/subscription-node.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -node-addon: patch ---- - -Fix `Subscription.primary` type to `FetchNode` instead of `PlanNode` in the distributed `index.d.ts` file. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7f18d40fe..b46fe2a9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3542,7 +3542,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.21" +version = "0.0.22" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 2d85ea362..3101125fb 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,10 @@ # @graphql-hive/router-query-planner changelog +## 0.0.22 (2026-04-15) + +### Fixes + +- Fix `Subscription.primary` type to `FetchNode` instead of `PlanNode` in the distributed `index.d.ts` file. + ## 0.0.21 (2026-04-15) ### Fixes diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index f656f1a02..4d941a711 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.21" +version = "0.0.22" name = "node-addon" publish = false diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index 22d1d6340..799a56596 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.21", + "version": "0.0.22", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", From 43c494be77d345e5d4e015dbd49e4b063d9c176a Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Thu, 16 Apr 2026 11:24:12 +0200 Subject: [PATCH 43/76] Update pull request reviewing guidelines (#925) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .gemini/styleguide.md | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 94ac727f1..4a9f2aef0 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -116,3 +116,48 @@ We are using `knope` with changesets for declaring changes. If you detect a new file in a PR under `.changeset/` directory, please confirm the following rules: - If a PR touches `config` crate and adds/changes to the `HiveRouterConfig` struct, it must have a `router` changeset that contains a YAML example on how the configuration needs to be used. + +--- + +## Pull Request Reviewing + + +### What to Look For + +Code that looks wrong in isolation may be correct given surrounding logic—and vice versa. +Read the full file to understand existing patterns, control flow, and error handling. + +**Bugs** - Your primary focus. +- Logic errors, off-by-one mistakes, incorrect conditionals +- If-else guards: missing guards, incorrect branching, unreachable code paths +- Edge cases: null/empty/undefined inputs, error conditions, race conditions +- Security issues: injection, auth bypass, data exposure +- Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught. + +**Structure** - Does the code fit the codebase? +- Does it follow existing patterns and conventions? +- Are there established abstractions it should use but doesn't? +- Excessive nesting that could be flattened with early returns or extraction + +**Performance** - Only flag if obviously problematic. +- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths + +**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional). + +--- + +### Before You Flag Something + +**Be certain.** If you're going to call something a bug, you need to be confident it actually is one. + +- Only review the changes - do not review pre-existing code that wasn't modified +- Don't flag something as a bug if you're unsure - investigate first +- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks +- If you need more context to be sure, use the tools below to get it + +**Don't be a zealot about style.** When checking code against conventions: + +- Verify the code is *actually* in violation. Don't complain about else statements if early returns are already being used correctly. +- Some "violations" are acceptable when they're the simplest option. +- Excessive nesting is a legitimate concern regardless of other style choices. +- Don't flag style preferences as issues unless they clearly violate established project conventions. From 7bb097f46fe3109426f577ade2fc19beafd0df14 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 17 Apr 2026 12:07:05 +0200 Subject: [PATCH 44/76] Persisted Documents (#868) This PR introduces Persisted Documents support with configurable extraction and storage, plus lot of e2e tests. Closes #311 --- Documentation PR: https://github.com/graphql-hive/docs/pull/76 - Preview of [security/persisted-documents](https://dc3c070a-hive-platform-docs.theguild.workers.dev/graphql/hive/docs/router/security/persisted-documents) - Preview of [configuration/persisted_documents](https://dc3c070a-hive-platform-docs.theguild.workers.dev/graphql/hive/docs/router/configuration/persisted_documents) --- Supports document ID extraction from: - `documentId` body field or URL query param (by default) - Apollo-style `extensions.persistedQuery.sha256Hash` (by default) - custom `json_path` (like `doc_id` or `extensions.whatever.id` - `url_query_param` (like `?doc_id=123`) - `url_path_param` (like `/graphql/:id`) In the example below, we first look for the path pattern and then the query param. ```yaml persisted_documents: extractors: - type: url_path_param template: /:id # relative to configured endpoint - type: url_query_param name: id # `?id=123 ``` Supports different document storages: - file manifest (in Apollo and Key-Value Relay style formats) - Hive CDN (via `hive-console-sdk`) File storage has **watch mode** by default (works well with `relay-compiler --watch`), so when a file changes (we debounce the events for 150ms) the document manifest is reloaded and served fresh. Hive storage includes syntax validation of the provided document id. We make sure we don't send what `str.replace('~', '/')` produces to the Hive CDN without verification. If we do, people would see 404 with no info that doc id is incorrect. Includes `require_id: boolean` to control whether to require requests with document id only or not. Includes `log_missing_id_requests: bool` (false by default) that logs information about requests with no document id. Helpful if you migrate from regular to queryless requests. Regarding Hive CDN. We don't rely only on `appName~appVersion~documentId` format of the document id, but app's name and version can be inferred from client identification headers (`graphql-client-name` etc - configurable via telemetry settings). We support it for reasons mentioned in the Slack Canvas doc (better DX and reusable `clientAwarness` feature of Apollo Client). I also added two metrics to measure: - requests with no document id - so devs know that some requests still send no id - document resolution failures - so devs know that some requests with doc id that has no document text ## Noteworthy implementation details Persisted documents are implemented under `pipeline/persisted_documents/*` with clear split: - extraction (`extract/*`) - resolution (`resolve/*`) - runtime (`mod.rs`, `types.rs`) Closes #867 - as I introduced single-flight resolution of documents in the SDK. The **Err had to be cloanable** (otherwise I would have to change the API to return Arc), so some error enum variants in the SDK was converted to `String` instead of raw errors from 3rd-party libraries. I also added a **negative cache** to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default) to not keep repeating the same requests that eventually give errors or 404s. I cleaned up and moved the code responsible for preparation of graphql params, decoding of GET and POST payloads into `GraphQLGetInput` and `GraphQLPostInput` and `OperationPreparation` structs. This way the flow is clear, like what happens when we receive GET request, what when we receive POST, and how it's all translated to what the rest of the pipeline expects. It's in `bin/router/src/pipeline/execution_request.rs`. I did bunch of tricks to make sure we're performant: - custom query param reader (based on `memchr`) - conditional extraction of non standard JSON fields (fields that are not `query`, `extensions` etc) - built-in extraction of `documentId` during deserialization - supafast validation of document ids (based on `memchr`) --- There are many new lines of code, but majority is just e2e tests. For reviewers, I recommend to check: - `docs/persisted-documents` to understand what I built and why - `bin/router/src/pipeline/persisted_documents` - pretty much everything related to persisted documents, how things are extracted, how documents are resolved - `bin/router/src/pipeline/execution_request.rs` - to understand how we convert POST and GET request into data consumed by the rest of the pipeline and this is when extraction and resolution of persisted documents happen. Performance is identical as before (check `persisted-documents` bench in CI). --------- Co-authored-by: theguild-bot Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/negative_cache_single_flight.md | 10 + .changeset/persisted_documents.md | 35 + .github/workflows/ci.yaml | 13 +- Cargo.lock | 90 +- Cargo.toml | 1 + bench/configs/persisted-documents.config.yaml | 15 + bench/k6.js | 14 +- bench/persisted-documents.json | 3 + bin/router/Cargo.toml | 6 +- .../persisted_documents_matcher_benches.rs | 218 +++++ bin/router/src/error.rs | 2 + bin/router/src/lib.rs | 28 + bin/router/src/pipeline/error.rs | 23 +- bin/router/src/pipeline/execution_request.rs | 834 +++++++++++++++--- bin/router/src/pipeline/mod.rs | 62 +- .../persisted_documents/extract/core.rs | 292 ++++++ .../extract/extractors/apollo.rs | 16 + .../extract/extractors/document_id.rs | 13 + .../extract/extractors/json_path.rs | 118 +++ .../extract/extractors/mod.rs | 5 + .../extract/extractors/url_path_param.rs | 63 ++ .../extract/extractors/url_query_param.rs | 232 +++++ .../persisted_documents/extract/mod.rs | 7 + .../src/pipeline/persisted_documents/mod.rs | 82 ++ .../persisted_documents/resolve/file.rs | 326 +++++++ .../persisted_documents/resolve/hive.rs | 341 +++++++ .../persisted_documents/resolve/mod.rs | 62 ++ .../src/pipeline/persisted_documents/types.rs | 72 ++ bin/router/src/shared_state.rs | 7 + docs/README.md | 76 ++ .../persisted-documents/apollo-client.md | 108 +++ docs/design/persisted-documents/main.md | 352 ++++++++ .../persisted-documents/relay-client.md | 101 +++ e2e/src/lib.rs | 2 + e2e/src/persisted_documents/defaults.rs | 54 ++ .../persisted_documents/extractor_apollo.rs | 134 +++ .../extractor_document_id.rs | 81 ++ .../extractor_json_path.rs | 137 +++ .../extractor_precedence.rs | 44 + .../extractor_url_path_param.rs | 224 +++++ .../extractor_url_query_param.rs | 210 +++++ e2e/src/persisted_documents/method_get.rs | 221 +++++ e2e/src/persisted_documents/mod.rs | 12 + e2e/src/persisted_documents/policy.rs | 75 ++ e2e/src/persisted_documents/shared.rs | 43 + e2e/src/persisted_documents/storage_file.rs | 63 ++ e2e/src/persisted_documents/storage_hive.rs | 395 +++++++++ e2e/src/telemetry/metrics.rs | 93 +- e2e/src/testkit/mod.rs | 28 +- .../src/plugins/hooks/on_graphql_params.rs | 44 +- .../src/persisted_documents.rs | 131 ++- lib/internal/Cargo.toml | 1 + lib/internal/src/json.rs | 24 + lib/internal/src/lib.rs | 1 + lib/internal/src/telemetry/metrics/catalog.rs | 6 + lib/internal/src/telemetry/metrics/mod.rs | 4 + .../metrics/persisted_documents_metrics.rs | 51 ++ lib/router-config/src/lib.rs | 5 + lib/router-config/src/persisted_documents.rs | 452 ++++++++++ lib/router-config/src/primitives/file_path.rs | 13 +- 60 files changed, 5961 insertions(+), 214 deletions(-) create mode 100644 .changeset/negative_cache_single_flight.md create mode 100644 .changeset/persisted_documents.md create mode 100644 bench/configs/persisted-documents.config.yaml create mode 100644 bench/persisted-documents.json create mode 100644 bin/router/benches/persisted_documents_matcher_benches.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/core.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs create mode 100644 bin/router/src/pipeline/persisted_documents/extract/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/file.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/hive.rs create mode 100644 bin/router/src/pipeline/persisted_documents/resolve/mod.rs create mode 100644 bin/router/src/pipeline/persisted_documents/types.rs create mode 100644 docs/design/persisted-documents/apollo-client.md create mode 100644 docs/design/persisted-documents/main.md create mode 100644 docs/design/persisted-documents/relay-client.md create mode 100644 e2e/src/persisted_documents/defaults.rs create mode 100644 e2e/src/persisted_documents/extractor_apollo.rs create mode 100644 e2e/src/persisted_documents/extractor_document_id.rs create mode 100644 e2e/src/persisted_documents/extractor_json_path.rs create mode 100644 e2e/src/persisted_documents/extractor_precedence.rs create mode 100644 e2e/src/persisted_documents/extractor_url_path_param.rs create mode 100644 e2e/src/persisted_documents/extractor_url_query_param.rs create mode 100644 e2e/src/persisted_documents/method_get.rs create mode 100644 e2e/src/persisted_documents/mod.rs create mode 100644 e2e/src/persisted_documents/policy.rs create mode 100644 e2e/src/persisted_documents/shared.rs create mode 100644 e2e/src/persisted_documents/storage_file.rs create mode 100644 e2e/src/persisted_documents/storage_hive.rs create mode 100644 lib/internal/src/json.rs create mode 100644 lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs create mode 100644 lib/router-config/src/persisted_documents.rs diff --git a/.changeset/negative_cache_single_flight.md b/.changeset/negative_cache_single_flight.md new file mode 100644 index 000000000..57331098b --- /dev/null +++ b/.changeset/negative_cache_single_flight.md @@ -0,0 +1,10 @@ +--- +hive-console-sdk: minor +hive-router: patch +--- + +# Negative Cache and Single-Flight + +Introduced single-flight resolution of documents in the SDK. + +Added a negative cache to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default). It's meant to not keep repeating the same requests that eventually give errors or 404s. diff --git a/.changeset/persisted_documents.md b/.changeset/persisted_documents.md new file mode 100644 index 000000000..f8c4e311b --- /dev/null +++ b/.changeset/persisted_documents.md @@ -0,0 +1,35 @@ +--- +hive-router-plan-executor: minor +hive-router-config: minor +hive-router: minor +hive-router-internal: minor +hive-console-sdk: minor +--- + +# Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 57bd1073f..5dd556fe9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -188,6 +188,12 @@ jobs: binary: "hive_router_with_plugin" config: "bench/configs/plugins.config.yaml" compare_with_default: true + - name: "persisted-documents" + args: "" + package: "hive-router" + binary: "hive_router" + config: "bench/configs/persisted-documents.config.yaml" + compare_with_default: true name: benchmark / router / ${{ matrix.name }} runs-on: ubuntu-latest env: @@ -220,7 +226,12 @@ jobs: ROUTER_CONFIG_FILE_PATH: ${{matrix.config}} - name: Run k6 benchmark for ${{ github.ref }} if: github.event_name == 'pull_request' - run: k6 run -e SUMMARY_PATH=./bench/results/pr bench/k6.js + run: | + if [ "${{ matrix.name }}" = "persisted-documents" ]; then + k6 run -e SUMMARY_PATH=./bench/results/pr -e BENCH_PERSISTED_MODE=true -e BENCH_DOCUMENT_ID=bench_test_query bench/k6.js + else + k6 run -e SUMMARY_PATH=./bench/results/pr bench/k6.js + fi - name: Checkout main branch in a separate directory if: github.event_name == 'pull_request' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/Cargo.lock b/Cargo.lock index b46fe2a9a..ce1c5152a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,7 +443,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -1983,6 +1983,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -2387,11 +2396,15 @@ dependencies = [ "jsonwebtoken", "lasso2", "lazy_static", + "matchit 0.9.1", "md5", "mediatype", + "memchr", "mimalloc", "moka", + "notify", "ntex", + "percent-encoding", "prometheus", "rand 0.10.1", "regex-automata", @@ -2465,6 +2478,7 @@ dependencies = [ "opentelemetry-zipkin", "opentelemetry_sdk", "prometheus", + "serde", "sonic-rs", "strum 0.28.0", "thiserror 2.0.18", @@ -2945,6 +2959,26 @@ dependencies = [ "snafu 0.7.5", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3132,6 +3166,26 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lalrpop" version = "0.22.2" @@ -3282,6 +3336,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + [[package]] name = "md-5" version = "0.10.6" @@ -3358,6 +3418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3601,6 +3662,33 @@ dependencies = [ "serde", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "ntex" version = "3.7.2" diff --git a/Cargo.toml b/Cargo.toml index 05b8cd21a..6caa85f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ strum = { version = "0.28.0", features = ["derive"] } mockito = "1.7.0" futures-util = "0.3.31" axum = "0.8.4" +notify = "8.2.0" # Telemetry opentelemetry = "0.31.0" diff --git a/bench/configs/persisted-documents.config.yaml b/bench/configs/persisted-documents.config.yaml new file mode 100644 index 000000000..79c9b81a4 --- /dev/null +++ b/bench/configs/persisted-documents.config.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql + +persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: ../persisted-documents.json + watch: false + +log: + level: info diff --git a/bench/k6.js b/bench/k6.js index 600a24ce9..3c5da60bd 100644 --- a/bench/k6.js +++ b/bench/k6.js @@ -5,6 +5,8 @@ import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js"; const endpoint = __ENV.ROUTER_ENDPOINT || "http://0.0.0.0:4000/graphql"; const vus = __ENV.BENCH_VUS ? parseInt(__ENV.BENCH_VUS) : 50; const duration = __ENV.BENCH_OVER_TIME || "30s"; +const persistedMode = __ENV.BENCH_PERSISTED_MODE === "true"; +const documentId = __ENV.BENCH_DOCUMENT_ID || "bench_test_query"; export const options = { vus, @@ -42,9 +44,7 @@ function runOnce(identifier, cb) { return cb(); } -const graphqlRequest = { - payload: JSON.stringify({ - query: `fragment User on User { +const graphqlQuery = `fragment User on User { id username name @@ -101,8 +101,12 @@ const graphqlRequest = { } } } - }`, - }), + }`; + +const graphqlRequest = { + payload: JSON.stringify( + persistedMode ? { documentId } : { query: graphqlQuery }, + ), params: { headers: { "Content-Type": "application/json", diff --git a/bench/persisted-documents.json b/bench/persisted-documents.json new file mode 100644 index 000000000..39fc1d000 --- /dev/null +++ b/bench/persisted-documents.json @@ -0,0 +1,3 @@ +{ + "bench_test_query": "fragment User on User { id username name } fragment Review on Review { id body } fragment Product on Product { inStock name price shippingEstimate upc weight } query TestQuery { users { ...User reviews { ...Review product { ...Product reviews { ...Review author { ...User reviews { ...Review product { ...Product } } } } } } } topProducts { ...Product reviews { ...Review author { ...User reviews { ...Review product { ...Product } } } } } }" +} diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index a9df18ee1..79d2ab2a0 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -58,6 +58,10 @@ ahash = { workspace = true } rustls = { workspace = true, features = ["aws-lc-rs"] } hyper-rustls = { workspace = true, features = ["aws-lc-rs"]} dashmap = { workspace = true } +notify = { workspace = true } +memchr = "2.8.0" +percent-encoding = "2.3.2" +matchit = "0.9.1" moka = { workspace = true } ulid = "1.2.1" @@ -85,5 +89,5 @@ criterion = { workspace = true } insta = { workspace = true } [[bench]] -name = "router_benches" +name = "persisted_documents_matcher_benches" harness = false diff --git a/bin/router/benches/persisted_documents_matcher_benches.rs b/bin/router/benches/persisted_documents_matcher_benches.rs new file mode 100644 index 000000000..9a2725a1e --- /dev/null +++ b/bin/router/benches/persisted_documents_matcher_benches.rs @@ -0,0 +1,218 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use hive_router::pipeline::persisted_documents::extract::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, +}; +use hive_router_config::persisted_documents::PersistedDocumentsConfig; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use std::hint::black_box; + +struct PathCase { + name: &'static str, + template: &'static str, + hit_path: &'static str, + miss_path: &'static str, +} + +const GRAPHQL_ENDPOINT: &str = "/graphql"; + +fn build_resolver(template: &str) -> DocumentIdResolver { + let manifest_path = std::env::temp_dir().join("persisted-docs-bench.json"); + std::fs::write(&manifest_path, "{}").expect("bench manifest should be writable"); + + let raw = format!( + r#"{{ + "enabled": true, + "storage": {{ + "type": "file", + "path": "{}", + "watch": false + }}, + "selectors": [ + {{ "type": "url_path_param", "template": "{template}" }} + ] +}}"#, + manifest_path.display() + ); + + let config: PersistedDocumentsConfig = + serde_json::from_str(&raw).expect("bench config should parse"); + DocumentIdResolver::from_config(&config, GRAPHQL_ENDPOINT) + .expect("resolver config should compile") +} + +fn build_query_param_resolver(name: &str) -> DocumentIdResolver { + let manifest_path = std::env::temp_dir().join("persisted-docs-bench.json"); + std::fs::write(&manifest_path, "{}").expect("bench manifest should be writable"); + + let raw = format!( + r#"{{ + "enabled": true, + "storage": {{ + "type": "file", + "path": "{}", + "watch": false + }}, + "selectors": [ + {{ "type": "url_query_param", "name": "{name}" }} + ] +}}"#, + manifest_path.display() + ); + + let config: PersistedDocumentsConfig = + serde_json::from_str(&raw).expect("bench config should parse"); + DocumentIdResolver::from_config(&config, GRAPHQL_ENDPOINT) + .expect("resolver config should compile") +} + +fn persisted_documents_matcher_benchmark(c: &mut Criterion) { + let cases = [ + PathCase { + name: "simple_id", + template: "/p/:id", + hit_path: "/graphql/p/abc-123", + miss_path: "/graphql/p", + }, + PathCase { + name: "single_wildcard", + template: "/v1/*/:id/details", + hit_path: "/graphql/v1/mobile/abc-123/details", + miss_path: "/graphql/v1/mobile/abc-123", + }, + ]; + + let graphql_params = GraphQLParams::default(); + + for case in cases { + let resolver = build_resolver(case.template); + + let hit_context = HttpRequestContext::from_parts(case.hit_path, None); + let miss_context = HttpRequestContext::from_parts(case.miss_path, None); + + let mut group = c.benchmark_group(format!("persisted_docs_path_match/{}", case.name)); + + group.bench_with_input( + BenchmarkId::new("current", "hit"), + &hit_context, + |b, ctx| { + b.iter(|| { + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("current", "miss"), + &miss_context, + |b, ctx| { + b.iter(|| { + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.finish(); + } +} + +fn persisted_documents_query_param_benchmark(c: &mut Criterion) { + let resolver = build_query_param_resolver("documentId"); + let graphql_params = GraphQLParams::default(); + + let hit_query = "documentId=sha256:abc"; + let miss_query = "foo=bar"; + let long_miss_query = + "a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10&k=11&l=12&m=13&n=14&o=15&p=16&q=17&r=18&s=19&t=20"; + let encoded_hit_query = "documentId=sha256%3Aabc"; + + let mut group = c.benchmark_group("persisted_docs_query_param/current"); + + group.bench_with_input( + BenchmarkId::new("lookup", "hit_plain"), + &hit_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "miss_plain"), + &miss_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "miss_long"), + &long_miss_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.bench_with_input( + BenchmarkId::new("lookup", "hit_encoded"), + &encoded_hit_query, + |b, query| { + b.iter(|| { + let ctx = HttpRequestContext::from_parts("/graphql", Some(query)); + let input = DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id: None, + nonstandard_json_fields: None, + request_context: &ctx, + }; + black_box(resolver.resolve_document_id(input)) + }) + }, + ); + + group.finish(); +} + +criterion_group!( + benches, + persisted_documents_matcher_benchmark, + persisted_documents_query_param_benchmark, +); +criterion_main!(benches); diff --git a/bin/router/src/error.rs b/bin/router/src/error.rs index d083ccdce..c2390cd60 100644 --- a/bin/router/src/error.rs +++ b/bin/router/src/error.rs @@ -28,6 +28,8 @@ pub enum RouterInitError { TelemetryInitError(#[from] TelemetryInitError), #[error(transparent)] PluginRegistryError(#[from] PluginRegistryError), + #[error("Persisted documents endpoint incompatible: {0}")] + PersistedDocumentsEndpointIncompatible(String), #[error("Endpoints of '{endpoint_name_one}' and '{endpoint_name_two}' cannot both use the same endpoint: {endpoint}")] EndpointConflict { endpoint_name_one: String, diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 06bf96266..41222ef79 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -28,6 +28,7 @@ use crate::{ header::ResponseMode, http_callback::handler, long_lived_client_limit::LongLivedClientLimitService, + persisted_documents::PersistedDocumentsRuntime, request_extensions::{ read_graphql_operation_metric_identity, read_graphql_response_metric_status, read_request_body_size, write_graphql_response_metric_status, @@ -353,8 +354,28 @@ pub async fn configure_app_from_config( config: max_aliases_config.clone(), })); } + let persisted_documents_runtime = PersistedDocumentsRuntime::init( + &router_config_arc.persisted_documents, + &router_config_arc.http.graphql_endpoint, + bg_tasks_manager, + ) + .await + .map_err(|err| crate::shared_state::SharedStateError::PersistedDocuments(Box::new(err)))?; + + if !persisted_documents_runtime + .supports_graphql_endpoint(&router_config_arc.http.graphql_endpoint) + { + // url_path_param extractor depends on path segments relative to graphql endpoint. + // Root endpoint would make all routes ambiguous for persisted-document extraction. + // Even /health could be treated as a graphql request with document id == "health". + return Err(RouterInitError::PersistedDocumentsEndpointIncompatible( + "http.graphql_endpoint='/' is not allowed when persisted_documents.selectors contains type=url_path_param. Use a non-root endpoint like '/graphql'.".to_string(), + )); + } + let shared_state = Arc::new(RouterSharedState::new( router_config_arc, + persisted_documents_runtime, jwt_runtime, hive_usage_agent, validation_plan, @@ -474,6 +495,13 @@ pub fn configure_ntex_app( }), ); } + + // Enables /graphql/sha256:12345 cases for persisted documents + if paths.graphql != "/" { + cfg.service( + web::scope(paths.graphql.as_str()).default_service(web::to(graphql_endpoint_handler)), + ); + } } /// Initializes the rustls cryptographic provider for the entire process. diff --git a/bin/router/src/pipeline/error.rs b/bin/router/src/pipeline/error.rs index e5f53bd01..5a30b56d0 100644 --- a/bin/router/src/pipeline/error.rs +++ b/bin/router/src/pipeline/error.rs @@ -53,9 +53,6 @@ pub enum PipelineError { UnsupportedContentType, // GET Specific pipeline errors - #[error("Failed to deserialize query parameters")] - #[strum(serialize = "INVALID_QUERY_PARAMS")] - GetInvalidQueryParams, #[error("Missing query parameter: {0}")] #[strum(serialize = "MISSING_QUERY_PARAM")] GetMissingQueryParam(&'static str), @@ -79,6 +76,18 @@ pub enum PipelineError { #[error("Failed to parse GraphQL operation: {0}")] #[strum(serialize = "GRAPHQL_PARSE_FAILED")] FailedToParseOperation(#[from] Arc), + #[error("Persisted document not found: {0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_NOT_FOUND")] + PersistedDocumentNotFound(String), + #[error("Persisted document id is required")] + #[strum(serialize = "PERSISTED_DOCUMENT_ID_REQUIRED")] + PersistedDocumentIdRequired, + #[error("{0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_EXTRACTION_FAILED")] + PersistedDocumentExtraction(String), + #[error("{0}")] + #[strum(serialize = "PERSISTED_DOCUMENT_RESOLUTION_FAILED")] + PersistedDocumentResolution(String), #[error("Failed to minify parsed GraphQL operation: {0}")] #[strum(serialize = "GRAPHQL_PARSE_MINIFY_FAILED")] FailedToMinifyParsedOperation(String), @@ -204,11 +213,17 @@ impl PipelineError { (Self::UnsupportedHttpMethod(_), _) => StatusCode::METHOD_NOT_ALLOWED, (Self::InvalidHeaderValue(_), _) => StatusCode::BAD_REQUEST, (Self::GetUnprocessableQueryParams(_), _) => StatusCode::BAD_REQUEST, - (Self::GetInvalidQueryParams, _) => StatusCode::BAD_REQUEST, (Self::GetMissingQueryParam(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseBody(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseVariables(_), _) => StatusCode::BAD_REQUEST, (Self::FailedToParseExtensions(_), _) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentNotFound(_), false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentNotFound(_), true) => StatusCode::OK, + (Self::PersistedDocumentIdRequired, false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentIdRequired, true) => StatusCode::OK, + (Self::PersistedDocumentExtraction(_), false) => StatusCode::BAD_REQUEST, + (Self::PersistedDocumentExtraction(_), true) => StatusCode::OK, + (Self::PersistedDocumentResolution(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::FailedToParseOperation(_), false) => StatusCode::BAD_REQUEST, (Self::FailedToParseOperation(_), true) => StatusCode::OK, (Self::FailedToMinifyParsedOperation(_), false) => StatusCode::BAD_REQUEST, diff --git a/bin/router/src/pipeline/execution_request.rs b/bin/router/src/pipeline/execution_request.rs index 343b6c191..fb9ed27a0 100644 --- a/bin/router/src/pipeline/execution_request.rs +++ b/bin/router/src/pipeline/execution_request.rs @@ -1,5 +1,9 @@ +use std::borrow::Cow; use std::collections::HashMap; +use std::fmt; +use hive_router_internal::json::MapAccessSerdeExt; +use hive_router_internal::telemetry::metrics::Metrics; use hive_router_plan_executor::hooks::on_graphql_params::{ GraphQLParams, OnGraphQLParamsEndHookPayload, OnGraphQLParamsStartHookPayload, }; @@ -9,21 +13,240 @@ use http::{header::CONTENT_TYPE, Method}; use ntex::util::Bytes; use ntex::web::types::Query; use ntex::web::HttpRequest; -use tracing::{trace, warn}; +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, Visitor}; +use std::sync::Arc; +use tracing::{info, trace, warn}; use crate::pipeline::error::PipelineError; use crate::pipeline::header::SingleContentType; +use crate::pipeline::persisted_documents::extract::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, DOCUMENT_ID_FIELD, +}; +use crate::pipeline::persisted_documents::resolve::PersistedDocumentResolveInput; +use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; +use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; +use crate::shared_state::RouterSharedState; #[derive(serde::Deserialize, Debug)] -struct GETQueryParams { +struct GraphQLGetInput { pub query: Option, - #[serde(rename = "camelCase")] + #[serde(rename = "operationName")] pub operation_name: Option, + #[serde(rename = "documentId")] + pub document_id: Option, pub variables: Option, pub extensions: Option, } -impl TryInto for GETQueryParams { +impl GraphQLGetInput { + pub fn empty() -> Self { + Self { + query: None, + operation_name: None, + document_id: None, + variables: None, + extensions: None, + } + } +} + +#[derive(Debug, Default)] +struct GraphQLPostInput { + query: Option, + operation_name: Option, + variables: HashMap, + extensions: Option>, + document_id: Option, + nonstandard_json_fields: Option>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum GraphQLDocumentIdValue { + String(String), + U64(u64), +} + +impl GraphQLDocumentIdValue { + #[inline] + fn into_string(self) -> String { + match self { + Self::String(value) => value, + Self::U64(value) => value.to_string(), + } + } +} + +#[derive(Debug)] +pub struct PreparedOperation { + pub graphql_params: GraphQLParams, + /// Represents the resolved document ID, if one was found, + /// according to the document ID resolver plan. + pub resolved_document_id: Option, +} + +impl PreparedOperation { + #[inline] + fn from_get( + get_input: GraphQLGetInput, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + ) -> Result { + let document_id = get_input.document_id.clone(); + Ok(Self::from_graphql_params( + get_input.try_into()?, + document_id_resolver, + request_context, + document_id.as_deref(), + None, + )) + } + + #[inline] + fn from_post( + post_input: GraphQLPostInput, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + ) -> Self { + let GraphQLPostInput { + query, + operation_name, + variables, + extensions, + document_id, + nonstandard_json_fields, + } = post_input; + + Self::from_graphql_params( + GraphQLParams { + query, + operation_name, + variables, + extensions, + }, + document_id_resolver, + request_context, + document_id.as_deref(), + nonstandard_json_fields.as_ref(), + ) + } + + #[inline] + fn from_graphql_params( + graphql_params: GraphQLParams, + document_id_resolver: &DocumentIdResolver, + request_context: HttpRequestContext<'_>, + document_id: Option<&str>, + nonstandard_json_fields: Option<&HashMap>, + ) -> Self { + let persisted_document_id = if document_id_resolver.is_enabled() { + document_id_resolver.resolve_document_id(DocumentIdResolverInput { + graphql_params: &graphql_params, + document_id, + nonstandard_json_fields, + request_context: &request_context, + }) + } else { + None + }; + + Self { + graphql_params, + resolved_document_id: persisted_document_id, + } + } +} + +struct GraphQLPostBodySeed<'a> { + document_id_resolver: &'a DocumentIdResolver, +} + +impl<'a> GraphQLPostBodySeed<'a> { + #[inline] + fn new(document_id_resolver: &'a DocumentIdResolver) -> Self { + Self { + document_id_resolver, + } + } +} + +struct GraphQLPostBodyVisitor { + // wether to capture extra fields from the POST body + // besides the query, operation name, variables, extensions and documentId. + // We only need it when the document ID resolver requires something else than: + // - documentId + // - extensions.* + capture_nonstandard_json_fields: bool, +} + +impl<'de> DeserializeSeed<'de> for GraphQLPostBodySeed<'_> { + type Value = GraphQLPostInput; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(GraphQLPostBodyVisitor { + capture_nonstandard_json_fields: self + .document_id_resolver + .requires_nonstandard_json_fields(), + }) + } +} + +impl<'de> Visitor<'de> for GraphQLPostBodyVisitor { + type Value = GraphQLPostInput; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a GraphQL POST JSON object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut query: Option = None; + let mut operation_name: Option = None; + let mut variables: Option> = None; + let mut extensions: Option> = None; + let mut document_id: Option = None; + let mut nonstandard_json_fields: Option> = + self.capture_nonstandard_json_fields.then(HashMap::new); + + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "query" => map.deserialize_once_into_option(&mut query, "query")?, + "operationName" => { + map.deserialize_once_into_option(&mut operation_name, "operationName")? + } + "variables" => map.deserialize_once_into_option(&mut variables, "variables")?, + "extensions" => map.deserialize_once_into_option(&mut extensions, "extensions")?, + DOCUMENT_ID_FIELD => { + map.deserialize_once_into_option(&mut document_id, DOCUMENT_ID_FIELD)? + } + _ => { + if let Some(nonstandard_json_fields) = nonstandard_json_fields.as_mut() { + let value = map.next_value::()?; + nonstandard_json_fields.insert(key.into_owned(), value); + } else { + let _ = map.next_value::()?; + } + } + } + } + + Ok(GraphQLPostInput { + query, + operation_name, + variables: variables.unwrap_or_default(), + extensions, + document_id: document_id.map(GraphQLDocumentIdValue::into_string), + nonstandard_json_fields, + }) + } +} + +impl TryInto for GraphQLGetInput { type Error = PipelineError; fn try_into(self) -> Result { @@ -70,124 +293,537 @@ impl GetQueryStr for GraphQLParams { } } -pub enum DeserializationResult { +pub enum OperationPreparationResult { EarlyResponse(ntex::web::HttpResponse), - GraphQLParams(GraphQLParams), + Operation(PreparedOperation), } -#[inline] -pub async fn deserialize_graphql_params( - req: &HttpRequest, +pub struct OperationPreparation<'a> { + req: &'a HttpRequest, + persisted_documents_runtime: &'a PersistedDocumentsRuntime, + plugin_req_state: &'a Option>, body: Bytes, - plugin_req_state: &Option>, -) -> Result { - /* Handle on_deserialize hook in the plugins - START */ - let mut deserialization_end_callbacks = vec![]; - - let mut graphql_params = None; - let mut body = body; - if let Some(plugin_req_state) = plugin_req_state.as_ref() { - let mut deserialization_payload: OnGraphQLParamsStartHookPayload = - OnGraphQLParamsStartHookPayload { - router_http_request: &plugin_req_state.router_http_request, + require_id: bool, + persisted_documents_enabled: bool, + log_missing_id_requests: bool, + client_identity: ClientIdentity<'a>, + metrics: Arc, +} + +impl<'a> OperationPreparation<'a> { + #[inline] + pub async fn prepare( + req: &'a HttpRequest, + shared_state: &'a Arc, + plugin_req_state: &'a Option>, + body: Bytes, + client_name: Option<&'a str>, + client_version: Option<&'a str>, + ) -> Result { + Self { + req, + persisted_documents_runtime: &shared_state.persisted_documents_runtime, + plugin_req_state, + body, + require_id: shared_state.router_config.persisted_documents.require_id, + persisted_documents_enabled: shared_state.router_config.persisted_documents.enabled, + log_missing_id_requests: shared_state + .router_config + .persisted_documents + .log_missing_id, + client_identity: ClientIdentity { + name: client_name, + version: client_version, + }, + metrics: shared_state.telemetry_context.metrics.clone(), + } + .extract_and_resolve() + .await + } + + async fn extract_and_resolve(mut self) -> Result { + let mut graphql_params_from_plugins = None; + let mut graphql_params_end_callbacks = Vec::new(); + + if let Some(plugin_req_state) = self.plugin_req_state.as_ref() { + let mut deserialization_payload: OnGraphQLParamsStartHookPayload = + OnGraphQLParamsStartHookPayload { + router_http_request: &plugin_req_state.router_http_request, + context: &plugin_req_state.context, + body: self.body.clone(), + graphql_params: None, + }; + + for plugin in plugin_req_state.plugins.as_ref() { + let result = plugin.on_graphql_params(deserialization_payload).await; + deserialization_payload = result.payload; + match result.control_flow { + StartControlFlow::Proceed => {} + StartControlFlow::EndWithResponse(response) => { + return Ok(OperationPreparationResult::EarlyResponse(response)); + } + StartControlFlow::OnEnd(callback) => { + graphql_params_end_callbacks.push(callback); + } + } + } + + graphql_params_from_plugins = deserialization_payload.graphql_params; + self.body = deserialization_payload.body; + } + + let mut operation = self.decode_or_use_plugin_override(graphql_params_from_plugins)?; + + if self.persisted_documents_enabled && operation.resolved_document_id.is_none() { + self.metrics.persisted_documents.record_missing_id(); + } + + if self.persisted_documents_enabled + && self.log_missing_id_requests + && operation.resolved_document_id.is_none() + { + info!( + event = "persisted_documents.missing_id_request", + method = %self.req.method(), + path = %self.req.uri().path(), + require_id = self.require_id, + operation_name = operation.graphql_params.operation_name.as_deref().unwrap_or(""), + operation_body = operation.graphql_params.query.as_deref().unwrap_or(""), + client_name = self.client_identity.name.unwrap_or(""), + client_version = self.client_identity.version.unwrap_or(""), + "request without document id" + ); + } + + self.enforce_require_id_policy(&mut operation)?; + + // Apollo's APQ requests may include both `query` and a persisted id/hash in `extensions`. + // The require-id policy above normalizes query/id precedence before this branch. + if self.persisted_documents_enabled && operation.graphql_params.query.is_none() { + self.resolve_query_from_document_id(&mut operation).await?; + } + + if let Some(plugin_req_state) = self.plugin_req_state.as_ref() { + let mut payload = OnGraphQLParamsEndHookPayload { + graphql_params: operation.graphql_params, context: &plugin_req_state.context, - body, - graphql_params: None, }; - for plugin in plugin_req_state.plugins.as_ref() { - let result = plugin.on_graphql_params(deserialization_payload).await; - deserialization_payload = result.payload; - match result.control_flow { - StartControlFlow::Proceed => { /* continue to next plugin */ } - StartControlFlow::EndWithResponse(response) => { - return Ok(DeserializationResult::EarlyResponse(response)); - } - StartControlFlow::OnEnd(callback) => { - deserialization_end_callbacks.push(callback); + + for callback in graphql_params_end_callbacks { + let result = callback(payload); + payload = result.payload; + match result.control_flow { + EndControlFlow::Proceed => {} + EndControlFlow::EndWithResponse(response) => { + return Ok(OperationPreparationResult::EarlyResponse(response)); + } } } + + operation.graphql_params = payload.graphql_params; } - // Give the ownership back to variables - graphql_params = deserialization_payload.graphql_params; - body = deserialization_payload.body; + + Ok(OperationPreparationResult::Operation(operation)) } - let mut graphql_params = match graphql_params { - Some(params) => params, - None => { - let http_method = req.method(); - match *http_method { - Method::GET => { - trace!("processing GET GraphQL operation"); - let query_params_str = req - .uri() - .query() - .ok_or_else(|| PipelineError::GetInvalidQueryParams)?; - let query_params = Query::::from_query(query_params_str)?.0; + #[inline] + fn decode_or_use_plugin_override( + &self, + graphql_params_override: Option, + ) -> Result { + if let Some(graphql_params) = graphql_params_override { + return Ok(PreparedOperation::from_graphql_params( + graphql_params, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + None, + None, + )); + } - trace!("parsed GET query params: {:?}", query_params); + match *self.req.method() { + Method::GET => self.decode_get(), + Method::POST => self.decode_post(), + _ => { + warn!("unsupported HTTP method: {}", self.req.method()); + Err(PipelineError::UnsupportedHttpMethod( + self.req.method().to_owned(), + )) + } + } + } - query_params.try_into()? - } - Method::POST => { - trace!("Processing POST GraphQL request"); - - match req.headers().get(CONTENT_TYPE) { - Some(value) => { - let content_type_str = value - .to_str() - .map_err(|_| PipelineError::InvalidHeaderValue(CONTENT_TYPE))?; - if !content_type_str.contains(SingleContentType::JSON.as_ref()) { - warn!( - "Invalid content type on a POST request: {}", - content_type_str - ); - return Err(PipelineError::UnsupportedContentType); - } - } - None => { - trace!("POST without content type detected"); - return Err(PipelineError::MissingContentTypeHeader); - } - } + #[inline] + fn decode_get(&self) -> Result { + let query_params_str = self.req.uri().query(); + let query_params = if let Some(q) = query_params_str { + Query::::from_query(q)?.0 + } else { + // We need it to be able to use Persisted Documents in `GET /graphql/:id` format + GraphQLGetInput::empty() + }; - let execution_request = unsafe { - sonic_rs::from_slice_unchecked::(&body).map_err(|e| { - warn!("Failed to parse body: {}", e); - PipelineError::FailedToParseBody(e) - })? - }; + PreparedOperation::from_get( + query_params, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + ) + } - execution_request + #[inline] + fn decode_post(&self) -> Result { + match self.req.headers().get(CONTENT_TYPE) { + Some(value) => { + let content_type_str = value + .to_str() + .map_err(|_| PipelineError::InvalidHeaderValue(CONTENT_TYPE))?; + if !content_type_str.contains(SingleContentType::JSON.as_ref()) { + warn!( + "Invalid content type on a POST request: {}", + content_type_str + ); + return Err(PipelineError::UnsupportedContentType); } - _ => { - warn!("unsupported HTTP method: {}", http_method); + } + None => { + trace!("POST without content type detected"); + return Err(PipelineError::MissingContentTypeHeader); + } + } - return Err(PipelineError::UnsupportedHttpMethod(http_method.to_owned())); - } + let mut deserializer = sonic_rs::Deserializer::from_slice(&self.body); + + let post_input = + GraphQLPostBodySeed::new(&self.persisted_documents_runtime.document_id_resolver) + .deserialize(&mut deserializer) + .map_err(PipelineError::FailedToParseBody)?; + + // Calling end() is important to ensure there is no trailing garbage after the JSON payload. + // Without calling it, this might be accepted: + // {"query":"{ me { id } }"} garbage + // or even: + // {"query":"{ me { id } }"}{"another":"object"} + deserializer + .end() + .map_err(PipelineError::FailedToParseBody)?; + + Ok(PreparedOperation::from_post( + post_input, + &self.persisted_documents_runtime.document_id_resolver, + self.req.into(), + )) + } + + #[inline] + fn enforce_require_id_policy( + &self, + prepared_operation: &mut PreparedOperation, + ) -> Result<(), PipelineError> { + if !self.persisted_documents_enabled { + // If persisted documents are disabled, clear the resolved document ID, + // as it's not meant to be used in that case. + prepared_operation.resolved_document_id = None; + return Ok(()); + } + + if self.require_id { + // If require_id is set, clear the query to make the document ID-based resolution mandatory. + prepared_operation.graphql_params.query = None; + if prepared_operation.resolved_document_id.is_none() { + return Err(PipelineError::PersistedDocumentIdRequired); } + return Ok(()); } + + if prepared_operation.graphql_params.query.is_some() { + // if a query is present, clear the resolved document ID, + // as the query takes precedence over the document ID. + prepared_operation.resolved_document_id = None; + } + + Ok(()) + } + + #[inline] + async fn resolve_query_from_document_id( + &self, + prepared_operation: &mut PreparedOperation, + ) -> Result<(), PipelineError> { + if let Some(document_id) = prepared_operation.resolved_document_id.as_ref() { + let resolver = self + .persisted_documents_runtime + .persisted_document_resolver + .as_ref() + .ok_or_else(|| { + PipelineError::PersistedDocumentResolution( + "Persisted documents storage is not configured".to_string(), + ) + })?; + + let resolved = resolver + .resolve(PersistedDocumentResolveInput { + persisted_document_id: document_id, + client_identity: self.client_identity, + }) + .await + .map_err(|error| { + self.metrics.persisted_documents.record_resolution_failure(); + PipelineError::from(error) + })?; + + prepared_operation.graphql_params.query = Some(resolved.text.to_string()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use async_trait::async_trait; + use hive_router_config::persisted_documents::PersistedDocumentsConfig; + use hive_router_internal::telemetry::metrics::Metrics; + use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; + use hive_router_plan_executor::plugin_context::PluginRequestState; + use ntex::util::Bytes; + use ntex::web::test::TestRequest; + use ntex::web::HttpRequest; + + use super::{OperationPreparation, PreparedOperation}; + use crate::pipeline::error::PipelineError; + use crate::pipeline::persisted_documents::extract::DocumentIdResolver; + use crate::pipeline::persisted_documents::resolve::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, }; + use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; + use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; - if let Some(plugin_req_state) = &plugin_req_state { - let mut payload = OnGraphQLParamsEndHookPayload { - graphql_params, - context: &plugin_req_state.context, - }; - for deserialization_end_callback in deserialization_end_callbacks { - let result = deserialization_end_callback(payload); - payload = result.payload; - match result.control_flow { - EndControlFlow::Proceed => { /* continue to next plugin */ } - EndControlFlow::EndWithResponse(response) => { - return Ok(DeserializationResult::EarlyResponse(response)); - } - } + struct StaticResolver { + document: Arc, + } + + #[async_trait] + impl PersistedDocumentResolver for StaticResolver { + async fn resolve( + &self, + _input: PersistedDocumentResolveInput<'_>, + ) -> Result { + Ok(ResolvedDocument { + text: Arc::clone(&self.document), + }) + } + } + + fn document_id_resolver() -> DocumentIdResolver { + DocumentIdResolver::from_config(&PersistedDocumentsConfig::default(), "/graphql") + .expect("resolver config should compile") + } + + fn request() -> HttpRequest { + TestRequest::with_uri("/graphql").to_http_request() + } + + fn operation(query: Option<&str>, persisted_id: Option<&str>) -> PreparedOperation { + PreparedOperation { + graphql_params: GraphQLParams { + query: query.map(ToString::to_string), + operation_name: None, + variables: HashMap::new(), + extensions: None, + }, + resolved_document_id: PersistedDocumentId::from_option(persisted_id), } - graphql_params = payload.graphql_params; } - /* Handle on_deserialize hook in the plugins - END */ + #[ntex::test] + async fn resolves_query_from_persisted_document_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_resolver: Arc = Arc::new(StaticResolver { + document: Arc::::from("query { me { id } }"), + }); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: Some(persisted_resolver.clone()), + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = PreparedOperation { + graphql_params: GraphQLParams { + query: None, + operation_name: None, + variables: HashMap::new(), + extensions: None, + }, + resolved_document_id: Some(PersistedDocumentId::try_from("sha256:abc").unwrap()), + }; + + prep.resolve_query_from_document_id(&mut op) + .await + .expect("query should resolve"); + + assert_eq!( + op.graphql_params.query.as_deref(), + Some("query { me { id } }") + ); + } + + #[test] + fn require_id_enabled_drops_query_and_keeps_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("require_id policy should pass"); + + assert!(op.graphql_params.query.is_none()); + assert!(op.resolved_document_id.is_some()); + } + + #[test] + fn require_id_enabled_without_id_returns_required_error() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), None); + + let err = prep + .enforce_require_id_policy(&mut op) + .expect_err("missing id should fail"); + + assert!(matches!(err, PipelineError::PersistedDocumentIdRequired)); + } + + #[test] + fn require_id_disabled_query_wins_and_drops_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); - Ok(DeserializationResult::GraphQLParams(graphql_params)) + assert!(op.graphql_params.query.is_some()); + assert!(op.resolved_document_id.is_none()); + } + + #[test] + fn persisted_documents_disabled_always_drops_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: true, + persisted_documents_enabled: false, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(Some("query { me { id } }"), Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); + + assert!(op.graphql_params.query.is_some()); + assert!(op.resolved_document_id.is_none()); + } + + #[test] + fn query_missing_with_require_id_disabled_keeps_persisted_id() { + let req = request(); + let resolver = Arc::new(document_id_resolver()); + let persisted_documents_runtime = PersistedDocumentsRuntime { + document_id_resolver: resolver, + persisted_document_resolver: None, + }; + let plugin_req_state: Option> = None; + let prep = OperationPreparation { + req: &req, + persisted_documents_runtime: &persisted_documents_runtime, + plugin_req_state: &plugin_req_state, + body: Bytes::new(), + require_id: false, + persisted_documents_enabled: true, + log_missing_id_requests: false, + client_identity: ClientIdentity::default(), + metrics: Arc::new(Metrics::new(None)), + }; + let mut op = operation(None, Some("sha256:abc")); + + prep.enforce_require_id_policy(&mut op) + .expect("policy should pass"); + + assert!(op.graphql_params.query.is_none()); + assert!(op.resolved_document_id.is_some()); + } } diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 352365da0..58a18745a 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -38,7 +38,7 @@ use crate::{ csrf_prevention::perform_csrf_prevention, error::PipelineError, execution::{execute_plan, PlannedRequest}, - execution_request::{deserialize_graphql_params, DeserializationResult, GetQueryStr}, + execution_request::{GetQueryStr, OperationPreparation, OperationPreparationResult}, header::{RequestAccepts, ResponseMode, TEXT_HTML_MIME}, introspection_policy::handle_introspection_policy, normalize::{normalize_request_with_cache, GraphQLNormalizationPayload}, @@ -77,6 +77,7 @@ pub mod long_lived_client_limit; pub mod multipart_subscribe; pub mod normalize; pub mod parser; +pub mod persisted_documents; pub mod progressive_override; pub mod query_plan; pub mod request_extensions; @@ -138,31 +139,6 @@ pub async fn graphql_request_handler( write_request_body_size(req, body_bytes.len() as u64); http_server_request_span.record_body_size(body_bytes.len()); - let mut plugin_req_state = None; - - if let (Some(plugins), Some(plugin_context)) = ( - shared_state.plugins.as_ref(), - req.extensions().get::>(), - ) { - plugin_req_state = Some(PluginRequestState { - plugins: plugins.clone(), - router_http_request: req.into(), - context: plugin_context.clone(), - }); - } - - let deserialization_result = - deserialize_graphql_params(req, body_bytes, &plugin_req_state).await?; - - let graphql_params = match deserialization_result { - DeserializationResult::GraphQLParams(params) => params, - DeserializationResult::EarlyResponse(response) => { - return Ok(response); - } - }; - - write_graphql_operation_metric_identity(req, graphql_params.operation_name.clone(), None); - let client_name = req .headers() .get( @@ -184,6 +160,40 @@ pub async fn graphql_request_handler( ) .and_then(|v| v.to_str().ok()); + let mut plugin_req_state = None; + + if let (Some(plugins), Some(plugin_context)) = ( + shared_state.plugins.as_ref(), + req.extensions().get::>(), + ) { + plugin_req_state = Some(PluginRequestState { + plugins: plugins.clone(), + router_http_request: req.into(), + context: plugin_context.clone(), + }); + } + + let operation_preparation_result = OperationPreparation::prepare( + req, + shared_state, + &plugin_req_state, + body_bytes, + client_name, + client_version, + ) + .await?; + + let prepared_operation = match operation_preparation_result { + OperationPreparationResult::Operation(prepared_operation) => prepared_operation, + OperationPreparationResult::EarlyResponse(response) => { + return Ok(response); + } + }; + + let graphql_params = prepared_operation.graphql_params; + + write_graphql_operation_metric_identity(req, graphql_params.operation_name.clone(), None); + let parser_result = parse_operation_with_cache(shared_state, &graphql_params, &plugin_req_state).await?; diff --git a/bin/router/src/pipeline/persisted_documents/extract/core.rs b/bin/router/src/pipeline/persisted_documents/extract/core.rs new file mode 100644 index 000000000..b38fd4cb1 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/core.rs @@ -0,0 +1,292 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::ops::Deref; + +use hive_router_config::persisted_documents::{ + PersistedDocumentExtractorConfig, PersistedDocumentUrlTemplate, PersistedDocumentsConfig, +}; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use ntex::web::HttpRequest; +use sonic_rs::OwnedLazyValue; +use thiserror::Error; + +use crate::pipeline::persisted_documents::extract::extractors::apollo::{ + ApolloExtractor, APOLLO_HASH_PATH, +}; +use crate::pipeline::persisted_documents::extract::extractors::document_id::{ + DocumentIdExtractor, DOCUMENT_ID_FIELD, +}; +use crate::pipeline::persisted_documents::extract::extractors::json_path::JsonPathExtractor; +use crate::pipeline::persisted_documents::extract::extractors::url_path_param::UrlPathParamExtractor; +use crate::pipeline::persisted_documents::extract::extractors::url_query_param::{ + QueryParams, UrlQueryParamExtractor, +}; + +use super::super::types::PersistedDocumentId; + +pub struct HttpRequestContext<'a> { + pub(crate) path: &'a str, + pub(crate) query: Option>, +} + +pub struct DocumentIdResolverInput<'a> { + pub graphql_params: &'a GraphQLParams, + pub document_id: Option<&'a str>, + pub nonstandard_json_fields: Option<&'a HashMap>, + pub request_context: &'a HttpRequestContext<'a>, +} + +impl<'a> From<&'a HttpRequest> for HttpRequestContext<'a> { + fn from(req: &'a HttpRequest) -> Self { + Self::from_parts(req.uri().path(), req.uri().query()) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn from_parts(path: &'a str, query: Option<&'a str>) -> Self { + Self { + path, + query: query.map(QueryParams::new), + } + } +} + +pub struct DocumentIdResolver { + graphql_endpoint: GraphQLEndpointPath, + state: ResolverState, +} + +#[derive(Debug, Error)] +pub enum PersistedDocumentExtractError { + #[error("url_path_param.template must contain ':id' segment: {template}")] + MissingIdParam { template: String }, + #[error("failed to compile url_path_param.template: {0}")] + MatcherCompile(String), +} + +enum ResolverState { + Disabled, + Enabled(ActivePlan), +} + +struct ActivePlan { + selectors: Vec>, + requires_nonstandard_json_fields: bool, + depends_on_graphql_path: bool, +} + +pub(super) trait DocumentIdSourceExtractor: Send + Sync { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option; +} + +#[derive(Debug)] +struct GraphQLEndpointPath(String); + +impl Deref for GraphQLEndpointPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for GraphQLEndpointPath { + fn as_ref(&self) -> &str { + &self.0 + } +} + +pub(super) struct ExtractionContext<'a> { + pub(crate) graphql_params: &'a GraphQLParams, + document_id: Option<&'a str>, + pub(crate) nonstandard_json_fields: Option<&'a HashMap>, + relative_path: Option<&'a str>, + pub(crate) request_context: &'a HttpRequestContext<'a>, +} + +impl<'a> ExtractionContext<'a> { + fn new(input: DocumentIdResolverInput<'a>, graphql_endpoint: &GraphQLEndpointPath) -> Self { + Self { + graphql_params: input.graphql_params, + document_id: input.document_id, + nonstandard_json_fields: input.nonstandard_json_fields, + relative_path: graphql_endpoint.relative_path(input.request_context.path()), + request_context: input.request_context, + } + } + + pub(super) fn document_id(&self) -> Option<&'a str> { + self.document_id + } + + pub(super) fn relative_path(&self) -> Option<&'a str> { + self.relative_path + } + + pub(super) fn query_param(&self, name: &str) -> Option> { + self.request_context.query_param(name) + } +} + +impl DocumentIdResolver { + pub fn from_config( + config: &PersistedDocumentsConfig, + graphql_endpoint: &str, + ) -> Result { + let graphql_endpoint = GraphQLEndpointPath::from(graphql_endpoint); + + if !config.enabled { + return Ok(Self { + graphql_endpoint, + state: ResolverState::Disabled, + }); + } + + let configured_selectors = match config.selectors.as_ref() { + Some(selectors) => selectors.clone(), + None => PersistedDocumentsConfig::default_selectors(), + }; + + let mut selectors = Vec::with_capacity(configured_selectors.len()); + let mut requires_nonstandard_json_fields = false; + let mut depends_on_graphql_path = false; + + for selector_config in &configured_selectors { + let (extractor, requires_nonstandard_fields, depends_on_url_path) = + build_extractor(selector_config)?; + requires_nonstandard_json_fields |= requires_nonstandard_fields; + depends_on_graphql_path |= depends_on_url_path; + selectors.push(extractor); + } + + Ok(Self { + graphql_endpoint, + state: ResolverState::Enabled(ActivePlan { + selectors, + requires_nonstandard_json_fields, + depends_on_graphql_path, + }), + }) + } + + #[inline] + pub fn is_enabled(&self) -> bool { + matches!(self.state, ResolverState::Enabled(_)) + } + + #[inline] + pub fn requires_nonstandard_json_fields(&self) -> bool { + match &self.state { + ResolverState::Disabled => false, + ResolverState::Enabled(active_plan) => active_plan.requires_nonstandard_json_fields, + } + } + + pub fn depends_on_graphql_path(&self) -> bool { + match &self.state { + ResolverState::Disabled => false, + ResolverState::Enabled(active_plan) => active_plan.depends_on_graphql_path, + } + } + + pub fn resolve_document_id( + &self, + input: DocumentIdResolverInput<'_>, + ) -> Option { + let active_plan = match &self.state { + ResolverState::Disabled => return None, + ResolverState::Enabled(active_plan) => active_plan, + }; + + let ctx = ExtractionContext::new(input, &self.graphql_endpoint); + + for selector in &active_plan.selectors { + if let Some(persisted_document_id) = selector.extract(&ctx) { + return Some(persisted_document_id); + } + } + + None + } +} + +fn build_extractor( + extractor_config: &PersistedDocumentExtractorConfig, +) -> Result<(Box, bool, bool), PersistedDocumentExtractError> { + match extractor_config { + PersistedDocumentExtractorConfig::JsonPath { path } => { + if path.as_str() == DOCUMENT_ID_FIELD { + return Ok((Box::new(DocumentIdExtractor), false, false)); + } + + if path.as_str() == APOLLO_HASH_PATH { + return Ok((Box::new(ApolloExtractor), false, false)); + } + + let segments = path + .as_str() + .split('.') + .map(|s| s.to_string()) + .collect::>(); + + let requires_extra = JsonPathExtractor::requires_nonstandard_json_fields(&segments); + + Ok(( + Box::new(JsonPathExtractor { segments }), + requires_extra, + false, + )) + } + PersistedDocumentExtractorConfig::UrlQueryParam { name } => Ok(( + Box::new(UrlQueryParamExtractor { + name: name.as_str().to_string(), + }), + false, + false, + )), + PersistedDocumentExtractorConfig::UrlPathParam { template } => { + let extractor: UrlPathParamExtractor = template.try_into()?; + Ok((Box::new(extractor), false, true)) + } + } +} + +impl From<&str> for GraphQLEndpointPath { + fn from(endpoint: &str) -> Self { + if endpoint.is_empty() || endpoint == "/" { + return Self("/".to_string()); + } + + let with_leading_slash = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{endpoint}") + }; + + Self(with_leading_slash.trim_end_matches('/').to_string()) + } +} + +impl GraphQLEndpointPath { + fn relative_path<'a>(&self, request_path: &'a str) -> Option<&'a str> { + let suffix = if self.as_ref() == "/" { + request_path + } else { + let suffix = request_path.strip_prefix(self.as_ref())?; + if !suffix.is_empty() && !suffix.starts_with('/') { + return None; + } + suffix + }; + + Some(suffix) + } +} + +impl TryFrom<&PersistedDocumentUrlTemplate> for UrlPathParamExtractor { + type Error = PersistedDocumentExtractError; + + fn try_from(template: &PersistedDocumentUrlTemplate) -> Result { + UrlPathParamExtractor::try_from_template(template) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs new file mode 100644 index 000000000..fc7057caf --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/apollo.rs @@ -0,0 +1,16 @@ +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +pub(crate) const APOLLO_HASH_PATH: &str = "extensions.persistedQuery.sha256Hash"; +pub(crate) const APOLLO_HASH_PATH_SEGMENTS: &[&str; 3] = + &["extensions", "persistedQuery", "sha256Hash"]; + +/// Extracts "$.extensions.persistedQuery.sha256Hash" from the GraphQL request body. +pub(crate) struct ApolloExtractor; + +impl DocumentIdSourceExtractor for ApolloExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.json_path(APOLLO_HASH_PATH_SEGMENTS) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs new file mode 100644 index 000000000..246009972 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/document_id.rs @@ -0,0 +1,13 @@ +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +pub(crate) const DOCUMENT_ID_FIELD: &str = "documentId"; + +/// Extracts "$.documentId" from the GraphQL request body. +pub(crate) struct DocumentIdExtractor; + +impl DocumentIdSourceExtractor for DocumentIdExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + PersistedDocumentId::from_option(ctx.document_id()) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs new file mode 100644 index 000000000..a2799f9fd --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/json_path.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; +use std::collections::HashMap; + +use sonic_rs::{JsonValueTrait, OwnedLazyValue, Value}; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +/// Extracts "$.x.y.z" from the GraphQL request body. +pub(crate) struct JsonPathExtractor { + // TODO: Add e2e coverage for JSON-path extraction edge cases + pub(crate) segments: Vec, +} + +impl DocumentIdSourceExtractor for JsonPathExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.json_path(&self.segments) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} + +impl JsonPathExtractor { + pub(crate) fn requires_nonstandard_json_fields(segments: &[String]) -> bool { + // We only skip capturing unknown top-level fields when extraction can be + // satisfied by extensions.* + // Everything else requires captured nonstandard JSON fields. + if segments.is_empty() { + // Config validation rejects empty json_path + return false; + } + + segments[0] != "extensions" + } +} + +impl<'a> ExtractionContext<'a> { + pub(super) fn json_path>(&self, segments: &[T]) -> Option> { + let (first, rest) = segments.split_first()?; + let first = first.as_ref(); + + match first { + // We don't support JSON paths for these fields + "query" | "operationName" | "variables" => None, + "extensions" => self + .graphql_params + .extensions + .as_ref() + .and_then(|obj| Self::extract_from_object_map(obj, rest)), + other => self + .nonstandard_json_fields + .and_then(|map| map.get(other)) + .and_then(|value| extract_from_json_path(value, rest)), + } + } + + fn extract_from_object_map<'b, T: AsRef>( + obj: &'b HashMap, + segments: &[T], + ) -> Option> { + let (first, rest) = segments.split_first()?; + let value = obj.get(first.as_ref())?; + extract_from_json_path(value, rest) + } +} + +/// The trait exist to reuse `extract_from_json_path` logic across different types +pub(crate) trait JsonPathNode { + fn get_child(&self, key: &str) -> Option<&Self>; + fn as_document_id_value(&self) -> Option>; +} + +/// It's for extraction of document id from `extensions.*` +impl JsonPathNode for Value { + #[inline] + fn get_child(&self, key: &str) -> Option<&Self> { + self.get(key) + } + + #[inline] + fn as_document_id_value(&self) -> Option> { + if let Some(value) = self.as_str() { + return Some(Cow::Borrowed(value)); + } + + self.as_u64().map(|value| Cow::Owned(value.to_string())) + } +} + +/// It's for extraction of document id from non-standard fields +impl JsonPathNode for OwnedLazyValue { + #[inline] + fn get_child(&self, key: &str) -> Option<&Self> { + self.get(key) + } + + #[inline] + fn as_document_id_value(&self) -> Option> { + if let Some(value) = self.as_str() { + return Some(Cow::Borrowed(value)); + } + + self.as_u64().map(|value| Cow::Owned(value.to_string())) + } +} + +#[inline] +pub(crate) fn extract_from_json_path<'a, N, T>(value: &'a N, segments: &[T]) -> Option> +where + N: JsonPathNode, + T: AsRef, +{ + let mut current = value; + for segment in segments { + current = current.get_child(segment.as_ref())?; + } + + current.as_document_id_value() +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs new file mode 100644 index 000000000..087beaabf --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod apollo; +pub(crate) mod document_id; +pub(crate) mod json_path; +pub(crate) mod url_path_param; +pub(crate) mod url_query_param; diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs new file mode 100644 index 000000000..e3666cacc --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_path_param.rs @@ -0,0 +1,63 @@ +use hive_router_config::persisted_documents::PersistedDocumentUrlTemplate; +use matchit::Router; + +use crate::pipeline::persisted_documents::extract::HttpRequestContext; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{ + DocumentIdSourceExtractor, ExtractionContext, PersistedDocumentExtractError, +}; + +/// Extracts a value from the URL path using a template. +pub(crate) struct UrlPathParamExtractor { + pub(crate) router: Router<()>, +} + +impl DocumentIdSourceExtractor for UrlPathParamExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + let relative_path = ctx.relative_path()?; + let matched = self.router.at(relative_path).ok()?; + PersistedDocumentId::from_option(matched.params.get("id")) + } +} + +impl UrlPathParamExtractor { + pub(crate) fn try_from_template( + template: &PersistedDocumentUrlTemplate, + ) -> Result { + // Templates are validated to start with '/', so the first split segment is always empty. + let raw_segments: Vec<&str> = template.as_str().split('/').skip(1).collect(); + if !raw_segments.contains(&":id") { + return Err(PersistedDocumentExtractError::MissingIdParam { + template: template.as_str().to_string(), + }); + } + + let mut wildcard_index = 0; + // Converts our template syntax to `matchit` crate's syntax. + // We do it to not rely on `matchit` and be able to change the implementation later. + let route_segments = raw_segments.into_iter().map(|segment| match segment { + ":id" => "{id}".to_string(), + "*" => { + let route_param = format!("{{_w{wildcard_index}}}"); + wildcard_index += 1; + route_param + } + literal => literal.to_string(), + }); + let matchit_template = format!("/{}", route_segments.collect::>().join("/")); + + let mut router = Router::new(); + router + .insert(matchit_template, ()) + .map_err(|error| PersistedDocumentExtractError::MatcherCompile(error.to_string()))?; + + Ok(Self { router }) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn path(&self) -> &str { + self.path + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs new file mode 100644 index 000000000..7332df990 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/extractors/url_query_param.rs @@ -0,0 +1,232 @@ +use std::borrow::Cow; + +use crate::pipeline::persisted_documents::extract::HttpRequestContext; + +use super::super::super::types::PersistedDocumentId; +use super::super::core::{DocumentIdSourceExtractor, ExtractionContext}; + +/// Extracts a value from the URL query string. +pub(crate) struct UrlQueryParamExtractor { + pub(crate) name: String, +} + +impl DocumentIdSourceExtractor for UrlQueryParamExtractor { + fn extract(&self, ctx: &ExtractionContext<'_>) -> Option { + ctx.query_param(&self.name) + .and_then(|value| PersistedDocumentId::try_from(value.as_ref()).ok()) + } +} + +impl<'a> HttpRequestContext<'a> { + pub fn query_param(&self, name: &str) -> Option> { + self.query.as_ref()?.get(name) + } +} + +pub(crate) struct QueryParams<'a> { + raw: &'a str, +} + +/// I tried to use different for url decoding, and query params parsing, +/// but they all either allocated entire HashMaps or were slow. +/// The difference sometimes was 10ns vs 400ns, +/// Especially on large set of query params, where the key was not found. +/// I decided to implement a custom query params parser that does not allocate, +/// and use url decoding crate, to perform safer decoding. +impl<'a> QueryParams<'a> { + pub(crate) fn new(raw: &'a str) -> Self { + Self { raw } + } + + pub(crate) fn get(&self, name: &str) -> Option> { + let value = Self::find_first_value(self.raw, name)?; + Self::decode_if_needed(value) + } + + #[inline] + fn find_first_value<'b>(query: &'b str, name: &str) -> Option<&'b str> { + let bytes = query.as_bytes(); + let name_bytes = name.as_bytes(); + + if name_bytes.is_empty() { + return None; + } + + // First-match semantics: + // - first `name=value` returns `Some(value)` + // - first `name` or `name=` returns `None` + // - once the first `name` is seen, later duplicates are ignored + for idx in memchr::memchr_iter(name_bytes[0], bytes) { + // If it is not preceded by a '&' or it's not the first character, skip it. + // Example: + // - /graphql?bar=1&foo=2 + // - /graphql?foo=3 + // - /graphql?foo=3&bar=1 + // - /graphql?xfoo=3 [continue] + // - /graphql?bar=1foo [continue] + if !Self::is_pair_boundary(bytes, idx) { + continue; + } + + // Confirm full key match at this boundary. + let Some(key_end) = Self::match_key_at(bytes, idx, name_bytes) else { + continue; + }; + + // Bare key at end (`...&name`) is treated as empty, + // so we return None to indicate the key is present but has no value. + if key_end == bytes.len() { + return None; + } + + // Separator after key: + // - `name&` + // - '=' => key-value pair, continue parsing + // - other => prefix match like `names=...`, keep scanning + let separator = bytes[key_end]; + // `name&` means the key is present but has no value, so we return None. + if separator == b'&' { + return None; + } + + // `names` is a prefix match, so we keep scanning. + if separator != b'=' { + continue; + } + + // `name=` and `name=&...` are treated as no value, so we return None. + let value_start = key_end + 1; + if value_start >= bytes.len() || bytes[value_start] == b'&' { + return None; + } + + let suffix = &bytes[value_start..]; + // Find the next `&` or end of string, if any. + let value_end = if let Some(offset) = memchr::memchr(b'&', suffix) { + value_start + offset + } else { + query.len() + }; + + // Value is present, return it. + if value_start < value_end { + return Some(&query[value_start..value_end]); + } + } + + None + } + + #[inline] + /// Returns `true` if the byte at `idx` is the start of a key-value pair boundary. + /// It is either the start of the query string or the previous character is `&`. + fn is_pair_boundary(bytes: &[u8], idx: usize) -> bool { + idx == 0 || bytes[idx - 1] == b'&' + } + + #[inline] + fn match_key_at(bytes: &[u8], idx: usize, name_bytes: &[u8]) -> Option { + let key_end = idx + name_bytes.len(); + // Key end is beyond the end of the query string, skip it. + if key_end > bytes.len() { + return None; + } + + // Key does not match, skip it. + if &bytes[idx..key_end] != name_bytes { + return None; + } + + Some(key_end) + } + + /// Decode url encoded value if necessary. + fn decode_if_needed<'b>(value: &'b str) -> Option> { + let value_bytes = value.as_bytes(); + let percent_at = memchr::memchr(b'%', value_bytes); + let plus_at = memchr::memchr(b'+', value_bytes); + + if percent_at.is_none() && plus_at.is_none() { + // No need to decode, return as is. + return Some(Cow::Borrowed(value)); + } + + let Some(plus_at) = plus_at else { + return percent_encoding::percent_decode(value_bytes) + .decode_utf8() + .ok(); + }; + + // Special case we need to handle. + // `+` is a space character in url encoding, so we replace it with a space. + // I tried to use form_urlencoded crate but it was 4x slower than this. + // The percent_encoding does not handle `+` as a space character, so we replace it first. + // That's why we use Cow::Owned here, and Cow in general to avoid allocations. + let replaced = Self::replace_plus(value_bytes, plus_at); + + let decoded = percent_encoding::percent_decode(&replaced) + .decode_utf8() + .ok()?; + Some(Cow::Owned(decoded.into_owned())) + } + + fn replace_plus(input: &[u8], first_position: usize) -> Cow<'_, [u8]> { + let mut replaced = input.to_owned(); + replaced[first_position] = b' '; + for byte in &mut replaced[first_position + 1..] { + if *byte == b'+' { + *byte = b' '; + } + } + Cow::Owned(replaced) + } +} + +#[cfg(test)] +mod tests { + use super::QueryParams; + + fn query_param(raw_query: &str, name: &str) -> Option { + QueryParams::new(raw_query) + .get(name) + .map(|value| value.into_owned()) + } + + #[test] + fn query_params_lookup_rules() { + let cases = [ + ("key=first&key=second", "key", Some("first")), + ("key=&key=second", "key", None), + ("key&key=second", "key", None), + ("keys=1&key=value", "key", Some("value")), + ("xkey=1&key=value", "key", Some("value")), + ("foo=bar", "key", None), + ("", "key", None), + ("key=value", "", None), + ]; + + for (query, name, expected) in cases { + let actual = query_param(query, name); + assert_eq!( + actual.as_deref(), + expected, + "query='{query}', name='{name}'" + ); + } + } + + #[test] + fn query_params_decoding_rules() { + let cases = [ + ("key=a+b", Some("a b")), + ("key=a%2Bb", Some("a+b")), + ("key=sha256%3Aabc", Some("sha256:abc")), + ("key=abc%ZZ", Some("abc%ZZ")), + ]; + + for (query, expected) in cases { + let actual = query_param(query, "key"); + assert_eq!(actual.as_deref(), expected, "query='{query}'"); + } + } +} diff --git a/bin/router/src/pipeline/persisted_documents/extract/mod.rs b/bin/router/src/pipeline/persisted_documents/extract/mod.rs new file mode 100644 index 000000000..a44e5a8e5 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/extract/mod.rs @@ -0,0 +1,7 @@ +mod core; +mod extractors; + +pub use core::{ + DocumentIdResolver, DocumentIdResolverInput, HttpRequestContext, PersistedDocumentExtractError, +}; +pub(crate) use extractors::document_id::DOCUMENT_ID_FIELD; diff --git a/bin/router/src/pipeline/persisted_documents/mod.rs b/bin/router/src/pipeline/persisted_documents/mod.rs new file mode 100644 index 000000000..6bb578aa8 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/mod.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use hive_router_config::persisted_documents::{ + PersistedDocumentsConfig, PersistedDocumentsStorageConfig, +}; +use hive_router_internal::background_tasks::BackgroundTasksManager; + +use crate::pipeline::persisted_documents::extract::DocumentIdResolver; +use crate::pipeline::persisted_documents::resolve::{ + FileManifestReloadTask, FileManifestResolver, HiveCDNResolver, PersistedDocumentResolver, + PersistedDocumentResolverError, +}; + +pub mod extract; +pub mod resolve; +pub mod types; + +pub struct PersistedDocumentsRuntime { + pub document_id_resolver: Arc, + pub persisted_document_resolver: Option>, +} + +impl PersistedDocumentsRuntime { + pub async fn init( + config: &PersistedDocumentsConfig, + graphql_endpoint: &str, + background_tasks_mgr: &mut BackgroundTasksManager, + ) -> Result { + let document_id_resolver = Arc::new( + DocumentIdResolver::from_config(config, graphql_endpoint).map_err(|error| { + PersistedDocumentResolverError::Configuration(format!( + "failed to build persisted document extraction plan: {error}" + )) + })?, + ); + + let persisted_document_resolver = if config.enabled { + let storage = config + .storage + .as_ref() + .ok_or(PersistedDocumentResolverError::StorageNotConfigured)?; + match storage { + PersistedDocumentsStorageConfig::File { config } => { + let resolver = + Arc::new(FileManifestResolver::from_storage_config(config).await?); + if resolver.has_watcher() { + background_tasks_mgr + .register_task(FileManifestReloadTask(resolver.clone())); + } + Some(resolver as Arc) + } + PersistedDocumentsStorageConfig::Hive { config } => { + let resolver = Arc::new(HiveCDNResolver::from_storage_config(config)?); + Some(resolver as Arc) + } + } + } else { + None + }; + + Ok(Self { + document_id_resolver, + persisted_document_resolver, + }) + } + + pub fn supports_graphql_endpoint(&self, graphql_endpoint: &str) -> bool { + if !self.document_id_resolver.is_enabled() { + return true; + } + + if !self.document_id_resolver.depends_on_graphql_path() { + return true; + } + + let is_root_endpoint = graphql_endpoint.trim_end_matches('/').is_empty(); + + // `/` can't be used as it would conflict with the path param extractor. + // The `/:id` would match `/health` endpoint for example. + !is_root_endpoint + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/file.rs b/bin/router/src/pipeline/persisted_documents/resolve/file.rs new file mode 100644 index 000000000..49eb8e498 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/file.rs @@ -0,0 +1,326 @@ +use arc_swap::ArcSwap; +use async_trait::async_trait; +use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Deserialize; +use std::borrow::Cow; +use std::collections::HashMap; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use thiserror::Error; +use tokio::sync::{Mutex, Notify}; +use tracing::{info, warn}; + +use hive_router_config::persisted_documents::PersistedDocumentsFileStorageConfig; +use hive_router_internal::background_tasks::{BackgroundTask, CancellationToken}; + +use super::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, +}; + +const RELOAD_EVENT_DEBOUNCE: Duration = Duration::from_millis(150); + +// In-memory map used by the file manifest resolver. +// Values are Arc-backed so lookups only clone cheap references. +struct DocumentsById(HashMap>); + +impl Deref for DocumentsById { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl PersistedDocumentResolver for FileManifestResolver { + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result { + // File manifests are keyed only by persisted document id. + // Client identity is ignored for this source type. + let text = self + .documents + .load() + .get(input.persisted_document_id.as_ref()) + .cloned() + .ok_or_else(|| { + PersistedDocumentResolverError::NotFound(input.persisted_document_id.to_string()) + })?; + + Ok(ResolvedDocument { text }) + } +} + +pub struct FileManifestResolver { + manifest_path: String, + // Snapshot of currently active documents for lock-free reads. + documents: ArcSwap, + // Signals a potential file change + dirty: Arc, + // Ensures at-most-one reload in flight so watcher events do not race + // and publish snapshots out of order. + reload_guard: Mutex<()>, + // Notification channel from watcher callback to background reload task. + reload_signal: Arc, + watcher: Option, +} + +// Background task wrapper registered in the shared task manager. +pub struct FileManifestReloadTask(pub Arc); + +impl Deref for FileManifestReloadTask { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Deserialize)] +struct ApolloPersistedQueryManifest<'a> { + #[serde(borrow)] + format: Cow<'a, str>, + version: u8, + #[serde(borrow)] + operations: Vec>, +} + +#[derive(Deserialize)] +struct ApolloPersistedQueryOperation<'a> { + #[serde(borrow)] + id: Cow<'a, str>, + #[serde(borrow)] + body: Cow<'a, str>, +} + +type KeyValueManifest<'a> = HashMap, Cow<'a, str>>; + +#[derive(Deserialize)] +#[serde(untagged)] +#[serde(bound(deserialize = "'de: 'a"))] +enum PersistedDocumentsManifest<'a> { + Apollo(ApolloPersistedQueryManifest<'a>), + KeyValue(KeyValueManifest<'a>), +} + +#[derive(Debug, Error)] +pub enum FileResolverError { + #[error("failed to read persisted documents manifest at '{path}': {message}")] + ReadManifest { path: String, message: String }, + #[error("failed to parse persisted documents manifest at '{path}': {message}")] + ParseManifest { path: String, message: String }, + #[error("unsupported apollo manifest format. Expected 'apollo-persisted-query-manifest', received '{format}'")] + UnsupportedApolloManifestFormat { format: String }, + #[error("unsupported apollo manifest version. Expected '1', received '{version}'")] + UnsupportedApolloManifestVersion { version: u8 }, + #[error("failed to initialize persisted documents file watcher for '{path}': {message}")] + WatcherInit { path: String, message: String }, + #[error("failed to watch persisted documents path '{path}': {message}")] + WatcherWatchPath { path: String, message: String }, +} + +impl FileManifestResolver { + pub async fn from_storage_config( + config: &PersistedDocumentsFileStorageConfig, + ) -> Result { + let manifest_path = config.path.absolute.clone(); + let documents = Self::read_manifest_documents(&manifest_path).await?; + let dirty = Arc::new(AtomicBool::new(false)); + let reload_signal = Arc::new(Notify::new()); + let watcher = if config.watch { + Some(Self::create_watcher( + &manifest_path, + Arc::clone(&dirty), + Arc::clone(&reload_signal), + )?) + } else { + None + }; + + Ok(Self { + manifest_path, + documents: ArcSwap::from_pointee(documents), + dirty, + reload_guard: Mutex::new(()), + reload_signal, + watcher, + }) + } + + pub(crate) fn has_watcher(&self) -> bool { + self.watcher.is_some() + } + + fn create_watcher( + manifest_path: &str, + dirty: Arc, + reload_signal: Arc, + ) -> Result { + let path = Path::new(manifest_path); + let manifest_path_buf = PathBuf::from(manifest_path); + // Watch the parent directory so replace/rename save patterns are observed. + let watch_target = path.parent().unwrap_or(path); + + let mut watcher = match RecommendedWatcher::new( + move |result: notify::Result| { + let should_signal_reload = match result { + Ok(event) => { + let is_relevant_kind = matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ); + let touches_manifest = + event.paths.iter().any(|path| path == &manifest_path_buf); + is_relevant_kind && touches_manifest + } + Err(err) => { + warn!("persisted documents watcher event failed: {err}"); + true + } + }; + + if should_signal_reload { + dirty.store(true, Ordering::Relaxed); + reload_signal.notify_one(); + } + }, + NotifyConfig::default(), + ) { + Ok(watcher) => watcher, + Err(err) => { + return Err(FileResolverError::WatcherInit { + path: manifest_path.to_string(), + message: err.to_string(), + } + .into()); + } + }; + + if let Err(err) = watcher.watch(watch_target, RecursiveMode::NonRecursive) { + return Err(FileResolverError::WatcherWatchPath { + path: manifest_path.to_string(), + message: err.to_string(), + } + .into()); + } + + Ok(watcher) + } + + // Keeps last known good snapshot active when reload fails + pub(crate) async fn reload_if_needed(&self) -> Result<(), PersistedDocumentResolverError> { + let _reload_guard = self.reload_guard.lock().await; + + if !self.dirty.swap(false, Ordering::Relaxed) { + return Ok(()); + } + + let documents = Self::read_manifest_documents(&self.manifest_path).await?; + self.documents.store(Arc::new(documents)); + info!( + "reloaded persisted documents manifest from '{}'", + self.manifest_path + ); + Ok(()) + } + + async fn read_manifest_documents( + manifest_path: &str, + ) -> Result { + tokio::fs::read(manifest_path) + .await + .map_err(|err| { + PersistedDocumentResolverError::from(FileResolverError::ReadManifest { + path: manifest_path.to_string(), + message: err.to_string(), + }) + }) + .and_then(|raw| { + let manifest: PersistedDocumentsManifest<'_> = + sonic_rs::from_slice(&raw).map_err(|err| FileResolverError::ParseManifest { + path: manifest_path.to_string(), + message: err.to_string(), + })?; + + manifest.try_into() + }) + } +} + +#[async_trait] +impl BackgroundTask for FileManifestReloadTask { + fn id(&self) -> &str { + "persisted-documents-file-reloader" + } + + async fn run(&self, token: CancellationToken) { + // Watcher events are debounced to reduce noisy save/update actions + while token + .run_until_cancelled(async { + self.reload_signal.notified().await; + tokio::time::sleep(RELOAD_EVENT_DEBOUNCE).await; + }) + .await + .is_some() + { + if let Err(err) = self.reload_if_needed().await { + warn!("persisted documents background reload failed: {err}"); + } + } + } +} + +impl<'a> TryFrom> for DocumentsById { + type Error = PersistedDocumentResolverError; + + fn try_from(value: PersistedDocumentsManifest<'a>) -> Result { + match value { + PersistedDocumentsManifest::Apollo(manifest) => manifest.try_into(), + PersistedDocumentsManifest::KeyValue(manifest) => Ok(manifest.into()), + } + } +} + +impl<'a> TryFrom> for DocumentsById { + type Error = PersistedDocumentResolverError; + + fn try_from(manifest: ApolloPersistedQueryManifest<'a>) -> Result { + if manifest.format != "apollo-persisted-query-manifest" { + return Err(FileResolverError::UnsupportedApolloManifestFormat { + format: manifest.format.into_owned(), + } + .into()); + } + + if manifest.version != 1 { + return Err(FileResolverError::UnsupportedApolloManifestVersion { + version: manifest.version, + } + .into()); + } + + Ok(DocumentsById( + manifest + .operations + .into_iter() + .map(|op| (op.id.into_owned(), Arc::::from(op.body))) + .collect::>(), + )) + } +} + +impl<'a> From> for DocumentsById { + fn from(manifest: KeyValueManifest<'a>) -> Self { + DocumentsById( + manifest + .into_iter() + .map(|(id, text)| (id.into_owned(), Arc::::from(text))) + .collect(), + ) + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/hive.rs b/bin/router/src/pipeline/persisted_documents/resolve/hive.rs new file mode 100644 index 000000000..d2a7219f6 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/hive.rs @@ -0,0 +1,341 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use async_trait::async_trait; +use hive_console_sdk::circuit_breaker::CircuitBreakerBuilder; +use hive_console_sdk::persisted_documents::{PersistedDocumentsError, PersistedDocumentsManager}; +use hive_router_config::persisted_documents::PersistedDocumentsHiveStorageConfig; +use thiserror::Error; + +use crate::consts::ROUTER_VERSION; + +use super::{ + PersistedDocumentResolveInput, PersistedDocumentResolver, PersistedDocumentResolverError, + ResolvedDocument, +}; + +pub struct HiveCDNResolver { + manager: PersistedDocumentsManager, +} + +static CLIENT_INSTRUCTIONS: &str = "Provide both client name and version headers, or send persisted document id in 'appName~appVersion~documentId' format"; + +#[derive(Debug, Error)] +pub enum HiveResolverError { + #[error("persisted_documents.storage.hive.endpoint is not configured")] + MissingEndpoint, + #[error("persisted_documents.storage.hive.key is not configured")] + MissingKey, + #[error("Document id format is invalid. Either 'appName~appVersion~documentId' or 'documentId' is accepted, received: {0}")] + InvalidDocumentIdFormat(String), + #[error("Client identity is missing. {CLIENT_INSTRUCTIONS}")] + ClientIdentityMissing, + #[error("Client identity is partial. {CLIENT_INSTRUCTIONS}")] + ClientIdentityPartial, + #[error("Initialization failed: {0}")] + ManagerInit(String), + #[error("SDK error: {0}")] + SDKError(String), +} + +struct AppDocumentId<'a>(Cow<'a, str>); + +enum DocumentIdSyntax<'a> { + App(&'a str), + Plain(&'a str), +} + +impl<'a> TryFrom<&'a str> for DocumentIdSyntax<'a> { + type Error = HiveResolverError; + + // It uses memchr. I performed a benchmark with a lot of solutions, even byte by byte scanning. + // It was the best bang for the buck option. + fn try_from(value: &'a str) -> Result { + let bytes = value.as_bytes(); + + // First '~' separates app name from app version. + let Some(first) = memchr::memchr(b'~', bytes) else { + // If there is no '~', the entire value is a plain document id + return Ok(Self::Plain(value)); + }; + + // We found "~...." - empty app name segment + if first == 0 { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Second '~' separates app version from document id + let Some(second_relative) = memchr::memchr(b'~', &bytes[first + 1..]) else { + // We found "appName~documentId", so it lacks an app version segment + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + }; + // If the relative position of the second '~' is 0, it means it's right after the first '~'. + // Found "appName~~documentId", so it has the app version segment, but it's empty. + if second_relative == 0 { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Compute the absolute position of the second '~' + let second = first + 1 + second_relative; + + // Check if it's not the last character of the string. + // If it is, we found an empty document id segment. + if second + 1 >= bytes.len() { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Syntax with more than 2 separators is invalid. + if memchr::memchr(b'~', &bytes[second + 1..]).is_some() { + return Err(HiveResolverError::InvalidDocumentIdFormat( + value.to_string(), + )); + } + + // Syntax is valid, return appName~appVersion~documentId. + Ok(Self::App(value)) + } +} + +impl<'a> TryFrom> for AppDocumentId<'a> { + type Error = HiveResolverError; + + fn try_from(input: PersistedDocumentResolveInput<'a>) -> Result { + let persisted_document_id = input.persisted_document_id.as_ref(); + + match DocumentIdSyntax::try_from(persisted_document_id)? { + DocumentIdSyntax::App(app_document_id) => { + // Raw app-included id takes precedence over client identity headers. + Ok(Self(Cow::Borrowed(app_document_id))) + } + DocumentIdSyntax::Plain(document_id) => { + match (input.client_identity.name, input.client_identity.version) { + (Some(name), Some(version)) => { + Ok(Self(Cow::Owned(format!("{name}~{version}~{document_id}")))) + } + (Some(_), None) | (None, Some(_)) => { + Err(HiveResolverError::ClientIdentityPartial) + } + (None, None) => Err(HiveResolverError::ClientIdentityMissing), + } + } + } + } +} + +impl AsRef for AppDocumentId<'_> { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl HiveCDNResolver { + pub fn from_storage_config( + config: &PersistedDocumentsHiveStorageConfig, + ) -> Result { + let endpoints: Vec = config + .endpoint + .clone() + .ok_or(HiveResolverError::MissingEndpoint)? + .into(); + let key = config.key.clone().ok_or(HiveResolverError::MissingKey)?; + + let circuit_breaker = CircuitBreakerBuilder::default() + .error_threshold(config.circuit_breaker.error_threshold) + .volume_threshold(config.circuit_breaker.volume_threshold) + .reset_timeout(config.circuit_breaker.reset_timeout); + + let mut builder = PersistedDocumentsManager::builder() + .key(key) + .accept_invalid_certs(config.accept_invalid_certs) + .connect_timeout(config.connect_timeout) + .request_timeout(config.request_timeout) + .max_retries(config.retry_policy.max_retries) + .cache_size(config.cache_size) + .circuit_breaker(circuit_breaker) + .user_agent(format!("hive-router/{ROUTER_VERSION}")); + + if let Some(negative_cache) = config.negative_cache.enabled_config() { + builder = builder.negative_cache_ttl(negative_cache.ttl); + } + + for endpoint in endpoints { + builder = builder.add_endpoint(endpoint); + } + + let manager = builder + .build() + .map_err(|err| HiveResolverError::ManagerInit(err.to_string()))?; + Ok(Self { manager }) + } +} + +#[async_trait] +impl PersistedDocumentResolver for HiveCDNResolver { + // TODO: Consider implementing stale-while-revalidate (SWR). + // + // Requirements: + // - We should not spawn a task/thread per request when an entry becomes stale. + // - Revalidation must be bounded (queue + capped worker concurrency) to avoid overload. + // - Requests should keep serving stale entries during the grace window while refresh runs. + // - Refreshes should be de-duplicated per document id (avoid N concurrent refreshes for same key). + // - Queue overflow and cancellation/shutdown behavior must be defined explicitly. + // - Interaction with SDK cache and negative-cache semantics needs careful handling. + // + // Suggested: + // - Add a background task (similar to file resolver) with "notify" + bounded queue. + // - Keep per-entry freshness metadata: fresh until / stale until. + // - On stale hit: return stale + enqueue refresh + // - On miss/expired: fetch + // - Add observability counters for stale-served, refresh-enqueued, refresh-failed, queue-dropped. + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result { + let app_document_id = AppDocumentId::try_from(input)?; + let text = self + .manager + .resolve_document(app_document_id.as_ref()) + .await + .map_err(|err| match err { + PersistedDocumentsError::DocumentNotFound => { + PersistedDocumentResolverError::NotFound(app_document_id.as_ref().to_string()) + } + other => HiveResolverError::SDKError(other.to_string()).into(), + })?; + + Ok(ResolvedDocument { + text: Arc::::from(text), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{AppDocumentId, PersistedDocumentResolveInput}; + use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; + + struct Case { + raw_id: &'static str, + client_name: Option<&'static str>, + client_version: Option<&'static str>, + expected: Result<&'static str, &'static str>, + } + + #[test] + fn app_document_id_conversion_matrix() { + let cases = [ + Case { + raw_id: "documentId", + client_name: Some("app"), + client_version: Some("1.0.0"), + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "app~1.0.0~documentId", + client_name: None, + client_version: None, + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "app~1.0.0~documentId", + client_name: Some("app"), + client_version: Some("1.2.3"), + expected: Ok("app~1.0.0~documentId"), + }, + Case { + raw_id: "documentId", + client_name: None, + client_version: None, + expected: Err("missing"), + }, + Case { + raw_id: "documentId", + client_name: Some("app"), + client_version: None, + expected: Err("partial"), + }, + Case { + raw_id: "documentId", + client_name: None, + client_version: Some("1.0.0"), + expected: Err("partial"), + }, + Case { + raw_id: "app~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "app~~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "~1.0.0~documentId", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "app~1.0.0~", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + Case { + raw_id: "a~b~c~d", + client_name: None, + client_version: None, + expected: Err("invalid"), + }, + ]; + + for (idx, case) in cases.into_iter().enumerate() { + let persisted_document_id = + PersistedDocumentId::try_from(case.raw_id).expect("fixture id should parse"); + let input = PersistedDocumentResolveInput { + persisted_document_id: &persisted_document_id, + client_identity: ClientIdentity { + name: case.client_name, + version: case.client_version, + }, + }; + + match (AppDocumentId::try_from(input), case.expected) { + (Ok(actual), Ok(expected)) => { + assert_eq!(actual.as_ref(), expected, "case_index={idx}") + } + (Err(err), Err(expected)) => { + assert!( + err.to_string().contains(expected), + "case_index={}, err={}", + idx, + err + ); + } + (Ok(actual), Err(expected)) => panic!( + "case_index={} expected err containing '{}' but got Ok({})", + idx, + expected, + actual.as_ref() + ), + (Err(err), Ok(expected)) => { + panic!( + "case_index={} expected Ok({}) but got Err({})", + idx, expected, err + ) + } + } + } + } +} diff --git a/bin/router/src/pipeline/persisted_documents/resolve/mod.rs b/bin/router/src/pipeline/persisted_documents/resolve/mod.rs new file mode 100644 index 000000000..ba1b8ac57 --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/resolve/mod.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use std::sync::Arc; + +use crate::pipeline::error::PipelineError; +use crate::pipeline::persisted_documents::types::{ClientIdentity, PersistedDocumentId}; +use file::FileResolverError; +use hive::HiveResolverError; + +pub mod file; +pub mod hive; + +pub use file::{FileManifestReloadTask, FileManifestResolver}; +pub use hive::HiveCDNResolver; + +#[derive(Debug, Clone, Copy)] +pub struct PersistedDocumentResolveInput<'a> { + pub persisted_document_id: &'a PersistedDocumentId, + pub client_identity: ClientIdentity<'a>, +} + +#[derive(Debug, thiserror::Error)] +pub enum PersistedDocumentResolverError { + #[error("Persisted document not found: {0}")] + NotFound(String), + #[error("Persisted documents configuration error: {0}")] + Configuration(String), + #[error("Persisted documents storage is not configured")] + StorageNotConfigured, + #[error("Hive Storage: {0}")] + Hive(#[from] HiveResolverError), + #[error("File Storage: {0}")] + File(#[from] FileResolverError), +} + +impl From for PipelineError { + fn from(value: PersistedDocumentResolverError) -> Self { + match value { + PersistedDocumentResolverError::NotFound(document_id) => { + PipelineError::PersistedDocumentNotFound(document_id) + } + PersistedDocumentResolverError::Hive(HiveResolverError::InvalidDocumentIdFormat(_)) + | PersistedDocumentResolverError::Hive(HiveResolverError::ClientIdentityMissing) + | PersistedDocumentResolverError::Hive(HiveResolverError::ClientIdentityPartial) => { + PipelineError::PersistedDocumentExtraction(value.to_string()) + } + other => PipelineError::PersistedDocumentResolution(other.to_string()), + } + } +} + +#[derive(Debug)] +pub struct ResolvedDocument { + pub text: Arc, +} + +#[async_trait] +pub trait PersistedDocumentResolver: Send + Sync { + async fn resolve( + &self, + input: PersistedDocumentResolveInput<'_>, + ) -> Result; +} diff --git a/bin/router/src/pipeline/persisted_documents/types.rs b/bin/router/src/pipeline/persisted_documents/types.rs new file mode 100644 index 000000000..030d35dfc --- /dev/null +++ b/bin/router/src/pipeline/persisted_documents/types.rs @@ -0,0 +1,72 @@ +use std::fmt; +use std::ops::Deref; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedDocumentId(String); + +impl PersistedDocumentId { + #[inline] + pub fn new(id: String) -> Self { + Self(id) + } + + #[inline] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + #[inline] + pub fn from_option(raw: Option<&str>) -> Option { + raw.and_then(|raw| raw.try_into().ok()) + } +} + +impl TryFrom<&str> for PersistedDocumentId { + type Error = (); + + fn try_from(raw: &str) -> Result { + if raw.is_empty() { + return Err(()); + } + + // Keep IDs exactly as provided (including algorithm prefixes like + // "sha256:...") so extraction and storage use the same key. + Ok(Self::new(raw.to_string())) + } +} + +impl Deref for PersistedDocumentId { + type Target = str; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl AsRef for PersistedDocumentId { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for PersistedDocumentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ClientIdentity<'a> { + // Optional client name and version provided by request identification. + // Not all persisted-document sources need these fields. + pub name: Option<&'a str>, + pub version: Option<&'a str>, +} + +impl ClientIdentity<'_> { + pub fn is_empty(&self) -> bool { + self.name.is_none() && self.version.is_none() + } +} diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index de96706d3..eb259c3d4 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -36,6 +36,8 @@ use crate::pipeline::multipart_subscribe::{ self, APOLLO_MULTIPART_HTTP_CONTENT_TYPE, INCREMENTAL_DELIVERY_CONTENT_TYPE, }; use crate::pipeline::parser::ParseCacheEntry; +use crate::pipeline::persisted_documents::resolve::PersistedDocumentResolverError; +use crate::pipeline::persisted_documents::PersistedDocumentsRuntime; use crate::pipeline::progressive_override::{OverrideLabelsCompileError, OverrideLabelsEvaluator}; use crate::pipeline::sse; @@ -269,6 +271,7 @@ impl Expiry> for JwtClaimsExpiry { pub struct RouterSharedState { pub validation_plan: Arc, pub parse_cache: Cache, + pub persisted_documents_runtime: PersistedDocumentsRuntime, pub router_config: Arc, pub headers_plan: Arc, pub override_labels_evaluator: OverrideLabelsEvaluator, @@ -295,6 +298,7 @@ impl RouterSharedState { #[allow(clippy::too_many_arguments)] pub fn new( router_config: Arc, + persisted_documents_runtime: PersistedDocumentsRuntime, jwt_auth_runtime: Option, hive_usage_agent: Option, validation_plan: ValidationPlan, @@ -308,6 +312,7 @@ impl RouterSharedState { validation_plan: Arc::new(validation_plan), headers_plan: Arc::new(compile_headers_plan(&router_config.headers).map_err(Box::new)?), parse_cache, + persisted_documents_runtime, cors_runtime: Cors::from_config(&router_config.cors).map_err(Box::new)?, jwt_claims_cache: Cache::builder() // High capacity due to potentially high token diversity. @@ -349,6 +354,8 @@ pub enum SharedStateError { OverrideLabelsCompile(#[from] Box), #[error("error creating hive usage agent: {0}")] UsageAgent(#[from] Box), + #[error("invalid persisted documents config: {0}")] + PersistedDocuments(#[from] Box), #[error("invalid introspection config: {0}")] IntrospectionPolicyCompile(#[from] Box), } diff --git a/docs/README.md b/docs/README.md index e4bf7fa64..a51ad8d32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ |[**log**](#log)|`object`|The router logger configuration.
Default: `{"filter":null,"format":"json","level":"info"}`
|| |[**override\_labels**](#override_labels)|`object`|Configuration for overriding labels.
|| |[**override\_subgraph\_urls**](#override_subgraph_urls)|`object`|Configuration for overriding subgraph URLs.
Default: `{}`
|| +|[**persisted\_documents**](#persisted_documents)|`object`|Configuration for persisted documents extraction and resolution.
Default: `{"enabled":false,"log_missing_id":false,"require_id":false,"selectors":null,"storage":null}`
|| |[**plugins**](#plugins)|`object`|Configuration for custom plugins
|| |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| |[**subscriptions**](#subscriptions)|`object`|Configuration for subscriptions.
Default: `{"broadcast_capacity":0,"enabled":false}`
|| @@ -117,6 +118,12 @@ override_subgraph_urls: .default } +persisted_documents: + enabled: false + log_missing_id: false + require_id: false + selectors: null + storage: null plugins: {} query_planner: allow_expose: false @@ -1886,6 +1893,75 @@ products: |----|----|-----------|--------| |**url**||Overrides for the URL of the subgraph.

For convenience, a plain string in your configuration will be treated as a static URL.

### Static URL Example
```yaml
url: "https://api.example.com/graphql"
```

### Dynamic Expression Example

The expression has access to the following variables:
- `request`: The incoming HTTP request, including headers and other metadata.
- `default`: The original URL of the subgraph (from supergraph sdl).

```yaml
url:
expression: \|
if .request.headers."x-region" == "us-east" {
"https://products-us-east.example.com/graphql"
} else if .request.headers."x-region" == "eu-west" {
"https://products-eu-west.example.com/graphql"
} else {
.default
}
|yes| +
+## persisted\_documents: object + +Configuration for persisted documents extraction and resolution. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`|Default: `false`
|| +|**log\_missing\_id**|`boolean`|Default: `false`
|| +|**require\_id**|`boolean`|Default: `false`
|| +|[**selectors**](#persisted_documentsselectors)|`array`||| +|**storage**|||| + +**Example** + +```yaml +enabled: false +log_missing_id: false +require_id: false +selectors: null +storage: null + +``` + + +### persisted\_documents\.selectors\[\]: array,null + +**Items** + +  +**Option 1 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**path**|`string`||yes| +|**type**|`string`|Constant Value: `"json_path"`
|yes| + + +  +**Option 2 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**template**|`string`||yes| +|**type**|`string`|Constant Value: `"url_path_param"`
|yes| + + +  +**Option 3 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**name**|`string`||yes| +|**type**|`string`|Constant Value: `"url_query_param"`
|yes| + + +**Example** + +```yaml +{} + +``` + ## plugins: object diff --git a/docs/design/persisted-documents/apollo-client.md b/docs/design/persisted-documents/apollo-client.md new file mode 100644 index 000000000..e04cb4f47 --- /dev/null +++ b/docs/design/persisted-documents/apollo-client.md @@ -0,0 +1,108 @@ +# Persisted Documents - Apollo ClientRepository with an example + +Example available here: https://github.com/kamilkisiela/graphql-persisted-operations-example/tree/main/apps/apollo + +Here’s what Apollo recommends: https://www.apollographql.com/docs/react/data/persisted-queries + +## Manifest + +Manifest is generated with https://www.npmjs.com/package/@apollo/generate-persisted-query-manifest + +```bash +$ npx generate-persisted-query-manifest +``` + +A new file is created ./persisted-query-manifest.json with this content: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87", + "name": "ApolloCountries", + "type": "query", + "body": "query ApolloCountries {\n countries {\n code\n name\n emoji\n __typename\n }\n}" + } + ] +} +``` + +## Client setup + +Here’s the recommended (by Apollo docs) Apollo Client setup: + +```js +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { generatePersistedQueryIdsFromManifest } from "@apollo/persisted-query-lists"; +import { PersistedQueryLink } from "@apollo/client/link/persisted-queries"; + +const persistedQueryLink = new PersistedQueryLink( + generatePersistedQueryIdsFromManifest({ + loadManifest: () => import("../persisted-query-manifest.json"), + }), +); + +const httpLink = new HttpLink({ + uri: "http://localhost:4000/graphql", +}); + +export const apolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: persistedQueryLink.concat(httpLink), + clientAwareness: { + name: "example", + version: "1.0.0", + }, +}); +`` + +Manifest is consumed by Apollo Client with https://www.npmjs.com/package/@apollo/persisted-query-lists. +We pass client’s name and version the way it’s intended in Apollo Client. + +### Http Request + +What Apollo Client sends to the server. + +Body: +```json +{ + "operationName":"ApolloCountries", + "variables":{}, + "extensions":{ + "clientLibrary":{ + "name":"@apollo/client", + "version":"4.1.6" + }, + "persistedQuery":{ + "version":1, + "sha256Hash":"9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87" + } + } +} +``` + +Headers: +``` +apollographql-client-name: example +apollographql-client-version: 1.0.0 +``` + +## Gateway + +What the gateway knows: + +* it’s Apollo format (use of extensions with persistedQuery field) +* document’s id +* operation’s name +* operation’s variables +* client library (name + version) +* app’s name and version + + +With this knowledge the gateway is capable of resolving a document from Hive CDN: + +``` +example~1.0.0~9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87 +``` diff --git a/docs/design/persisted-documents/main.md b/docs/design/persisted-documents/main.md new file mode 100644 index 000000000..8dfdcc245 --- /dev/null +++ b/docs/design/persisted-documents/main.md @@ -0,0 +1,352 @@ +# Persisted Documents in Hive Router + +I’m planning to implement this feature in Hive Router. + +## HTTP Request (Input) + +There is only one piece of data (Hive CDN is an exception here) that Hive Router needs from the graphql client, it’s the identity of the document. I will call it “document id”, but in reality it could be anything: a hash, custom string, combination of both. +This document id can be included in the HTTP Request in many ways: + +* URL + * `/graphql/` + * `/graphql?id=` +* Header + * `graphql-document-id: ` +* Body + * `{ "document_id": }` + * `{ "extensions": { "whatever": { "doc_id": } } }` + * you get the idea... + +The point I’m trying to make here, and you will see it when I’ll cover different GraphQL clients, is that there is no standard, there are many, and there could be new standards soon. +That’s why Hive Router needs to be flexible enough to support all kinds of kinky shit. + +We do care about performance, so relying on VRL expression for the extraction of the document id, on every request, is not really an option, at least not the one we should do and call it a day :) + +### Apollo Client + +[Persisted Documents in Apollo Client](./apollo-client.md) + +This is what Apollo Client sends by default (when you configure it the way it is intended by Apollo team - according to docs) + +```json +{ + "operationName":"ApolloCountries", + "variables":{}, + "extensions":{ + "clientLibrary":{ + "name":"@apollo/client", + "version":"4.1.6" + }, + "persistedQuery":{ + "version":1, + "sha256Hash":"9f9d50d29760468b4b4779822fa742270723d2b426a4dcfc93eb3d63d38fda87" + } + } +} +``` + +When you configure the clientAwarness feature (as I described in the linked canvas, you also get these headers + +``` +apollographql-client-name: example +apollographql-client-version: 1.0.0 +``` + +### Relay Client + +[Persisted Documents in Relay Client](./relay-client.md) + +Relay case is interesting as it’s not enforcing any patterns. You can do whatever as you control the network layer. +What it showcases though, in the documentation and what is de facto a standard: + +```graphql +{ + "doc_id":"0ebf7938810e26eb3938a5362307cf95", + "operationName":"AppCountriesQuery", + "variables":{} +} +``` + +### GraphQL HTTP Specification + +Persisted Documents: GraphQL HTTP Specification does not really specify anything... +The draft or whatever the status of it is... accepts anything that may or may not contain : character. +When it does not you treat everything is the document id, but when it includes the semicolon, you get the xyz:. + +``` +: +sha256:7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e + + +7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e + +x-: +x-hive:7dba4bd717b41f10434822356a93c32b1fb4907b983e854300ad839f84cdcd6e +``` + +## Storage + +It’s not only where we store but also what we store. + +### Storage Format + +Both GraphQL Codegen’s Client Preset and Relay’s Compiler produce a similar manifest file: + +```json +{ "": "" } +``` + +Apollo Client on the other hand, produces something more complex and different: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "", + "name": "", + "type": "query", + "body": "query { ... }" + } + ] +} +``` + +An alternative approach would be to store a single document per file, where the name of the file contains the document id. This is what Hive CDN does. + +### Storage Space + +Imo these are 4 popular ways of storing manifests or document-per-file files. + +#### HTTP endpoint + +I can imagine people writing their own registries (link to my talk) or doing some weird proxies. In order to support them we should give them a nice API. +Instead of creating some weird specification of how to fetch documents based on IDs, that is generic and easy to implement, and have ways to invalidate the cache etc, we should just point them to Plugin System and either expose a clean and easy to use API or rely on what the plugin system offers today. +A must here, is to create an example, at least in docs... + +#### S3 compatible + +Most of the time people will host the documents or manifests on S3/GCS/R1, basically S3 compatible storages. +I think we should natively support it in Hive Router and not point them to the plugin system. +There are many problems here: + +* how to provide auth credentials given there are many different vendors +* different bucket names +* what’s stored may be different (document or manifest with documents?) + +#### File + +Mostly for development, as I can’t imagine people persisting a file next to the Router binary at scale... + +* watch mode / polling is a must +* I say we support one manifest file instead of a persisted/*.json globs or whole directory pointers ./persisted - one we have a need, we can add it. Let’s not overcomplicate it from day one as 99.9999% of cases it’s a single manifest file. +* support different manifest formats (should the config explicitly say what format the file is? Not sure as it has a DX cost and could be auto detected) + +#### Hive CDN + +What we should recommend and polish really well. + +``` +GET https://cdn.graphql-hive.com/artifacts/v1/:targetId/apps/:appName/:appVersion/:documentId +``` + +This is a bit tricky, because we not only rely on document id, but also app’s name and version. +The app’s name and version could be provided in many ways, but we should limit that to 2 options: + +* client identification (request header for name, request header for version) +* hardcoded in document id (name~version~id) + +Providing app’s name and version in headers gives a much better UX: + +* Apollo Client has clientAwareness feature in which users provide name and version (apollographql-client-name/version headers) +* document id generated by “document extraction + persisting” tools is always a hash, so it’s natural to pass it as is to the http payload (Relay has params.id that could be used as {"doc_id": params.id } +* Aligns better with Usage Reporting, tracing, metrics and logging + +### Cache invalidation + +What should happen when a document was resolved from the source? +When documents should be invalidated? When schema changes (naive but safe...). + +Invalidation strategies per storage space: + +* File - invalidate when file changes + * to increase the cache-hits ratio: + * we could produce a checksum of the document text (minified) + * store that checksum + * compare the checksum and invalidate if gone or different +* HTTP - up to the plugin implementor to decide +* S3 - reuse cache headers sent back by the S3 storage or give an option to specify the TTL +* Hive - same rules as for S3 really + + +We need to not only cache the happy path (OK 200 with the file), but failures too. +The 404s should be cached for some time as well. +All configured with sensible defaults. + +When it’s time to check whether the document is still active or not, we should serve the old one, but fetch the new one in the background, to swap later on. Basically the stale-while-revalidate pattern. + +## Request Acceptance + +I guess we could have a few level of strictness + +* allow to execute only persisted documents +* allow to execute both persisted documents and regular requests + +Additional logs for the migration period (from regular to persisted): + +* ability to info log requests that are not persisted (full document body was sent) +* useful to detect rejected operations due to lack of document id + +Apollo offers safelisting based on document’s string, not only based on the id, with ability to opt-in to require the id and reject non-id requests. + + +## Pipeline + +When an http request with the document id hits the server: + +1. document id is extracted from the request (Extraction) +2. document is resolved (Resolution) +3. document is injected into the graphql request +4. the graphql request continues to flow through the rest of the pipeline + 1. parse + 2. validate + 3. normalize + 4. plan + 5. execute + +This adds latency to the first request (and identical-id requests that will be accepted during the time). + +### Extraction + +Gets info from HTTP request. +I think we should support these built-in extractors: + +* URL path segment +* URL query param +* header +* JSON body field (path to get the id) +* Relay doc_id +* Apollo’s extensions.persistedQuery.id + +and optional custom extractor via plugin or VRL only as fallback. +These extractors could be defined in Hive Router as a list to configure precedence. + +Dumb example code to what I mean: + +```rust +struct DocumentRef<'a> { + raw: &'a str, + kind: DocumentRefKind, +} + +enum DocumentRefKind<'a> { + Opaque, + Hash { algorithm: HashAlgorithm }, + Custom { prefix: &'a str }, +} + +struct ResolvedDocument<'a> { + source: ResolvedDocumentSource, + id: &'a str, + text: Arc, + operation_name_hint: Option<&'a str>, + metadata: DocumentMetadata, +} + +struct ClientIdentity<'a> { + name: Option<&'a str>, + version: Option<&'a str>, +} + +trait DocumentRefExtractor { + fn extract<'a>(&self, request: &'a HttpRequestParts, body: Option<&'a [u8]>) -> ExtractionResult<'a>; +} + +struct ExtractionResult<'a> { + document_ref: Option>, + client_identity: ClientIdentity<'a>, + metadata: ExtractionMetadata, +} +``` + +At the extraction level, we should enforce Request Acceptance. + +### Resolution + +Uses extracted info to load document text. +It should include a caching layer that resolves: + +* found (doc text + metadata) +* not found +* error (reason) + +The 404 cases should be treated differently than other errors. 5XX for example, should be retried, have shorter TTL. +Bult-in resolution impls: + +* File manifest (format autodetected) +* S3 manifest (format autodetected) +* S3 object +* generic http (maybe?) +* Hive CDN + +Dumb example code to what I mean: + +```rust +trait PersistedDocumentResolver { + async fn resolve<'a>( + &self, + reference: &DocumentRef<'a>, + client_identity: &ClientIdentity<'a>, + ctx: &ResolveContext, + ) -> Result, ResolveError>; +} +``` + +### Prewarming the caches + +This is relatively cheap, because we multiplex the parsing, validation, normalization and planning to identical documents, happening at the same time. We do the work once. +We also don’t know all the persisted documents in advance (maybe we should?). We only know about those executed in the past. +When a new schema is loaded, the caches are busted. +This gives us an opportunity to avoid a spike in latencies of future requests! +We could prewarm the caches for recently used persisted operations, so next time the operation happens, it’s reusing the caches already. +It should be either opt-in or opt-out - to be decided. + +If we knew all documents in advance, we could have prewarmed them all (or some, based on some factors like popularity), both on startup and on schema reload. + +## Potential performance bottlenecks + +* outbound HTTP request to Hive CDN for every fresh app name + app version + hash combination request +* big impact on latency on schema reloads (caches are nuked) +* big impact on latency on startup (fresh requests are uncached) +* http request to Hive CDN may take forever or be retired forever, when network issues occur (let’s have a sensible and configurable timeout) +* lots of http request resolved concurrently - we would have to put a limit on document resolver + +## Observability + +### Tracing + +We should at least add the document id as the attribute. The client’s name and version is already attached to spans. + +### Logs + +We should at least add the document id as the attribute. We should also include client’s name and version. +Depending on Request Acceptance we should also inform user about rejections. + +### Metrics + +We should observe the two stages: + +* document id extraction +* document resolution + +Observe the duration, observe the hit/miss/error rates, when it makes sense. +Depending on Request Acceptance we should also inform user about rejections. + + +## Random stuff + +* I think we should have a bypass behavior - like a header or something +* Research: the error codes and http status codes - basically how graphql clients handle the failures +* Ensure: when deserializing the request body, we don’t fail on extra/unknown fields +* Think: when allowing /graphql/1234, should it be treated as a persisted operation only or fallback to /graphql on invalid ids or something diff --git a/docs/design/persisted-documents/relay-client.md b/docs/design/persisted-documents/relay-client.md new file mode 100644 index 000000000..045db3307 --- /dev/null +++ b/docs/design/persisted-documents/relay-client.md @@ -0,0 +1,101 @@ +# Persisted Documents in Relay Client + +Repository with an example: https://github.com/kamilkisiela/graphql-persisted-operations-example/tree/main/apps/relay + +Here’s what Relay recommends: https://relay.dev/docs/guides/persisted-queries/ + +## Manifest + +The manifest is generated with relay-compiler that is capable of watching code files and generating a new manifest on every file change. + +An example `package.json`: + +```json +{ + "scripts": { + "persisted": "relay-compiler", + "persisted:watch": "relay-compiler --watch" + }, + "relay": { + "src": "./src", + "schema": "./schema.graphql", + "language": "javascript", + "artifactDirectory": "./src/__generated__", + "persistConfig": { + "file": "./persisted-queries.json", + "algorithm": "MD5" + } + } +} +``` + +When `$ relay-compiler` runs it generates code in `src/__generated__`. That’s not unusual, that’s the regular workflow when using Relay. +The only difference is that the generated queries contain a unique id. +The compiler writes also a persisted-queries.json file with the manifest (mapping between ids and document texts). + +## Client setup + +It’s really up the the user, but the documentation says: + +```js +import { Environment, Network, RecordSource, Store } from "relay-runtime" + +async function fetchGraphQL(params, variables) { + const response = await fetch("http://localhost:4000/graphql", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + doc_id: params.id, + operationName: params.name, + variables, + }), + }) + + return response.json() +} + +export const relayEnvironment = new Environment({ + network: Network.create(fetchGraphQL), + store: new Store(new RecordSource()), +}) +```` + +The relay’s compiler make sure to add id to every query in code, that’s why it’s available in params. No need to refer to the json file + +### Http Request + +What Relay Client sends to the server. + +Body: +```json +{ + "doc_id":"0ebf7938810e26eb3938a5362307cf95", + "operationName":"AppCountriesQuery", + "variables":{} +} +``` + +Headers: +``` +graphql-client-name: example +graphql-client-version: v1.0.0 +``` + +## Gateway + +What the gateway knows: + +* it’s Relay format (doc_id) +* document’s id +* operation’s name +* operation’s variables +* app’s name and version + + +With this knowledge the gateway is capable of resolving a document from Hive CDN: + +``` +example~1.0.0~0ebf7938810e26eb3938a5362307cf95 +``` diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index f42fcfd76..2daf0e7bc 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -39,6 +39,8 @@ mod max_tokens; #[cfg(test)] mod override_subgraph_urls; #[cfg(test)] +mod persisted_documents; +#[cfg(test)] mod probes; #[cfg(test)] mod router_timeout; diff --git a/e2e/src/persisted_documents/defaults.rs b/e2e/src/persisted_documents/defaults.rs new file mode 100644 index 000000000..b6b1b5fd4 --- /dev/null +++ b/e2e/src/persisted_documents/defaults.rs @@ -0,0 +1,54 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure Hive Router accepts by default selectors: +// - json: documentId +// - json: extensions.persistedQuery.sha256 +async fn default_selectors() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": DOC_ID }), None) + .await; + + assert_resolves_successfully(response).await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": DOC_ID + } + } + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_apollo.rs b/e2e/src/persisted_documents/extractor_apollo.rs new file mode 100644 index 000000000..288f79c61 --- /dev/null +++ b/e2e/src/persisted_documents/extractor_apollo.rs @@ -0,0 +1,134 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure apollo's PQ format works +async fn extracts_sha256_hash_from_extensions() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.persistedQuery.sha256Hash + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": DOC_ID + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure documentId does not collide with apollo's hash +async fn returns_none_when_hash_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.persistedQuery.sha256Hash + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "documentId": "1ab2", + "extensions": { + "persistedQuery": {} + } + })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure non-string values are accepted by the apollo extractor +async fn accepts_non_string_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "persistedQuery": { + "sha256Hash": 123 + } + } + })) + .await + .expect("failed to send graphql request"); + + // If Hive Router does not support u64, + // the error code would be PERSISTED_DOCUMENT_ID_REQUIRED + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/extractor_document_id.rs b/e2e/src/persisted_documents/extractor_document_id.rs new file mode 100644 index 000000000..ce60ba90d --- /dev/null +++ b/e2e/src/persisted_documents/extractor_document_id.rs @@ -0,0 +1,81 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Empty documentId is treated as missing +async fn empty_id_is_ignored() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ "documentId": "" })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure non-string values are accepted by the document id extractor +async fn accepts_non_string_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "documentId": 123 + })) + .await + .expect("failed to send graphql request"); + + // If Hive Router does not support u64, + // the error code would be PERSISTED_DOCUMENT_ID_REQUIRED + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/extractor_json_path.rs b/e2e/src/persisted_documents/extractor_json_path.rs new file mode 100644 index 000000000..19305ae1c --- /dev/null +++ b/e2e/src/persisted_documents/extractor_json_path.rs @@ -0,0 +1,137 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure json_path extractor extracts from extensions nested path +async fn extracts_from_extensions_nested_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "custom": { + "document": { + "id": DOC_ID + } + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure json_path extractor extracts from non-standard root field +async fn extracts_from_nonstandard_root_field() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "custom": { + "document": { + "id": DOC_ID + } + } + })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Make sure json_path extractor does not error when path is missing, +// but returns none instead +async fn returns_none_when_path_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: extensions.custom.document.id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({ + "extensions": { + "custom": {} + } + })) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} diff --git a/e2e/src/persisted_documents/extractor_precedence.rs b/e2e/src/persisted_documents/extractor_precedence.rs new file mode 100644 index 000000000..f67b3f0be --- /dev/null +++ b/e2e/src/persisted_documents/extractor_precedence.rs @@ -0,0 +1,44 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Extractors are applied in order, and the first match wins. +async fn uses_first_match() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: json_path + path: documentId + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql?documentId=notfound") + .send_json(&json!({ "documentId": DOC_ID })) + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_url_path_param.rs b/e2e/src/persisted_documents/extractor_url_path_param.rs new file mode 100644 index 000000000..6b4ef3fcb --- /dev/null +++ b/e2e/src/persisted_documents/extractor_url_path_param.rs @@ -0,0 +1,224 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, PATH_DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn extracts_id_from_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request(&format!("/graphql/docs/{PATH_DOC_ID}"), json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn mismatch() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql/other/abc-123", json!({}), None) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +async fn matches_wildcard_template() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /v1/*/:id/details + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + &format!("/graphql/v1/anything/{PATH_DOC_ID}/details"), + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn works_with_custom_graphql_endpoint() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + http: + graphql_endpoint: /custom + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request(&format!("/custom/docs/{PATH_DOC_ID}"), json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn uses_first_match_with_other_selectors() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + &format!("/graphql/docs/{PATH_DOC_ID}?documentId=sha256%3Anotfound"), + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies queryless GET path requests can resolve persisted document id from url_path_param extractor. +async fn resolves_id_from_queryless_get_path() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_path_param + template: /docs/:id + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get(&format!("/graphql/docs/{PATH_DOC_ID}")) + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/extractor_url_query_param.rs b/e2e/src/persisted_documents/extractor_url_query_param.rs new file mode 100644 index 000000000..b9e4f7352 --- /dev/null +++ b/e2e/src/persisted_documents/extractor_url_query_param.rs @@ -0,0 +1,210 @@ +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure url_query_param extractor does not error on missing query string, +// but returns none instead +async fn missing_query_string_returns_none() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .post("/graphql") + .send_json(&json!({})) + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Make sure url_query_param extractor decodes percent-encoded values correctly +async fn decodes_percent_encoded_value() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql?documentId=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// url_query_param extractor uses first match +async fn uses_first_value_for_duplicate_keys() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + // first: correct, second: incorrect + "/graphql?documentId=sha256%3Aabc123&documentId=sha256%3Aother", + json!({}), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// url_query_param extractor matches first param, even if it's empty +async fn first_empty_match() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: documentId + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + // documentId=& + "/graphql?documentId=&documentId=sha256%3Aabc123", + json!({}), + None, + ) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; + + let response = router + .send_post_request( + // documentId& + "/graphql?documentId&documentId=sha256%3Aabc123", + json!({}), + None, + ) + .await; + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// url_query_param matches exactly the param name, not a prefix/suffix +async fn ignores_prefix_matches_and_continues() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: key + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql?keys=1&key=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; + + let response = router + .send_post_request("/graphql?skey=1&key=sha256%3Aabc123", json!({}), None) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/method_get.rs b/e2e/src/persisted_documents/method_get.rs new file mode 100644 index 000000000..f391bd01a --- /dev/null +++ b/e2e/src/persisted_documents/method_get.rs @@ -0,0 +1,221 @@ +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Verifies GET requests can resolve persisted documents via the default documentId query parameter. +async fn resolves_from_document_id_query_param() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?documentId=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies GET requests can resolve persisted documents through a configured custom query parameter. +async fn resolves_from_custom_query_param_extractor() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?pid=sha256:abc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies GET requests with an empty query and no persisted document id do not resolve a document +async fn requires_id_when_query_is_empty() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?query=") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Verifies queryless GET request is possible +async fn requires_id_when_queryless_get_has_no_id() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} + +#[ntex::test] +// Verifies percent-encoded values in the configured custom query parameter are decoded before lookup +async fn decodes_percent_encoded_custom_param() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?pid=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +// Verifies a configured custom query parameter extractor does not fall back to default `documentId` +async fn requires_id_when_custom_param_is_missing() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + selectors: + - type: url_query_param + name: pid + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .serv() + .get("/graphql?documentId=sha256%3Aabc123") + .send() + .await + .expect("failed to send graphql request"); + + assert_error_code(response, "PERSISTED_DOCUMENT_ID_REQUIRED").await; +} diff --git a/e2e/src/persisted_documents/mod.rs b/e2e/src/persisted_documents/mod.rs new file mode 100644 index 000000000..2e088db7d --- /dev/null +++ b/e2e/src/persisted_documents/mod.rs @@ -0,0 +1,12 @@ +mod defaults; +mod extractor_apollo; +mod extractor_document_id; +mod extractor_json_path; +mod extractor_precedence; +mod extractor_url_path_param; +mod extractor_url_query_param; +mod method_get; +mod policy; +mod shared; +mod storage_file; +mod storage_hive; diff --git a/e2e/src/persisted_documents/policy.rs b/e2e/src/persisted_documents/policy.rs new file mode 100644 index 000000000..f5a163c4f --- /dev/null +++ b/e2e/src/persisted_documents/policy.rs @@ -0,0 +1,75 @@ +use sonic_rs::json; + +use super::shared::{assert_resolves_successfully, write_manifest}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn query_wins_when_require_id_is_false() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: false + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "query": "{ topProducts { name } }", + "documentId": "sha256:notfound" + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} + +#[ntex::test] +async fn disabled_mode_ignores_extracted_id() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: false + require_id: true + "#, + ) + .build() + .start() + .await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "query": "{ topProducts { name } }", + "documentId": "sha256:notfound" + }), + None, + ) + .await; + + assert_resolves_successfully(response).await; +} diff --git a/e2e/src/persisted_documents/shared.rs b/e2e/src/persisted_documents/shared.rs new file mode 100644 index 000000000..8520b3e5a --- /dev/null +++ b/e2e/src/persisted_documents/shared.rs @@ -0,0 +1,43 @@ +use sonic_rs::{json, JsonValueTrait}; +use tempfile::NamedTempFile; + +use crate::testkit::ClientResponseExt; + +pub(super) const DOC_ID: &str = "sha256:abc123"; +pub(super) const PATH_DOC_ID: &str = "abc-123"; +pub(super) const DOC_QUERY: &str = "{ topProducts { name } }"; + +pub(super) fn write_manifest() -> NamedTempFile { + let file = NamedTempFile::new().expect("failed to create temp persisted document manifest"); + std::fs::write( + file.path(), + sonic_rs::to_string(&json!({ + DOC_ID: DOC_QUERY, + PATH_DOC_ID: DOC_QUERY, + })) + .expect("failed to serialize persisted document manifest"), + ) + .expect("failed to write persisted document manifest"); + file +} + +pub(super) async fn assert_resolves_successfully(response: ntex::client::ClientResponse) { + assert!(response.status().is_success(), "expected 2xx response"); + let body = response.json_body().await; + assert!( + body["errors"].is_null(), + "unexpected graphql errors: {body}" + ); + assert!( + body["data"]["topProducts"].is_array(), + "expected resolved persisted query data: {body}" + ); +} + +pub(super) async fn assert_error_code(response: ntex::client::ClientResponse, code: &str) { + let body = response.json_body().await; + let got = body["errors"][0]["extensions"]["code"] + .as_str() + .expect("expected graphql error code string"); + assert_eq!(got, code, "unexpected response body: {body}"); +} diff --git a/e2e/src/persisted_documents/storage_file.rs b/e2e/src/persisted_documents/storage_file.rs new file mode 100644 index 000000000..19e0937df --- /dev/null +++ b/e2e/src/persisted_documents/storage_file.rs @@ -0,0 +1,63 @@ +use std::time::Duration; + +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully, write_manifest, DOC_ID}; +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +// Make sure the file watch works as expected and updates the manifest. +async fn file_watch_works() { + let manifest = write_manifest(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + "#, + manifest.path().display(), + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": DOC_ID }), None) + .await; + + // We expect the first request to resolve successfully, + // because the DOC_ID is present in the manifest. + assert_resolves_successfully(response).await; + + // Now we replace the manifest with new content, + // that lacks the DOC_ID. + std::fs::write(manifest.path(), r#"{"foo":"{__typename}"}"#) + .expect("failed to update manifest"); + + // Debounce of 150ms is configured for file watch events, + // so let's wait a double the time before making the request. + tokio::time::sleep(Duration::from_millis(300)).await; + + let response = router + .send_post_request( + "/graphql", + json!({ + "documentId": DOC_ID + }), + None, + ) + .await; + + // We expect the request to fail, + // because the DOC_ID is no longer present in the manifest. + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; +} diff --git a/e2e/src/persisted_documents/storage_hive.rs b/e2e/src/persisted_documents/storage_hive.rs new file mode 100644 index 000000000..e8fb29225 --- /dev/null +++ b/e2e/src/persisted_documents/storage_hive.rs @@ -0,0 +1,395 @@ +use std::time::Duration; + +use sonic_rs::json; + +use super::shared::{assert_error_code, assert_resolves_successfully}; +use crate::testkit::{some_header_map, TestRouter, TestSubgraphs}; + +mod negative_cache { + use super::*; + + const MISSING_DOC_ID: &str = "app~1.0.0~missing-doc"; + const MISSING_DOC_CDN_PATH: &str = "/apps/app/1.0.0/missing-doc"; + + #[ntex::test] + // Verifies that negative cache is enabled by default for Hive storage. + // Expects that the second request for same missing id within default TTL avoids a second CDN fetch. + async fn default_skips_second_miss_within_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that explicitly disabling negative cache forces misses to hit CDN each time. + // Expects repeated requests for same missing id trigger a CDN fetch each time. + async fn disabled_retries_each_miss() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(2) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that negative cache entries expire after configured TTL. + // Expects that the same missing id triggers CDN fetch again after TTL has elapsed. + async fn expires_and_refetches_after_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(2) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: + ttl: 100ms + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + tokio::time::sleep(Duration::from_millis(250)).await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } + + #[ntex::test] + // Verifies that enabling negative cache with boolean true uses default cache configuration. + // Expects the second request for same missing id within default TTL to avoid a second CDN fetch. + async fn enabled_uses_default_ttl() { + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let miss = server + .mock("GET", MISSING_DOC_CDN_PATH) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(404) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + negative_cache: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": MISSING_DOC_ID }), None) + .await; + assert_error_code(response, "PERSISTED_DOCUMENT_NOT_FOUND").await; + + miss.assert(); + } +} + +#[ntex::test] +// Verifies successful Hive lookups are cached in memory by default. +// Expects that two router requests for the same document id produce only one CDN fetch. +async fn reuses_cached_document_on_second_request() { + let doc_id: &str = "app~1.0.0~found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body("{ topProducts { name } }") + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + hit.assert(); +} + +#[ntex::test] +// Verifies app-qualified and header-qualified forms of the same document id share one cache key. +// Expects the second router request to reuse the first fetch and avoid a second CDN hit. +async fn caches_documents_by_id() { + let app_doc_id: &str = "app~1.0.0~found-doc"; + let plain_doc_id: &str = "found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body("{ topProducts { name } }") + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let response = router + .send_post_request("/graphql", json!({ "documentId": app_doc_id }), None) + .await; + assert_resolves_successfully(response).await; + + let response = router + .send_post_request( + "/graphql", + json!({ "documentId": plain_doc_id }), + some_header_map!( + ::http::header::HeaderName::from_static("graphql-client-name") => "app", + ::http::header::HeaderName::from_static("graphql-client-version") => "1.0.0", + ), + ) + .await; + assert_resolves_successfully(response).await; + + hit.assert(); +} + +#[ntex::test] +// Verifies concurrent requests for the same logical document id reuse a single CDN fetch. +// Expects one request to Hive CDN when one app-qualified and one header-qualified request hit router concurrently. +async fn concurrent_requests_share_one_cdn_fetch() { + let app_doc_id: &str = "app~1.0.0~found-doc"; + let plain_doc_id: &str = "found-doc"; + let cdn_path: &str = "/apps/app/1.0.0/found-doc"; + + let mut server = mockito::Server::new_async().await; + let host = server.host_with_port(); + let hit = server + .mock("GET", cdn_path) + .expect(1) + .match_header("x-hive-cdn-key", "dummy_key") + .with_status(200) + .with_header("content-type", "text/plain") + .with_chunked_body(|writer| { + std::thread::sleep(Duration::from_millis(150)); + writer.write_all(b"{ topProducts { name } }") + }) + .create(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + persisted_documents: + enabled: true + require_id: true + storage: + type: hive + endpoint: http://{host} + key: dummy_key + retry_policy: + max_retries: 0 + "# + )) + .build() + .start() + .await; + + let app_id_request = + router.send_post_request("/graphql", json!({ "documentId": app_doc_id }), None); + let plain_id_request = router.send_post_request( + "/graphql", + json!({ "documentId": plain_doc_id }), + some_header_map!( + ::http::header::HeaderName::from_static("graphql-client-name") => "app", + ::http::header::HeaderName::from_static("graphql-client-version") => "1.0.0", + ), + ); + + let (app_id_response, plain_id_response) = tokio::join!(app_id_request, plain_id_request); + + assert_resolves_successfully(app_id_response).await; + assert_resolves_successfully(plain_id_response).await; + + hit.assert(); +} diff --git a/e2e/src/telemetry/metrics.rs b/e2e/src/telemetry/metrics.rs index ea03997ca..a1ee47250 100644 --- a/e2e/src/telemetry/metrics.rs +++ b/e2e/src/telemetry/metrics.rs @@ -12,6 +12,7 @@ use hive_router::{ plugins::hooks::on_plugin_init::OnPluginInitResult, plugins::plugin_trait::RouterPlugin, }; use hive_router_internal::telemetry::metrics::catalog::{labels, labels_for, names, values}; +use tempfile::NamedTempFile; async fn wait_for_metrics_export() { tokio::time::sleep(Duration::from_millis(500)).await; @@ -136,7 +137,93 @@ async fn test_otlp_http_metrics_export_with_graphql_request() { assert_histogram_count(&metrics, names::PLAN_CACHE_DURATION, &no_attrs, 2); } -/// Verify cache size metrics are exported as gauges +#[ntex::test] +async fn test_otlp_persisted_documents_failure_and_missing_id_counters() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + + let manifest = NamedTempFile::new().expect("failed to create temp persisted manifest"); + std::fs::write( + manifest.path(), + sonic_rs::to_string(&sonic_rs::json!({ + "sha256:known": "{ topProducts { name } }" + })) + .expect("failed to serialize manifest"), + ) + .expect("failed to write manifest"); + + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_metrics_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {} + + persisted_documents: + enabled: true + require_id: true + storage: + type: file + path: "{}" + + telemetry: + metrics: + exporters: + - kind: otlp + endpoint: {} + protocol: http + interval: 30ms + max_export_timeout: 50ms + "#, + supergraph_path.to_str().unwrap(), + manifest.path().display(), + otlp_endpoint + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Missing id request. + let _ = router + .send_post_request("/graphql", sonic_rs::json!({}), None) + .await; + + // Resolve failure request. + let _ = router + .send_post_request( + "/graphql", + sonic_rs::json!({ "documentId": "sha256:not-found" }), + None, + ) + .await; + + wait_for_metrics_export().await; + + let metrics = otlp_collector.metrics_view().await; + let no_attrs: [(&str, &str); 0] = []; + + assert_counter_eq( + &metrics, + names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, + &no_attrs, + 1.0, + ); + assert_counter_eq( + &metrics, + names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, + &no_attrs, + 1.0, + ); +} + #[ntex::test] async fn test_otlp_cache_size_metrics_exported_as_gauges() { let supergraph_path = @@ -435,6 +522,8 @@ async fn test_otlp_all_metrics_path_attribute_names() { (names::PLAN_CACHE_REQUESTS_TOTAL, &[][..]), (names::PLAN_CACHE_DURATION, &[][..]), (names::PLAN_CACHE_SIZE, &[][..]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[][..]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[][..]), ] { assert_metric_has_attrs(&metrics, name, ignore); } @@ -533,6 +622,8 @@ async fn test_otlp_all_metrics_happy_path_attribute_names() { (names::PLAN_CACHE_REQUESTS_TOTAL, &[][..]), (names::PLAN_CACHE_DURATION, &[][..]), (names::PLAN_CACHE_SIZE, &[][..]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[][..]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[][..]), ] { assert_metric_has_attrs(&metrics, name, ignore); } diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index af2b06ac4..475ca1721 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -942,10 +942,27 @@ impl TestRouter { query: &str, variables: Option, headers: Option, + ) -> ClientResponse { + self.send_post_request( + self.graphql_path(), + json!({ + "query": query, + "variables": variables, + }), + headers, + ) + .await + } + + pub async fn send_post_request( + &self, + path: &str, + payload: sonic_rs::Value, + headers: Option, ) -> ClientResponse { let mut req = self .serv() - .post(self.graphql_path()) + .post(path) .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/graphql-response+json"); @@ -955,12 +972,9 @@ impl TestRouter { } } - req.send_json(&json!({ - "query": query, - "variables": variables, - })) - .await - .expect("Failed to send graphql request") + req.send_json(&payload) + .await + .expect("Failed to send graphql request") } pub async fn ws(&self) -> WsConnection { diff --git a/lib/executor/src/plugins/hooks/on_graphql_params.rs b/lib/executor/src/plugins/hooks/on_graphql_params.rs index 99482abac..c04639915 100644 --- a/lib/executor/src/plugins/hooks/on_graphql_params.rs +++ b/lib/executor/src/plugins/hooks/on_graphql_params.rs @@ -1,10 +1,14 @@ -use core::fmt; - +use std::borrow::Cow; use std::collections::HashMap; +use std::fmt; +use hive_router_internal::json::MapAccessSerdeExt; use ntex::util::Bytes; +use serde::de; +use serde::de::IgnoredAny; +use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; -use serde::{de, Deserialize, Deserializer}; use sonic_rs::Value; use crate::plugin_context::PluginContext; @@ -63,37 +67,21 @@ impl<'de> Deserialize<'de> for GraphQLParams { let mut operation_name = None; let mut variables: Option> = None; let mut extensions: Option> = None; - let mut extra_params = HashMap::new(); - - while let Some(key) = map.next_key::()? { - match key.as_str() { - "query" => { - if query.is_some() { - return Err(de::Error::duplicate_field("query")); - } - query = map.next_value::>()?; - } + + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "query" => map.deserialize_once_into_option(&mut query, "query")?, "operationName" => { - if operation_name.is_some() { - return Err(de::Error::duplicate_field("operationName")); - } - operation_name = map.next_value::>()?; + map.deserialize_once_into_option(&mut operation_name, "operationName")? } "variables" => { - if variables.is_some() { - return Err(de::Error::duplicate_field("variables")); - } - variables = map.next_value::>>()?; + map.deserialize_once_into_option(&mut variables, "variables")? } "extensions" => { - if extensions.is_some() { - return Err(de::Error::duplicate_field("extensions")); - } - extensions = map.next_value::>>()?; + map.deserialize_once_into_option(&mut extensions, "extensions")? } - other => { - let value: Value = map.next_value()?; - extra_params.insert(other.to_string(), value); + _ => { + let _ = map.next_value::()?; } } } diff --git a/lib/hive-console-sdk/src/persisted_documents.rs b/lib/hive-console-sdk/src/persisted_documents.rs index 4a5aab95c..f7f38c281 100644 --- a/lib/hive-console-sdk/src/persisted_documents.rs +++ b/lib/hive-console-sdk/src/persisted_documents.rs @@ -2,6 +2,7 @@ use std::time::Duration; use crate::agent::usage_agent::non_empty_string; use crate::circuit_breaker::CircuitBreakerBuilder; +use crate::circuit_breaker::CircuitBreakerError; use moka::future::Cache; use recloser::AsyncRecloser; use reqwest::header::HeaderMap; @@ -16,23 +17,24 @@ use tracing::{debug, info, warn}; pub struct PersistedDocumentsManager { client: ClientWithMiddleware, cache: Cache, + negative_cache: Option>, endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, Clone)] pub enum PersistedDocumentsError { #[error("Failed to read body: {0}")] FailedToReadBody(String), #[error("Failed to parse body: {0}")] - FailedToParseBody(serde_json::Error), + FailedToParseBody(String), #[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), + FailedToFetchFromCDN(String), #[error("Failed to read CDN response body")] - FailedToReadCDNResponse(reqwest::Error), + FailedToReadCDNResponse(String), #[error("No persisted document provided, or document id cannot be resolved.")] PersistedDocumentRequired, #[error("Missing required configuration option: {0}")] @@ -40,15 +42,33 @@ pub enum PersistedDocumentsError { #[error("Invalid CDN key {0}")] InvalidCDNKey(String), #[error("Failed to create HTTP client: {0}")] - HTTPClientCreationError(reqwest::Error), + HTTPClientCreationError(String), #[error("unable to create circuit breaker: {0}")] - CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), + CircuitBreakerCreationError(String), #[error("rejected by the circuit breaker")] CircuitBreakerRejected, #[error("unknown error")] Unknown, } +impl From for PersistedDocumentsError { + fn from(err: reqwest_middleware::Error) -> Self { + PersistedDocumentsError::FailedToFetchFromCDN(err.to_string()) + } +} + +impl From for PersistedDocumentsError { + fn from(err: serde_json::Error) -> Self { + PersistedDocumentsError::FailedToParseBody(err.to_string()) + } +} + +impl From for PersistedDocumentsError { + fn from(err: CircuitBreakerError) -> Self { + PersistedDocumentsError::CircuitBreakerCreationError(err.to_string()) + } +} + impl PersistedDocumentsError { pub fn message(&self) -> String { self.to_string() @@ -105,7 +125,7 @@ impl PersistedDocumentsManager { .call(response_fut) .await .map_err(|e| match e { - recloser::Error::Inner(e) => PersistedDocumentsError::FailedToFetchFromCDN(e), + recloser::Error::Inner(e) => PersistedDocumentsError::from(e), recloser::Error::Rejected => PersistedDocumentsError::CircuitBreakerRejected, })?; @@ -113,21 +133,17 @@ impl PersistedDocumentsManager { 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; + .map_err(|e| PersistedDocumentsError::FailedToReadCDNResponse(e.to_string()))?; + debug!("Document fetched from CDN: {}", document); return Ok(document); } + let status = response.status(); + warn!( "Document fetch from CDN failed: HTTP {}, Body: {:?}", - response.status(), + status, response .text() .await @@ -136,42 +152,57 @@ impl PersistedDocumentsManager { 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; + if let Some(negative_cache) = &self.negative_cache { + if negative_cache.get(document_id).await.is_some() { + debug!( + "Document {} found in negative cache, skipping CDN fetch", + document_id + ); + return Err(PersistedDocumentsError::DocumentNotFound); + } + } - match cached_record { - Some(document) => { - debug!("Document {} found in cache: {}", document_id, document); + if let Some(cached_document) = self.cache.get(document_id).await { + return Ok(cached_document); + } - Ok(document) - } - None => { + let result = self + .cache + .try_get_with_by_ref(document_id, async { 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 + for (endpoint, circuit_breaker) in self.endpoints_with_circuit_breakers.iter() { + match self .resolve_from_endpoint(endpoint, document_id, circuit_breaker) - .await; - match result { + .await + { Ok(document) => return Ok(document), - Err(e) => { - last_error = Some(e); - } + Err(error) => last_error = Some(error), } } - match last_error { - Some(e) => Err(e), - None => Err(PersistedDocumentsError::Unknown), - } + + Err(last_error.unwrap_or(PersistedDocumentsError::Unknown)) + }) + .await + .map_err(|error| error.as_ref().clone()); + + if matches!(&result, Err(PersistedDocumentsError::DocumentNotFound)) { + if let Some(negative_cache) = &self.negative_cache { + negative_cache.insert(document_id.to_string(), ()).await; } } + + result } } @@ -183,6 +214,7 @@ pub struct PersistedDocumentsManagerBuilder { request_timeout: Duration, retry_policy: ExponentialBackoff, cache_size: u64, + negative_cache_ttl: Option, user_agent: Option, circuit_breaker: CircuitBreakerBuilder, } @@ -197,6 +229,7 @@ impl Default for PersistedDocumentsManagerBuilder { request_timeout: Duration::from_secs(15), retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), cache_size: 10_000, + negative_cache_ttl: None, user_agent: None, circuit_breaker: CircuitBreakerBuilder::default(), } @@ -260,6 +293,21 @@ impl PersistedDocumentsManagerBuilder { self } + /// TTL for negative cache entries (failed lookups / not found responses). + /// + /// When set, repeated misses for the same document id are served from in-memory cache + /// until the TTL expires. + pub fn negative_cache_ttl(mut self, ttl: Duration) -> Self { + self.negative_cache_ttl = Some(ttl); + self + } + + /// Circuit breaker configuration for persisted document CDN requests. + pub fn circuit_breaker(mut self, circuit_breaker: CircuitBreakerBuilder) -> Self { + self.circuit_breaker = circuit_breaker; + 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)); @@ -293,12 +341,18 @@ impl PersistedDocumentsManagerBuilder { let reqwest_agent = reqwest_agent .build() - .map_err(PersistedDocumentsError::HTTPClientCreationError)?; + .map_err(|e| PersistedDocumentsError::HTTPClientCreationError(e.to_string()))?; let client = ClientBuilder::new(reqwest_agent) .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) .build(); let cache = Cache::::new(self.cache_size); + let negative_cache = self.negative_cache_ttl.map(|ttl| { + Cache::builder() + .max_capacity(self.cache_size) + .time_to_live(ttl) + .build() + }); if self.endpoints.is_empty() { return Err(PersistedDocumentsError::MissingConfigurationOption( @@ -309,15 +363,12 @@ impl PersistedDocumentsManagerBuilder { Ok(PersistedDocumentsManager { client, cache, + negative_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)?; + let circuit_breaker = self.circuit_breaker.clone().build_async()?; Ok((endpoint, circuit_breaker)) }) .collect::, PersistedDocumentsError>>()?, diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 58a5bad25..0a885bf59 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -35,6 +35,7 @@ thiserror = { workspace = true } ahash = { workspace = true } dashmap = { workspace = true } tokio-stream = "0.1.18" +serde = { workspace = true } # telemetry opentelemetry = { workspace = true, features = ["trace"] } diff --git a/lib/internal/src/json.rs b/lib/internal/src/json.rs new file mode 100644 index 000000000..d048f826a --- /dev/null +++ b/lib/internal/src/json.rs @@ -0,0 +1,24 @@ +use serde::de; + +pub trait MapAccessSerdeExt<'de>: de::MapAccess<'de> { + #[inline] + /// Deserializes an optional field value from the current map entry into `slot`. + /// Returns a duplicate-field error when the same field appears more than once. + fn deserialize_once_into_option( + &mut self, + slot: &mut Option, + field_name: &'static str, + ) -> Result<(), Self::Error> + where + T: serde::Deserialize<'de>, + { + if slot.is_some() { + return Err(de::Error::duplicate_field(field_name)); + } + + *slot = self.next_value::>()?; + Ok(()) + } +} + +impl<'de, A> MapAccessSerdeExt<'de> for A where A: de::MapAccess<'de> {} diff --git a/lib/internal/src/lib.rs b/lib/internal/src/lib.rs index 7b6d626d0..1a6e55040 100644 --- a/lib/internal/src/lib.rs +++ b/lib/internal/src/lib.rs @@ -4,6 +4,7 @@ pub mod expressions; pub mod graphql; pub mod http; pub mod inflight; +pub mod json; pub mod telemetry; pub type BoxError = Box; diff --git a/lib/internal/src/telemetry/metrics/catalog.rs b/lib/internal/src/telemetry/metrics/catalog.rs index 1d26044e0..6b6fd5230 100644 --- a/lib/internal/src/telemetry/metrics/catalog.rs +++ b/lib/internal/src/telemetry/metrics/catalog.rs @@ -107,6 +107,10 @@ pub mod names { pub const PLAN_CACHE_REQUESTS_TOTAL: &str = "hive.router.plan_cache.requests_total"; pub const PLAN_CACHE_DURATION: &str = "hive.router.plan_cache.duration"; pub const PLAN_CACHE_SIZE: &str = "hive.router.plan_cache.size"; + pub const PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL: &str = + "hive.router.persisted_documents.storage.failures_total"; + pub const PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL: &str = + "hive.router.persisted_documents.extract.missing_id_total"; } pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ @@ -234,6 +238,8 @@ pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ (names::PLAN_CACHE_REQUESTS_TOTAL, &[labels::RESULT]), (names::PLAN_CACHE_DURATION, &[labels::RESULT]), (names::PLAN_CACHE_SIZE, &[]), + (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[]), + (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[]), ]; pub fn labels_for(metric_name: &str) -> Option<&'static [&'static str]> { diff --git a/lib/internal/src/telemetry/metrics/mod.rs b/lib/internal/src/telemetry/metrics/mod.rs index 499403224..15643faae 100644 --- a/lib/internal/src/telemetry/metrics/mod.rs +++ b/lib/internal/src/telemetry/metrics/mod.rs @@ -4,6 +4,7 @@ pub mod catalog; pub mod graphql_metrics; pub mod http_client_metrics; pub mod http_server_metrics; +pub mod persisted_documents_metrics; pub mod setup; pub mod supergraph_metrics; @@ -16,6 +17,7 @@ use crate::telemetry::metrics::cache_metrics::CacheMetrics; use crate::telemetry::metrics::graphql_metrics::GraphQLMetrics; use crate::telemetry::metrics::http_client_metrics::HttpClientMetrics; use crate::telemetry::metrics::http_server_metrics::HttpServerMetrics; +use crate::telemetry::metrics::persisted_documents_metrics::PersistedDocumentsMetrics; use crate::telemetry::metrics::supergraph_metrics::SupergraphMetrics; pub struct Metrics { @@ -24,6 +26,7 @@ pub struct Metrics { pub graphql: GraphQLMetrics, pub supergraph: SupergraphMetrics, pub cache: CacheMetrics, + pub persisted_documents: PersistedDocumentsMetrics, } impl Metrics { @@ -34,6 +37,7 @@ impl Metrics { graphql: GraphQLMetrics::new(meter), supergraph: SupergraphMetrics::new(meter), cache: CacheMetrics::new(meter), + persisted_documents: PersistedDocumentsMetrics::new(meter), } } } diff --git a/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs b/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs new file mode 100644 index 000000000..5ee813d16 --- /dev/null +++ b/lib/internal/src/telemetry/metrics/persisted_documents_metrics.rs @@ -0,0 +1,51 @@ +use opentelemetry::metrics::{Counter, Meter}; + +use crate::telemetry::metrics::catalog::names; + +struct PersistedDocumentsInstruments { + storage_failures_total: Option>, + extract_missing_id_total: Option>, +} + +pub struct PersistedDocumentsMetrics { + instruments: PersistedDocumentsInstruments, +} + +impl PersistedDocumentsMetrics { + pub fn new(meter: Option<&Meter>) -> Self { + let storage_failures_total = meter.map(|meter| { + meter + .u64_counter(names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL) + .with_unit("{failure}") + .with_description("Total number of failed persisted document resolutions") + .build() + }); + + let extract_missing_id_total = meter.map(|meter| { + meter + .u64_counter(names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL) + .with_unit("{request}") + .with_description("Total number of requests without persisted document id") + .build() + }); + + Self { + instruments: PersistedDocumentsInstruments { + storage_failures_total, + extract_missing_id_total, + }, + } + } + + pub fn record_resolution_failure(&self) { + if let Some(counter) = &self.instruments.storage_failures_total { + counter.add(1, &[]); + } + } + + pub fn record_missing_id(&self) { + if let Some(counter) = &self.instruments.extract_missing_id_total { + counter.add(1, &[]); + } + } +} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index afa00d4d2..bd74267b3 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -11,6 +11,7 @@ pub mod limits; pub mod log; pub mod override_labels; pub mod override_subgraph_urls; +pub mod persisted_documents; pub mod primitives; pub mod query_planner; pub mod subscriptions; @@ -127,6 +128,10 @@ pub struct HiveRouterConfig { /// Configuration of router's WebSocket server. #[serde(default)] pub websocket: websocket::WebSocketConfig, + + /// Configuration for persisted documents extraction and resolution. + #[serde(default)] + pub persisted_documents: persisted_documents::PersistedDocumentsConfig, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] diff --git a/lib/router-config/src/persisted_documents.rs b/lib/router-config/src/persisted_documents.rs new file mode 100644 index 000000000..11c271d40 --- /dev/null +++ b/lib/router-config/src/persisted_documents.rs @@ -0,0 +1,452 @@ +use schemars::JsonSchema; +use serde::{de::Error as _, Deserialize, Serialize}; +use std::collections::HashSet; +use std::time::Duration; + +use crate::primitives::file_path::FilePath; +use crate::primitives::retry_policy::RetryPolicyConfig; +use crate::primitives::single_or_multiple::SingleOrMultiple; +use crate::primitives::toggle::ToggleWith; + +#[derive(Debug, Serialize, JsonSchema, Clone, Default)] +pub struct PersistedDocumentsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub require_id: bool, + #[serde(default)] + pub log_missing_id: bool, + #[serde(default)] + pub storage: Option, + #[serde(default)] + pub selectors: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPersistedDocumentsConfig { + #[serde(default)] + enabled: bool, + #[serde(default)] + require_id: bool, + #[serde(default)] + log_missing_id: bool, + #[serde(default)] + storage: Option, + #[serde(default)] + selectors: Option>, +} + +impl<'de> Deserialize<'de> for PersistedDocumentsConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawPersistedDocumentsConfig::deserialize(deserializer)?; + + if raw.enabled && matches!(raw.selectors.as_ref(), Some(selectors) if selectors.is_empty()) + { + return Err(D::Error::custom( + "persisted_documents.selectors must not be an explicit empty list when persisted_documents.enabled=true", + )); + } + + if raw.enabled && raw.storage.is_none() { + return Err(D::Error::custom( + "persisted_documents.storage is required when persisted_documents.enabled=true", + )); + } + + if let Some(selectors) = raw.selectors.as_ref() { + let mut seen = HashSet::new(); + for selector in selectors { + if !seen.insert(selector.clone()) { + return Err(D::Error::custom(format!( + "persisted_documents.selectors contains a duplicate entry: {selector:?}" + ))); + } + } + } + + Ok(Self { + enabled: raw.enabled, + require_id: raw.require_id, + log_missing_id: raw.log_missing_id, + storage: raw.storage, + selectors: raw.selectors, + }) + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] +pub enum PersistedDocumentsStorageConfig { + File { + #[serde(flatten)] + config: PersistedDocumentsFileStorageConfig, + }, + Hive { + #[serde(flatten)] + config: PersistedDocumentsHiveStorageConfig, + }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsFileStorageConfig { + pub path: FilePath, + #[serde(default = "default_watch")] + pub watch: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveStorageConfig { + /// The CDN endpoint from Hive Console target. + /// Can also be set using the `HIVE_CDN_ENDPOINT` environment variable. + pub endpoint: Option>, + /// The CDN Access Token with from the Hive Console target. + /// Can also be set using the `HIVE_CDN_KEY` environment variable. + pub key: Option, + #[serde(default = "default_hive_accept_invalid_certs")] + pub accept_invalid_certs: bool, + #[serde( + default = "default_hive_connect_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub connect_timeout: Duration, + #[serde( + default = "default_hive_request_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub request_timeout: Duration, + #[serde(default = "default_hive_retry_policy")] + pub retry_policy: RetryPolicyConfig, + #[serde(default = "default_hive_cache_size")] + pub cache_size: u64, + #[serde(default)] + pub circuit_breaker: PersistedDocumentsHiveCircuitBreakerConfig, + #[serde(default = "default_hive_negative_cache")] + pub negative_cache: ToggleWith, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveNegativeCacheConfig { + #[serde( + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub ttl: Duration, +} + +impl Default for PersistedDocumentsHiveNegativeCacheConfig { + fn default() -> Self { + Self { + ttl: Duration::from_secs(5), + } + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct PersistedDocumentsHiveCircuitBreakerConfig { + #[serde(default = "default_circuit_breaker_error_threshold")] + pub error_threshold: f32, + #[serde(default = "default_circuit_breaker_volume_threshold")] + pub volume_threshold: usize, + #[serde( + default = "default_circuit_breaker_reset_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + pub reset_timeout: Duration, +} + +impl Default for PersistedDocumentsHiveCircuitBreakerConfig { + fn default() -> Self { + Self { + error_threshold: default_circuit_breaker_error_threshold(), + volume_threshold: default_circuit_breaker_volume_threshold(), + reset_timeout: default_circuit_breaker_reset_timeout(), + } + } +} + +fn default_hive_accept_invalid_certs() -> bool { + false +} + +fn default_hive_connect_timeout() -> Duration { + Duration::from_secs(5) +} + +fn default_hive_request_timeout() -> Duration { + Duration::from_secs(15) +} + +fn default_hive_retry_policy() -> RetryPolicyConfig { + RetryPolicyConfig { max_retries: 3 } +} + +fn default_hive_cache_size() -> u64 { + 10_000 +} + +fn default_hive_negative_cache() -> ToggleWith { + ToggleWith::Enabled(PersistedDocumentsHiveNegativeCacheConfig::default()) +} + +fn default_circuit_breaker_error_threshold() -> f32 { + 0.5 +} + +fn default_circuit_breaker_volume_threshold() -> usize { + 5 +} + +fn default_circuit_breaker_reset_timeout() -> Duration { + Duration::from_secs(10) +} + +const fn default_watch() -> bool { + true +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PersistedDocumentExtractorConfig { + JsonPath { + path: PersistedDocumentJsonPath, + }, + UrlPathParam { + template: PersistedDocumentUrlTemplate, + }, + UrlQueryParam { + name: PersistedDocumentQueryParamName, + }, +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentJsonPath(String); + +impl PersistedDocumentJsonPath { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentJsonPath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // TODO: add more validations (like " char etc) + let path = String::deserialize(deserializer)?; + if path.is_empty() { + return Err(D::Error::custom("json_path cannot be empty")); + } + if path.chars().any(char::is_whitespace) { + return Err(D::Error::custom("json_path cannot include whitespace")); + } + if path.contains('[') || path.contains(']') { + return Err(D::Error::custom("json_path cannot include array syntax")); + } + if path.contains('*') { + return Err(D::Error::custom("json_path cannot include wildcard syntax")); + } + if path.split('.').any(str::is_empty) { + return Err(D::Error::custom( + "json_path cannot include empty segments (e.g. '..')", + )); + } + + if matches!( + path.split('.').next(), + Some("query" | "operationName" | "variables") + ) { + return Err(D::Error::custom( + "json_path cannot access root GraphQL fields: query, operationName, variables", + )); + } + + Ok(Self(path)) + } +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentUrlTemplate(String); + +impl PersistedDocumentUrlTemplate { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentUrlTemplate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let template = String::deserialize(deserializer)?; + + validate_url_path_template(&template).map_err(D::Error::custom)?; + + Ok(Self(template)) + } +} + +fn validate_url_path_template(template: &str) -> Result<(), String> { + if template.is_empty() { + return Err("url_path_param.template cannot be empty".to_string()); + } + if !template.starts_with('/') { + return Err("url_path_param.template must start with '/'".to_string()); + } + if template.contains('?') || template.contains('#') { + return Err("url_path_param.template cannot include query string or fragment".to_string()); + } + + let raw_segments: Vec<&str> = template.split('/').skip(1).collect(); + if raw_segments.iter().any(|segment| segment.is_empty()) { + return Err("url_path_param.template cannot include empty segments".to_string()); + } + + let mut id_count = 0; + for (index, segment) in raw_segments.iter().enumerate() { + match *segment { + ":id" => id_count += 1, + "*" => {} + "**" => { + return Err("url_path_param.template does not support '**' segments".to_string()); + } + literal if literal.starts_with(':') => { + return Err(format!( + "url_path_param.template has unsupported parameter segment '{literal}' at index {index}; only ':id' is allowed" + )); + } + _ => {} + } + } + + if id_count != 1 { + return Err("url_path_param.template must include exactly one ':id' segment".to_string()); + } + + Ok(()) +} + +#[derive(Debug, Serialize, JsonSchema, Clone, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct PersistedDocumentQueryParamName(String); + +impl PersistedDocumentQueryParamName { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PersistedDocumentQueryParamName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + // TODO: improve it + if name.trim().is_empty() { + return Err(D::Error::custom("url_query_param.name cannot be empty")); + } + Ok(Self(name)) + } +} + +impl PersistedDocumentsConfig { + pub fn default_selectors() -> Vec { + vec![ + PersistedDocumentExtractorConfig::JsonPath { + path: PersistedDocumentJsonPath("documentId".to_string()), + }, + PersistedDocumentExtractorConfig::JsonPath { + path: PersistedDocumentJsonPath("extensions.persistedQuery.sha256Hash".to_string()), + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::{ + PersistedDocumentJsonPath, PersistedDocumentUrlTemplate, PersistedDocumentsConfig, + }; + + #[test] + fn rejects_root_graphql_fields_for_json_path() { + for path in ["query", "operationName", "variables", "query.foo"] { + let raw = format!("\"{path}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_err(), "expected path '{path}' to be rejected"); + } + } + + #[test] + fn allows_non_root_graphql_fields_for_json_path() { + for path in [ + "documentId", + "extensions.persistedQuery.sha256Hash", + "foo.query", + ] { + let raw = format!("\"{path}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_ok(), "expected path '{path}' to be allowed"); + } + } + + #[test] + fn enabled_persisted_documents_require_storage() { + let parsed = serde_json::from_str::( + r#"{ + "enabled": true + }"#, + ); + + assert!( + parsed.is_err(), + "expected storage to be required when enabled" + ); + } + + #[test] + fn url_template_rejects_unknown_parameter_segment() { + let parsed = serde_json::from_str::(r#""/p/:docId""#); + assert!(parsed.is_err(), "expected unknown parameter to be rejected"); + } + + #[test] + fn url_template_accepts_supported_segment_types() { + for template in ["/v1/p/:id", "/v1/*/:id", "/v1/*/:id/details"] { + let raw = format!("\"{template}\""); + let parsed = serde_json::from_str::(&raw); + assert!(parsed.is_ok(), "expected template '{template}' to be valid"); + } + } + + #[test] + fn url_template_rejects_globstar_segment() { + for template in ["/v1/**/:id", "/:id/**/v2"] { + let raw = format!("\"{template}\""); + let parsed = serde_json::from_str::(&raw); + assert!( + parsed.is_err(), + "expected template '{template}' to be rejected" + ); + } + } +} diff --git a/lib/router-config/src/primitives/file_path.rs b/lib/router-config/src/primitives/file_path.rs index f8140562a..b184b1e03 100644 --- a/lib/router-config/src/primitives/file_path.rs +++ b/lib/router-config/src/primitives/file_path.rs @@ -61,13 +61,24 @@ impl<'de> Visitor<'de> for FilePathVisitor { type Value = FilePath; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a string representing a relative file path") + formatter.write_str("a string representing a file path") } fn visit_str(self, v: &str) -> Result where E: de::Error, { + let path = Path::new(v); + if path.is_absolute() { + let canonical_path = fs::canonicalize(path) + .map_err(|err| E::custom(format!("Failed to canonicalize path: {}", err)))?; + + return Ok(FilePath { + relative: v.to_string(), + absolute: canonical_path.to_string_lossy().to_string(), + }); + } + CONTEXT_START_PATH.with(|ctx| { if let Some(start_path) = ctx.borrow().as_ref() { match FilePath::resolve_relative(start_path, v, true) { From 2831a0f5742748d36a3513cc9e9fedbe2271f53d Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 17 Apr 2026 13:24:18 +0300 Subject: [PATCH 45/76] enhance(plugins): introduce `EarlyHTTPResponse` for a better API to return responses with headers (#926) It is not a breaking change as `PlanExecutionOutput` is still the main "response" type of `on_query_plan` and `on_execute`. - `end_with_response` now accepts any type that can be converted to `PlanExecutionOutput`, so `EarlyHTTPResponse` is one of them. - `EarlyHTTPResponse` takes `headers` directly instead of aggregator, so plugins can send headers as `HeaderMap` directly ```rust payload.end_with_response( EarlyHTTPResponse { body, headers, status_code, } ); ``` --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/expose-early-http-response.md | 15 +++++++ lib/executor/src/plugins/plugin_trait.rs | 55 ++++++++++++++++++++--- plugin_examples/response_cache/src/lib.rs | 22 ++++++--- 3 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 .changeset/expose-early-http-response.md diff --git a/.changeset/expose-early-http-response.md b/.changeset/expose-early-http-response.md new file mode 100644 index 000000000..ebf212a49 --- /dev/null +++ b/.changeset/expose-early-http-response.md @@ -0,0 +1,15 @@ +--- +hive-router-plan-executor: patch +--- + +Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. + +```rust +payload.end_with_response( + EarlyHTTPResponse { + body, + headers, + status_code, + } +); +``` \ No newline at end of file diff --git a/lib/executor/src/plugins/plugin_trait.rs b/lib/executor/src/plugins/plugin_trait.rs index 2799e8cd8..2a053680c 100644 --- a/lib/executor/src/plugins/plugin_trait.rs +++ b/lib/executor/src/plugins/plugin_trait.rs @@ -1,4 +1,6 @@ use crate::{ + execution::plan::PlanExecutionOutput, + headers::plan::{HeaderAggregationStrategy, ResponseHeaderAggregator}, hooks::{ on_execute::{OnExecuteStartHookPayload, OnExecuteStartHookResult}, on_graphql_error::{OnGraphQLErrorHookPayload, OnGraphQLErrorHookResult}, @@ -20,6 +22,8 @@ use crate::{ }, response::graphql_error::GraphQLError, }; +use ahash::HashMap; +use http::{HeaderName, HeaderValue}; use serde::de::DeserializeOwned; use sonic_rs::json; @@ -65,13 +69,13 @@ where } /// End the hook execution and return a response to the client immediately, skipping the rest of the execution flow. - fn end_with_response<'exec>( + fn end_with_response<'exec, TResponseInput: Into>( self, - output: TResponse, + output: TResponseInput, ) -> StartHookResult<'exec, Self, TEndPayload, TResponse> { StartHookResult { payload: self, - control_flow: StartControlFlow::EndWithResponse(output), + control_flow: StartControlFlow::EndWithResponse(output.into()), } } @@ -164,10 +168,13 @@ where } /// End the hook execution and return a response to the client immediately, skipping the rest of the execution flow. - fn end_with_response(self, output: TResponse) -> EndHookResult { + fn end_with_response>( + self, + output: TResponseInput, + ) -> EndHookResult { EndHookResult { payload: self, - control_flow: EndControlFlow::EndWithResponse(output), + control_flow: EndControlFlow::EndWithResponse(output.into()), } } @@ -434,3 +441,41 @@ pub enum CacheHint { Hit, Miss, } + +#[derive(Default)] +pub struct EarlyHTTPResponse { + pub body: Vec, + pub headers: http::HeaderMap, + pub status_code: http::StatusCode, +} + +impl From for PlanExecutionOutput { + fn from(val: EarlyHTTPResponse) -> Self { + let response_headers_aggregator = if val.headers.is_empty() { + None + } else { + let mut entries: HashMap)> = + Default::default(); + let mut last_name = None; + for (name, value) in val.headers.into_iter() { + if let Some(name) = name { + last_name = Some(name); + } + if let Some(name) = &last_name { + let aggregated_header = entries + .entry(name.clone()) + .or_insert_with(|| (HeaderAggregationStrategy::Append, Vec::new())); + aggregated_header.1.push(value); + } + } + + Some(ResponseHeaderAggregator { entries }) + }; + PlanExecutionOutput { + body: val.body, + response_headers_aggregator, + error_count: 0, + status_code: val.status_code, + } + } +} diff --git a/plugin_examples/response_cache/src/lib.rs b/plugin_examples/response_cache/src/lib.rs index a5a32be32..413dc133b 100644 --- a/plugin_examples/response_cache/src/lib.rs +++ b/plugin_examples/response_cache/src/lib.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use hive_router::http::StatusCode; +use hive_router::http::HeaderMap; use hive_router::plugins::hooks::on_execute::{ OnExecuteEndHookPayload, OnExecuteStartHookPayload, OnExecuteStartHookResult, }; @@ -8,9 +8,11 @@ use hive_router::plugins::hooks::on_plugin_init::{OnPluginInitPayload, OnPluginI use hive_router::plugins::hooks::on_supergraph_load::{ OnSupergraphLoadStartHookPayload, OnSupergraphLoadStartHookResult, }; -use hive_router::plugins::plugin_trait::{EndHookPayload, RouterPlugin, StartHookPayload}; +use hive_router::plugins::plugin_trait::{ + EarlyHTTPResponse, EndHookPayload, RouterPlugin, StartHookPayload, +}; use hive_router::ArcSwap; -use hive_router::{async_trait, graphql_tools, sonic_rs, PlanExecutionOutput}; +use hive_router::{async_trait, graphql_tools, sonic_rs}; use redis::Commands; use serde::Deserialize; @@ -70,11 +72,17 @@ impl RouterPlugin for ResponseCachePlugin { key, String::from_utf8_lossy(&body) ); - return payload.end_with_response(PlanExecutionOutput { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Cache-Status", + "HIT" + .parse() + .expect("X-Cache-Status and HIT are valid header name and value"), + ); + return payload.end_with_response(EarlyHTTPResponse { body, - error_count: 0, - response_headers_aggregator: None, - status_code: StatusCode::OK, + headers, + ..Default::default() }); } } From d8c1db0b1ce0598798aa0ca04e9fb33e69363909 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 17 Apr 2026 13:45:57 +0300 Subject: [PATCH 46/76] fix(query-planner): handle both `@skip` and `@include` directives on the same selection (#913) Fix query planner handling for combined `@skip` and `@include` conditions. - Preserve both directives when converting inline fragment conditions into fetch step selections - Build the expected nested condition nodes for combined skip/include execution paths - Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing - Add regression snapshot tests for field-level and fragment-level combined conditions For example a query like this: ```graphql query($skip: Boolean!, $include: Boolean!) { user { name @skip(if: $skip) @include(if: $include) } } ``` Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. - `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. - `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. - Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Kamil Kisiela Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/olive-ducks-bake.md | 26 ++ e2e/src/conditional_directives.rs | 282 ++++++++++++++++++ e2e/src/lib.rs | 2 + lib/query-planner/src/ast/merge_path.rs | 50 ++-- lib/query-planner/src/ast/selection_set.rs | 62 +++- .../src/planner/fetch/fetch_step_data.rs | 3 + .../fetch/optimize/batch_multi_type.rs | 5 + .../src/planner/fetch/selections.rs | 36 +-- lib/query-planner/src/planner/plan_nodes.rs | 12 + lib/query-planner/src/tests/include_skip.rs | 160 ++++++++++ 10 files changed, 598 insertions(+), 40 deletions(-) create mode 100644 .changeset/olive-ducks-bake.md create mode 100644 e2e/src/conditional_directives.rs diff --git a/.changeset/olive-ducks-bake.md b/.changeset/olive-ducks-bake.md new file mode 100644 index 000000000..e1a1c25d4 --- /dev/null +++ b/.changeset/olive-ducks-bake.md @@ -0,0 +1,26 @@ +--- +hive-router-query-planner: patch +--- + +Fix query planner handling for combined `@skip` and `@include` conditions. + +- Preserve both directives when converting inline fragment conditions into fetch step selections +- Build the expected nested condition nodes for combined skip/include execution paths +- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing +- Add regression snapshot tests for field-level and fragment-level combined conditions + +For example a query like this: + +```graphql +query($skip: Boolean!, $include: Boolean!) { + user { + name @skip(if: $skip) @include(if: $include) + } +} +``` + +Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. + +- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. +- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. +- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. \ No newline at end of file diff --git a/e2e/src/conditional_directives.rs b/e2e/src/conditional_directives.rs new file mode 100644 index 000000000..77caab509 --- /dev/null +++ b/e2e/src/conditional_directives.rs @@ -0,0 +1,282 @@ +#[cfg(test)] +mod conditional_directives_e2e_tests { + // These tests ensure the behavior when a selection has both @skip and @include directives. + // The expected behavior is; + // 1. If @skip(if: $skip) is true, the selection should be skipped regardless of the @include directive. + // 2. If @skip(if: $skip) is false, the selection should be included only if @include(if: $include) is true. + // 3. If both @skip(if: $skip) and @include(if: $include) are false, the selection should be skipped. + // 4. If both @skip(if: $skip) and @include(if: $include) are true, the selection should be skipped. + + use sonic_rs::{pointer, JsonValueTrait}; + + use crate::testkit::{ClientResponseExt, Started, TestRouter, TestSubgraphs}; + + async fn build_router_with_supergraph() -> (TestSubgraphs, TestRouter) { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + "#, + ) + .build() + .start() + .await; + (subgraphs, router) + } + + fn check_response_includes_product_name(json_body: sonic_rs::Value, expected_included: bool) { + let product_name = + json_body.pointer(&pointer!["data", "me", "reviews", 0, "product", "name",]); + if expected_included { + assert!( + product_name.is_some(), + "Expected product name to be included, but it was not. Response body: {}", + json_body + ); + } else { + assert!( + product_name.is_none(), + "Expected product name to be skipped, but it was included. Response body: {}", + json_body + ); + } + } + + async fn run_test(query: &str, skip: bool, include: bool, expected_included: bool) { + let (_subgraphs, router) = build_router_with_supergraph().await; + let variables = sonic_rs::json!({ + "include": include, + "skip": skip, + }); + let res = router + .send_graphql_request(query, Some(variables), None) + .await; + assert!(res.status().is_success(), "Expected 200 OK"); + + let json_body = res.json_body().await; + let data = json_body.pointer(&pointer!["data"]); + assert!( + data.is_some_and(|value| value.is_object()), + "Expected response.data to be an object. Response body: {}", + json_body + ); + let errors = json_body.pointer(&pointer!["errors"]); + assert!( + errors.is_none_or(|value| value.is_null()), + "Expected response.errors to be null or missing. Response body: {}", + json_body + ); + + check_response_includes_product_name(json_body, expected_included); + } + + const FIELD_CONDITIONS_SKIP_THEN_INCLUDE_QUERY: &'static str = r#" + query($skip: Boolean!, $include: Boolean!) { + me { + name + reviews { + product { + upc + name @skip(if: $skip) @include(if: $include) + } + } + } + } + "#; + + const FIELD_CONDITIONS_INCLUDE_THEN_SKIP_QUERY: &'static str = r#" + query($skip: Boolean!, $include: Boolean!) { + me { + name + reviews { + product { + upc + name @include(if: $include) @skip(if: $skip) + } + } + } + } + "#; + + const INLINE_FRAGMENT_CONDITIONS_SKIP_THEN_INCLUDE_QUERY: &'static str = r#" + query($skip: Boolean!, $include: Boolean!) { + me { + name + reviews { + product { + upc + ... on Product @skip(if: $skip) @include(if: $include) { + name + } + } + } + } + } + "#; + + const INLINE_FRAGMENT_CONDITIONS_INCLUDE_THEN_SKIP_QUERY: &'static str = r#" + query($skip: Boolean!, $include: Boolean!) { + me { + name + reviews { + product { + upc + ... on Product @include(if: $include) @skip(if: $skip) { + name + } + } + } + } + } + "#; + + // If skip: true, the selection should be skipped regardless of the include value + #[ntex::test] + async fn field_skip_true_and_include_true() { + run_test(FIELD_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, true, true, false).await; + } + #[ntex::test] + async fn field_skip_true_and_include_false() { + run_test(FIELD_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, true, false, false).await; + } + + // If skip: false, the selection should be included only if include: true + #[ntex::test] + async fn field_skip_false_and_include_true() { + run_test(FIELD_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, false, true, true).await; + } + #[ntex::test] + async fn field_skip_false_and_include_false() { + run_test( + FIELD_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, + false, + false, + false, + ) + .await; + } + + // Make sure the order of directives does not matter + // So this time, `@include` comes before `@skip`, but the behavior should be the same as the previous 4 tests + #[ntex::test] + async fn field_include_then_skip_skip_true_and_include_true() { + // In this case, `@skip` is true, so the selection should be skipped regardless of the `@include` value + run_test(FIELD_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, true, true, false).await; + } + #[ntex::test] + async fn field_include_then_skip_skip_true_and_include_false() { + // In this case, `@skip` is true, so the selection should be skipped regardless of the `@include` value + run_test(FIELD_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, true, false, false).await; + } + #[ntex::test] + async fn field_include_then_skip_skip_false_and_include_true() { + // In this case, `@skip` is false and `@include` is true, so the selection should be included + run_test(FIELD_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, false, true, true).await; + } + #[ntex::test] + async fn field_include_then_skip_skip_false_and_include_false() { + // In this case, `@skip` is false but `@include` is also false, so the selection should be skipped + run_test( + FIELD_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, + false, + false, + false, + ) + .await; + } + + // If skip: true, the selection should be skipped regardless of the include value + #[ntex::test] + async fn inline_fragment_skip_true_and_include_true() { + // In this case, `@skip` is true, so the selection should be skipped regardless of the `@include` value + run_test( + INLINE_FRAGMENT_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, + true, + true, + false, + ) + .await; + } + #[ntex::test] + async fn inline_fragment_skip_true_and_include_false() { + // In this case, `@skip` is true, so the selection should be skipped regardless of the `@include` value + run_test( + INLINE_FRAGMENT_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, + true, + false, + false, + ) + .await; + } + // If skip: false, the selection should be included only if include: true + #[ntex::test] + async fn inline_fragment_skip_false_and_include_true() { + // In this case, `@skip` is false and `@include` is true, so the selection should be included + run_test( + INLINE_FRAGMENT_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, + false, + true, + true, + ) + .await; + } + #[ntex::test] + async fn inline_fragment_skip_false_and_include_false() { + // In this case, `@skip` is false but `@include` is also false, so the selection should be skipped + run_test( + INLINE_FRAGMENT_CONDITIONS_SKIP_THEN_INCLUDE_QUERY, + false, + false, + false, + ) + .await; + } + + // Make sure the order of directives does not matter + // So this time, `@include` comes before `@skip`, but the behavior should be the same as the previous 4 tests + #[ntex::test] + async fn inline_fragment_include_then_skip_skip_true_and_include_true() { + run_test( + INLINE_FRAGMENT_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, + true, + true, + false, + ) + .await; + } + #[ntex::test] + async fn inline_fragment_include_then_skip_skip_true_and_include_false() { + run_test( + INLINE_FRAGMENT_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, + true, + false, + false, + ) + .await; + } + #[ntex::test] + async fn inline_fragment_include_then_skip_skip_false_and_include_true() { + run_test( + INLINE_FRAGMENT_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, + false, + true, + true, + ) + .await; + } + #[ntex::test] + async fn inline_fragment_include_then_skip_skip_false_and_include_false() { + run_test( + INLINE_FRAGMENT_CONDITIONS_INCLUDE_THEN_SKIP_QUERY, + false, + false, + false, + ) + .await; + } +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 2daf0e7bc..5d7695a9f 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -5,6 +5,8 @@ mod authorization_directives_reject; #[cfg(test)] mod body_limit; #[cfg(test)] +mod conditional_directives; +#[cfg(test)] mod disable_introspection; #[cfg(test)] mod entity_batching; diff --git a/lib/query-planner/src/ast/merge_path.rs b/lib/query-planner/src/ast/merge_path.rs index 7c43d8682..bce691732 100644 --- a/lib/query-planner/src/ast/merge_path.rs +++ b/lib/query-planner/src/ast/merge_path.rs @@ -12,18 +12,21 @@ type SelectionIdentifier = String; pub enum Condition { Skip(String), Include(String), + SkipAndInclude { skip: String, include: String }, } impl Condition { pub fn to_skip_if(&self) -> Option { match self { Condition::Skip(var) => Some(var.clone()), + Condition::SkipAndInclude { skip, .. } => Some(skip.clone()), _ => None, } } pub fn to_include_if(&self) -> Option { match self { Condition::Include(var) => Some(var.clone()), + Condition::SkipAndInclude { include, .. } => Some(include.clone()), _ => None, } } @@ -34,45 +37,52 @@ impl Display for Condition { match self { Self::Skip(condition) => write!(f, "@skip(if: ${})", condition), Self::Include(condition) => write!(f, "@include(if: ${})", condition), + Self::SkipAndInclude { skip, include } => { + write!(f, "@skip(if: ${}) @include(if: ${})", skip, include) + } } } } impl From<&FieldSelection> for Option { fn from(field: &FieldSelection) -> Self { - if let Some(variable) = &field.skip_if { - return Some(Condition::Skip(variable.clone())); - } - if let Some(variable) = &field.include_if { - return Some(Condition::Include(variable.clone())); + match (&field.skip_if, &field.include_if) { + (Some(skip), Some(include)) => Some(Condition::SkipAndInclude { + skip: skip.clone(), + include: include.clone(), + }), + (Some(variable), None) => Some(Condition::Skip(variable.clone())), + (None, Some(variable)) => Some(Condition::Include(variable.clone())), + (None, None) => None, } - None } } impl From<&mut FieldSelection> for Option { fn from(field: &mut FieldSelection) -> Self { - if let Some(variable) = &field.skip_if { - return Some(Condition::Skip(variable.clone())); - } - if let Some(variable) = &field.include_if { - return Some(Condition::Include(variable.clone())); + match (&field.skip_if, &field.include_if) { + (Some(skip), Some(include)) => Some(Condition::SkipAndInclude { + skip: skip.clone(), + include: include.clone(), + }), + (Some(variable), None) => Some(Condition::Skip(variable.clone())), + (None, Some(variable)) => Some(Condition::Include(variable.clone())), + (None, None) => None, } - None } } impl From<&InlineFragmentSelection> for Option { fn from(fragment: &InlineFragmentSelection) -> Self { - if let Some(variable) = &fragment.skip_if { - return Some(Condition::Skip(variable.clone())); + match (&fragment.skip_if, &fragment.include_if) { + (Some(skip), Some(include)) => Some(Condition::SkipAndInclude { + skip: skip.clone(), + include: include.clone(), + }), + (Some(variable), None) => Some(Condition::Skip(variable.clone())), + (None, Some(variable)) => Some(Condition::Include(variable.clone())), + (None, None) => None, } - - if let Some(variable) = &fragment.include_if { - return Some(Condition::Include(variable.clone())); - } - - None } } diff --git a/lib/query-planner/src/ast/selection_set.rs b/lib/query-planner/src/ast/selection_set.rs index 050ae71c7..12dbcdb81 100644 --- a/lib/query-planner/src/ast/selection_set.rs +++ b/lib/query-planner/src/ast/selection_set.rs @@ -575,9 +575,15 @@ pub fn field_condition_equal(cond: &Option, field: &FieldSelection) - match cond { Some(cond) => match cond { Condition::Include(var_name) => { - field.include_if.as_ref().is_some_and(|v| v == var_name) + field.include_if.as_ref().is_some_and(|v| v == var_name) && field.skip_if.is_none() + } + Condition::Skip(var_name) => { + field.skip_if.as_ref().is_some_and(|v| v == var_name) && field.include_if.is_none() + } + Condition::SkipAndInclude { skip, include } => { + field.skip_if.as_ref().is_some_and(|v| v == skip) + && field.include_if.as_ref().is_some_and(|v| v == include) } - Condition::Skip(var_name) => field.skip_if.as_ref().is_some_and(|v| v == var_name), }, None => field.include_if.is_none() && field.skip_if.is_none(), } @@ -588,8 +594,16 @@ fn fragment_condition_equal(cond: &Option, fragment: &InlineFragmentS Some(cond) => match cond { Condition::Include(var_name) => { fragment.include_if.as_ref().is_some_and(|v| v == var_name) + && fragment.skip_if.is_none() + } + Condition::Skip(var_name) => { + fragment.skip_if.as_ref().is_some_and(|v| v == var_name) + && fragment.include_if.is_none() + } + Condition::SkipAndInclude { skip, include } => { + fragment.skip_if.as_ref().is_some_and(|v| v == skip) + && fragment.include_if.as_ref().is_some_and(|v| v == include) } - Condition::Skip(var_name) => fragment.skip_if.as_ref().is_some_and(|v| v == var_name), }, None => fragment.include_if.is_none() && fragment.skip_if.is_none(), } @@ -864,4 +878,46 @@ mod tests { assert_eq!(target.items.len(), 2); } + + #[test] + // Field Condition should only be equal to Condition::SkipAndInclude + fn field_condition_skip_and_include() { + let skip_cond = Some(Condition::Skip("skip".to_string())); + let include_cond: Option = Some(Condition::Include("include".to_string())); + let skip_and_include_cond = Some(Condition::SkipAndInclude { + skip: "skip".to_string(), + include: "include".to_string(), + }); + let field = FieldSelection { + name: "name".to_string(), + selections: SelectionSet::default(), + alias: None, + arguments: None, + skip_if: Some("skip".to_string()), + include_if: Some("include".to_string()), + }; + assert!(!field_condition_equal(&skip_cond, &field)); + assert!(!field_condition_equal(&include_cond, &field)); + assert!(field_condition_equal(&skip_and_include_cond, &field)); + } + + #[test] + // Fragment Condition should only be equal to Condition::SkipAndInclude + fn fragment_condition_skip_and_include() { + let skip_cond = Some(Condition::Skip("skip".to_string())); + let include_cond: Option = Some(Condition::Include("include".to_string())); + let skip_and_include_cond = Some(Condition::SkipAndInclude { + skip: "skip".to_string(), + include: "include".to_string(), + }); + let fragment = InlineFragmentSelection { + type_condition: "Product".to_string(), + selections: SelectionSet::default(), + skip_if: Some("skip".to_string()), + include_if: Some("include".to_string()), + }; + assert!(!fragment_condition_equal(&skip_cond, &fragment)); + assert!(!fragment_condition_equal(&include_cond, &fragment)); + assert!(fragment_condition_equal(&skip_and_include_cond, &fragment)); + } } diff --git a/lib/query-planner/src/planner/fetch/fetch_step_data.rs b/lib/query-planner/src/planner/fetch/fetch_step_data.rs index 3526d0e1c..b210d7ba4 100644 --- a/lib/query-planner/src/planner/fetch/fetch_step_data.rs +++ b/lib/query-planner/src/planner/fetch/fetch_step_data.rs @@ -82,6 +82,9 @@ impl Display for FetchStepData { match condition { Condition::Include(var_name) => write!(f, " [@include(if: ${})]", var_name)?, Condition::Skip(var_name) => write!(f, " [@skip(if: ${})]", var_name)?, + Condition::SkipAndInclude { skip, include } => { + write!(f, " [@skip(if: ${}) @include(if: ${})]", skip, include)? + } } } diff --git a/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs b/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs index 2885e1565..3eaac81ce 100644 --- a/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs +++ b/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs @@ -396,6 +396,11 @@ fn normalized_path_hash(path: &MergePath) -> u64 { "Condition(Include)".hash(&mut hasher); variable.hash(&mut hasher); } + Some(Condition::SkipAndInclude { skip, include }) => { + "Condition(SkipAndInclude)".hash(&mut hasher); + skip.hash(&mut hasher); + include.hash(&mut hasher); + } None => "Condition(None)".hash(&mut hasher), } } diff --git a/lib/query-planner/src/planner/fetch/selections.rs b/lib/query-planner/src/planner/fetch/selections.rs index 0a2847d48..ff91425ba 100644 --- a/lib/query-planner/src/planner/fetch/selections.rs +++ b/lib/query-planner/src/planner/fetch/selections.rs @@ -68,14 +68,8 @@ impl From<&FetchStepSelections> for SelectionSet { SelectionItem::InlineFragment(InlineFragmentSelection { type_condition: type_name.to_string(), - include_if: match &condition { - Condition::Include(var_name) => Some(var_name.clone()), - Condition::Skip(_) => None, - }, - skip_if: match &condition { - Condition::Skip(var_name) => Some(var_name.clone()), - Condition::Include(_) => None, - }, + include_if: condition.to_include_if(), + skip_if: condition.to_skip_if(), selections: selections_for_wrapper, }) }) @@ -85,17 +79,16 @@ impl From<&FetchStepSelections> for SelectionSet { } fn inline_fragment_condition(fragment: &InlineFragmentSelection) -> Option { - // Return a condition: - match (fragment.include_if.as_ref(), fragment.skip_if.as_ref()) { - // Either @include - (Some(var_name), None) => Some(Condition::Include(var_name.clone())), - // or @skip - (None, Some(var_name)) => Some(Condition::Skip(var_name.clone())), - // not when both are available - _ => None, - } + fragment.into() } +/// Attempts to lift a common condition from the top-level inline fragments for +/// `type_name` into the wrapper `... on Type` fragment we build in `From`. +/// +/// Lifting is valid when every top-level item is an inline fragment on the same +/// type and they all share the same condition. That condition may be +/// `@include`, `@skip`, or both directives together +/// (`Condition::SkipAndInclude`). fn try_lift_condition( type_name: &str, selections: &SelectionSet, @@ -331,6 +324,15 @@ impl FetchStepSelections { include_if: None, })]; } + Condition::SkipAndInclude { skip, include } => { + selection_set.items = + vec![SelectionItem::InlineFragment(InlineFragmentSelection { + type_condition: def_name.to_string(), + selections: prev, + skip_if: Some(skip.clone()), + include_if: Some(include.clone()), + })]; + } } } diff --git a/lib/query-planner/src/planner/plan_nodes.rs b/lib/query-planner/src/planner/plan_nodes.rs index fafe9862e..350f12eab 100644 --- a/lib/query-planner/src/planner/plan_nodes.rs +++ b/lib/query-planner/src/planner/plan_nodes.rs @@ -602,6 +602,18 @@ impl PlanNode { if_clause: None, else_clause: Some(Box::new(node)), }), + Condition::SkipAndInclude { skip, include } => { + let include_node = PlanNode::Condition(ConditionNode { + condition: include.clone(), + if_clause: Some(Box::new(node)), + else_clause: None, + }); + PlanNode::Condition(ConditionNode { + condition: skip.clone(), + if_clause: None, + else_clause: Some(Box::new(include_node)), + }) + } }, None => node, } diff --git a/lib/query-planner/src/tests/include_skip.rs b/lib/query-planner/src/tests/include_skip.rs index fcca985c7..5a758eac4 100644 --- a/lib/query-planner/src/tests/include_skip.rs +++ b/lib/query-planner/src/tests/include_skip.rs @@ -238,6 +238,166 @@ fn skip_basic_test() -> Result<(), Box> { Ok(()) } +#[test] +fn skip_and_include_field_condition_test() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($skip: Boolean = false, $include: Boolean = true) { + product { + price + neverCalledInclude @skip(if: $skip) @include(if: $include) + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($include:Boolean=true,$skip:Boolean=false) { + product { + __typename + price + id + ... on Product @skip(if: $skip) @include(if: $include) { + price + __typename + id + } + } + } + }, + Skip(if: $skip) { + Include(if: $include) { + Sequence { + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + { + ... on Product { + isExpensive + } + } + }, + }, + Flatten(path: "product") { + Fetch(service: "c") { + { + ... on Product { + __typename + isExpensive + id + } + } => + { + ... on Product { + neverCalledInclude + } + } + }, + }, + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn skip_and_include_fragment_condition_test() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($skip: Boolean = false, $include: Boolean = true) { + product { + price + ... on Product @skip(if: $skip) @include(if: $include) { + neverCalledInclude + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($include:Boolean=true,$skip:Boolean=false) { + product { + price + ... on Product @skip(if: $skip) @include(if: $include) { + __typename + id + price + } + } + } + }, + Skip(if: $skip) { + Include(if: $include) { + Sequence { + Flatten(path: "product|[Product]") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + { + ... on Product { + isExpensive + } + } + }, + }, + Flatten(path: "product|[Product]") { + Fetch(service: "c") { + { + ... on Product { + __typename + isExpensive + id + } + } => + { + ... on Product { + neverCalledInclude + } + } + }, + }, + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + #[test] fn include_at_root_fetch_test() -> Result<(), Box> { init_logger(); From 00ed4ce24127e500da5f067a071e949b9e9eb508 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 17 Apr 2026 12:59:05 +0200 Subject: [PATCH 47/76] Add GraphiQL feature flag (#928) Adds an optional `graphiql` Cargo feature for `hive-router`. When enabled, the Router serves GraphiQL HTML and skips Laboratory asset generation so `npm` and `node` dependencies are not needed. By default, this feature is disabled and existing Laboratory behavior is unchanged. ```bash cargo run -p hive-router --features graphiql cargo build -p hive-router --features graphiql ``` --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/graphiql_feature_flag.md | 12 +++++ bin/router/Cargo.toml | 1 + bin/router/build.rs | 5 ++ bin/router/src/lib.rs | 3 ++ bin/router/static/graphiql.html | 77 +++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 .changeset/graphiql_feature_flag.md create mode 100644 bin/router/static/graphiql.html diff --git a/.changeset/graphiql_feature_flag.md b/.changeset/graphiql_feature_flag.md new file mode 100644 index 000000000..5a6a9386b --- /dev/null +++ b/.changeset/graphiql_feature_flag.md @@ -0,0 +1,12 @@ +--- +hive-router: patch +--- + +Adds an optional `graphiql` Cargo feature for `hive-router`. +When enabled, the Router serves GraphiQL HTML and skips Laboratory asset generation so `npm` and `node` dependencies are not needed. +By default, this feature is disabled and existing Laboratory behavior is unchanged. + +```bash +cargo run -p hive-router --features graphiql +cargo build -p hive-router --features graphiql +``` diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 79d2ab2a0..7eb1b69a6 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [features] noop_otlp_exporter = ["hive-router-internal/noop_otlp_exporter"] testing = [] +graphiql = [] [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.7.0" } diff --git a/bin/router/build.rs b/bin/router/build.rs index 7e953f896..5c3dc8a60 100644 --- a/bin/router/build.rs +++ b/bin/router/build.rs @@ -5,6 +5,11 @@ use std::{ }; fn main() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GRAPHIQL"); + if env::var_os("CARGO_FEATURE_GRAPHIQL").is_some() { + return; + } + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); let output_file = out_dir.join("laboratory.html"); diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 41222ef79..3c3aca45f 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -77,7 +77,10 @@ pub use tokio; pub use tracing; use tracing::{info, warn, Instrument}; +#[cfg(not(feature = "graphiql"))] static LABORATORY_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/laboratory.html")); +#[cfg(feature = "graphiql")] +static LABORATORY_HTML: &str = include_str!("../static/graphiql.html"); struct CallbackServer(std::sync::Mutex>); diff --git a/bin/router/static/graphiql.html b/bin/router/static/graphiql.html new file mode 100644 index 000000000..e509a0b8a --- /dev/null +++ b/bin/router/static/graphiql.html @@ -0,0 +1,77 @@ + + + + + Hive Router GraphiQL + + + + + + + + +
Loading GraphiQL...
+ + + + + From c7f9144bd31223efb38a15969d7d25524db16411 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 17 Apr 2026 13:17:50 +0200 Subject: [PATCH 48/76] chore(fork): fix compilation error in apollo plugin (#930) Fixes https://github.com/graphql-hive/router/actions/runs/24561191644/job/71809981068 and corrects changesets Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/expose-early-http-response.md | 1 + .changeset/persisted_documents.md | 1 + apollo-router-workspace/bin/router/src/persisted_documents.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/expose-early-http-response.md b/.changeset/expose-early-http-response.md index ebf212a49..8e1f2b39c 100644 --- a/.changeset/expose-early-http-response.md +++ b/.changeset/expose-early-http-response.md @@ -1,5 +1,6 @@ --- hive-router-plan-executor: patch +hive-router: patch --- Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. diff --git a/.changeset/persisted_documents.md b/.changeset/persisted_documents.md index f8c4e311b..68b9aa0bc 100644 --- a/.changeset/persisted_documents.md +++ b/.changeset/persisted_documents.md @@ -4,6 +4,7 @@ hive-router-config: minor hive-router: minor hive-router-internal: minor hive-console-sdk: minor +hive-apollo-router-plugin: patch --- # Persisted Documents diff --git a/apollo-router-workspace/bin/router/src/persisted_documents.rs b/apollo-router-workspace/bin/router/src/persisted_documents.rs index db7302e32..67a7ddb2d 100644 --- a/apollo-router-workspace/bin/router/src/persisted_documents.rs +++ b/apollo-router-workspace/bin/router/src/persisted_documents.rs @@ -313,7 +313,7 @@ struct ExpectedBodyStructure { fn extract_document_id(body: &[u8]) -> Result { serde_json::from_slice::(body) - .map_err(PersistedDocumentsError::FailedToParseBody) + .map_err(|error| PersistedDocumentsError::FailedToParseBody(error.to_string())) } /// To test this plugin, we do the following: From 1ec40860a5ef2c5250ff85c84b4e99b66a09df2f Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Fri, 17 Apr 2026 18:16:24 +0300 Subject: [PATCH 49/76] feat(router): TLS support (#810) Ref ROUTER-100 Ref ROUTER-118 Closes https://github.com/graphql-hive/router/issues/340 Documentation https://github.com/graphql-hive/docs/pull/94 # TLS Support Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. ## TLS Directions TLS Support has implementations for the following 4 directions: ### Router -> Client - Regular TLS Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` ### Client -> Router - mTLS Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` ### Subgraph -> Router - Regular TLS Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. ### Router -> Subgraph - mTLS Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. ## TLS Directions Diagram ```mermaid flowchart LR Client["Client"] Router["Router"] Subgraph["Subgraph"] %% Router -> Client: Regular TLS Router -- "TLS\n(cert_file + key_file)" --> Client Client -. "validates router identity\n(cert_file)" .-> Router %% Client -> Router: mTLS / Client Auth Client -- "mTLS\n(client identity)" --> Router Router -. "validates client identity\n(client_auth.cert_file)" .-> Client %% Subgraph -> Router: Regular TLS Subgraph -- "TLS\n(cert_file)" --> Router Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph %% Router -> Subgraph: mTLS Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph Subgraph -. "validates router identity\n(cert_file)" .-> Router ``` ## Configuration Structure ```yaml traffic_shaping: router: key_file: # Router server private key cert_file: # Router server certificate(s) client_auth: # mTLS: Client -> Router cert_file: # Trusted client CA certificate(s) all: # Default TLS for all subgraph connections cert_file: # Trusted subgraph CA certificate(s) client_auth: # mTLS: Router -> Subgraph cert_file: # Router client certificate(s) key_file: # Router client private key subgraphs: SUBGRAPH_NAME: # Per-subgraph TLS override cert_file: # Trusted subgraph CA certificate(s) client_auth: # mTLS: Router -> Subgraph cert_file: # Router client certificate(s) key_file: # Router client private key ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: theguild-bot Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/tls_support.md | 71 ++ Cargo.lock | 1111 ++++++++++------- bin/router/src/error.rs | 3 + bin/router/src/lib.rs | 23 +- bin/router/src/tls.rs | 36 + docs/README.md | 72 ++ e2e/Cargo.toml | 6 +- e2e/src/error_handling.rs | 6 +- e2e/src/lib.rs | 2 + e2e/src/override_subgraph_urls.rs | 8 +- e2e/src/testkit/mod.rs | 140 ++- e2e/src/tls.rs | 844 +++++++++++++ lib/executor/src/executors/error.rs | 19 +- lib/executor/src/executors/map.rs | 65 +- lib/executor/src/executors/mod.rs | 1 + lib/executor/src/executors/tls.rs | 188 +++ lib/executor/src/executors/websocket.rs | 15 +- .../src/executors/websocket_client.rs | 20 +- lib/router-config/src/traffic_shaping.rs | 47 +- plugin_examples/error_mapping/src/test.rs | 2 +- 20 files changed, 2134 insertions(+), 545 deletions(-) create mode 100644 .changeset/tls_support.md create mode 100644 bin/router/src/tls.rs create mode 100644 e2e/src/tls.rs create mode 100644 lib/executor/src/executors/tls.rs diff --git a/.changeset/tls_support.md b/.changeset/tls_support.md new file mode 100644 index 000000000..117efdff5 --- /dev/null +++ b/.changeset/tls_support.md @@ -0,0 +1,71 @@ +--- +hive-router-config: minor +hive-router-plan-executor: minor +hive-router: minor +--- + +# TLS Support + +Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. + +## TLS Directions + +TLS Support has implementations for the following 4 directions: + +### Router -> Client - Regular TLS +Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` + +### Client -> Router - mTLS +Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` + +### Subgraph -> Router - Regular TLS +Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. + +### Router -> Subgraph - mTLS +Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. + +## TLS Directions Diagram + +```mermaid +flowchart LR + Client["Client"] + Router["Router"] + Subgraph["Subgraph"] + + %% Router -> Client: Regular TLS + Router -- "TLS\n(cert_file + key_file)" --> Client + Client -. "validates router identity\n(cert_file)" .-> Router + + %% Client -> Router: mTLS / Client Auth + Client -- "mTLS\n(client identity)" --> Router + Router -. "validates client identity\n(client_auth.cert_file)" .-> Client + + %% Subgraph -> Router: Regular TLS + Subgraph -- "TLS\n(cert_file)" --> Router + Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph + + %% Router -> Subgraph: mTLS + Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph + Subgraph -. "validates router identity\n(cert_file)" .-> Router +``` + +## Configuration Structure +```yaml +traffic_shaping: + router: + key_file: # Router server private key + cert_file: # Router server certificate(s) + client_auth: # mTLS: Client -> Router + cert_file: # Trusted client CA certificate(s) + all: # Default TLS for all subgraph connections + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key + subgraphs: + SUBGRAPH_NAME: # Per-subgraph TLS override + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key +``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ce1c5152a..ff45b420b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,10 +7,6 @@ name = "Inflector" version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -dependencies = [ - "lazy_static", - "regex", -] [[package]] name = "addr2line" @@ -119,15 +115,15 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apollo-sandbox-plugin-example" @@ -149,9 +145,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -195,6 +191,45 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -242,24 +277,24 @@ dependencies = [ [[package]] name = "async-graphql" -version = "7.0.17" +version = "7.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980" +checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40" dependencies = [ "async-graphql-derive", "async-graphql-parser", "async-graphql-value", - "async-stream", + "async-io", "async-trait", + "asynk-strim", "base64", "bytes", "fast_chemail", "fnv", - "futures-timer", "futures-util", "handlebars", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "mime", "multer", "num-traits", @@ -270,14 +305,14 @@ dependencies = [ "serde_urlencoded", "static_assertions_next", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "async-graphql-axum" -version = "7.0.17" +version = "7.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8725874ecfbf399e071150b8619c4071d7b2b7a2f117e173dddef53c6bdb6bb1" +checksum = "a1e37c5532e4b686acf45e7162bc93da91fc2c702fb0d465efc2c20c8f973795" dependencies = [ "async-graphql", "axum", @@ -292,19 +327,19 @@ dependencies = [ [[package]] name = "async-graphql-derive" -version = "7.0.17" +version = "7.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd45deb3dbe5da5cdb8d6a670a7736d735ba65b455328440f236dfb113727a3d" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" dependencies = [ "Inflector", "async-graphql-parser", - "darling 0.20.11", + "darling 0.23.0", "proc-macro-crate", "proc-macro2", "quote", - "strum 0.26.3", - "syn 2.0.116", - "thiserror 1.0.69", + "strum 0.27.2", + "syn 2.0.117", + "thiserror 2.0.18", ] [[package]] @@ -326,11 +361,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" dependencies = [ "bytes", - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_json", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -372,7 +425,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -389,7 +442,17 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", +] + +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", ] [[package]] @@ -406,9 +469,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -416,9 +479,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -428,9 +491,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64", @@ -481,6 +544,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -510,9 +595,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base62" -version = "2.2.3" +version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" [[package]] name = "base64" @@ -559,9 +644,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -595,9 +680,9 @@ dependencies = [ [[package]] name = "bollard" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "base64", "bollard-stubs", @@ -644,9 +729,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecount" @@ -680,9 +765,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -730,7 +815,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -758,9 +843,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -832,18 +917,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -851,9 +936,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmac" @@ -868,9 +953,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -927,9 +1012,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.19" +version = "0.15.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -947,21 +1032,20 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "const-hex" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1253,9 +1337,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ "ctor-proc-macro", "dtor", @@ -1278,9 +1362,9 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", @@ -1311,7 +1395,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1326,12 +1410,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1345,21 +1429,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1370,18 +1453,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1424,16 +1507,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1452,7 +1580,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.116", + "syn 2.0.117", "unicode-xid", ] @@ -1476,11 +1604,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -1494,7 +1622,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1554,14 +1682,14 @@ checksum = "8d1a6796ad411f6812d691955066ad27450196bfb181bb91b66a643cc3e8f5b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "dtor" -version = "0.1.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" dependencies = [ "dtor-proc-macro", ] @@ -1591,6 +1719,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", + "axum-server", "bollard", "bytes", "dashmap", @@ -1612,7 +1741,9 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry-proto", "prost 0.14.3", + "rcgen", "reqwest", + "rustls", "serde", "serde_json", "sonic-rs", @@ -1726,18 +1857,18 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", ] [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "env_filter", "log", @@ -1760,7 +1891,7 @@ checksum = "452c458dfb890a2fea64e4c894f83daad61689fb9bbe84c382e1ce6d7536710b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1771,9 +1902,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -1842,9 +1973,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "faststr" @@ -1977,6 +2108,16 @@ dependencies = [ "num", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2051,6 +2192,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2059,7 +2210,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2130,21 +2281,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core 0.10.0", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -2181,9 +2332,9 @@ dependencies = [ [[package]] name = "grok" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2d7bd791814b06a609b74361ac35b448eb4718548937c6de718554a4348577" +checksum = "6ddab6a9c8bb998cb2fc3101fde8ef561b7c4970db3957be7a8eee1e168f666b" dependencies = [ "glob", "onig", @@ -2212,7 +2363,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2232,16 +2383,18 @@ dependencies = [ [[package]] name = "handlebars" -version = "5.1.2" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" dependencies = [ + "derive_builder", "log", + "num-order", "pest", "pest_derive", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -2280,6 +2433,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.10.0" @@ -2348,7 +2507,7 @@ dependencies = [ "moka", "recloser", "regex-automata", - "regress 0.11.0", + "regress 0.11.1", "reqwest", "reqwest-middleware", "reqwest-retry 0.8.0", @@ -2514,7 +2673,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "insta", "itoa", "mockito", @@ -2539,7 +2698,7 @@ dependencies = [ name = "hive-router-query-planner" version = "2.7.0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "criterion", "graphql-tools", "insta", @@ -2658,9 +2817,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -2673,7 +2832,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2696,9 +2854,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", @@ -2706,7 +2864,6 @@ dependencies = [ "log", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2790,12 +2947,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2803,9 +2961,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2816,9 +2974,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2830,15 +2988,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2850,15 +3008,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2927,12 +3085,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2965,7 +3123,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -2991,9 +3149,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.3" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console", "once_cell", @@ -3025,15 +3183,15 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3059,9 +3217,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -3075,10 +3233,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -3249,9 +3409,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -3281,15 +3441,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -3413,9 +3573,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -3440,7 +3600,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.4", "regex", "serde_json", "serde_urlencoded", @@ -3450,9 +3610,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.13" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -3514,7 +3674,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3525,11 +3685,11 @@ checksum = "6e3d189da485332e96ba8a5ef646a311871abd7915bf06ac848a9117f19cf6e4" [[package]] name = "napi" -version = "3.8.3" +version = "3.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6944d0bf100571cd6e1a98a316cdca262deb6fccf8d93f5ae1502ca3fc88bd3" +checksum = "fb7848c221fb7bb789e02f01875287ebb1e078b92a6566a34de01ef8806e7c2b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "ctor", "futures", "napi-build", @@ -3549,16 +3709,16 @@ checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" [[package]] name = "napi-derive" -version = "3.5.2" +version = "3.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c914b5e420182bfb73504e0607592cdb8e2e21437d450883077669fb72a114d" +checksum = "60867ff9a6f76e82350e0c3420cb0736f5866091b61d7d8a024baa54b0ec17dd" dependencies = [ "convert_case 0.11.0", "ctor", "napi-derive-backend", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3571,7 +3731,7 @@ dependencies = [ "proc-macro2", "quote", "semver", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3591,11 +3751,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -3613,12 +3773,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "nohash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f889fb66f7acdf83442c35775764b51fed3c606ab9cee51500dbde2cf528ca" - [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3668,7 +3822,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", @@ -3686,7 +3840,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -3696,7 +3850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4735978b410d8496a1d89bac416af3277f6d36e9ae56d1e3977b96b81ab8048" dependencies = [ "base64", - "bitflags 2.11.0", + "bitflags 2.11.1", "derive_more", "encoding_rs", "env_logger", @@ -3759,7 +3913,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdc658d0b4caced7d7cfeefaeddd50f6c80265dc98e944beae3ac6601337b267" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "log", "ntex-codec", "ntex-io", @@ -3770,9 +3924,9 @@ dependencies = [ [[package]] name = "ntex-error" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba4b4124a2b50218182d3420428ccf56ee95c4f28a15efba1be9e97cc271d9a" +checksum = "614a4c1c3cd23c231172fe20ab42bb66e8572e8c4cf282285723fc423b64834e" dependencies = [ "backtrace", "foldhash 0.2.0", @@ -3782,11 +3936,11 @@ dependencies = [ [[package]] name = "ntex-h2" -version = "3.9.0" +version = "3.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2924795c85efb587dffe38b2d7f9295d8dfacc5cb6d00270dd96d7c34d0eb0eb" +checksum = "5e400e7ea01ad28eb530e1a34840f700718cb0e7191cfb9160554b04f464f88b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "foldhash 0.2.0", "log", "nanorand", @@ -3826,7 +3980,7 @@ version = "3.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c1d3d9a67a9abcad8981967bb1f3c59ec2c34e442771d16ed33acf663f0361" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "log", "ntex-bytes", "ntex-codec", @@ -3841,7 +3995,7 @@ version = "0.7.120" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e60f4a52aae6b07b8a4c560f05802be74c10c2924838f1007f48684233aaaa" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "libc", "sc", @@ -3855,7 +4009,7 @@ checksum = "51138717dfe591b9b4063bf167ddcdc6fa8e3552157316f29f12c321493e3710" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3864,7 +4018,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83c7c631404d704766913028124c60712457b1157923d85156aaf49fbd2551e9" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "libc", "log", @@ -3988,7 +4142,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d799e658d04ad8be6d750b09a82fa01ad11dde264d9ec40fac873f835e87e85" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "foldhash 0.2.0", "futures-core", "futures-timer", @@ -4068,9 +4222,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -4092,6 +4246,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -4125,9 +4294,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -4167,11 +4336,20 @@ dependencies = [ "cipher", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "one-of-plugin-example" @@ -4200,7 +4378,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -4284,9 +4462,9 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ "http", "opentelemetry", @@ -4371,7 +4549,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.2", + "rand 0.9.4", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4438,9 +4616,9 @@ dependencies = [ [[package]] name = "papaya" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" dependencies = [ "equivalent", "seize", @@ -4573,7 +4751,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4593,7 +4771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -4604,7 +4782,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", ] @@ -4637,29 +4815,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4690,9 +4868,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plotters" @@ -4729,6 +4907,20 @@ dependencies = [ "hive-router", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -4748,9 +4940,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -4793,7 +4985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4807,9 +4999,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -4849,13 +5041,13 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "num-traits", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -4892,7 +5084,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4905,7 +5097,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4950,9 +5142,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.191" +version = "2.1.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd37f9ee74352f77f64b63295b43486c3c1790ccffed30088d45aec307704f0" +checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b" dependencies = [ "psl-types", ] @@ -4980,7 +5172,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5034,7 +5226,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -5062,18 +5254,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "quoted_printable" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" @@ -5081,6 +5273,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "r2d2" version = "0.8.10" @@ -5114,9 +5312,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5129,8 +5327,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", - "getrandom 0.4.1", - "rand_core 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -5173,9 +5371,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" @@ -5188,9 +5386,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -5206,6 +5404,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "recloser" version = "1.3.1" @@ -5218,9 +5430,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.0.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe7f6e08ce1c6a9b21684e643926f6fc3b683bc006cb89afd72a5e0eb16e3a2" +checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" dependencies = [ "arcstr", "combine", @@ -5250,7 +5462,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -5270,7 +5482,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5313,23 +5525,21 @@ dependencies = [ [[package]] name = "regex-filtered" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c11639076bf147be211b90e47790db89f4c22b6c8a9ca6e960833869da67166" +checksum = "ac5f7b31fbef748cc46643c1f9ba17f6d5c7c6f0ba5e372fc9c48d31ad1c8612" dependencies = [ "aho-corasick", - "indexmap 2.13.0", - "itertools 0.13.0", - "nohash", + "itertools 0.14.0", "regex", "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "regress" @@ -5343,9 +5553,9 @@ dependencies = [ [[package]] name = "regress" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07948de9abc2e83adbeb7543c061a5ddaf7d944afcafbdd6e6b39aeacd40504b" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" dependencies = [ "hashbrown 0.16.1", "memchr", @@ -5483,7 +5693,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" dependencies = [ - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -5518,7 +5728,7 @@ checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" dependencies = [ "bytes", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "munge", "ptr_meta", "rancor", @@ -5536,16 +5746,16 @@ checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "ron" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "once_cell", "serde", "serde_derive", @@ -5603,9 +5813,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ "arrayvec", "num-traits", @@ -5619,9 +5829,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -5632,13 +5842,22 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -5647,9 +5866,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -5733,9 +5952,9 @@ checksum = "010e18bd3bfd1d45a7e666b236c78720df0d9a7698ebaa9c1c559961eb60a38b" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -5796,7 +6015,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5808,7 +6027,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5845,11 +6064,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -5858,9 +6077,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -5878,9 +6097,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -5925,7 +6144,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5936,7 +6155,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5971,28 +6190,28 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6009,15 +6228,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -6028,14 +6247,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6044,7 +6263,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -6147,9 +6366,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -6233,7 +6452,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6244,28 +6463,28 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "sonic-number" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a74044c092f4f43ca7a6cfd62854cf9fb5ac8502b131347c990bf22bef1dfe" +checksum = "3775c3390edf958191f1ab1e8c5c188907feebd0f3ce1604cb621f72961dbf32" dependencies = [ "cfg-if", ] [[package]] name = "sonic-rs" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4425ea8d66ec950e0a8f2ef52c766cc3d68d661d9a0845c353c40833179fd866" +checksum = "d971cc77a245ccf1756dbd1a87c3e7f709c0191464096510d43eec056d0f2c4f" dependencies = [ "ahash", "bumpalo", @@ -6274,19 +6493,19 @@ dependencies = [ "faststr", "itoa", "ref-cast", - "ryu", "serde", "simdutf8", "sonic-number", "sonic-simd", "thiserror 2.0.18", + "zmij", ] [[package]] name = "sonic-simd" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5707edbfb34a40c9f2a55fa09a49101d9fec4e0cc171ce386086bd9616f34257" +checksum = "f99e664ecd2d85a68c87e3c7a3cfe691f647ea9e835de984aba4d54a41f817d4" dependencies = [ "cfg-if", ] @@ -6348,11 +6567,11 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] @@ -6366,15 +6585,14 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6386,7 +6604,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6449,9 +6667,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -6475,7 +6693,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6496,12 +6714,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6551,7 +6769,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6562,7 +6780,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6628,9 +6846,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -6648,9 +6866,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -6663,9 +6881,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -6680,13 +6898,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6712,9 +6930,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -6738,9 +6956,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "serde_core", "serde_spanned", @@ -6751,20 +6969,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -6772,18 +6990,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "tonic" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum", @@ -6811,9 +7029,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost 0.14.3", @@ -6828,7 +7046,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -6845,7 +7063,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -6889,7 +7107,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6941,9 +7159,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -6981,19 +7199,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", - "utf-8", ] [[package]] @@ -7019,7 +7236,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7059,7 +7276,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.116", + "syn 2.0.117", "thiserror 2.0.18", "unicode-ident", ] @@ -7077,15 +7294,15 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.116", + "syn 2.0.117", "typify-impl", ] [[package]] name = "ua-parser" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c06b979bd5606d182759ff9cd3dda2b034b584a1ed41116407cb92abf3c995a" +checksum = "f01b7ba339f8874b643216c6c957d792047143abe848568131e5d91d2ae2ada1" dependencies = [ "regex", "regex-filtered", @@ -7104,7 +7321,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.2", + "rand 0.9.4", "web-time", ] @@ -7134,9 +7351,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -7196,12 +7413,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8-width" version = "0.1.8" @@ -7216,11 +7427,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.21.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -7249,7 +7460,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7299,7 +7510,7 @@ dependencies = [ "hostname", "iana-time-zone", "idna", - "indexmap 2.13.0", + "indexmap 2.14.0", "indoc", "influxdb-line-protocol", "ipcrypt-rs", @@ -7418,9 +7629,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -7431,23 +7642,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7455,22 +7662,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -7492,7 +7699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -7518,9 +7725,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -7540,9 +7747,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7619,7 +7826,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7630,7 +7837,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7666,15 +7873,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -7824,9 +8022,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -7859,9 +8057,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -7877,7 +8075,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -7889,8 +8087,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7909,7 +8107,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7931,9 +8129,27 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] [[package]] name = "xxhash-rust" @@ -7958,11 +8174,20 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7971,54 +8196,54 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -8030,9 +8255,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -8041,9 +8266,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -8052,20 +8277,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "zlib-rs" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zmij" diff --git a/bin/router/src/error.rs b/bin/router/src/error.rs index c2390cd60..03a2f2fae 100644 --- a/bin/router/src/error.rs +++ b/bin/router/src/error.rs @@ -1,4 +1,5 @@ use hive_router_config::RouterConfigError; +use hive_router_plan_executor::executors::error::TlsCertificatesError; use crate::{ jwt::jwks_manager::JwksSourceError, pipeline::usage_reporting::UsageReportingError, @@ -36,4 +37,6 @@ pub enum RouterInitError { endpoint_name_two: String, endpoint: String, }, + #[error(transparent)] + TlsCertificatesError(#[from] TlsCertificatesError), } diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 3c3aca45f..c70ee7994 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -76,6 +76,7 @@ pub use sonic_rs; pub use tokio; pub use tracing; use tracing::{info, warn, Instrument}; +pub mod tls; #[cfg(not(feature = "graphiql"))] static LABORATORY_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/laboratory.html")); @@ -254,10 +255,11 @@ pub async fn router_entrypoint(plugin_registry: PluginRegistry) -> Result<(), Ro let paths = RouterPaths::new(graphql_path.clone(), websocket_path, callback_path); paths.detect_conflicts(&prometheus)?; + let graphql_path = graphql_path.to_string(); let long_lived_client_limit_service = LongLivedClientLimitService::new(&shared_state.router_config); - let maybe_error = web::HttpServer::new(async move || { + let server = web::HttpServer::new(async move || { let landing_page_path = graphql_path.clone(); let prometheus = prometheus.clone(); let long_lived_client_limit_service = long_lived_client_limit_service.clone(); @@ -277,9 +279,22 @@ pub async fn router_entrypoint(plugin_registry: PluginRegistry) -> Result<(), Ro .default_service(web::to(move || { landing_page_handler(landing_page_path.clone()) })) - }) - .bind(&addr) - .map_err(|err| RouterInitError::HttpServerBindError(addr, err))? + }); + + let tls_config = shared_state_clone + .router_config + .traffic_shaping + .router + .tls + .as_ref(); + + let maybe_error = if let Some(tls_config) = tls_config { + let rustls_config = tls::build_rustls_config(tls_config)?; + server.bind_rustls(&addr, &rustls_config) + } else { + server.bind(&addr) + } + .map_err(|err| RouterInitError::HttpServerBindError(addr.to_string(), err))? .run() .await .map_err(RouterInitError::HttpServerStartError); diff --git a/bin/router/src/tls.rs b/bin/router/src/tls.rs new file mode 100644 index 000000000..eb4bb036e --- /dev/null +++ b/bin/router/src/tls.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use hive_router_config::traffic_shaping::ServerTLSConfig; +use hive_router_plan_executor::executors::{ + error::TlsCertificatesError, tls::from_cert_file_config_to_certificate_der, +}; +use rustls::{ + pki_types::{pem::PemObject, PrivateKeyDer}, + server::{NoClientAuth, WebPkiClientVerifier}, + RootCertStore, ServerConfig, +}; + +pub fn build_rustls_config( + tls_config: &ServerTLSConfig, +) -> Result { + let client_auth = if let Some(client_auth_config) = tls_config.client_auth.as_ref() { + let certs = from_cert_file_config_to_certificate_der(&client_auth_config.cert_file)?; + let mut roots = RootCertStore::empty(); + roots.add_parsable_certificates(certs); + let builder = WebPkiClientVerifier::builder(roots.into()); + let required = client_auth_config.required.unwrap_or(true); + if required { + builder.build()? + } else { + builder.allow_unauthenticated().build()? + } + } else { + Arc::new(NoClientAuth) + }; + let certs = from_cert_file_config_to_certificate_der(&tls_config.cert_file)?; + let key = PrivateKeyDer::from_pem_file(&tls_config.key_file.absolute) + .map_err(|err| TlsCertificatesError::CustomTlsCertificatesError("key_file", err))?; + Ok(ServerConfig::builder() + .with_client_cert_verifier(client_auth) + .with_single_cert(certs, key)?) +} diff --git a/docs/README.md b/docs/README.md index a51ad8d32..7eddd7ab0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3152,6 +3152,7 @@ The default configuration that will be applied to all subgraphs, unless overridd |**dedupe\_enabled**|`boolean`|Enables/disables request deduplication to subgraphs.

When requests exactly matches the hashing mechanism (e.g., subgraph name, URL, headers, query, variables), and are executed at the same time, they will
be deduplicated by sharing the response of other in-flight requests.
Default: `true`
|| |**pool\_idle\_timeout**|`string`|Timeout for idle sockets being kept-alive.
Default: `"50s"`
|| |**request\_timeout**||Optional timeout configuration for requests to subgraphs.

Example with a fixed duration:
```yaml
timeout:
duration: 5s
```

Or with a VRL expression that can return a duration based on the operation kind:
```yaml
timeout:
expression: \|
if (.request.operation.type == "mutation") {
"10s"
} else {
"15s"
}
```
Default: `"30s"`
|| +|[**tls**](#traffic_shapingalltls)|`object`, `null`||| **Additional Properties:** not allowed **Example** @@ -3163,6 +3164,29 @@ request_timeout: 30s ``` + +#### traffic\_shaping\.all\.tls: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||| +|[**client\_auth**](#traffic_shapingalltlsclient_auth)|`object`, `null`||yes| +|**insecure\_skip\_ca\_verification**|`boolean`|Default: `false`
|| + +**Additional Properties:** not allowed + +##### traffic\_shaping\.all\.tls\.client\_auth: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||yes| +|**key\_file**|`string`|Format: `"path"`
|yes| + +**Additional Properties:** not allowed ### traffic\_shaping\.router: object @@ -3176,6 +3200,7 @@ Configuration for the router itself, e.g., for handling incoming requests, or ot |[**dedupe**](#traffic_shapingrouterdedupe)|`object`|Default: `{"enabled":false,"headers":"all"}`
|| |**max\_long\_lived\_clients**|`integer`|Maximum number of concurrent long-lived clients (WebSocket connections and HTTP streaming responses).
Regular non-streaming requests are not counted toward this limit.
When the limit is reached, new WebSocket and streaming HTTP requests are rejected with 503.
If both WebSockets and Subscriptions are disabled, this setting has no effect.
Default: `128`
Format: `"uint"`
Minimum: `0`
|| |**request\_timeout**|`string`|Optional timeout configuration for incoming requests to the router.
It starts from the moment the request is received by the router,
and includes the entire processing of the request (validation, execution, etc.) until a response is sent back to the client.
If a request takes longer than the specified duration, it will be aborted and a timeout error will be returned to the client.
Default: `"1m"`
|| +|[**tls**](#traffic_shapingroutertls)|`object`, `null`||yes| **Additional Properties:** not allowed **Example** @@ -3208,6 +3233,29 @@ headers: all ``` + +#### traffic\_shaping\.router\.tls: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||yes| +|[**client\_auth**](#traffic_shapingroutertlsclient_auth)|`object`, `null`||yes| +|**key\_file**|`string`|Format: `"path"`
|yes| + +**Additional Properties:** not allowed + +##### traffic\_shaping\.router\.tls\.client\_auth: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||yes| +|**required**|`boolean`, `null`||no| + +**Additional Properties:** not allowed ### traffic\_shaping\.subgraphs: object @@ -3230,6 +3278,30 @@ Optional per-subgraph configurations that will override the default configuratio |**dedupe\_enabled**|`boolean`, `null`|Enables/disables request deduplication to subgraphs.

When requests exactly matches the hashing mechanism (e.g., subgraph name, URL, headers, query, variables), and are executed at the same time, they will
be deduplicated by sharing the response of other in-flight requests.
|| |**pool\_idle\_timeout**|`string`, `null`|Timeout for idle sockets being kept-alive.
|| |**request\_timeout**||Optional timeout configuration for requests to subgraphs.

Example with a fixed duration:
```yaml
timeout:
duration: 5s
```

Or with a VRL expression that can return a duration based on the operation kind:
```yaml
timeout:
expression: \|
if (.request.operation.type == "mutation") {
"10s"
} else {
"15s"
}
```
|| +|[**tls**](#traffic_shapingsubgraphsadditionalpropertiestls)|`object`, `null`||| + +**Additional Properties:** not allowed + +##### traffic\_shaping\.subgraphs\.additionalProperties\.tls: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||| +|[**client\_auth**](#traffic_shapingsubgraphsadditionalpropertiestlsclient_auth)|`object`, `null`||yes| +|**insecure\_skip\_ca\_verification**|`boolean`|Default: `false`
|| + +**Additional Properties:** not allowed + +###### traffic\_shaping\.subgraphs\.additionalProperties\.tls\.client\_auth: object,null + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**cert\_file**|||yes| +|**key\_file**|`string`|Format: `"path"`
|yes| **Additional Properties:** not allowed diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml index ef0e1ea81..be83eccdc 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -17,7 +17,7 @@ sonic-rs = { workspace = true } lazy_static = { workspace = true } jsonwebtoken = { workspace = true } insta = { workspace = true } -reqwest = { workspace = true, features = ["json"]} +reqwest = { workspace = true, features = ["json", "rustls-tls"] } opentelemetry-otlp = { workspace = true, features = ["http-proto"] } opentelemetry-proto = { workspace = true } prost = { workspace = true } @@ -31,6 +31,7 @@ dashmap = { workspace = true } axum = { workspace = true } bytes = { workspace = true } arc-swap = { workspace = true } +rustls = { workspace = true } hive-router = { path = "../bin/router", features = ["testing"] } hive-router-config = { path = "../lib/router-config" } @@ -46,4 +47,5 @@ hex = "0.4" tiny_http = "0.12" futures-util = { workspace = true } bollard = "0.20.0" - +axum-server = { version = "0.8.0", features = ["tls-rustls"] } +rcgen = "0.14.7" diff --git a/e2e/src/error_handling.rs b/e2e/src/error_handling.rs index eff2a6ecf..d609cb477 100644 --- a/e2e/src/error_handling.rs +++ b/e2e/src/error_handling.rs @@ -5,7 +5,7 @@ mod error_handling_e2e_tests { #[ntex::test] async fn should_continue_execution_when_a_subgraph_is_down() { let subgraphs = TestSubgraphs::builder().build().start().await; - let subgraphs_addr = subgraphs.addr(); + let subgraphs_url = subgraphs.url(); let router = TestRouter::builder() // we dont set subgraphs avoiding the port change in the supergrph. @@ -18,9 +18,9 @@ mod error_handling_e2e_tests { path: supergraph.graphql override_subgraph_urls: accounts: - url: "http://{subgraphs_addr}/accounts" + url: "{subgraphs_url}/accounts" reviews: - url: "http://{subgraphs_addr}/reviews" + url: "{subgraphs_url}/reviews" products: url: "http://0.0.0.0:1000/products" "#, diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 5d7695a9f..cbbe6981f 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -55,6 +55,8 @@ mod telemetry; #[cfg(test)] mod timeout_per_subgraph; #[cfg(test)] +mod tls; +#[cfg(test)] mod websocket; pub use insta; diff --git a/e2e/src/override_subgraph_urls.rs b/e2e/src/override_subgraph_urls.rs index 079bda884..7fbabf9d1 100644 --- a/e2e/src/override_subgraph_urls.rs +++ b/e2e/src/override_subgraph_urls.rs @@ -12,7 +12,7 @@ mod override_subgraph_urls_e2e_tests { /// This way we can verify that the override is applied correctly. async fn should_override_subgraph_url_based_on_static_value() { let subgraphs = TestSubgraphs::builder().build().start().await; - let subgraphs_addr = subgraphs.addr(); + let subgraphs_url = subgraphs.url(); let router = TestRouter::builder() .inline_config(format!( @@ -22,7 +22,7 @@ mod override_subgraph_urls_e2e_tests { path: supergraph.graphql override_subgraph_urls: accounts: - url: "http://{subgraphs_addr}/accounts" + url: "{subgraphs_url}/accounts" "#, )) .build() @@ -54,7 +54,7 @@ mod override_subgraph_urls_e2e_tests { /// Without the header, the request goes to 4200 and fail (thanks to `.default`). async fn should_override_subgraph_url_based_on_header_value() { let subgraphs = TestSubgraphs::builder().build().start().await; - let subgraphs_addr = subgraphs.addr(); + let subgraphs_url = subgraphs.url(); let router = TestRouter::builder() .inline_config(format!( @@ -67,7 +67,7 @@ mod override_subgraph_urls_e2e_tests { url: expression: | if .request.headers."x-accounts-port" == "4100" {{ - "http://{subgraphs_addr}/accounts" + "{subgraphs_url}/accounts" }} else {{ .default }} diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index 475ca1721..b802b3adc 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -1,6 +1,7 @@ pub mod docker; pub mod otel; +use axum_server::{tls_rustls::RustlsConfig, Handle}; use bytes::Bytes; use dashmap::DashMap; use hive_router_plan_executor::plugin_trait::RouterPlugin; @@ -24,10 +25,7 @@ use std::{ time::{Duration, Instant}, }; use tempfile::{NamedTempFile, TempPath}; -use tokio::{ - sync::{oneshot, Semaphore}, - time, -}; +use tokio::{sync::Semaphore, time}; use tracing::{info, warn}; use hive_router::{ @@ -97,27 +95,18 @@ pub use some_header_map; /// subgraphs address and returns the modified supergraph. /// /// It will replace all occurrences of `0.0.0.0:4200` with the test subgraphs address. -pub fn supergraph_with_subgraphs( - supergraph: impl Into, - subgraphs: impl Into, -) -> String { - let original: String = supergraph.into(); - let subgraphs_addr = subgraphs.into(); - original.replace("0.0.0.0:4200", subgraphs_addr.to_string().as_str()) +pub fn supergraph_with_subgraphs(supergraph: &str, subgraphs: &str) -> String { + supergraph.replace("http://0.0.0.0:4200", subgraphs) } /// Creates a temporary supergraph file with the content of the given file but with the subgraphs /// address replaced with the test subgraphs address. /// /// The temp file will be automatically deleted when the returned TempPath is dropped. -pub fn supergraph_temp_file_with_subgraphs( - supergraph_file: &str, - subgraphs: impl Into, -) -> TempPath { +pub fn supergraph_temp_file_with_subgraphs(supergraph_file: &str, subgraphs: &str) -> TempPath { let original = std::fs::read_to_string(supergraph_file).expect("failed to read supergraph file"); - let subgraphs_addr = subgraphs.into(); - let with_addr = supergraph_with_subgraphs(original, subgraphs_addr); + let with_addr = supergraph_with_subgraphs(&original, subgraphs); let temp_file = NamedTempFile::with_suffix(".graphql").expect("failed to create temp supergraph file"); @@ -132,7 +121,7 @@ pub fn supergraph_temp_file_with_subgraphs( temp_path .to_str() .expect("failed to convert temp path to string"), - subgraphs_addr + subgraphs.to_string() ); temp_path @@ -259,6 +248,7 @@ type OnRequest = dyn Fn(RequestLike) -> Option + Send + Sync; pub struct TestSubgraphsBuilder { subscriptions_protocol: HTTPStreamingSubscriptionProtocol, on_request: Option>, + rustls_config: Option, delay: Option, } @@ -266,6 +256,7 @@ impl TestSubgraphsBuilder { pub fn new() -> Self { Self { on_request: None, + rustls_config: None, delay: None, subscriptions_protocol: HTTPStreamingSubscriptionProtocol::default(), } @@ -287,6 +278,12 @@ impl TestSubgraphsBuilder { self } + #[allow(unused)] + pub fn with_rustls_config(mut self, rustls_config: RustlsConfig) -> Self { + self.rustls_config = Some(rustls_config); + self + } + /// Adds a cooperative async delay to every subgraph request. /// Unlike `with_on_request` with `std::thread::sleep`, this yields /// back to the tokio runtime, allowing other tasks (like schema @@ -300,6 +297,7 @@ impl TestSubgraphsBuilder { pub fn build(self) -> TestSubgraphs { TestSubgraphs { on_request: self.on_request, + rustls_config: self.rustls_config, delay: self.delay, subscriptions_protocol: self.subscriptions_protocol, handle: None, @@ -315,7 +313,7 @@ impl Default for TestSubgraphsBuilder { } struct TestSubgraphsHandle { - shutdown_tx: Option>, + server_handle: Handle, addr: SocketAddr, state: Arc, } @@ -323,6 +321,7 @@ struct TestSubgraphsHandle { pub struct TestSubgraphs { subscriptions_protocol: HTTPStreamingSubscriptionProtocol, on_request: Option>, + rustls_config: Option, delay: Option, handle: Option, _state: PhantomData, @@ -404,6 +403,7 @@ impl TestSubgraphs { .await .expect("failed to bind tcp listener"); let addr = listener.local_addr().expect("failed to get local address"); + drop(listener); // release the listener; the axum_server will bind to the same addr let mut app = subgraphs_app(self.subscriptions_protocol.clone()); @@ -430,22 +430,38 @@ impl TestSubgraphs { record_requests, )); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let rustls_config_clone = self.rustls_config.clone(); + + let server_handle = Handle::new(); + let server_handle_clone = server_handle.clone(); tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async { - shutdown_rx.await.ok(); - }) - .await - .expect("failed to start subgraphs server"); + if let Some(rustls_config) = self.rustls_config { + axum_server::bind_rustls(addr, rustls_config) + .handle(server_handle_clone.clone()) + .serve(app.into_make_service()) + .await + .expect("failed to start subgraphs server"); + } else { + axum_server::bind(addr) + .handle(server_handle_clone.clone()) + .serve(app.into_make_service()) + .await + .expect("failed to start subgraphs server"); + } }); + let addr = server_handle + .listening() + .await + .expect("failed to get subgraphs server address"); + TestSubgraphs { on_request: self.on_request, + rustls_config: rustls_config_clone, delay: self.delay, subscriptions_protocol: self.subscriptions_protocol, handle: Some(TestSubgraphsHandle { - shutdown_tx: Some(shutdown_tx), + server_handle, addr, state: middleware_state, }), @@ -456,8 +472,14 @@ impl TestSubgraphs { impl TestSubgraphs { #[allow(unused)] - pub fn addr(&self) -> SocketAddr { - self.handle.as_ref().expect("subgraphs not started").addr + pub fn url(&self) -> String { + let addr = self.handle.as_ref().expect("subgraphs not started").addr; + let protocol = if self.rustls_config.is_some() { + "https" + } else { + "http" + }; + format!("{}://{}", protocol, addr) } /// Returns the list of requests received on the given subgraph. Supply the subgarph name. @@ -475,20 +497,14 @@ impl TestSubgraphs { /// subgraphs address and returns the modified supergraph. /// /// It will replace all occurrences of `0.0.0.0:4200` with the test subgraphs address. - pub fn supergraph(&self, supergraph: impl Into) -> String { - supergraph_with_subgraphs(supergraph, self.addr()) - } -} - -impl From<&TestSubgraphs> for SocketAddr { - fn from(subgraphs: &TestSubgraphs) -> Self { - subgraphs.addr() + pub fn supergraph(&self, supergraph: &str) -> String { + supergraph_with_subgraphs(supergraph, &self.url()) } } impl Drop for TestSubgraphsHandle { fn drop(&mut self) { - let _ = self.shutdown_tx.take().map(|tx| tx.send(())); + self.server_handle.graceful_shutdown(None); } } @@ -499,7 +515,7 @@ pub struct TestRouterBuilder { wait_for_ready_on_start: bool, config: Option, plugins: Vec PluginRegistry>>, - subgraphs_addr: Option, + subgraphs_url: Option, port: u16, listener: Option, } @@ -511,7 +527,7 @@ impl TestRouterBuilder { wait_for_ready_on_start: true, config: None, plugins: vec![], - subgraphs_addr: None, + subgraphs_url: None, port: 0, listener: None, } @@ -538,8 +554,12 @@ impl TestRouterBuilder { self } - pub fn with_subgraphs(mut self, subgraphs: impl Into) -> Self { - self.subgraphs_addr = Some(subgraphs.into()); + pub fn with_subgraphs(self, subgraphs: &TestSubgraphs) -> Self { + self.with_subgraphs_url(subgraphs.url()) + } + + pub fn with_subgraphs_url(mut self, subgraphs_url: String) -> Self { + self.subgraphs_url = Some(subgraphs_url); self } @@ -576,14 +596,14 @@ impl TestRouterBuilder { let mut _hold_until_drop: Vec> = vec![]; // change the supergraph to use the test subgraphs address - if let Some(subgraphs_addr) = self.subgraphs_addr { + if let Some(subgraphs_url) = self.subgraphs_url { match &config.supergraph { hive_router_config::supergraph::SupergraphSource::File { path, .. } => { let supergraph_path = path.as_ref().expect("supergraph file path is required"); let temp_path = supergraph_temp_file_with_subgraphs( supergraph_path.absolute.as_str(), - subgraphs_addr, + &subgraphs_url, ); let supergraph_file_path = @@ -715,7 +735,9 @@ impl TestRouter { TestRouterBuilder::new() } - pub async fn start(mut self) -> TestRouter { + // When self-signed certificates are used, the ntex test client doesn't work + // So we can't use it to call the healthcheck endpoints + pub async fn start_without_healthcheck(mut self) -> TestRouter { init_rustls_crypto_provider(); let config = self.config.take().unwrap(); let (telemetry, subscriber) = Telemetry::init_testing_subscriber(&config) @@ -804,7 +826,19 @@ impl TestRouter { let serv_paths = paths.clone(); let serv_prometheus = prometheus.clone(); let long_lived_limit = LongLivedClientLimitService::new(&shared_state.router_config); - let serv = test::server_with(test::config().listener(serv_listener), move || { + let mut serv_config = test::config().listener(serv_listener); + if let Some(tls_config) = serv_shared_state + .router_config + .traffic_shaping + .router + .tls + .as_ref() + { + let rustls_config = hive_router::tls::build_rustls_config(tls_config) + .expect("failed to build rustls config for test router"); + serv_config = serv_config.rustls(rustls_config); + } + let serv = test::server_with(serv_config, move || { let shared_state = serv_shared_state.clone(); let schema_state = serv_schema_state.clone(); let paths = serv_paths.clone(); @@ -842,7 +876,7 @@ impl TestRouter { let mut hold_until_drop = self._hold_until_drop; hold_until_drop.push(Box::new(subscription_guard)); - let started = TestRouter { + TestRouter { port: serv_port, listener: None, wait_for_healthy_on_start: self.wait_for_healthy_on_start, @@ -861,14 +895,18 @@ impl TestRouter { plugins: self.plugins, _hold_until_drop: hold_until_drop, _state: PhantomData, - }; + } + } + + pub async fn start(self) -> TestRouter { + let started = self.start_without_healthcheck().await; - if self.wait_for_healthy_on_start { + if started.wait_for_healthy_on_start { info!("Waiting for healthcheck to pass..."); started.wait_for_healthy(None).await; } - if self.wait_for_ready_on_start { + if started.wait_for_ready_on_start { info!("Waiting for readiness check to pass..."); started.wait_for_ready(None).await; } @@ -985,7 +1023,7 @@ impl TestRouter { ); let ws_url = url.as_str().replace("http://", "ws://"); let ws_uri = ws_url.parse::().expect("Failed to parse ws url"); - websocket_client::connect(&ws_uri) + websocket_client::connect(&ws_uri, None) .await .expect("Failed to connect to websocket") } diff --git a/e2e/src/tls.rs b/e2e/src/tls.rs new file mode 100644 index 000000000..ac35b246f --- /dev/null +++ b/e2e/src/tls.rs @@ -0,0 +1,844 @@ +#[cfg(test)] +mod tls_tests { + use std::{io::Write, sync::Arc}; + + use axum_server::tls_rustls::RustlsConfig; + use hive_router::init_rustls_crypto_provider; + use rcgen::generate_simple_self_signed; + use rustls::{ + pki_types::{pem::PemObject, PrivateKeyDer}, + server::WebPkiClientVerifier, + RootCertStore, ServerConfig, + }; + use sonic_rs::json; + use tempfile::NamedTempFile; + use tonic::transport::CertificateDer; + + use crate::testkit::{some_header_map, ClientResponseExt, Started, TestRouter, TestSubgraphs}; + + struct GeneratedKeyPair { + cert_file: NamedTempFile, + cert_file_path: String, + cert_pem: String, + key_file: NamedTempFile, + key_file_path: String, + key_pem: String, + } + + async fn generate_keypair() -> GeneratedKeyPair { + let cert_key = generate_simple_self_signed(vec![ + "127.0.0.1".to_string(), + "localhost".to_string(), + "0.0.0.0".to_string(), + ]) + .expect("Failed to generate self-signed certificate"); + + let mut cert_file = + NamedTempFile::new().expect("Failed to create temporary file for certificate"); + let cert = cert_key.cert; + let cert_pem = cert.pem(); + cert_file + .write(cert_pem.as_bytes()) + .expect("Failed to write certificate to temporary file"); + + let mut key_file = + NamedTempFile::new().expect("Failed to create temporary file for private key"); + + let key = cert_key.signing_key; + let key_str = key.serialize_pem(); + key_file + .write(key_str.as_bytes()) + .expect("Failed to write private key to temporary file"); + + GeneratedKeyPair { + cert_file_path: cert_file + .path() + .to_str() + .expect("Failed to convert certificate file path to string") + .to_string(), + cert_file, + cert_pem, + key_file_path: key_file + .path() + .to_str() + .expect("Failed to convert private key file path to string") + .to_string(), + key_file, + key_pem: key_str, + } + } + + // Setup TLS on router + // And send a request from a client, that has the router's certificate configured as a trusted root, to the router + // Verify that the request succeeds, indicating that TLS is working correctly on the router + #[ntex::test] + async fn router_tls() { + init_rustls_crypto_provider(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let generated_key_pair = generate_keypair().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + + "#, + generated_key_pair.key_file_path, generated_key_pair.cert_file_path + )) + .build() + .start_without_healthcheck() + .await; + let graphql_endpoint = router.serv().url(router.graphql_path()); + let client = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .build() + .expect("Failed to build reqwest client with custom TLS configuration"); + let resp = client + .post(graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request to router with TLS"); + insta::assert_snapshot!( + resp.text().await.expect("Failed to parse text response from router with TLS") + , @r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#); + } + + async fn generate_tls_subgraph() -> (TestSubgraphs, GeneratedKeyPair) { + let generated_key_pair = generate_keypair().await; + let rustls_config = RustlsConfig::from_pem_file( + &generated_key_pair.cert_file_path, + &generated_key_pair.key_file_path, + ) + .await + .expect("Failed to create RustlsConfig from PEM files"); + let subgraphs = TestSubgraphs::builder() + .with_rustls_config(rustls_config) + .build() + .start() + .await; + (subgraphs, generated_key_pair) + } + + /// Setup TLS on a subgraph + /// Configure the router to trust the subgraph's certificate authority + /// Send a request to the router that requires communication with the TLS-enabled subgraph and verify that the request succeeds, + /// indicating that TLS is working correctly between the router and the subgraph + #[ntex::test] + async fn overriding_cert_auth_for_subgraphs() { + init_rustls_crypto_provider(); + let (subgraphs, generated_key_pair) = generate_tls_subgraph().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + subgraphs: + accounts: + tls: + cert_file: "{}" + "#, + generated_key_pair.cert_file_path + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + insta::assert_snapshot!( + resp.json_body_string_pretty().await + , @r###" + { + "data": { + "me": { + "name": "Uri Goldshtein" + } + } + } + "###); + } + + // Setup two subgraph servers with TLS, each with its own certificate authority + // Configure the router to trust both certificate authorities + // Send a request to the router that requires communication with both TLS-enabled subgraphs and verify + // that the request succeeds, indicating that TLS is working correctly between the router and both subgraphs + #[ntex::test] + async fn overriding_multiple_cert_auth_for_subgraphs() { + init_rustls_crypto_provider(); + let (subgraph1, generated_key_pair1) = generate_tls_subgraph().await; + let (subgraph2, generated_key_pair2) = generate_tls_subgraph().await; + let mut combined_ca_file = + NamedTempFile::new().expect("Failed to create temporary file for certificate"); + let combined_ca_pem = format!( + "{}\n{}", + generated_key_pair1.cert_pem, generated_key_pair2.cert_pem + ); + combined_ca_file + .write(combined_ca_pem.as_bytes()) + .expect("Failed to write combined certificate to temporary file"); + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + tls: + cert_file: "{}" + override_subgraph_urls: + accounts: + url: "{}/accounts" + reviews: + url: "{}/reviews" + "#, + combined_ca_file + .path() + .to_str() + .expect("Expected to have a path for the combined ca file"), + subgraph1.url(), + subgraph2.url() + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request("{ me { name reviews { body } } }", None, None) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + insta::assert_snapshot!( + resp.json_body_string_pretty().await + , @r#" + { + "data": { + "me": { + "name": "Uri Goldshtein", + "reviews": [ + { + "body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + }, + { + "body": "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi" + } + ] + } + } + } + "#); + } + // Setup mTLS on a subgraph + // Configure the router to communicate with the subgraph using mTLS + // Send a request to the router that requires communication with the mTLS-enabled subgraph and + // verify that the request succeeds, indicating that mTLS is working correctly between the router and the subgraph + #[ntex::test] + async fn mtls_subgraph() { + init_rustls_crypto_provider(); + let generated_keypair = generate_keypair().await; + let client_auth_generated_key_pair = generate_keypair().await; + + let mut client_auth_roots = RootCertStore::empty(); + let client_auth_cert: CertificateDer<'static> = + CertificateDer::from_pem_file(client_auth_generated_key_pair.cert_file_path) + .expect("Failed to read certificate from PEM file"); + client_auth_roots + .add(client_auth_cert.clone()) + .expect("Failed to add certificate to root store"); + + let cert = CertificateDer::from_pem_file(generated_keypair.cert_file_path) + .expect("Failed to read certificate from PEM file"); + let key: PrivateKeyDer<'static> = + PrivateKeyDer::from_pem_file(generated_keypair.key_file_path) + .expect("Failed to read private key from PEM file"); + let rustls_config = RustlsConfig::from_config(Arc::new( + ServerConfig::builder() + .with_client_cert_verifier( + WebPkiClientVerifier::builder(client_auth_roots.into()) + .build() + .expect("Failed to build WebPkiClientVerifier for mTLS test"), + ) + .with_single_cert(vec![cert], key) + .unwrap(), + )); + + let subgraphs = TestSubgraphs::builder() + .with_rustls_config(rustls_config) + .build() + .start() + .await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + subgraphs: + accounts: + tls: + cert_file: "{}" + client_auth: + cert_file: "{}" + key_file: "{}" + "#, + generated_keypair + .cert_file + .path() + .to_str() + .expect("Failed to convert key file path to string"), + client_auth_generated_key_pair + .cert_file + .path() + .to_str() + .expect("Failed to convert key file path to string"), + client_auth_generated_key_pair + .key_file + .path() + .to_str() + .expect("Failed to convert key file path to string") + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + insta::assert_snapshot!( + resp.json_body_string_pretty().await + , @r###" + { + "data": { + "me": { + "name": "Uri Goldshtein" + } + } + } + "###); + } + + // Setup mTLS on the router + // And setup an HTTP client with mTLS configured to communicate with the router + // Send a request to the router and verify that it succeeds, indicating that mTLS is + // working correctly on the router + #[ntex::test] + async fn mtls_router() { + init_rustls_crypto_provider(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let generated_key_pair = generate_keypair().await; + let client_auth_generated_key_pair = generate_keypair().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + client_auth: + cert_file: "{}" + "#, + generated_key_pair.key_file_path, + generated_key_pair.cert_file_path, + client_auth_generated_key_pair.cert_file_path + )) + .build() + .start_without_healthcheck() + .await; + let graphql_endpoint = router.serv().url(router.graphql_path()); + + let mut client_auth_buf = Vec::new(); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.cert_pem.as_bytes()); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.key_pem.as_bytes()); + let identity = reqwest::Identity::from_pem(&client_auth_buf) + .expect("Failed to create identity from PEM file for mTLS test"); + + let client = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .identity(identity) + .build() + .expect("Failed to build reqwest client with custom TLS configuration"); + let resp = client + .post(graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request to router with TLS"); + insta::assert_snapshot!( + resp.text().await.expect("Failed to parse text response from router with TLS") + , @r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#); + } + + #[ntex::test] + async fn mtls_router_two_certs() { + init_rustls_crypto_provider(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let generated_key_pair = generate_keypair().await; + let client_auth_generated_key_pair_1 = generate_keypair().await; + let client_auth_generated_key_pair_2 = generate_keypair().await; + let ca_contains_both_pem = format!( + "{}\n{}", + client_auth_generated_key_pair_1.cert_pem, client_auth_generated_key_pair_2.cert_pem + ); + let mut ca_file = + NamedTempFile::new().expect("Failed to create temporary file for certificate"); + ca_file + .write(ca_contains_both_pem.as_bytes()) + .expect("Failed to write combined certificate to temporary file"); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + client_auth: + cert_file: "{}" + "#, + generated_key_pair.key_file_path, + generated_key_pair.cert_file_path, + ca_file + .path() + .to_str() + .expect("Expected to have a path for the combined ca file") + )) + .build() + .start_without_healthcheck() + .await; + let graphql_endpoint = router.serv().url(router.graphql_path()); + + async fn test_with_client_auth_pair( + graphql_endpoint: &str, + generated_key_pair: &GeneratedKeyPair, + client_auth_generated_key_pair: &GeneratedKeyPair, + ) { + let mut client_auth_buf = Vec::new(); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.cert_pem.as_bytes()); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.key_pem.as_bytes()); + let identity = reqwest::Identity::from_pem(&client_auth_buf) + .expect("Failed to create identity from PEM file for mTLS test"); + + let client = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .identity(identity) + .build() + .expect("Failed to build reqwest client with custom TLS configuration"); + let resp = client + .post(graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request to router with TLS"); + + assert_eq!(resp.status(), 200, "Expected 200 OK from router with mTLS"); + assert_eq!( + resp.text() + .await + .expect("Failed to parse text response from router with TLS"), + r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#, + "Unexpected response body from router with mTLS" + ); + } + + test_with_client_auth_pair( + &graphql_endpoint, + &generated_key_pair, + &client_auth_generated_key_pair_1, + ) + .await; + + test_with_client_auth_pair( + &graphql_endpoint, + &generated_key_pair, + &client_auth_generated_key_pair_2, + ) + .await; + } + + /// Setup TLS on a subgraph, configure the router to trust the subgraph's certificate, + /// and verify that SSE subscriptions work correctly over the TLS connection. + #[ntex::test] + async fn sse_subscription_over_tls_subgraph() { + init_rustls_crypto_provider(); + let (subgraphs, generated_key_pair) = generate_tls_subgraph().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + traffic_shaping: + all: + tls: + cert_file: "{}" + "#, + generated_key_pair.cert_file_path + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + ntex::http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + let body = resp.string_body().await; + assert!( + body.contains( + r#"data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}"# + ), + "Expected at least one emitted event, got: {}", + body + ); + assert!(body.contains("event: complete")); + } + + /// Setup TLS on a subgraph, configure the router to trust the subgraph's certificate, + /// and verify that WebSocket subscriptions work correctly over the TLS (wss://) connection. + #[ntex::test] + async fn websocket_subscription_over_tls_subgraph() { + init_rustls_crypto_provider(); + let (subgraphs, generated_key_pair) = generate_tls_subgraph().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + websocket: + subgraphs: + reviews: + path: /reviews/ws + traffic_shaping: + all: + tls: + cert_file: "{}" + "#, + generated_key_pair.cert_file_path + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + ntex::http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + let body = resp.string_body().await; + assert!( + body.contains( + r#"data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}"# + ), + "Expected at least one emitted event, got: {}", + body + ); + assert!(body.contains("event: complete")); + } + + /// Setup mTLS on a subgraph and verify that WebSocket subscriptions work correctly + /// when the router authenticates itself to the subgraph via client certificate. + #[ntex::test] + async fn websocket_subscription_over_mtls_subgraph() { + init_rustls_crypto_provider(); + let generated_keypair = generate_keypair().await; + let client_auth_generated_key_pair = generate_keypair().await; + + let mut client_auth_roots = RootCertStore::empty(); + let client_auth_cert: CertificateDer<'static> = + CertificateDer::from_pem_file(&client_auth_generated_key_pair.cert_file_path) + .expect("Failed to read certificate from PEM file"); + client_auth_roots + .add(client_auth_cert.clone()) + .expect("Failed to add certificate to root store"); + + let cert = CertificateDer::from_pem_file(&generated_keypair.cert_file_path) + .expect("Failed to read certificate from PEM file"); + let key: PrivateKeyDer<'static> = + PrivateKeyDer::from_pem_file(&generated_keypair.key_file_path) + .expect("Failed to read private key from PEM file"); + let rustls_config = RustlsConfig::from_config(Arc::new( + ServerConfig::builder() + .with_client_cert_verifier( + WebPkiClientVerifier::builder(client_auth_roots.into()) + .build() + .expect("Failed to build WebPkiClientVerifier for mTLS test"), + ) + .with_single_cert(vec![cert], key) + .unwrap(), + )); + + let subgraphs = TestSubgraphs::builder() + .with_rustls_config(rustls_config) + .build() + .start() + .await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + subscriptions: + enabled: true + websocket: + subgraphs: + reviews: + path: /reviews/ws + traffic_shaping: + all: + tls: + cert_file: "{}" + client_auth: + cert_file: "{}" + key_file: "{}" + "#, + generated_keypair + .cert_file + .path() + .to_str() + .expect("Failed to convert cert file path to string"), + client_auth_generated_key_pair + .cert_file + .path() + .to_str() + .expect("Failed to convert cert file path to string"), + client_auth_generated_key_pair + .key_file + .path() + .to_str() + .expect("Failed to convert key file path to string") + )) + .build() + .start() + .await; + let resp = router + .send_graphql_request( + r#" + subscription { + reviewAdded(intervalInMs: 0) { + id + product { + name + } + } + } + "#, + None, + some_header_map!( + ntex::http::header::ACCEPT => "text/event-stream" + ), + ) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + let body = resp.string_body().await; + assert!( + body.contains( + r#"data: {"data":{"reviewAdded":{"id":"1","product":{"name":"Table"}}}}"# + ), + "Expected at least one emitted event, got: {}", + body + ); + assert!(body.contains("event: complete")); + } + + /// Setup TLS on a subgraph with a self-signed certificate, and verify that when + /// `insecure_skip_ca_verification` is enabled, the router successfully connects + /// without needing to trust the subgraph's CA. + #[ntex::test] + async fn insecure_skip_ca_verification() { + init_rustls_crypto_provider(); + let (subgraphs, _generated_key_pair) = generate_tls_subgraph().await; + // Note: we do NOT configure cert_file here — normally this would fail + // because the subgraph has a self-signed cert. + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + tls: + insecure_skip_ca_verification: true + "# + .to_string(), + ) + .build() + .start() + .await; + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success(), "Expected 200 OK"); + insta::assert_snapshot!( + resp.json_body_string_pretty().await + , @r###" + { + "data": { + "me": { + "name": "Uri Goldshtein" + } + } + } + "###); + } + + /// Setup mTLS on the router with `required: false` in client_auth config. + /// Verify that clients WITHOUT a certificate can still connect successfully, + /// and clients WITH a valid certificate also connect successfully. + #[ntex::test] + async fn optional_mtls_router() { + init_rustls_crypto_provider(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let generated_key_pair = generate_keypair().await; + let client_auth_generated_key_pair = generate_keypair().await; + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + client_auth: + cert_file: "{}" + required: false + "#, + generated_key_pair.key_file_path, + generated_key_pair.cert_file_path, + client_auth_generated_key_pair.cert_file_path + )) + .build() + .start_without_healthcheck() + .await; + let graphql_endpoint = router.serv().url(router.graphql_path()); + + // Test 1: Client WITHOUT a certificate should succeed + let client_no_cert = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .build() + .expect("Failed to build reqwest client without client cert"); + let resp = client_no_cert + .post(&graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request without client cert"); + insta::assert_snapshot!( + resp.text().await.expect("Failed to parse response") + , @r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#); + + // Test 2: Client WITH a valid certificate should also succeed + let mut client_auth_buf = Vec::new(); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.cert_pem.as_bytes()); + client_auth_buf.extend_from_slice(client_auth_generated_key_pair.key_pem.as_bytes()); + let identity = reqwest::Identity::from_pem(&client_auth_buf) + .expect("Failed to create identity from PEM"); + let client_with_cert = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .identity(identity) + .build() + .expect("Failed to build reqwest client with client cert"); + let resp = client_with_cert + .post(&graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request with client cert"); + insta::assert_snapshot!( + resp.text().await.expect("Failed to parse response") + , @r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#); + } +} diff --git a/lib/executor/src/executors/error.rs b/lib/executor/src/executors/error.rs index c1f7cc46b..148931ac9 100644 --- a/lib/executor/src/executors/error.rs +++ b/lib/executor/src/executors/error.rs @@ -1,4 +1,5 @@ use http::{uri::InvalidUri, StatusCode}; +use rustls::server::VerifierBuilderError; use strum::IntoStaticStr; #[derive(thiserror::Error, Debug, IntoStaticStr)] @@ -53,9 +54,9 @@ pub enum SubgraphExecutorError { #[error("Failed to deserialize subgraph response: {0}")] #[strum(serialize = "SUBGRAPH_RESPONSE_DESERIALIZATION_FAILURE")] ResponseDeserializationFailure(sonic_rs::Error), - #[error("Failed to initialize or load native TLS root certificates: {0}")] + #[error(transparent)] #[strum(serialize = "SUBGRAPH_HTTPS_CERTS_FAILURE")] - NativeTlsCertificatesError(std::io::Error), + TlsCertificatesError(#[from] TlsCertificatesError), #[error("Unsupported content-type '{0}': expected 'multipart/mixed' or 'text/event-stream' for HTTP subscriptions")] #[strum(serialize = "SUBGRAPH_SUBSCRIPTION_UNSUPPORTED_CONTENT_TYPE")] UnsupportedContentTypeError(String), @@ -99,3 +100,17 @@ impl SubgraphExecutorError { self.into() } } + +#[derive(thiserror::Error, Debug, IntoStaticStr)] +pub enum TlsCertificatesError { + #[error("Failed to initialize or load native TLS root certificates: {0}")] + NativeTlsCertificatesError(std::io::Error), + #[error("Failed to load custom TLS certificate {0}: {1}")] + CustomTlsCertificatesError(&'static str, rustls::pki_types::pem::Error), + #[error("Unexpected invalid certificates: {0}")] + InvalidTlsCertificates(String), + #[error("Failed to build TLS client configuration: {0}")] + TlsConfigFailure(#[from] rustls::Error), + #[error("Failed to build TLS verifier: {0}")] + TlsVerifierFailure(#[from] VerifierBuilderError), +} diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index 56eef932f..b015eb29f 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -17,9 +17,8 @@ use hive_router_internal::{ telemetry::TelemetryContext, }; use http::Uri; -use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; use hyper_util::{ - client::legacy::{connect::HttpConnector, Client}, + client::legacy::Client, rt::{TokioExecutor, TokioTimer}, }; use tokio::sync::Semaphore; @@ -31,6 +30,7 @@ use crate::{ error::SubgraphExecutorError, http::{HTTPSubgraphExecutor, HttpClient, SubgraphHttpResponse}, http_callback::{CallbackSubscriptionsMap, HttpCallbackSubgraphExecutor}, + tls::{build_https_client_config, build_https_connector, get_merged_tls_config}, websocket::WsSubgraphExecutor, }, hooks::on_subgraph_execute::{ @@ -77,14 +77,6 @@ pub struct SubgraphExecutorMap { /// Shared map of active HTTP callback subscriptions callback_subscriptions: CallbackSubscriptionsMap, } - -fn build_https_executor() -> Result, SubgraphExecutorError> { - HttpsConnectorBuilder::new() - .with_native_roots() - .map_err(SubgraphExecutorError::NativeTlsCertificatesError) - .map(|b| b.https_or_http().enable_http1().enable_http2().build()) -} - impl SubgraphExecutorMap { pub fn new( config: Arc, @@ -95,7 +87,9 @@ impl SubgraphExecutorMap { .pool_timer(TokioTimer::new()) .pool_idle_timeout(config.traffic_shaping.all.pool_idle_timeout) .pool_max_idle_per_host(config.traffic_shaping.max_connections_per_host) - .build(build_https_executor()?); + .build(build_https_connector( + config.traffic_shaping.all.tls.as_ref(), + )?); let max_connections_per_host = config.traffic_shaping.max_connections_per_host; @@ -474,10 +468,25 @@ impl SubgraphExecutorMap { ) })?; + // Resolve TLS config for the subgraph (merging global + per-subgraph) + let tls_config = get_merged_tls_config( + self.config.traffic_shaping.all.tls.as_ref(), + self.config + .traffic_shaping + .subgraphs + .get(subgraph_name) + .and_then(|s| s.tls.as_ref()), + ); + let ws_tls_config = match tls_config.as_ref() { + Some(tls) => Some(Arc::new(build_https_client_config(Some(tls))?)), + None => None, + }; + let ws_executor = WsSubgraphExecutor::new( subgraph_name.to_string(), // we use the new constructed ws_endpoint_uri here ws_endpoint_uri, + ws_tls_config, ) .to_boxed_arc(); @@ -499,10 +508,12 @@ impl SubgraphExecutorMap { let heartbeat_interval_ms = callback_config.heartbeat_interval.as_millis() as u64; + let subgraph_config = self.resolve_subgraph_config(subgraph_name)?; + let callback_executor = HttpCallbackSubgraphExecutor::new( subgraph_name.to_string(), endpoint_uri, - self.client.clone(), + subgraph_config.client, callback_config.public_url.to_string(), heartbeat_interval_ms, self.callback_subscriptions.clone(), @@ -535,18 +546,24 @@ impl SubgraphExecutorMap { return Ok(config); }; - // Override client only if pool idle timeout is customized - if let Some(pool_idle_timeout) = subgraph_config.pool_idle_timeout { - // Only override if it's different from the global setting - if pool_idle_timeout != self.config.traffic_shaping.all.pool_idle_timeout { - config.client = Arc::new( - Client::builder(TokioExecutor::new()) - .pool_timer(TokioTimer::new()) - .pool_idle_timeout(pool_idle_timeout) - .pool_max_idle_per_host(self.max_connections_per_host) - .build(build_https_executor()?), - ); - } + let pool_idle_timeout = subgraph_config + .pool_idle_timeout + .unwrap_or(self.config.traffic_shaping.all.pool_idle_timeout); + // Override client only if pool idle timeout is customized or TLS config is provided + if pool_idle_timeout != self.config.traffic_shaping.all.pool_idle_timeout + || subgraph_config.tls.is_some() + { + let tls_config = get_merged_tls_config( + self.config.traffic_shaping.all.tls.as_ref(), + subgraph_config.tls.as_ref(), + ); + config.client = Arc::new( + Client::builder(TokioExecutor::new()) + .pool_timer(TokioTimer::new()) + .pool_idle_timeout(pool_idle_timeout) + .pool_max_idle_per_host(self.max_connections_per_host) + .build(build_https_connector(tls_config.as_ref())?), + ); } // Apply other subgraph-specific overrides diff --git a/lib/executor/src/executors/mod.rs b/lib/executor/src/executors/mod.rs index 283e331e3..e7d9df22f 100644 --- a/lib/executor/src/executors/mod.rs +++ b/lib/executor/src/executors/mod.rs @@ -7,6 +7,7 @@ pub mod http_callback; pub mod map; pub mod multipart_subscribe; pub mod sse; +pub mod tls; pub mod websocket; pub mod websocket_client; pub mod websocket_common; diff --git a/lib/executor/src/executors/tls.rs b/lib/executor/src/executors/tls.rs new file mode 100644 index 000000000..7d8a4e090 --- /dev/null +++ b/lib/executor/src/executors/tls.rs @@ -0,0 +1,188 @@ +use std::sync::Arc; + +use hive_router_config::{ + primitives::{file_path::FilePath, single_or_multiple::SingleOrMultiple}, + traffic_shaping::ClientTLSConfig, +}; +use hyper_rustls::{ConfigBuilderExt, HttpsConnector, HttpsConnectorBuilder}; +use hyper_util::client::legacy::connect::HttpConnector; +use rustls::{ + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer, ServerName, UnixTime}, + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, +}; + +use crate::executors::error::{SubgraphExecutorError, TlsCertificatesError}; + +pub fn from_cert_file_config_to_certificate_der<'a>( + cert_file_path: &SingleOrMultiple, +) -> Result>, TlsCertificatesError> { + match cert_file_path { + SingleOrMultiple::Single(cert_file_path) => { + CertificateDer::pem_file_iter(&cert_file_path.absolute) + .and_then(|res| res.collect::, _>>()) + .map_err(|err| TlsCertificatesError::CustomTlsCertificatesError("cert_file", err)) + } + SingleOrMultiple::Multiple(file_paths) => file_paths + .iter() + .map(|file_path| { + CertificateDer::pem_file_iter(&file_path.absolute) + .and_then(|res| res.collect::, _>>()) + .map_err(|err| { + TlsCertificatesError::CustomTlsCertificatesError("cert_file", err) + }) + }) + .try_fold(Vec::new(), |mut acc, certs_result| { + certs_result.map(|mut certs| { + acc.append(&mut certs); + acc + }) + }), + } +} + +/// A certificate verifier that accepts any server certificate without validation. +/// Only for use in development/testing environments. +#[derive(Debug)] +struct NoVerifier; + +impl ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} + +pub fn build_https_client_config( + tls_config: Option<&ClientTLSConfig>, +) -> Result { + let insecure_skip = tls_config + .map(|c| c.insecure_skip_ca_verification) + .unwrap_or(false); + + let tls_config_for_rustls = if insecure_skip { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + } else if let Some(cert_file_path) = tls_config.and_then(|c| c.cert_file.as_ref()) { + // Read trust roots + let certs = from_cert_file_config_to_certificate_der(cert_file_path)?; + + if certs.is_empty() { + return Err(TlsCertificatesError::InvalidTlsCertificates(format!( + "No valid certificates found in {:#?}", + cert_file_path + ))); + } + + let certs_len = certs.len(); + let mut roots = RootCertStore::empty(); + let (valid, _) = roots.add_parsable_certificates(certs); + if valid != certs_len { + return Err(TlsCertificatesError::InvalidTlsCertificates(format!( + "Expected {} certificates in {:#?}, but only {} were valid", + certs_len, cert_file_path, valid + ))); + } + // TLS client config using the custom CA store for lookups + ClientConfig::builder().with_root_certificates(roots) + } else { + ClientConfig::builder() + .with_native_roots() + .map_err(TlsCertificatesError::NativeTlsCertificatesError)? + }; + let client_config = if let Some(client_auth) = tls_config.and_then(|c| c.client_auth.as_ref()) { + let certs = from_cert_file_config_to_certificate_der(&client_auth.cert_file)?; + + let private_key = + PrivateKeyDer::from_pem_file(&client_auth.key_file.absolute).map_err(|err| { + TlsCertificatesError::CustomTlsCertificatesError("client_auth.key", err) + })?; + + tls_config_for_rustls + .with_client_auth_cert(certs, private_key) + .map_err(TlsCertificatesError::TlsConfigFailure)? + } else { + tls_config_for_rustls.with_no_client_auth() + }; + + Ok(client_config) +} + +pub fn build_https_connector( + tls_config: Option<&ClientTLSConfig>, +) -> Result, SubgraphExecutorError> { + Ok(HttpsConnectorBuilder::new() + .with_tls_config(build_https_client_config(tls_config)?) + .https_or_http() + .enable_all_versions() + .build()) +} + +pub fn get_merged_tls_config( + global: Option<&ClientTLSConfig>, + subgraph: Option<&ClientTLSConfig>, +) -> Option { + match (global, subgraph) { + (Some(global), Some(subgraph)) => { + // If both global and subgraph TLS configs are provided, we merge them by giving precedence to subgraph config values. + // If the subgraph config has a field set to None, we fall back to the global config for that field. + let merged = ClientTLSConfig { + cert_file: subgraph + .cert_file + .clone() + .or_else(|| global.cert_file.clone()), + client_auth: subgraph + .client_auth + .clone() + .or_else(|| global.client_auth.clone()), + insecure_skip_ca_verification: subgraph.insecure_skip_ca_verification + || global.insecure_skip_ca_verification, + }; + Some(merged) + } + (None, Some(subgraph)) => Some(subgraph.clone()), + (Some(global), None) => Some(global.clone()), + (None, None) => None, + } +} diff --git a/lib/executor/src/executors/websocket.rs b/lib/executor/src/executors/websocket.rs index f7fcef09b..f9754839d 100644 --- a/lib/executor/src/executors/websocket.rs +++ b/lib/executor/src/executors/websocket.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; @@ -17,13 +18,19 @@ use crate::response::subgraph_response::SubgraphResponse; pub struct WsSubgraphExecutor { subgraph_name: String, endpoint: http::Uri, + tls_config: Option>, } impl WsSubgraphExecutor { - pub fn new(subgraph_name: String, endpoint: http::Uri) -> Self { + pub fn new( + subgraph_name: String, + endpoint: http::Uri, + tls_config: Option>, + ) -> Self { Self { subgraph_name, endpoint, + tls_config, } } } @@ -42,6 +49,7 @@ impl SubgraphExecutor for WsSubgraphExecutor { ) -> Result, SubgraphExecutorError> { let endpoint = self.endpoint.clone(); let subgraph_name = self.subgraph_name.clone(); + let tls_config = self.tls_config.clone(); debug!( "establishing WebSocket connection to subgraph {} at {}", subgraph_name, endpoint @@ -60,7 +68,7 @@ impl SubgraphExecutor for WsSubgraphExecutor { // or earlier if connect/init fails. rt::spawn(async move { let result = async { - let connection = match connect(&endpoint).await { + let connection = match connect(&endpoint, tls_config).await { Ok(conn) => conn, Err(e) => { return Err(SubgraphExecutorError::WebSocketConnectFailure( @@ -120,6 +128,7 @@ impl SubgraphExecutor for WsSubgraphExecutor { let endpoint = self.endpoint.clone(); let subgraph_name = self.subgraph_name.clone(); + let tls_config = self.tls_config.clone(); let (subscribe_payload, init_payload) = build_subscribe_payload(execution_request); @@ -134,7 +143,7 @@ impl SubgraphExecutor for WsSubgraphExecutor { // this task ends when the websocket stream completes, the client drops the receiver, // or back-pressure fills the channel and we terminate the subscription. drop(rt::spawn(async move { - let connection = match connect(&endpoint).await { + let connection = match connect(&endpoint, tls_config).await { Ok(conn) => conn, Err(e) => { let _ = tx.try_send(Err(SubgraphExecutorError::WebSocketConnectFailure( diff --git a/lib/executor/src/executors/websocket_client.rs b/lib/executor/src/executors/websocket_client.rs index d17b376d3..c7dad70d6 100644 --- a/lib/executor/src/executors/websocket_client.rs +++ b/lib/executor/src/executors/websocket_client.rs @@ -86,17 +86,23 @@ impl From for SubgraphResponse<'static> { } } -pub async fn connect(uri: &http::Uri) -> Result, WsConnectError> { +pub async fn connect( + uri: &http::Uri, + custom_tls_config: Option>, +) -> Result, WsConnectError> { let scheme = uri .scheme_str() .ok_or_else(|| WsConnectError::MissingUriSchema(uri.to_string()))?; if scheme == "wss" { - let tls_config = Arc::new( - rustls::ClientConfig::builder() - .with_native_roots() - .map_err(|e| WsConnectError::NativeTlsCertificatesError(e.to_string()))? - .with_no_client_auth(), - ); + let tls_config = match custom_tls_config { + Some(config) => config, + None => Arc::new( + rustls::ClientConfig::builder() + .with_native_roots() + .map_err(|e| WsConnectError::NativeTlsCertificatesError(e.to_string()))? + .with_no_client_auth(), + ), + }; let ws_client = NtexWsClient::builder(uri) .protocols([WS_SUBPROTOCOL]) diff --git a/lib/router-config/src/traffic_shaping.rs b/lib/router-config/src/traffic_shaping.rs index 7dc22492d..348766d98 100644 --- a/lib/router-config/src/traffic_shaping.rs +++ b/lib/router-config/src/traffic_shaping.rs @@ -3,7 +3,9 @@ use std::{collections::HashMap, time::Duration}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::primitives::http_header::HttpHeaderName; +use crate::primitives::{ + file_path::FilePath, http_header::HttpHeaderName, single_or_multiple::SingleOrMultiple, +}; #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] @@ -88,6 +90,9 @@ pub struct TrafficShapingExecutorSubgraphConfig { /// } /// ``` pub request_timeout: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] @@ -129,6 +134,9 @@ pub struct TrafficShapingExecutorGlobalConfig { /// ``` #[serde(default = "default_request_timeout")] pub request_timeout: DurationOrExpression, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, } fn default_subgraph_pool_idle_timeout() -> Option { @@ -159,6 +167,7 @@ impl Default for TrafficShapingExecutorGlobalConfig { pool_idle_timeout: default_pool_idle_timeout(), dedupe_enabled: default_dedupe_enabled(), request_timeout: default_request_timeout(), + tls: None, } } } @@ -181,6 +190,9 @@ pub struct TrafficShapingRouterConfig { #[schemars(with = "String")] pub request_timeout: Duration, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, + /// Maximum number of concurrent long-lived clients (WebSocket connections and HTTP streaming responses). /// Regular non-streaming requests are not counted toward this limit. /// When the limit is reached, new WebSocket and streaming HTTP requests are rejected with 503. @@ -285,7 +297,40 @@ impl Default for TrafficShapingRouterConfig { Self { dedupe: Default::default(), request_timeout: default_router_request_timeout(), + tls: None, max_long_lived_clients: default_max_long_lived_clients(), } } } + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct ServerTLSConfig { + pub cert_file: SingleOrMultiple, + pub key_file: FilePath, + pub client_auth: Option, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct ServerClientAuthConfig { + pub cert_file: SingleOrMultiple, + #[serde(default)] + pub required: Option, +} + +#[derive(Default, Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct ClientTLSConfig { + pub cert_file: Option>, + pub client_auth: Option, + #[serde(default)] + pub insecure_skip_ca_verification: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct ClientAuthConfig { + pub cert_file: SingleOrMultiple, + pub key_file: FilePath, +} diff --git a/plugin_examples/error_mapping/src/test.rs b/plugin_examples/error_mapping/src/test.rs index c4e8f034a..99a89cbd5 100644 --- a/plugin_examples/error_mapping/src/test.rs +++ b/plugin_examples/error_mapping/src/test.rs @@ -9,7 +9,7 @@ mod tests { let mut subgraphs = mockito::Server::new_async().await; let router = TestRouter::builder() - .with_subgraphs(subgraphs.socket_address()) + .with_subgraphs_url(format!("http://{}", subgraphs.socket_address())) .file_config("../plugin_examples/error_mapping/router.config.yaml") .register_plugin::() .build() From 79e94dee7d6a929b113368cece56d10b0bf49b9f Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Sun, 19 Apr 2026 16:26:00 +0300 Subject: [PATCH 50/76] fix(changes): indent level Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/tls_support.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/tls_support.md b/.changeset/tls_support.md index 117efdff5..e1090952c 100644 --- a/.changeset/tls_support.md +++ b/.changeset/tls_support.md @@ -8,23 +8,23 @@ hive-router: minor Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. -## TLS Directions +### TLS Directions TLS Support has implementations for the following 4 directions: -### Router -> Client - Regular TLS +#### Router -> Client - Regular TLS Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` -### Client -> Router - mTLS +#### Client -> Router - mTLS Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` -### Subgraph -> Router - Regular TLS +#### Subgraph -> Router - Regular TLS Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. -### Router -> Subgraph - mTLS +#### Router -> Subgraph - mTLS Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. -## TLS Directions Diagram +### TLS Directions Diagram ```mermaid flowchart LR @@ -49,7 +49,7 @@ flowchart LR Subgraph -. "validates router identity\n(cert_file)" .-> Router ``` -## Configuration Structure +### Configuration Structure ```yaml traffic_shaping: router: @@ -68,4 +68,4 @@ traffic_shaping: client_auth: # mTLS: Router -> Subgraph cert_file: # Router client certificate(s) key_file: # Router client private key -``` \ No newline at end of file +``` From 4005fad94df33eacfd9361ba69ce51f1215f2132 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Sun, 19 Apr 2026 16:26:54 +0300 Subject: [PATCH 51/76] fix(changeset): fix title Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/expose-early-http-response.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/expose-early-http-response.md b/.changeset/expose-early-http-response.md index 8e1f2b39c..ce4b09443 100644 --- a/.changeset/expose-early-http-response.md +++ b/.changeset/expose-early-http-response.md @@ -3,6 +3,8 @@ hive-router-plan-executor: patch hive-router: patch --- +# Plugin System API improvements + Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. ```rust @@ -13,4 +15,4 @@ payload.end_with_response( status_code, } ); -``` \ No newline at end of file +``` From 152fc7b1092b2146c0cf339177f8defaa8e01913 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 11:02:38 +0300 Subject: [PATCH 52/76] fix(ci): fixes for apollo-router-fork build (#920) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- ...e.yaml => apollo-router-fork-release.yaml} | 100 +++++++----------- .github/workflows/apollo-router-updater.yaml | 41 +++---- .../apollo-router-updater/package-lock.json | 94 ---------------- .../apollo-router-updater/package.json | 12 --- .../apollo-router-updater/src/index.ts | 94 ---------------- .../apollo-router-updater/tsconfig.json | 27 ----- 6 files changed, 59 insertions(+), 309 deletions(-) rename .github/workflows/{apollo-router-release.yaml => apollo-router-fork-release.yaml} (81%) delete mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json delete mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json delete mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts delete mode 100644 apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json diff --git a/.github/workflows/apollo-router-release.yaml b/.github/workflows/apollo-router-fork-release.yaml similarity index 81% rename from .github/workflows/apollo-router-release.yaml rename to .github/workflows/apollo-router-fork-release.yaml index 2c0041b2d..4d78eeae3 100644 --- a/.github/workflows/apollo-router-release.yaml +++ b/.github/workflows/apollo-router-fork-release.yaml @@ -1,4 +1,4 @@ -name: apollo-router plugin +name: apollo-router-fork on: # For PRs, this pipeline will use the commit ID as Docker image tag and R2 artifact prefix. pull_request: @@ -6,11 +6,15 @@ on: - main paths: - "apollo-router-workspace/**" + - ".github/workflows/apollo-router-release.yaml" + - ".github/workflows/apollo-router-updater.yaml" # For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and # publish them only if changes are found and image does not exists in GH Packages. push: paths: - "apollo-router-workspace/**" + - ".github/workflows/apollo-router-release.yaml" + - ".github/workflows/apollo-router-updater.yaml" branches: - main @@ -28,7 +32,7 @@ jobs: # 2. Check if there are changes in the Cargo.toml file in the current commit # 3. If there are changes, check if the image tag exists in the GitHub Container Registry find-changes: - name: find changes for apollo-router release + name: evaluate changes runs-on: ubuntu-22.04 if: ${{ !github.event.pull_request.head.repo.fork }} outputs: @@ -36,7 +40,7 @@ jobs: release_version: ${{ steps.find_changes.outputs.release_version }} release_latest: ${{ steps.find_changes.outputs.release_latest }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 @@ -61,30 +65,41 @@ jobs: plugin_version=$(grep '^version' Cargo.toml | sed 's/version *= *"\(.*\)"/\1/') has_changes=$(git diff HEAD~ HEAD --name-only -- 'Cargo.toml' 'Cargo.lock') + echo "router_version: $router_version" + echo "plugin_version: $plugin_version" + echo "has_changes: $has_changes" + if [ "$has_changes" ]; then image_tag_version="router${router_version}-plugin${plugin_version}" + echo "found changes, image_tag_version: $image_tag_version" + response=$(curl -L \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ -s \ https://api.github.com/orgs/${github_org}/packages/container/${image_name}/versions) + + echo "tag check response: $response" tag_exists=$(echo "$response" | jq -r ".[] | .metadata.container.tags[] | select(. | contains(\"${image_tag_version}\"))") + echo "tag_exists: $tag_exists" if [ ! "$tag_exists" ]; then - echo "Found changes in version $version_to_publish" + echo "Found changes in version $version_to_publish , and tag does not existing" echo "release_version=$image_tag_version" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT echo "release_latest=true" >> $GITHUB_OUTPUT else - echo "No changes found in version $image_tag_version" + echo "Tag $image_tag_version already exists, skipping release" + echo "should_release=false" >> $GITHUB_OUTPUT + echo "release_latest=false" >> $GITHUB_OUTPUT fi fi # Builds Rust crates, and creates Docker images dockerize: - name: image build and dockerize apollo-router (${{ matrix.platform }}) + name: dockerize (${{ matrix.platform }}) needs: find-changes if: ${{ needs.find-changes.outputs.should_release == 'true' }} strategy: @@ -107,7 +122,7 @@ jobs: pull-requests: write steps: - name: checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 @@ -122,20 +137,6 @@ jobs: target: ${{ matrix.rust_target }} rust-src-dir: ./apollo-router-workspace - - name: Cache Rust - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - - - name: Cache target - uses: actions/cache@v3 - with: - path: apollo-router-workspace/target - key: apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} - apollo-router-target-${{ matrix.cache_key }}-build- - apollo-router-target-${{ matrix.cache_key }}- - apollo-router-target- - - name: Build run: cargo build --release --target ${{ matrix.rust_target }} @@ -213,7 +214,7 @@ jobs: **Image Tag**: `${{ needs.find-changes.outputs.release_version }}` # Test the Docker image, if it was published - test-image: + test-docker-image: name: test apollo-router docker image needs: - find-changes @@ -223,7 +224,7 @@ jobs: HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }} steps: - name: checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 - name: Run Docker image @@ -257,29 +258,32 @@ jobs: # Wait for the container to be ready echo "Waiting for the container to be ready..." sleep 20 - HTTP_RESPONSE=$(curl --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --silent --output /dev/null "http://127.0.0.1:8088/health") + HTTP_RESPONSE=$(curl -v --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --output /dev/null "http://127.0.0.1:8088/health") # Check if the HTTP response code is 200 (OK) if [ $HTTP_RESPONSE -eq 200 ]; then echo "Health check successful." - docker stop apollo_router_test - docker rm apollo_router_test exit 0 else echo "Health check failed with HTTP status code $HTTP_RESPONSE." - docker stop apollo_router_test - docker rm apollo_router_test exit 1 fi + - name: troubleshoot container + if: ${{ failure() }} + run: | + docker --version + docker ps --format json | jq . + docker logs apollo_router_test + # Build and publish Rust crates and binaries test-rust: - name: test apollo-router plugin + name: test needs: find-changes runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 @@ -293,25 +297,12 @@ jobs: with: rust-src-dir: ./apollo-router-workspace - - name: Cache Rust - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - - - name: Cache target - uses: actions/cache@v3 - with: - path: apollo-router-workspace/target - key: apollo-router-target-${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - apollo-router-target-${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} - apollo-router-target-${{ runner.os }}-test- - apollo-router-target-${{ runner.os }}- - apollo-router-target- - - name: Run tests run: cargo test - publish-rust: + publish-binary: needs: find-changes + if: ${{ needs.find-changes.outputs.should_release == 'true' }} strategy: fail-fast: false matrix: @@ -331,13 +322,12 @@ jobs: target: x86_64-apple-darwin binary_name: macos cache_key: macOS - name: publish apollo-router binary (${{ matrix.cache_key }}) + name: binary (${{ matrix.target }}) runs-on: ${{ matrix.os }} timeout-minutes: 60 - steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 @@ -352,20 +342,6 @@ jobs: target: ${{ matrix.target }} rust-src-dir: ./apollo-router-workspace - - name: Cache Rust - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 - - - name: Cache target - uses: actions/cache@v3 - with: - path: apollo-router-workspace/target - key: apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - apollo-router-target-${{ matrix.cache_key }}-build-${{ hashFiles('**/Cargo.lock') }} - apollo-router-target-${{ matrix.cache_key }}-build- - apollo-router-target-${{ matrix.cache_key }}- - apollo-router-target- - - name: Build run: cargo build --release --target ${{ matrix.target }} diff --git a/.github/workflows/apollo-router-updater.yaml b/.github/workflows/apollo-router-updater.yaml index 6b53b4ff4..620d01ccf 100644 --- a/.github/workflows/apollo-router-updater.yaml +++ b/.github/workflows/apollo-router-updater.yaml @@ -1,8 +1,8 @@ -name: Apollo Router Updater +name: apollo-router-updater on: schedule: # Every 2 hours - - cron: '0 */2 * * *' + - cron: "0 */2 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: {} defaults: @@ -18,44 +18,45 @@ jobs: contents: write steps: - name: checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 1 token: ${{ secrets.BOT_GITHUB_TOKEN }} - name: Install Rust - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 + uses: actions-rust-lang/setup-rust-toolchain@v1 # v1 with: - toolchain: '1.91.1' - default: true - override: true + rust-src-dir: ./apollo-router-workspace - - name: setup node - uses: the-guild-org/shared-config/setup@v1 - with: - node-version-file: .node-version - - - name: Check for updates + - name: find updates id: check run: | - cd scripts/apollo-router-updater - npm install - npm start + LATEST_VERSION=$(curl -s -X GET https://crates.io/api/v1/crates/apollo-router | jq -r '.crate.max_version') + echo "latest_version=$LATEST_VERSION" + echo "latest_version=$LATEST_VERSION" >> $GITHUB_OUTPUT + + CURRENT_VERSION=$(cargo tree -p apollo-router | grep apollo-router | awk '{print $2}' | sed 's/^v//') + echo "current_version=$CURRENT_VERSION" + echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + NEED_UPDATE=$( [ "$LATEST_VERSION" != "$CURRENT_VERSION" ] && echo 'true' || echo 'false' ) + echo "update=$NEED_UPDATE" + echo "update=$NEED_UPDATE" >> $GITHUB_OUTPUT - name: Run updates if: steps.check.outputs.update == 'true' - run: cargo update -p apollo-router --precise ${{ steps.check.outputs.version }} + run: cargo update -p apollo-router --precise ${{ steps.check.outputs.latest_version }} - name: Create Pull Request if: steps.check.outputs.update == 'true' uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 with: token: ${{ secrets.BOT_GITHUB_TOKEN }} - commit-message: Update apollo-router to version ${{ steps.check.outputs.version }} - branch: apollo-router-update-${{ steps.check.outputs.version }} + commit-message: Update apollo-router to version ${{ steps.check.outputs.latest_version }} + branch: apollo-router-update-${{ steps.check.outputs.latest_version }} delete-branch: true title: ${{ steps.check.outputs.title }} body: | - Automatic update of apollo-router to version ${{ steps.check.outputs.version }}. + Automatic update of apollo-router to version ${{ steps.check.outputs.latest_version }}. assignees: kamilkisiela,dotansimha reviewers: kamilkisiela,dotansimha diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json deleted file mode 100644 index a92ac08ff..000000000 --- a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package-lock.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "name": "apollo-router-updater", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "apollo-router-updater", - "dependencies": { - "@actions/core": "1.11.1", - "@types/node": "25.0.10" - } - }, - "node_modules/@actions/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", - "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", - "license": "MIT", - "dependencies": { - "@actions/exec": "^1.1.1", - "@actions/http-client": "^2.0.1" - } - }, - "node_modules/@actions/exec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", - "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", - "license": "MIT", - "dependencies": { - "@actions/io": "^1.0.1" - } - }, - "node_modules/@actions/http-client": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", - "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^5.25.4" - } - }, - "node_modules/@actions/io": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", - "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", - "license": "MIT" - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "license": "MIT", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT" - } - } -} diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json deleted file mode 100644 index d43e7acb8..000000000 --- a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "apollo-router-updater", - "type": "module", - "private": true, - "scripts": { - "start": "node src/index.ts" - }, - "dependencies": { - "@types/node": "25.0.10", - "@actions/core": "1.11.1" - } -} \ No newline at end of file diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts deleted file mode 100644 index 557cd828f..000000000 --- a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/src/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { setOutput } from '@actions/core'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const [localVersion, latestStableVersion] = await Promise.all([ - fetchLocalVersion(), - fetchLatestVersion(), -]); - -console.log(`Latest stable version: ${latestStableVersion}`); -console.log(`Local version: ${localVersion}`); - -if (localVersion === latestStableVersion) { - console.log('Local version is up to date'); - setOutput('update', 'false'); - process.exit(0); -} - -console.log('Local version is out of date'); - -if (await isPullRequestOpen(latestStableVersion)) { - console.log(`PR already exists`); - setOutput('update', 'false'); -} else { - console.log('PR does not exist.'); - console.log(`Run: cargo update -p apollo-router --precise ${latestStableVersion}`); - console.log('Then commit and push the changes.'); - setOutput('update', 'true'); - setOutput('version', latestStableVersion); -} - -async function fetchLatestVersion() { - const latestResponse = await fetch( - 'https://api.github.com/repos/apollographql/router/releases/latest', - { - method: 'GET', - }, - ); - - if (!latestResponse.ok) { - throw new Error('Failed to fetch versions'); - } - - const latest = await latestResponse.json(); - const latestStableVersion = latest.tag_name.replace('v', ''); - - if (!latestStableVersion) { - throw new Error('Failed to find latest stable version'); - } - - return latestStableVersion; -} -async function fetchLocalVersion() { - const lockFile = await readFile(join(__dirname, '../../../Cargo.toml'), 'utf-8'); - - const apolloRouterPackage = lockFile - .split('[[package]]') - .find(pkg => pkg.includes('name = "apollo-router"')); - - if (!apolloRouterPackage) { - throw new Error('Failed to find apollo-router package in Cargo.lock'); - } - - const versionMatch = apolloRouterPackage.match(/version = "(.*)"/); - - if (!versionMatch) { - throw new Error('Failed to find version of apollo-router package in Cargo.lock'); - } - - return versionMatch[1]; -} - -async function isPullRequestOpen(latestStableVersion: string) { - const prTitle = `Update apollo-router to ${latestStableVersion}`; - - setOutput('title', prTitle); - - const prResponse = await fetch(`https://api.github.com/repos/apollographql/router/pulls`); - - if (!prResponse.ok) { - throw new Error('Failed to fetch PRs'); - } - - const prs: Array<{ - title: string; - html_url: string; - }> = await prResponse.json(); - - return prs.some(pr => pr.title === prTitle); -} diff --git a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json b/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json deleted file mode 100644 index 6165d2b34..000000000 --- a/apollo-router-workspace/bin/router/scripts/apollo-router-updater/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "module": "esnext", - "target": "esnext", - "lib": ["esnext", "dom"], - "baseUrl": ".", - "outDir": "dist", - "rootDir": ".", - - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "importHelpers": true, - "allowJs": true, - "skipLibCheck": true, - - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - - "sourceMap": true, - "declaration": false, - "declarationMap": false, - "resolveJsonModule": false, - - "moduleResolution": "node", - "strict": true - } -} From e32e3907ba25520f1f712d610d3638701d0a8400 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 12:02:11 +0300 Subject: [PATCH 53/76] fix(ci): fix path for apollo-router-fork cargo file Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .github/workflows/apollo-router-fork-release.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/apollo-router-fork-release.yaml b/.github/workflows/apollo-router-fork-release.yaml index 4d78eeae3..14871e06e 100644 --- a/.github/workflows/apollo-router-fork-release.yaml +++ b/.github/workflows/apollo-router-fork-release.yaml @@ -62,8 +62,8 @@ jobs: image_name="apollo-router" github_org="graphql-hive" router_version=$(cargo tree -i apollo-router --quiet | head -n 1 | awk -F" v" '{print $2}') - plugin_version=$(grep '^version' Cargo.toml | sed 's/version *= *"\(.*\)"/\1/') - has_changes=$(git diff HEAD~ HEAD --name-only -- 'Cargo.toml' 'Cargo.lock') + plugin_version=$(grep '^version' ./bin/router/Cargo.toml | sed 's/version *= *"\(.*\)"/\1/') + has_changes=$(git diff HEAD~ HEAD --name-only -- './bin/router/Cargo.toml' 'Cargo.lock') echo "router_version: $router_version" echo "plugin_version: $plugin_version" From d9a0b6b1a80a5250638ca8b04935e26091b1e4ba Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 12:17:07 +0300 Subject: [PATCH 54/76] fix(ci): correct workflow name Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- ...pollo-router-fork-release.yaml => apollo-router-fork.yaml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{apollo-router-fork-release.yaml => apollo-router-fork.yaml} (99%) diff --git a/.github/workflows/apollo-router-fork-release.yaml b/.github/workflows/apollo-router-fork.yaml similarity index 99% rename from .github/workflows/apollo-router-fork-release.yaml rename to .github/workflows/apollo-router-fork.yaml index 14871e06e..c94d73c89 100644 --- a/.github/workflows/apollo-router-fork-release.yaml +++ b/.github/workflows/apollo-router-fork.yaml @@ -6,14 +6,14 @@ on: - main paths: - "apollo-router-workspace/**" - - ".github/workflows/apollo-router-release.yaml" + - ".github/workflows/apollo-router-fork.yaml" - ".github/workflows/apollo-router-updater.yaml" # For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and # publish them only if changes are found and image does not exists in GH Packages. push: paths: - "apollo-router-workspace/**" - - ".github/workflows/apollo-router-release.yaml" + - ".github/workflows/apollo-router-fork.yaml" - ".github/workflows/apollo-router-updater.yaml" branches: - main From 4ab5dd9a9e603a232d2e2cf229d602367ecdc510 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 12:18:44 +0300 Subject: [PATCH 55/76] fix(ci): run apollo-router test command only when needed Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .github/workflows/apollo-router-fork.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/apollo-router-fork.yaml b/.github/workflows/apollo-router-fork.yaml index c94d73c89..5515d6c9c 100644 --- a/.github/workflows/apollo-router-fork.yaml +++ b/.github/workflows/apollo-router-fork.yaml @@ -279,6 +279,7 @@ jobs: # Build and publish Rust crates and binaries test-rust: name: test + if: ${{ needs.find-changes.outputs.should_release == 'true' }} needs: find-changes runs-on: ubuntu-22.04 steps: From 3601edc8357fec33e3ec2e2b5048dbc0a6f4ddb9 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 14:06:41 +0300 Subject: [PATCH 56/76] fix(release): changesets adjustments Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/olive-ducks-bake.md | 2 ++ .changeset/tls_support.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.changeset/olive-ducks-bake.md b/.changeset/olive-ducks-bake.md index e1a1c25d4..5d5ebba34 100644 --- a/.changeset/olive-ducks-bake.md +++ b/.changeset/olive-ducks-bake.md @@ -1,5 +1,7 @@ --- hive-router-query-planner: patch +hive-router-plan-executor: patch +hive-router: patch --- Fix query planner handling for combined `@skip` and `@include` conditions. diff --git a/.changeset/tls_support.md b/.changeset/tls_support.md index e1090952c..76a8751ce 100644 --- a/.changeset/tls_support.md +++ b/.changeset/tls_support.md @@ -2,6 +2,7 @@ hive-router-config: minor hive-router-plan-executor: minor hive-router: minor +hive-router-internal: patch --- # TLS Support From 42ba7d423071c237346232535215ea63a902470d Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 20 Apr 2026 14:39:17 +0300 Subject: [PATCH 57/76] fix(release): node-addon is now released when query-planner changes Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/olive-ducks-bake.md | 1 + lib/node-addon/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/olive-ducks-bake.md b/.changeset/olive-ducks-bake.md index 5d5ebba34..d588b8e3c 100644 --- a/.changeset/olive-ducks-bake.md +++ b/.changeset/olive-ducks-bake.md @@ -2,6 +2,7 @@ hive-router-query-planner: patch hive-router-plan-executor: patch hive-router: patch +node-addon: patch --- Fix query planner handling for combined `@skip` and `@include` conditions. diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 4d941a711..66f95b2a2 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -2,7 +2,7 @@ edition = "2021" version = "0.0.22" name = "node-addon" -publish = false +publish = true [lib] crate-type = ["cdylib"] From 032884fbb46e24bf2218d46b9425efe1be6bf037 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:13:58 +0300 Subject: [PATCH 58/76] chore(release): router crates and artifacts (#929) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/expose-early-http-response.md | 18 --- .changeset/graphiql_feature_flag.md | 12 -- .changeset/negative_cache_single_flight.md | 10 -- .changeset/olive-ducks-bake.md | 29 ---- .changeset/persisted_documents.md | 36 ----- .changeset/tls_support.md | 72 --------- Cargo.lock | 14 +- apollo-router-workspace/Cargo.lock | 2 +- apollo-router-workspace/bin/router/Cargo.toml | 2 +- bin/router/CHANGELOG.md | 153 ++++++++++++++++++ bin/router/Cargo.toml | 12 +- lib/executor/CHANGELOG.md | 137 ++++++++++++++++ lib/executor/Cargo.toml | 8 +- lib/hive-console-sdk/CHANGELOG.md | 38 +++++ lib/hive-console-sdk/Cargo.toml | 2 +- lib/internal/CHANGELOG.md | 100 ++++++++++++ lib/internal/Cargo.toml | 4 +- lib/node-addon/CHANGELOG.md | 27 ++++ lib/node-addon/Cargo.toml | 2 +- lib/node-addon/package.json | 2 +- lib/query-planner/CHANGELOG.md | 27 ++++ lib/query-planner/Cargo.toml | 2 +- lib/router-config/CHANGELOG.md | 98 +++++++++++ lib/router-config/Cargo.toml | 2 +- 24 files changed, 606 insertions(+), 203 deletions(-) delete mode 100644 .changeset/expose-early-http-response.md delete mode 100644 .changeset/graphiql_feature_flag.md delete mode 100644 .changeset/negative_cache_single_flight.md delete mode 100644 .changeset/olive-ducks-bake.md delete mode 100644 .changeset/persisted_documents.md delete mode 100644 .changeset/tls_support.md diff --git a/.changeset/expose-early-http-response.md b/.changeset/expose-early-http-response.md deleted file mode 100644 index ce4b09443..000000000 --- a/.changeset/expose-early-http-response.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -hive-router-plan-executor: patch -hive-router: patch ---- - -# Plugin System API improvements - -Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. - -```rust -payload.end_with_response( - EarlyHTTPResponse { - body, - headers, - status_code, - } -); -``` diff --git a/.changeset/graphiql_feature_flag.md b/.changeset/graphiql_feature_flag.md deleted file mode 100644 index 5a6a9386b..000000000 --- a/.changeset/graphiql_feature_flag.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -hive-router: patch ---- - -Adds an optional `graphiql` Cargo feature for `hive-router`. -When enabled, the Router serves GraphiQL HTML and skips Laboratory asset generation so `npm` and `node` dependencies are not needed. -By default, this feature is disabled and existing Laboratory behavior is unchanged. - -```bash -cargo run -p hive-router --features graphiql -cargo build -p hive-router --features graphiql -``` diff --git a/.changeset/negative_cache_single_flight.md b/.changeset/negative_cache_single_flight.md deleted file mode 100644 index 57331098b..000000000 --- a/.changeset/negative_cache_single_flight.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -hive-console-sdk: minor -hive-router: patch ---- - -# Negative Cache and Single-Flight - -Introduced single-flight resolution of documents in the SDK. - -Added a negative cache to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default). It's meant to not keep repeating the same requests that eventually give errors or 404s. diff --git a/.changeset/olive-ducks-bake.md b/.changeset/olive-ducks-bake.md deleted file mode 100644 index d588b8e3c..000000000 --- a/.changeset/olive-ducks-bake.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -hive-router-query-planner: patch -hive-router-plan-executor: patch -hive-router: patch -node-addon: patch ---- - -Fix query planner handling for combined `@skip` and `@include` conditions. - -- Preserve both directives when converting inline fragment conditions into fetch step selections -- Build the expected nested condition nodes for combined skip/include execution paths -- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing -- Add regression snapshot tests for field-level and fragment-level combined conditions - -For example a query like this: - -```graphql -query($skip: Boolean!, $include: Boolean!) { - user { - name @skip(if: $skip) @include(if: $include) - } -} -``` - -Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. - -- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. -- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. -- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. \ No newline at end of file diff --git a/.changeset/persisted_documents.md b/.changeset/persisted_documents.md deleted file mode 100644 index 68b9aa0bc..000000000 --- a/.changeset/persisted_documents.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -hive-router-plan-executor: minor -hive-router-config: minor -hive-router: minor -hive-router-internal: minor -hive-console-sdk: minor -hive-apollo-router-plugin: patch ---- - -# Persisted Documents - -Introduces persisted documents support in Hive Router with configurable extraction and storage backends. - -Supports extracting persisted document IDs from: -- `documentId` in request body (default) -- `documentId` in URL query params (default) -- Apollo-style `extensions.persistedQuery.sha256Hash` (default) -- custom `json_path` (for example `doc_id` or `extensions.anything.id`) -- custom `url_query_param` (for example `?doc_id=123`) -- custom `url_path_param` (for example `/graphql/:id`) - -Order is configurable and evaluated top-to-bottom. - -Supports persisted document resolution from: -- file manifests (Apollo and Relay KV styles) -- Hive CDN (via `hive-console-sdk`) - -File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. -Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. - -Adds persisted-documents metrics: - -- `hive.router.persisted_documents.extract.missing_id_total` -- `hive.router.persisted_documents.storage.failures_total` - -These help track migration progress and resolution failures in production diff --git a/.changeset/tls_support.md b/.changeset/tls_support.md deleted file mode 100644 index 76a8751ce..000000000 --- a/.changeset/tls_support.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -hive-router-config: minor -hive-router-plan-executor: minor -hive-router: minor -hive-router-internal: patch ---- - -# TLS Support - -Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. - -### TLS Directions - -TLS Support has implementations for the following 4 directions: - -#### Router -> Client - Regular TLS -Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` - -#### Client -> Router - mTLS -Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` - -#### Subgraph -> Router - Regular TLS -Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. - -#### Router -> Subgraph - mTLS -Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. - -### TLS Directions Diagram - -```mermaid -flowchart LR - Client["Client"] - Router["Router"] - Subgraph["Subgraph"] - - %% Router -> Client: Regular TLS - Router -- "TLS\n(cert_file + key_file)" --> Client - Client -. "validates router identity\n(cert_file)" .-> Router - - %% Client -> Router: mTLS / Client Auth - Client -- "mTLS\n(client identity)" --> Router - Router -. "validates client identity\n(client_auth.cert_file)" .-> Client - - %% Subgraph -> Router: Regular TLS - Subgraph -- "TLS\n(cert_file)" --> Router - Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph - - %% Router -> Subgraph: mTLS - Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph - Subgraph -. "validates router identity\n(cert_file)" .-> Router -``` - -### Configuration Structure -```yaml -traffic_shaping: - router: - key_file: # Router server private key - cert_file: # Router server certificate(s) - client_auth: # mTLS: Client -> Router - cert_file: # Trusted client CA certificate(s) - all: # Default TLS for all subgraph connections - cert_file: # Trusted subgraph CA certificate(s) - client_auth: # mTLS: Router -> Subgraph - cert_file: # Router client certificate(s) - key_file: # Router client private key - subgraphs: - SUBGRAPH_NAME: # Per-subgraph TLS override - cert_file: # Trusted subgraph CA certificate(s) - client_auth: # mTLS: Router -> Subgraph - cert_file: # Router client certificate(s) - key_file: # Router client private key -``` diff --git a/Cargo.lock b/Cargo.lock index ff45b420b..151983694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2494,7 +2494,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hive-console-sdk" -version = "0.3.8" +version = "0.3.9" dependencies = [ "anyhow", "async-dropper-simple", @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.49" +version = "0.0.50" dependencies = [ "ahash", "anyhow", @@ -2589,7 +2589,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.30" +version = "0.0.31" dependencies = [ "config", "envconfig", @@ -2612,7 +2612,7 @@ dependencies = [ [[package]] name = "hive-router-internal" -version = "0.0.17" +version = "0.0.18" dependencies = [ "ahash", "async-trait", @@ -2653,7 +2653,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.11.0" +version = "6.12.0" dependencies = [ "ahash", "async-stream", @@ -2696,7 +2696,7 @@ dependencies = [ [[package]] name = "hive-router-query-planner" -version = "2.7.0" +version = "2.7.1" dependencies = [ "bitflags 2.11.1", "criterion", @@ -3763,7 +3763,7 @@ dependencies = [ [[package]] name = "node-addon" -version = "0.0.22" +version = "0.0.23" dependencies = [ "graphql-tools", "hive-router-query-planner", diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock index 44801a971..30237b869 100644 --- a/apollo-router-workspace/Cargo.lock +++ b/apollo-router-workspace/Cargo.lock @@ -2692,7 +2692,7 @@ dependencies = [ [[package]] name = "hive-console-sdk" -version = "0.3.8" +version = "0.3.9" dependencies = [ "anyhow", "async-dropper-simple", diff --git a/apollo-router-workspace/bin/router/Cargo.toml b/apollo-router-workspace/bin/router/Cargo.toml index 969ec1f7e..6cf5c3c3a 100644 --- a/apollo-router-workspace/bin/router/Cargo.toml +++ b/apollo-router-workspace/bin/router/Cargo.toml @@ -18,7 +18,7 @@ path = "src/lib.rs" [dependencies] apollo-router = { version = "^2.0.0" } -hive-console-sdk = { version = "=0.3.8", path = "../../../lib/hive-console-sdk"} +hive-console-sdk = { version = "0.3.9", path = "../../../lib/hive-console-sdk"} sha2 = { version = "0.10.8", features = ["std"] } anyhow = "1" tracing = "0.1" diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 246956037..27817bd52 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,159 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.50 (2026-04-20) + +### Features + +#### Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production + +#### TLS Support + +Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. + +#### TLS Directions + +TLS Support has implementations for the following 4 directions: + +##### Router -> Client - Regular TLS +Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` + +##### Client -> Router - mTLS +Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` + +##### Subgraph -> Router - Regular TLS +Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. + +##### Router -> Subgraph - mTLS +Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. + +#### TLS Directions Diagram + +```mermaid +flowchart LR + Client["Client"] + Router["Router"] + Subgraph["Subgraph"] + + %% Router -> Client: Regular TLS + Router -- "TLS\n(cert_file + key_file)" --> Client + Client -. "validates router identity\n(cert_file)" .-> Router + + %% Client -> Router: mTLS / Client Auth + Client -- "mTLS\n(client identity)" --> Router + Router -. "validates client identity\n(client_auth.cert_file)" .-> Client + + %% Subgraph -> Router: Regular TLS + Subgraph -- "TLS\n(cert_file)" --> Router + Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph + + %% Router -> Subgraph: mTLS + Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph + Subgraph -. "validates router identity\n(cert_file)" .-> Router +``` + +#### Configuration Structure +```yaml +traffic_shaping: + router: + key_file: # Router server private key + cert_file: # Router server certificate(s) + client_auth: # mTLS: Client -> Router + cert_file: # Trusted client CA certificate(s) + all: # Default TLS for all subgraph connections + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key + subgraphs: + SUBGRAPH_NAME: # Per-subgraph TLS override + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key +``` + +### Fixes + +#### Plugin System API improvements + +Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. + +```rust +payload.end_with_response( + EarlyHTTPResponse { + body, + headers, + status_code, + } +); +``` + +#### Adds an optional `graphiql` Cargo feature for `hive-router`. + +When enabled, the Router serves GraphiQL HTML and skips Laboratory asset generation so `npm` and `node` dependencies are not needed. +By default, this feature is disabled and existing Laboratory behavior is unchanged. + +```bash +cargo run -p hive-router --features graphiql +cargo build -p hive-router --features graphiql +``` + +#### Negative Cache and Single-Flight + +Introduced single-flight resolution of documents in the SDK. + +Added a negative cache to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default). It's meant to not keep repeating the same requests that eventually give errors or 404s. + +#### Fix query planner handling for combined `@skip` and `@include` conditions. + +- Preserve both directives when converting inline fragment conditions into fetch step selections +- Build the expected nested condition nodes for combined skip/include execution paths +- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing +- Add regression snapshot tests for field-level and fragment-level combined conditions + +For example a query like this: + +```graphql +query($skip: Boolean!, $include: Boolean!) { + user { + name @skip(if: $skip) @include(if: $include) + } +} +``` + +Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. + +- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. +- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. +- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. + ## 0.0.49 (2026-04-15) ### Features diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 7eb1b69a6..102081ecf 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.49" +version = "0.0.50" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -23,11 +23,11 @@ testing = [] graphiql = [] [dependencies] -hive-router-query-planner = { path = "../../lib/query-planner", version = "2.7.0" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.11.0" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.30" } -hive-router-internal = { path = "../../lib/internal", version = "0.0.17" } -hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.8" } +hive-router-query-planner = { path = "../../lib/query-planner", version = "2.7.1" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.12.0" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.31" } +hive-router-internal = { path = "../../lib/internal", version = "0.0.18" } +hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.9" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } tokio = { workspace = true } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index bc59adad9..d0e84ac0e 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,143 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.12.0 (2026-04-20) + +### Features + +#### Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production + +#### TLS Support + +Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. + +#### TLS Directions + +TLS Support has implementations for the following 4 directions: + +##### Router -> Client - Regular TLS +Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` + +##### Client -> Router - mTLS +Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` + +##### Subgraph -> Router - Regular TLS +Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. + +##### Router -> Subgraph - mTLS +Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. + +#### TLS Directions Diagram + +```mermaid +flowchart LR + Client["Client"] + Router["Router"] + Subgraph["Subgraph"] + + %% Router -> Client: Regular TLS + Router -- "TLS\n(cert_file + key_file)" --> Client + Client -. "validates router identity\n(cert_file)" .-> Router + + %% Client -> Router: mTLS / Client Auth + Client -- "mTLS\n(client identity)" --> Router + Router -. "validates client identity\n(client_auth.cert_file)" .-> Client + + %% Subgraph -> Router: Regular TLS + Subgraph -- "TLS\n(cert_file)" --> Router + Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph + + %% Router -> Subgraph: mTLS + Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph + Subgraph -. "validates router identity\n(cert_file)" .-> Router +``` + +#### Configuration Structure +```yaml +traffic_shaping: + router: + key_file: # Router server private key + cert_file: # Router server certificate(s) + client_auth: # mTLS: Client -> Router + cert_file: # Trusted client CA certificate(s) + all: # Default TLS for all subgraph connections + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key + subgraphs: + SUBGRAPH_NAME: # Per-subgraph TLS override + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key +``` + +### Fixes + +#### Plugin System API improvements + +Expose `EarlyHTTPResponse` instead of `PlanExecutionOutput` in the hooks that do not have internal fields like `response_headers_aggregator` etc, and it is easier to construct an HTTP response with a body, header map and status code. + +```rust +payload.end_with_response( + EarlyHTTPResponse { + body, + headers, + status_code, + } +); +``` + +#### Fix query planner handling for combined `@skip` and `@include` conditions. + +- Preserve both directives when converting inline fragment conditions into fetch step selections +- Build the expected nested condition nodes for combined skip/include execution paths +- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing +- Add regression snapshot tests for field-level and fragment-level combined conditions + +For example a query like this: + +```graphql +query($skip: Boolean!, $include: Boolean!) { + user { + name @skip(if: $skip) @include(if: $include) + } +} +``` + +Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. + +- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. +- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. +- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. + ## 6.11.0 (2026-04-15) ### Features diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index a2e79e5cb..83a2adcb0 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.11.0" +version = "6.12.0" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -15,9 +15,9 @@ authors = ["The Guild"] doctest = false [dependencies] -hive-router-query-planner = { path = "../query-planner", version = "2.7.0" } -hive-router-config = { path = "../router-config", version = "0.0.30" } -hive-router-internal = { path = "../internal", version = "0.0.17" } +hive-router-query-planner = { path = "../query-planner", version = "2.7.1" } +hive-router-config = { path = "../router-config", version = "0.0.31" } +hive-router-internal = { path = "../internal", version = "0.0.18" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } async-trait = { workspace = true } diff --git a/lib/hive-console-sdk/CHANGELOG.md b/lib/hive-console-sdk/CHANGELOG.md index 4bef40682..647e1a372 100644 --- a/lib/hive-console-sdk/CHANGELOG.md +++ b/lib/hive-console-sdk/CHANGELOG.md @@ -1,3 +1,41 @@ +## 0.3.9 (2026-04-20) + +### Features + +#### Negative Cache and Single-Flight + +Introduced single-flight resolution of documents in the SDK. + +Added a negative cache to store non 2XX requests for 5s (configurable, but in SDK it's disabled by default). It's meant to not keep repeating the same requests that eventually give errors or 404s. + +#### Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production + ## 0.3.8 (2026-03-16) ### Fixes diff --git a/lib/hive-console-sdk/Cargo.toml b/lib/hive-console-sdk/Cargo.toml index 43ad6be23..9f84d2dac 100644 --- a/lib/hive-console-sdk/Cargo.toml +++ b/lib/hive-console-sdk/Cargo.toml @@ -3,7 +3,7 @@ name = "hive-console-sdk" edition = "2021" license = "MIT" publish = true -version = "0.3.8" +version = "0.3.9" description = "Rust SDK for Hive Console" repository = "https://github.com/graphql-hive/router" homepage = "https://github.com/graphql-hive/router" diff --git a/lib/internal/CHANGELOG.md b/lib/internal/CHANGELOG.md index 97f59ffb5..bd47b2dcf 100644 --- a/lib/internal/CHANGELOG.md +++ b/lib/internal/CHANGELOG.md @@ -1,3 +1,103 @@ +## 0.0.18 (2026-04-20) + +### Features + +#### Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production + +### Fixes + +#### TLS Support + +Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. + +#### TLS Directions + +TLS Support has implementations for the following 4 directions: + +##### Router -> Client - Regular TLS +Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` + +##### Client -> Router - mTLS +Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` + +##### Subgraph -> Router - Regular TLS +Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. + +##### Router -> Subgraph - mTLS +Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. + +#### TLS Directions Diagram + +```mermaid +flowchart LR + Client["Client"] + Router["Router"] + Subgraph["Subgraph"] + + %% Router -> Client: Regular TLS + Router -- "TLS\n(cert_file + key_file)" --> Client + Client -. "validates router identity\n(cert_file)" .-> Router + + %% Client -> Router: mTLS / Client Auth + Client -- "mTLS\n(client identity)" --> Router + Router -. "validates client identity\n(client_auth.cert_file)" .-> Client + + %% Subgraph -> Router: Regular TLS + Subgraph -- "TLS\n(cert_file)" --> Router + Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph + + %% Router -> Subgraph: mTLS + Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph + Subgraph -. "validates router identity\n(cert_file)" .-> Router +``` + +#### Configuration Structure +```yaml +traffic_shaping: + router: + key_file: # Router server private key + cert_file: # Router server certificate(s) + client_auth: # mTLS: Client -> Router + cert_file: # Trusted client CA certificate(s) + all: # Default TLS for all subgraph connections + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key + subgraphs: + SUBGRAPH_NAME: # Per-subgraph TLS override + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key +``` + ## 0.0.17 (2026-04-15) ### Fixes diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 0a885bf59..e05f9f687 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-internal" -version = "0.0.17" +version = "0.0.18" edition = "2021" description = "GraphQL Hive Router internal crate" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.30" } +hive-router-config = { path = "../router-config", version = "0.0.31" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/node-addon/CHANGELOG.md b/lib/node-addon/CHANGELOG.md index 3101125fb..e72a70267 100644 --- a/lib/node-addon/CHANGELOG.md +++ b/lib/node-addon/CHANGELOG.md @@ -1,4 +1,31 @@ # @graphql-hive/router-query-planner changelog +## 0.0.23 (2026-04-20) + +### Fixes + +#### Fix query planner handling for combined `@skip` and `@include` conditions. + +- Preserve both directives when converting inline fragment conditions into fetch step selections +- Build the expected nested condition nodes for combined skip/include execution paths +- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing +- Add regression snapshot tests for field-level and fragment-level combined conditions + +For example a query like this: + +```graphql +query($skip: Boolean!, $include: Boolean!) { + user { + name @skip(if: $skip) @include(if: $include) + } +} +``` + +Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. + +- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. +- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. +- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. + ## 0.0.22 (2026-04-15) ### Fixes diff --git a/lib/node-addon/Cargo.toml b/lib/node-addon/Cargo.toml index 66f95b2a2..7be00af21 100644 --- a/lib/node-addon/Cargo.toml +++ b/lib/node-addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -version = "0.0.22" +version = "0.0.23" name = "node-addon" publish = true diff --git a/lib/node-addon/package.json b/lib/node-addon/package.json index 799a56596..c22ce3a72 100644 --- a/lib/node-addon/package.json +++ b/lib/node-addon/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/router-query-planner", - "version": "0.0.22", + "version": "0.0.23", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/lib/query-planner/CHANGELOG.md b/lib/query-planner/CHANGELOG.md index 88a75fbc3..e08fb4ff8 100644 --- a/lib/query-planner/CHANGELOG.md +++ b/lib/query-planner/CHANGELOG.md @@ -30,6 +30,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 2.7.1 (2026-04-20) + +### Fixes + +#### Fix query planner handling for combined `@skip` and `@include` conditions. + +- Preserve both directives when converting inline fragment conditions into fetch step selections +- Build the expected nested condition nodes for combined skip/include execution paths +- Handle `SkipAndInclude` in selection matching, fetch-step rendering, and multi-type batch path hashing +- Add regression snapshot tests for field-level and fragment-level combined conditions + +For example a query like this: + +```graphql +query($skip: Boolean!, $include: Boolean!) { + user { + name @skip(if: $skip) @include(if: $include) + } +} +``` + +Will now correctly generate a fetch step with an inline fragment that has both `@skip` and `@include` conditions, and the planner will properly evaluate the combined conditions when determining which selections to include in the execution plan. + +- `@skip(if: $skip)` is true, the selection will be skipped regardless of the `@include` condition. +- `@include(if: $include)` is false, the selection will be skipped regardless of the `@skip` condition. +- Only if `@skip(if: $skip)` is false and `@include(if: $include)` is true, the selection will be included in the execution plan. + ## 2.7.0 (2026-04-15) ### Features diff --git a/lib/query-planner/Cargo.toml b/lib/query-planner/Cargo.toml index a7225a60c..efc616288 100644 --- a/lib/query-planner/Cargo.toml +++ b/lib/query-planner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-query-planner" -version = "2.7.0" +version = "2.7.1" edition = "2021" description = "GraphQL query planner for Federation specification" license = "MIT" diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index c6226191a..4b7613e03 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.31 (2026-04-20) + +### Features + +#### Persisted Documents + +Introduces persisted documents support in Hive Router with configurable extraction and storage backends. + +Supports extracting persisted document IDs from: +- `documentId` in request body (default) +- `documentId` in URL query params (default) +- Apollo-style `extensions.persistedQuery.sha256Hash` (default) +- custom `json_path` (for example `doc_id` or `extensions.anything.id`) +- custom `url_query_param` (for example `?doc_id=123`) +- custom `url_path_param` (for example `/graphql/:id`) + +Order is configurable and evaluated top-to-bottom. + +Supports persisted document resolution from: +- file manifests (Apollo and Relay KV styles) +- Hive CDN (via `hive-console-sdk`) + +File storage includes watch mode by default (with 150ms debounce) to reload manifests after file changes. +Hive storage validates document ID syntax before generating CDN paths to avoid silent invalid-path behavior. + +Adds persisted-documents metrics: + +- `hive.router.persisted_documents.extract.missing_id_total` +- `hive.router.persisted_documents.storage.failures_total` + +These help track migration progress and resolution failures in production + +#### TLS Support + +Adds TLS support to Hive Router for both client and subgraph connections, including mutual TLS (mTLS) authentication. This allows secure communication between clients, the router, and subgraphs by encrypting data in transit and optionally verifying identities. + +#### TLS Directions + +TLS Support has implementations for the following 4 directions: + +##### Router -> Client - Regular TLS +Router has an `identity` (`cert`, `key`), and client has `cert`, then Client validates the router's `identity` + +##### Client -> Router - mTLS +Router has the `cert`, client has the `identity`, mTLS/Client Auth then the router validates the client's `identity` + +##### Subgraph -> Router - Regular TLS +Subgraph has the `identity` (`cert`, `key`), and router has `cert`, then Router validates the subgraph's `identity`. + +##### Router -> Subgraph - mTLS +Subgraph has the `cert`, router(which is the client this time) has the `identity`, then subgraph validates the router's `identity`. + +#### TLS Directions Diagram + +```mermaid +flowchart LR + Client["Client"] + Router["Router"] + Subgraph["Subgraph"] + + %% Router -> Client: Regular TLS + Router -- "TLS\n(cert_file + key_file)" --> Client + Client -. "validates router identity\n(cert_file)" .-> Router + + %% Client -> Router: mTLS / Client Auth + Client -- "mTLS\n(client identity)" --> Router + Router -. "validates client identity\n(client_auth.cert_file)" .-> Client + + %% Subgraph -> Router: Regular TLS + Subgraph -- "TLS\n(cert_file)" --> Router + Router -. "validates subgraph identity\n(all/subgraphs.cert_file)" .-> Subgraph + + %% Router -> Subgraph: mTLS + Router -- "mTLS\n(client_auth.cert_file + key_file)" --> Subgraph + Subgraph -. "validates router identity\n(cert_file)" .-> Router +``` + +#### Configuration Structure +```yaml +traffic_shaping: + router: + key_file: # Router server private key + cert_file: # Router server certificate(s) + client_auth: # mTLS: Client -> Router + cert_file: # Trusted client CA certificate(s) + all: # Default TLS for all subgraph connections + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key + subgraphs: + SUBGRAPH_NAME: # Per-subgraph TLS override + cert_file: # Trusted subgraph CA certificate(s) + client_auth: # mTLS: Router -> Subgraph + cert_file: # Router client certificate(s) + key_file: # Router client private key +``` + ## 0.0.30 (2026-04-15) ### Features diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index d64ef19d0..7bd472c02 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.30" +version = "0.0.31" edition = "2021" publish = true license = "MIT" From e71415d03921cab3750b9e257495245c73e3b4a5 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Mon, 27 Apr 2026 14:53:47 +0300 Subject: [PATCH 59/76] Test HTTP/2 Support and add a flag for H2C to subgraphs (#933) Closes https://github.com/graphql-hive/router/issues/340 Ref ROUTER-118 - Add E2E tests HTTP/2 support for Subgraphs <-> Router and Router <-> Client - Implement `http2_only` flag to enable H2C for Subgraphs <-> Router --------- Co-authored-by: theguild-bot Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/h2c_support.md | 34 ++ docs/README.md | 9 +- e2e/src/http2.rs | 382 +++++++++++++++++++++++ e2e/src/lib.rs | 2 + e2e/src/testkit/mod.rs | 93 ++++++ e2e/src/tls.rs | 74 +---- lib/executor/src/executors/map.rs | 37 ++- lib/router-config/src/traffic_shaping.rs | 18 ++ 8 files changed, 564 insertions(+), 85 deletions(-) create mode 100644 .changeset/h2c_support.md create mode 100644 e2e/src/http2.rs diff --git a/.changeset/h2c_support.md b/.changeset/h2c_support.md new file mode 100644 index 000000000..ebf958335 --- /dev/null +++ b/.changeset/h2c_support.md @@ -0,0 +1,34 @@ +--- +hive-router-config: patch +hive-router-plan-executor: patch +hive-router: patch +--- + +# HTTP/2 Cleartext (h2c) Support for Subgraph Connections + +Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. + +This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. + +## Configuration + +The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. + +### Global (all subgraphs) + +```yaml +traffic_shaping: + all: + allow_only_http2: true +``` + +### Per-subgraph + +```yaml +traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true +``` + +The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. diff --git a/docs/README.md b/docs/README.md index 7eddd7ab0..d1668c04f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ |[**subscriptions**](#subscriptions)|`object`|Configuration for subscriptions.
Default: `{"broadcast_capacity":0,"enabled":false}`
|| |[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
|| |[**telemetry**](#telemetry)|`object`|Default: `{"client_identification":{"name_header":"graphql-client-name","version_header":"graphql-client-version"},"hive":null,"metrics":{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}},"resource":{"attributes":{}},"tracing":{"collect":{"max_attributes_per_event":16,"max_attributes_per_link":32,"max_attributes_per_span":128,"max_events_per_span":128,"parent_based_sampler":false,"sampling":1},"exporters":[],"instrumentation":{"spans":{"mode":"spec_compliant"}},"propagation":{"b3":false,"baggage":false,"jaeger":false,"trace_context":true}}}`
|| -|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}}`
|| +|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"allow_only_http2":false,"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}}`
|| |[**websocket**](#websocket)|`object`|Configuration of router's WebSocket server.
Default: `{"enabled":false,"headers":{"persist":false,"source":"connection"},"path":null}`
|| **Additional Properties:** not allowed @@ -202,6 +202,7 @@ telemetry: trace_context: true traffic_shaping: all: + allow_only_http2: false dedupe_enabled: true pool_idle_timeout: 50s request_timeout: 30s @@ -3116,7 +3117,7 @@ Configuration for the traffic-shaping of the executor. Use these configurations |Name|Type|Description|Required| |----|----|-----------|--------| -|[**all**](#traffic_shapingall)|`object`|The default configuration that will be applied to all subgraphs, unless overridden by a specific subgraph configuration.
Default: `{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"}`
|| +|[**all**](#traffic_shapingall)|`object`|The default configuration that will be applied to all subgraphs, unless overridden by a specific subgraph configuration.
Default: `{"allow_only_http2":false,"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"}`
|| |**max\_connections\_per\_host**|`integer`|Limits the concurrent amount of requests/connections per host/subgraph.
Default: `100`
Format: `"uint"`
Minimum: `0`
|| |[**router**](#traffic_shapingrouter)|`object`|Configuration for the router itself, e.g., for handling incoming requests, or other router-level traffic shaping configurations.
Default: `{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}`
|| |[**subgraphs**](#traffic_shapingsubgraphs)|`object`|Optional per-subgraph configurations that will override the default configuration for specific subgraphs.
|| @@ -3126,6 +3127,7 @@ Configuration for the traffic-shaping of the executor. Use these configurations ```yaml all: + allow_only_http2: false dedupe_enabled: true pool_idle_timeout: 50s request_timeout: 30s @@ -3149,6 +3151,7 @@ The default configuration that will be applied to all subgraphs, unless overridd |Name|Type|Description|Required| |----|----|-----------|--------| +|**allow\_only\_http2**|`boolean`|Forces HTTP/2 for requests to subgraphs.

For plain HTTP, it will use HTTP/2 cleartext (h2c).
For HTTPS, it also requires HTTP/2.
This will make the subgraph requests never fall back to HTTP/1.1,
and will fail if the subgraph doesn't support HTTP/2.
Default: `false`
|| |**dedupe\_enabled**|`boolean`|Enables/disables request deduplication to subgraphs.

When requests exactly matches the hashing mechanism (e.g., subgraph name, URL, headers, query, variables), and are executed at the same time, they will
be deduplicated by sharing the response of other in-flight requests.
Default: `true`
|| |**pool\_idle\_timeout**|`string`|Timeout for idle sockets being kept-alive.
Default: `"50s"`
|| |**request\_timeout**||Optional timeout configuration for requests to subgraphs.

Example with a fixed duration:
```yaml
timeout:
duration: 5s
```

Or with a VRL expression that can return a duration based on the operation kind:
```yaml
timeout:
expression: \|
if (.request.operation.type == "mutation") {
"10s"
} else {
"15s"
}
```
Default: `"30s"`
|| @@ -3158,6 +3161,7 @@ The default configuration that will be applied to all subgraphs, unless overridd **Example** ```yaml +allow_only_http2: false dedupe_enabled: true pool_idle_timeout: 50s request_timeout: 30s @@ -3275,6 +3279,7 @@ Optional per-subgraph configurations that will override the default configuratio |Name|Type|Description|Required| |----|----|-----------|--------| +|**allow\_only\_http2**|`boolean`, `null`|Forces HTTP/2 for requests to subgraphs.

For plain HTTP, it will use HTTP/2 cleartext (h2c).
For HTTPS, it also requires HTTP/2.
This will make the subgraph requests never fall back to HTTP/1.1,
and will fail if the subgraph doesn't support HTTP/2.
|| |**dedupe\_enabled**|`boolean`, `null`|Enables/disables request deduplication to subgraphs.

When requests exactly matches the hashing mechanism (e.g., subgraph name, URL, headers, query, variables), and are executed at the same time, they will
be deduplicated by sharing the response of other in-flight requests.
|| |**pool\_idle\_timeout**|`string`, `null`|Timeout for idle sockets being kept-alive.
|| |**request\_timeout**||Optional timeout configuration for requests to subgraphs.

Example with a fixed duration:
```yaml
timeout:
duration: 5s
```

Or with a VRL expression that can return a duration based on the operation kind:
```yaml
timeout:
expression: \|
if (.request.operation.type == "mutation") {
"10s"
} else {
"15s"
}
```
|| diff --git a/e2e/src/http2.rs b/e2e/src/http2.rs new file mode 100644 index 000000000..a8d58345d --- /dev/null +++ b/e2e/src/http2.rs @@ -0,0 +1,382 @@ +#[cfg(test)] +mod http2_tests { + use hive_router::init_rustls_crypto_provider; + use sonic_rs::json; + + use crate::testkit::{ + generate_keypair, generate_tls_subgraph, ClientResponseExt, TestRouter, TestSubgraphs, + }; + + /// Verify that a client can communicate with the router using HTTP/2 over TLS. + /// The reqwest client with rustls and http2 features will negotiate h2 via ALPN. + #[ntex::test] + async fn client_to_router_http2_over_tls() { + init_rustls_crypto_provider(); + let subgraphs = TestSubgraphs::builder().build().start().await; + let generated_key_pair = generate_keypair().await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + "#, + generated_key_pair.key_file_path, generated_key_pair.cert_file_path + )) + .build() + .start_without_healthcheck() + .await; + + let graphql_endpoint = router.serv().url(router.graphql_path()); + + // Build a reqwest client that trusts the self-signed cert. + // reqwest with http2+rustls-tls will negotiate h2 via ALPN automatically. + let client = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(generated_key_pair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .build() + .expect("Failed to build reqwest client"); + + let resp = client + .post(&graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send HTTP/2 request to router"); + + assert_eq!( + resp.version(), + reqwest::Version::HTTP_2, + "Expected HTTP/2 but got {:?}", + resp.version() + ); + assert!(resp.status().is_success()); + + let body = resp.text().await.expect("Failed to read response body"); + assert!( + body.contains("Uri Goldshtein"), + "Response should contain expected data, got: {}", + body + ); + } + + /// Verify that the router communicates with TLS-enabled subgraphs using HTTP/2. + /// The router's hyper-rustls connector uses enable_all_versions() which negotiates h2 via ALPN. + #[ntex::test] + async fn router_to_subgraph_http2_over_tls() { + init_rustls_crypto_provider(); + let (subgraphs, subgraph_keypair) = generate_tls_subgraph().await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + subgraphs: + accounts: + tls: + cert_file: "{}" + "#, + subgraph_keypair.cert_file_path + )) + .build() + .start() + .await; + + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + + assert!(resp.status().is_success(), "Expected 200 OK"); + + // Check that the subgraph received the request over HTTP/2 + let subgraph_requests = subgraphs + .get_requests_log("accounts") + .expect("Expected requests sent to accounts subgraph"); + assert_eq!( + subgraph_requests.len(), + 1, + "Expected exactly 1 request to accounts subgraph" + ); + assert_eq!( + subgraph_requests[0].http_version, + http::Version::HTTP_2, + "Expected router→subgraph request to use HTTP/2, got {:?}", + subgraph_requests[0].http_version + ); + } + + /// Verify full HTTP/2 path: Client --h2--> Router --h2--> Subgraph (both directions over TLS). + #[ntex::test] + async fn full_http2_path_client_router_subgraph() { + init_rustls_crypto_provider(); + let (subgraphs, subgraph_keypair) = generate_tls_subgraph().await; + let router_keypair = generate_keypair().await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + router: + tls: + key_file: "{}" + cert_file: "{}" + subgraphs: + accounts: + tls: + cert_file: "{}" + "#, + router_keypair.key_file_path, + router_keypair.cert_file_path, + subgraph_keypair.cert_file_path + )) + .build() + .start_without_healthcheck() + .await; + + let graphql_endpoint = router.serv().url(router.graphql_path()); + + let client = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(router_keypair.cert_pem.as_bytes()) + .expect("Failed to create certificate from PEM"), + ) + .use_rustls_tls() + .build() + .expect("Failed to build reqwest client"); + + let resp = client + .post(&graphql_endpoint) + .json(&json!({ + "query": "{ me { name } }" + })) + .send() + .await + .expect("Failed to send request"); + + // Verify Client → Router is HTTP/2 + assert_eq!( + resp.version(), + reqwest::Version::HTTP_2, + "Client→Router should use HTTP/2, got {:?}", + resp.version() + ); + assert!(resp.status().is_success()); + + let body = resp.text().await.expect("Failed to read response body"); + assert!( + body.contains("Uri Goldshtein"), + "Response should contain expected data, got: {}", + body + ); + + // Verify Router → Subgraph is HTTP/2 + let subgraph_requests = subgraphs + .get_requests_log("accounts") + .expect("Expected requests sent to accounts subgraph"); + assert_eq!(subgraph_requests.len(), 1); + assert_eq!( + subgraph_requests[0].http_version, + http::Version::HTTP_2, + "Router→Subgraph should use HTTP/2, got {:?}", + subgraph_requests[0].http_version + ); + } + + /// Verify that plain HTTP (no TLS) connections use HTTP/1.1 (h2c is not enabled by default). + #[ntex::test] + async fn plain_http_uses_http1() { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + "# + .to_string(), + ) + .build() + .start() + .await; + + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success()); + + // Without TLS, subgraph should be reached via HTTP/1.1 + let subgraph_requests = subgraphs + .get_requests_log("accounts") + .expect("Expected requests sent to accounts subgraph"); + assert_eq!(subgraph_requests.len(), 1); + assert_eq!( + subgraph_requests[0].http_version, + http::Version::HTTP_11, + "Plain HTTP should use HTTP/1.1, got {:?}", + subgraph_requests[0].http_version + ); + } + + /// Verify that h2c (HTTP/2 cleartext) works when allow_only_http2 is enabled globally for all subgraphs. + #[ntex::test] + async fn h2c_router_to_subgraph_global_config() { + let subgraphs = TestSubgraphs::builder() + .with_http2_only() + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + all: + allow_only_http2: true + "# + .to_string(), + ) + .build() + .start() + .await; + + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success()); + + let body = resp.string_body().await; + assert!( + body.contains("Uri Goldshtein"), + "Response should contain expected data, got: {}", + body + ); + + // Verify the subgraph received the request over HTTP/2 (h2c) + let subgraph_requests = subgraphs + .get_requests_log("accounts") + .expect("Expected requests sent to accounts subgraph"); + assert_eq!(subgraph_requests.len(), 1); + assert_eq!( + subgraph_requests[0].http_version, + http::Version::HTTP_2, + "h2c: Router→Subgraph should use HTTP/2, got {:?}", + subgraph_requests[0].http_version + ); + } + + /// Verify that h2c works when allow_only_http2 is enabled for a specific subgraph. + #[ntex::test] + async fn h2c_router_to_subgraph_per_subgraph_config() { + let subgraphs = TestSubgraphs::builder() + .with_http2_only() + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true + "# + .to_string(), + ) + .build() + .start() + .await; + + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + assert!(resp.status().is_success()); + + let body = resp.string_body().await; + assert!( + body.contains("Uri Goldshtein"), + "Response should contain expected data, got: {}", + body + ); + + // Verify the subgraph received the request over HTTP/2 (h2c) + let subgraph_requests = subgraphs + .get_requests_log("accounts") + .expect("Expected requests sent to accounts subgraph"); + assert_eq!(subgraph_requests.len(), 1); + assert_eq!( + subgraph_requests[0].http_version, + http::Version::HTTP_2, + "h2c: Router→Subgraph should use HTTP/2, got {:?}", + subgraph_requests[0].http_version + ); + } + + /// Verify that without allow_only_http2 flag, plain HTTP to an h2c subgraph fails + /// (the router defaults to HTTP/1.1 and the h2c-only subgraph rejects it). + #[ntex::test] + async fn h2c_subgraph_rejects_http1_without_flag() { + let subgraphs = TestSubgraphs::builder() + .with_http2_only() + .build() + .start() + .await; + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + "# + .to_string(), + ) + .build() + .start() + .await; + + let resp = router + .send_graphql_request("{ me { name } }", None, None) + .await; + + // The request should fail because the router sent HTTP/1.1 + // but the subgraph only accepts HTTP/2 + let body = resp.string_body().await; + assert!( + body.contains("error") || body.contains("FETCH_ERROR"), + "Expected an error when sending HTTP/1.1 to h2c-only subgraph, got: {}", + body + ); + } +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index cbbe6981f..2f640afa3 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -23,6 +23,8 @@ mod hive_cdn_supergraph; #[cfg(test)] mod http; #[cfg(test)] +mod http2; +#[cfg(test)] mod http_callback; #[cfg(test)] mod introspection; diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index b802b3adc..6b71dd258 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -13,11 +13,13 @@ use ntex::{ web::{self, test}, ws::WsConnection, }; +use rcgen::generate_simple_self_signed; use reqwest::header::{ACCEPT, CONTENT_TYPE}; use sonic_rs::json; use std::{ any::Any, future::Future, + io::Write, marker::PhantomData, net::SocketAddr, path::PathBuf, @@ -220,6 +222,7 @@ pub struct RequestLike { pub headers: http::HeaderMap, #[allow(unused)] pub body: Option, + pub http_version: http::Version, } pub struct ResponseLike { @@ -250,6 +253,7 @@ pub struct TestSubgraphsBuilder { on_request: Option>, rustls_config: Option, delay: Option, + http2_only: bool, } impl TestSubgraphsBuilder { @@ -259,6 +263,7 @@ impl TestSubgraphsBuilder { rustls_config: None, delay: None, subscriptions_protocol: HTTPStreamingSubscriptionProtocol::default(), + http2_only: false, } } @@ -294,12 +299,21 @@ impl TestSubgraphsBuilder { self } + /// Enables HTTP/2 only mode (h2c) for the test subgraph server. + /// When enabled, the server will only accept HTTP/2 connections over plain TCP. + #[allow(unused)] + pub fn with_http2_only(mut self) -> Self { + self.http2_only = true; + self + } + pub fn build(self) -> TestSubgraphs { TestSubgraphs { on_request: self.on_request, rustls_config: self.rustls_config, delay: self.delay, subscriptions_protocol: self.subscriptions_protocol, + http2_only: self.http2_only, handle: None, _state: PhantomData, } @@ -323,6 +337,7 @@ pub struct TestSubgraphs { on_request: Option>, rustls_config: Option, delay: Option, + http2_only: bool, handle: Option, _state: PhantomData, } @@ -345,6 +360,7 @@ async fn record_requests( let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); let header_map = parts.headers.clone(); + let http_version = parts.version; let record = RequestLike { path, headers: header_map, @@ -353,6 +369,7 @@ async fn record_requests( } else { Some(body_bytes.clone()) }, + http_version, }; state.request_log.entry(subgraph).or_default().push(record); @@ -373,6 +390,7 @@ async fn handle_on_request( path: path.clone(), headers: parts.headers.clone(), body: None, // TODO: do we really care about the body? + http_version: parts.version, }; if let Some(new_resp) = on_request(req) { @@ -441,6 +459,13 @@ impl TestSubgraphs { .serve(app.into_make_service()) .await .expect("failed to start subgraphs server"); + } else if self.http2_only { + axum_server::bind(addr) + .http2_only() + .handle(server_handle_clone.clone()) + .serve(app.into_make_service()) + .await + .expect("failed to start subgraphs h2c server"); } else { axum_server::bind(addr) .handle(server_handle_clone.clone()) @@ -460,6 +485,7 @@ impl TestSubgraphs { rustls_config: rustls_config_clone, delay: self.delay, subscriptions_protocol: self.subscriptions_protocol, + http2_only: self.http2_only, handle: Some(TestSubgraphsHandle { server_handle, addr, @@ -1070,3 +1096,70 @@ pub async fn wait_until_mock_matched(mock: &Mock) -> Result<(), String> { } } } + +pub struct GeneratedKeyPair { + pub cert_file: NamedTempFile, + pub cert_file_path: String, + pub cert_pem: String, + pub key_file: NamedTempFile, + pub key_file_path: String, + pub key_pem: String, +} + +pub async fn generate_keypair() -> GeneratedKeyPair { + let cert_key = generate_simple_self_signed(vec![ + "127.0.0.1".to_string(), + "localhost".to_string(), + "0.0.0.0".to_string(), + ]) + .expect("Failed to generate self-signed certificate"); + + let mut cert_file = + NamedTempFile::new().expect("Failed to create temporary file for certificate"); + let cert = cert_key.cert; + let cert_pem = cert.pem(); + let _ = cert_file + .write(cert_pem.as_bytes()) + .expect("Failed to write certificate to temporary file"); + + let mut key_file = + NamedTempFile::new().expect("Failed to create temporary file for private key"); + let key = cert_key.signing_key; + let key_pem = key.serialize_pem(); + let _ = key_file + .write(key_pem.as_bytes()) + .expect("Failed to write private key to temporary file"); + + GeneratedKeyPair { + cert_file_path: cert_file + .path() + .to_str() + .expect("Failed to convert cert file path to string") + .to_string(), + cert_file, + cert_pem, + key_file_path: key_file + .path() + .to_str() + .expect("Failed to convert key file path to string") + .to_string(), + key_file, + key_pem, + } +} + +pub async fn generate_tls_subgraph() -> (TestSubgraphs, GeneratedKeyPair) { + let generated_key_pair = generate_keypair().await; + let rustls_config = RustlsConfig::from_pem_file( + &generated_key_pair.cert_file_path, + &generated_key_pair.key_file_path, + ) + .await + .expect("Failed to create RustlsConfig from PEM files"); + let subgraphs = TestSubgraphs::builder() + .with_rustls_config(rustls_config) + .build() + .start() + .await; + (subgraphs, generated_key_pair) +} diff --git a/e2e/src/tls.rs b/e2e/src/tls.rs index ac35b246f..d0a914a7f 100644 --- a/e2e/src/tls.rs +++ b/e2e/src/tls.rs @@ -4,7 +4,6 @@ mod tls_tests { use axum_server::tls_rustls::RustlsConfig; use hive_router::init_rustls_crypto_provider; - use rcgen::generate_simple_self_signed; use rustls::{ pki_types::{pem::PemObject, PrivateKeyDer}, server::WebPkiClientVerifier, @@ -14,59 +13,10 @@ mod tls_tests { use tempfile::NamedTempFile; use tonic::transport::CertificateDer; - use crate::testkit::{some_header_map, ClientResponseExt, Started, TestRouter, TestSubgraphs}; - - struct GeneratedKeyPair { - cert_file: NamedTempFile, - cert_file_path: String, - cert_pem: String, - key_file: NamedTempFile, - key_file_path: String, - key_pem: String, - } - - async fn generate_keypair() -> GeneratedKeyPair { - let cert_key = generate_simple_self_signed(vec![ - "127.0.0.1".to_string(), - "localhost".to_string(), - "0.0.0.0".to_string(), - ]) - .expect("Failed to generate self-signed certificate"); - - let mut cert_file = - NamedTempFile::new().expect("Failed to create temporary file for certificate"); - let cert = cert_key.cert; - let cert_pem = cert.pem(); - cert_file - .write(cert_pem.as_bytes()) - .expect("Failed to write certificate to temporary file"); - - let mut key_file = - NamedTempFile::new().expect("Failed to create temporary file for private key"); - - let key = cert_key.signing_key; - let key_str = key.serialize_pem(); - key_file - .write(key_str.as_bytes()) - .expect("Failed to write private key to temporary file"); - - GeneratedKeyPair { - cert_file_path: cert_file - .path() - .to_str() - .expect("Failed to convert certificate file path to string") - .to_string(), - cert_file, - cert_pem, - key_file_path: key_file - .path() - .to_str() - .expect("Failed to convert private key file path to string") - .to_string(), - key_file, - key_pem: key_str, - } - } + use crate::testkit::{ + generate_keypair, generate_tls_subgraph, some_header_map, ClientResponseExt, + GeneratedKeyPair, TestRouter, TestSubgraphs, + }; // Setup TLS on router // And send a request from a client, that has the router's certificate configured as a trusted root, to the router @@ -117,22 +67,6 @@ mod tls_tests { , @r#"{"data":{"me":{"name":"Uri Goldshtein"}}}"#); } - async fn generate_tls_subgraph() -> (TestSubgraphs, GeneratedKeyPair) { - let generated_key_pair = generate_keypair().await; - let rustls_config = RustlsConfig::from_pem_file( - &generated_key_pair.cert_file_path, - &generated_key_pair.key_file_path, - ) - .await - .expect("Failed to create RustlsConfig from PEM files"); - let subgraphs = TestSubgraphs::builder() - .with_rustls_config(rustls_config) - .build() - .start() - .await; - (subgraphs, generated_key_pair) - } - /// Setup TLS on a subgraph /// Configure the router to trust the subgraph's certificate authority /// Send a request to the router that requires communication with the TLS-enabled subgraph and verify that the request succeeds, diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index b015eb29f..5a78786b7 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -83,13 +83,17 @@ impl SubgraphExecutorMap { global_timeout: DurationOrProgram, telemetry_context: Arc, ) -> Result { - let client: HttpClient = Client::builder(TokioExecutor::new()) + let mut client_builder = Client::builder(TokioExecutor::new()); + client_builder .pool_timer(TokioTimer::new()) .pool_idle_timeout(config.traffic_shaping.all.pool_idle_timeout) - .pool_max_idle_per_host(config.traffic_shaping.max_connections_per_host) - .build(build_https_connector( - config.traffic_shaping.all.tls.as_ref(), - )?); + .pool_max_idle_per_host(config.traffic_shaping.max_connections_per_host); + if config.traffic_shaping.all.allow_only_http2 { + client_builder.http2_only(true); + } + let client: HttpClient = client_builder.build(build_https_connector( + config.traffic_shaping.all.tls.as_ref(), + )?); let max_connections_per_host = config.traffic_shaping.max_connections_per_host; @@ -549,21 +553,28 @@ impl SubgraphExecutorMap { let pool_idle_timeout = subgraph_config .pool_idle_timeout .unwrap_or(self.config.traffic_shaping.all.pool_idle_timeout); - // Override client only if pool idle timeout is customized or TLS config is provided + // Override client only if pool idle timeout is customized, TLS config is provided, or allow_only_http2 differs + let subgraph_allow_only_http2 = subgraph_config + .allow_only_http2 + .unwrap_or(self.config.traffic_shaping.all.allow_only_http2); if pool_idle_timeout != self.config.traffic_shaping.all.pool_idle_timeout || subgraph_config.tls.is_some() + || subgraph_allow_only_http2 != self.config.traffic_shaping.all.allow_only_http2 { let tls_config = get_merged_tls_config( self.config.traffic_shaping.all.tls.as_ref(), subgraph_config.tls.as_ref(), ); - config.client = Arc::new( - Client::builder(TokioExecutor::new()) - .pool_timer(TokioTimer::new()) - .pool_idle_timeout(pool_idle_timeout) - .pool_max_idle_per_host(self.max_connections_per_host) - .build(build_https_connector(tls_config.as_ref())?), - ); + let mut client_builder = Client::builder(TokioExecutor::new()); + client_builder + .pool_timer(TokioTimer::new()) + .pool_idle_timeout(pool_idle_timeout) + .pool_max_idle_per_host(self.max_connections_per_host); + if subgraph_allow_only_http2 { + client_builder.http2_only(true); + } + config.client = + Arc::new(client_builder.build(build_https_connector(tls_config.as_ref())?)); } // Apply other subgraph-specific overrides diff --git a/lib/router-config/src/traffic_shaping.rs b/lib/router-config/src/traffic_shaping.rs index 348766d98..280c40eeb 100644 --- a/lib/router-config/src/traffic_shaping.rs +++ b/lib/router-config/src/traffic_shaping.rs @@ -93,6 +93,14 @@ pub struct TrafficShapingExecutorSubgraphConfig { #[serde(skip_serializing_if = "Option::is_none")] pub tls: Option, + + /// Forces HTTP/2 for requests to subgraphs. + /// + /// For plain HTTP, it will use HTTP/2 cleartext (h2c). + /// For HTTPS, it also requires HTTP/2. + /// This will make the subgraph requests never fall back to HTTP/1.1, + /// and will fail if the subgraph doesn't support HTTP/2. + pub allow_only_http2: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] @@ -137,6 +145,15 @@ pub struct TrafficShapingExecutorGlobalConfig { #[serde(skip_serializing_if = "Option::is_none")] pub tls: Option, + + /// Forces HTTP/2 for requests to subgraphs. + /// + /// For plain HTTP, it will use HTTP/2 cleartext (h2c). + /// For HTTPS, it also requires HTTP/2. + /// This will make the subgraph requests never fall back to HTTP/1.1, + /// and will fail if the subgraph doesn't support HTTP/2. + #[serde(default)] + pub allow_only_http2: bool, } fn default_subgraph_pool_idle_timeout() -> Option { @@ -168,6 +185,7 @@ impl Default for TrafficShapingExecutorGlobalConfig { dedupe_enabled: default_dedupe_enabled(), request_timeout: default_request_timeout(), tls: None, + allow_only_http2: false, } } } From b009c22e1b658fa69e21158d03a553c5782bafbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:04:20 +0300 Subject: [PATCH 60/76] chore(deps): bump the cargo group across 2 directories with 2 updates (#942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the cargo group with 1 update in the / directory: [rustls-webpki](https://github.com/rustls/webpki). Bumps the cargo group with 2 updates in the /apollo-router-workspace directory: [rustls-webpki](https://github.com/rustls/webpki) and [thin-vec](https://github.com/mozilla/thin-vec). Updates `rustls-webpki` from 0.103.12 to 0.103.13
Release notes

Sourced from rustls-webpki's releases.

0.103.13

What's Changed

Full Changelog: https://github.com/rustls/webpki/compare/v/0.103.12...v/0.103.13

Commits
  • 2879b2c Prepare 0.103.13
  • 2c49773 Improve tests for padding of BitStringFlags
  • 4e3c0b3 Correct validation of BIT STRING constraints
  • 39c91d2 Actually fail closed for URI matching against excluded subtrees
  • See full diff in compare view

Updates `rustls-webpki` from 0.103.12 to 0.103.13
Release notes

Sourced from rustls-webpki's releases.

0.103.13

What's Changed

Full Changelog: https://github.com/rustls/webpki/compare/v/0.103.12...v/0.103.13

Commits
  • 2879b2c Prepare 0.103.13
  • 2c49773 Improve tests for padding of BitStringFlags
  • 4e3c0b3 Correct validation of BIT STRING constraints
  • 39c91d2 Actually fail closed for URI matching against excluded subtrees
  • See full diff in compare view

Updates `thin-vec` from 0.2.14 to 0.2.16
Changelog

Sourced from thin-vec's changelog.

Version 0.2.16 (2026-04-14)

  • Fix reserve() on auto arrays in gecko-ffi mode.
  • Fix two double-drop issues with ThinVec::clear() and ThinVec::into_iter() when the Drop implementation of the item panics.

Version 0.2.15 (2026-04-08)

  • Support AutoTArrays created from Rust in Gecko FFI mode.
  • Add extract_if.
  • Add const new() support behind feature flag.
  • Fix thin_vec macro not being hygienic when recursing
  • Improve extend() performance.
Commits
  • 3c96f1e chore: Bump version to v0.2.16
  • df64748 Fix two panic=unwind issues.
  • 4e3a217 ci: Ignore msrv job for now since it just hangs trying to pull deps.
  • 63c2f5f gecko-ffi: Fix auto-t-array push.
  • 6797813 tests: Appease clippy.
  • af81b17 ci: Don't use actions-rs/{cargo,clippy-check} as it's not allowed in mozilla/...
  • 360f9ef Update repository URL and various cleanups
  • 70bcca0 chore: Bump version to v0.2.15
  • 322423b Fix miri error on extract_if().
  • eca5334 Don't make push_unchecked public.
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/graphql-hive/router/network/alerts).
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Arda TANRIKULU Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/bump_rand_dep.md | 6 + Cargo.lock | 4 +- apollo-router-workspace/Cargo.lock | 238 ++++++++++++++++-- apollo-router-workspace/bin/router/Cargo.toml | 2 +- .../bin/router/src/persisted_documents.rs | 2 +- .../bin/router/src/usage.rs | 2 +- 6 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 .changeset/bump_rand_dep.md diff --git a/.changeset/bump_rand_dep.md b/.changeset/bump_rand_dep.md new file mode 100644 index 000000000..d45d21c67 --- /dev/null +++ b/.changeset/bump_rand_dep.md @@ -0,0 +1,6 @@ +--- +apollo-router-hive-fork: patch +--- + +Update `rand` dependency to fix a security vulnerability alert; +[GHSA-cq8v-f236-94qc](https://github.com/advisories/GHSA-cq8v-f236-94qc) diff --git a/Cargo.lock b/Cargo.lock index 151983694..771e0ed7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5904,9 +5904,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock index 30237b869..0c739cbcd 100644 --- a/apollo-router-workspace/Cargo.lock +++ b/apollo-router-workspace/Cargo.lock @@ -267,7 +267,7 @@ dependencies = [ "prost", "prost-types", "proteus", - "rand 0.9.3", + "rand 0.9.4", "regex", "reqwest", "rhai", @@ -1206,6 +1206,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -1458,6 +1469,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc16" version = "0.4.0" @@ -1548,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -2384,11 +2404,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "getset" version = "0.1.6" @@ -2635,7 +2669,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.3", + "rand 0.9.4", "ring", "thiserror 2.0.18", "tinyvec", @@ -2657,7 +2691,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.3", + "rand 0.9.4", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -2679,7 +2713,7 @@ dependencies = [ "httpmock", "jsonschema 0.29.1", "lazy_static", - "rand 0.9.3", + "rand 0.10.1", "schemars 1.0.4", "serde", "serde_json", @@ -3095,6 +3129,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3449,6 +3489,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "levenshtein" version = "1.0.5" @@ -4154,7 +4200,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.3", + "rand 0.9.4", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4684,7 +4730,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash 2.1.1", "rustls", @@ -4725,6 +4771,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -4738,14 +4790,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4784,6 +4847,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "recloser" version = "1.3.1" @@ -5036,7 +5105,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" dependencies = [ - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -5259,9 +5328,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -5599,7 +5668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5610,7 +5679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5945,9 +6014,9 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "thin-vec" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" dependencies = [ "serde", ] @@ -6448,7 +6517,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.3", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", @@ -6745,7 +6814,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -6806,6 +6884,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6819,6 +6919,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "semver", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -7386,6 +7498,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wmi" version = "0.14.5" diff --git a/apollo-router-workspace/bin/router/Cargo.toml b/apollo-router-workspace/bin/router/Cargo.toml index 6cf5c3c3a..6bede18c5 100644 --- a/apollo-router-workspace/bin/router/Cargo.toml +++ b/apollo-router-workspace/bin/router/Cargo.toml @@ -31,7 +31,7 @@ tokio = { version = "1.36.0", features = ["full"] } tower = { version = "0.5", features = ["full"] } http = "1" http-body-util = "0.1" -rand = "0.9.3" +rand = "0.10.1" tokio-util = "0.7.16" [dev-dependencies] diff --git a/apollo-router-workspace/bin/router/src/persisted_documents.rs b/apollo-router-workspace/bin/router/src/persisted_documents.rs index 67a7ddb2d..ed88c6ca8 100644 --- a/apollo-router-workspace/bin/router/src/persisted_documents.rs +++ b/apollo-router-workspace/bin/router/src/persisted_documents.rs @@ -4,9 +4,9 @@ use apollo_router::layers::ServiceBuilderExt; use apollo_router::plugin::Plugin; use apollo_router::plugin::PluginInit; use apollo_router::services::router; +use apollo_router::services::router::body::from_bytes; use apollo_router::services::router::Body; use apollo_router::Context; -use apollo_router::services::router::body::from_bytes; use core::ops::Drop; use futures::FutureExt; use hive_console_sdk::persisted_documents::PersistedDocumentsError; diff --git a/apollo-router-workspace/bin/router/src/usage.rs b/apollo-router-workspace/bin/router/src/usage.rs index 5af384947..c3c6cd971 100644 --- a/apollo-router-workspace/bin/router/src/usage.rs +++ b/apollo-router-workspace/bin/router/src/usage.rs @@ -11,7 +11,7 @@ use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent}; use hive_console_sdk::graphql_tools::parser::parse_schema; use hive_console_sdk::graphql_tools::parser::schema::Document; use http::HeaderValue; -use rand::Rng; +use rand::RngExt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashSet; From 1b7fcbe853d3a5b63c4278aac9e45b3cbd4f30ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:12:57 +0300 Subject: [PATCH 61/76] chore(deps-dev): bump the npm_and_yarn group across 1 directory with 2 updates (#945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm_and_yarn group with 2 updates in the / directory: [axios](https://github.com/axios/axios) and [follow-redirects](https://github.com/follow-redirects/follow-redirects). Updates `axios` from 1.14.0 to 1.15.2
Release notes

Sourced from axios's releases.

v1.15.2

This release delivers prototype-pollution hardening for the Node HTTP adapter, adds an opt-in allowedSocketPaths allowlist to mitigate SSRF via Unix domain sockets, fixes a keep-alive socket memory leak, and ships supply-chain hardening across CI and security docs.

🔒 Security Fixes

  • Prototype Pollution Hardening (HTTP Adapter): Hardened the Node HTTP adapter and resolveConfig/mergeConfig/validator paths to read only own properties and use null-prototype config objects, preventing polluted auth, baseURL, socketPath, beforeRedirect, and insecureHTTPParser from influencing requests. (#10779)
  • SSRF via socketPath: Rejects non-string socketPath values and adds an opt-in allowedSocketPaths config option to restrict permitted Unix domain socket paths, returning AxiosError ERR_BAD_OPTION_VALUE on mismatch. (#10777)
  • Supply-chain Hardening: Added .npmrc with ignore-scripts=true, lockfile lint CI, non-blocking reproducible build diff, scoped CODEOWNERS, expanded SECURITY.md/THREATMODEL.md with provenance verification (npm audit signatures), 60-day resolution policy, and maintainer incident-response runbook. (#10776)

🚀 New Features

  • allowedSocketPaths Config Option: New request config option (and TypeScript types) to allowlist Unix domain socket paths used by the Node http adapter; backwards compatible when unset. (#10777)

🐛 Bug Fixes

  • Keep-alive Socket Memory Leak: Installs a single per-socket error listener tracking the active request via kAxiosSocketListener/kAxiosCurrentReq, eliminating per-request listener accumulation, MaxListenersExceededWarning, and linear heap growth under concurrent or long-running keep-alive workloads (fixes #10780). (#10788)

🔧 Maintenance & Chores

  • Changelog: Updated CHANGELOG.md with v1.15.1 release notes. (#10781)

Full Changelog

v1.15.1

This release ships a coordinated set of security hardening fixes across headers, body/redirect limits, multipart handling, and XSRF/prototype-pollution vectors, alongside a broad sweep of bug fixes, test migrations, and threat-model documentation updates.

🔒 Security Fixes

  • Header Injection Hardening: Tightened validation and sanitisation across request header construction to close the header-injection attack surface. (#10749)
  • CRLF Stripping in Multipart Headers: Correctly strips CR/LF from multipart header values to prevent injection via field names and filenames. (#10758)
  • Prototype Pollution / Auth Bypass: Replaced unsafe in checks with hasOwnProperty to prevent authentication bypass via prototype pollution on config objects, with additional regression tests. (#10761, #10760)
  • withXSRFToken Truthy Bypass: Short-circuits on any truthy non-boolean value, so an ambiguous config no longer silently leaks the XSRF token cross-origin. (#10762)
  • maxBodyLength With Zero Redirects: Enforces maxBodyLength even when maxRedirects is set to 0, closing a bypass path for oversized request bodies. (#10753)
  • Streamed Response maxContentLength Bypass: Applies maxContentLength to streamed responses that previously bypassed the cap. (#10754)
  • Follow-up CVE Completion: Completes an earlier incomplete CVE fix to fully close the regression window. (#10755)

🚀 New Features

  • AI-Based Docs Translations: Initial scaffold for AI-assisted translations of the documentation site. (#10705)
  • Location Request Header Type: Adds Location to CommonRequestHeadersList for accurate typing of redirect-aware requests. (#7528)

🐛 Bug Fixes

  • FormData Handling: Removes Content-Type when no boundary is present on FormData fetch requests, supports multi-select fields, cancels request.body instead of the source stream on fetch abort, and fixes a recursion bug in form-data serialisation. (#7314, #10676, #10702, #10726)
  • HTTP Adapter: Handles socket-only request errors without leaking keep-alive listeners. (#10576)
  • Progress Events: Clamps loaded to total for computable upload/download progress events. (#7458)
  • Types: Aligns runWhen type with the runtime behaviour in InterceptorManager and makes response header keys case-insensitive. (#7529, #10677)
  • buildFullPath: Uses strict equality in the base/relative URL check. (#7252)
  • AxiosURLSearchParams Regex: Improves the regex used for param serialisation to avoid edge-case mismatches. (#10736)
  • Resilient Value Parsing: Parses out header/config values instead of throwing on malformed input. (#10687)

... (truncated)

Changelog

Sourced from axios's changelog.

v1.15.2 - April 21, 2026

This release delivers prototype-pollution hardening for the Node HTTP adapter, adds an opt-in allowedSocketPaths allowlist to mitigate SSRF via Unix domain sockets, fixes a keep-alive socket memory leak, and ships supply-chain hardening across CI and security docs.

🔒 Security Fixes

  • Prototype Pollution Hardening (HTTP Adapter): Hardened the Node HTTP adapter and resolveConfig/mergeConfig/validator paths to read only own properties and use null-prototype config objects, preventing polluted auth, baseURL, socketPath, beforeRedirect, and insecureHTTPParser from influencing requests. (#10779)
  • SSRF via socketPath: Rejects non-string socketPath values and adds an opt-in allowedSocketPaths config option to restrict permitted Unix domain socket paths, returning AxiosError ERR_BAD_OPTION_VALUE on mismatch. (#10777)
  • Supply-chain Hardening: Added .npmrc with ignore-scripts=true, lockfile lint CI, non-blocking reproducible build diff, scoped CODEOWNERS, expanded SECURITY.md/THREATMODEL.md with provenance verification (npm audit signatures), 60-day resolution policy, and maintainer incident-response runbook. (#10776)

🚀 New Features

  • allowedSocketPaths Config Option: New request config option (and TypeScript types) to allowlist Unix domain socket paths used by the Node http adapter; backwards compatible when unset. (#10777)

🐛 Bug Fixes

  • Keep-alive Socket Memory Leak: Installs a single per-socket error listener tracking the active request via kAxiosSocketListener/kAxiosCurrentReq, eliminating per-request listener accumulation, MaxListenersExceededWarning, and linear heap growth under concurrent or long-running keep-alive workloads (fixes #10780). (#10788)

🔧 Maintenance & Chores

  • Changelog: Updated CHANGELOG.md with v1.15.1 release notes. (#10781)

Full Changelog


v1.15.1 - April 19, 2026

This release ships a coordinated set of security hardening fixes across headers, body/redirect limits, multipart handling, and XSRF/prototype-pollution vectors, alongside a broad sweep of bug fixes, test migrations, and threat-model documentation updates.

🔒 Security Fixes

  • Header Injection Hardening: Tightened validation and sanitisation across request header construction to close the header-injection attack surface. (#10749)

  • CRLF Stripping in Multipart Headers: Correctly strips CR/LF from multipart header values to prevent injection via field names and filenames. (#10758)

  • Prototype Pollution / Auth Bypass: Replaced unsafe in checks with hasOwnProperty to prevent authentication bypass via prototype pollution on config objects, with additional regression tests. (#10761, #10760)

  • withXSRFToken Truthy Bypass: Short-circuits on any truthy non-boolean value, so an ambiguous config no longer silently leaks the XSRF token cross-origin. (#10762)

  • maxBodyLength With Zero Redirects: Enforces maxBodyLength even when maxRedirects is set to 0, closing a bypass path for oversized request bodies. (#10753)

  • Streamed Response maxContentLength Bypass: Applies maxContentLength to streamed responses that previously bypassed the cap. (#10754)

  • Follow-up CVE Completion: Completes an earlier incomplete CVE fix to fully close the regression window. (#10755)

🚀 New Features

  • AI-Based Docs Translations: Initial scaffold for AI-assisted translations of the documentation site. (#10705)

... (truncated)

Commits

Updates `follow-redirects` from 1.15.11 to 1.16.0
Commits
  • 0c23a22 Release version 1.16.0 of the npm package.
  • 844c4d3 Add sensitiveHeaders option.
  • 5e8b8d0 ci: add Node.js 24.x to the CI matrix
  • 7953e22 ci: upgrade GitHub Actions to use setup-node@v6 and checkout@v6
  • 86dc1f8 Sanitizing input.
  • See full diff in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/graphql-hive/router/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- package-lock.json | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ebab7614..082ef9101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ }, "lib/node-addon": { "name": "@graphql-hive/router-query-planner", - "version": "0.0.17", + "version": "0.0.23", "license": "MIT", "devDependencies": { "@napi-rs/cli": "3.5.1", @@ -1883,7 +1883,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2100,7 +2099,6 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2316,9 +2314,9 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { @@ -2928,9 +2926,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -4601,8 +4599,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/typanion": { "version": "3.14.0", From 4cbd0ccfc1f02a602da8b5328a1b9e4969f5ae5c Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Mon, 27 Apr 2026 17:37:34 +0300 Subject: [PATCH 62/76] fix(release): adjust changeset for h2c Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/h2c_support.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/h2c_support.md b/.changeset/h2c_support.md index ebf958335..200dc4b97 100644 --- a/.changeset/h2c_support.md +++ b/.changeset/h2c_support.md @@ -2,6 +2,7 @@ hive-router-config: patch hive-router-plan-executor: patch hive-router: patch +hive-router-internal: patch --- # HTTP/2 Cleartext (h2c) Support for Subgraph Connections From 34a9040c69af90d88a269855eee4239269d7d295 Mon Sep 17 00:00:00 2001 From: "knope-bot[bot]" <152252888+knope-bot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:39:21 +0300 Subject: [PATCH 63/76] chore(release): router crates and artifacts (#943) Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/bump_rand_dep.md | 6 ---- .changeset/h2c_support.md | 35 ------------------- Cargo.lock | 8 ++--- apollo-router-workspace/Cargo.lock | 2 +- apollo-router-workspace/bin/router/Cargo.toml | 2 +- bin/router/CHANGELOG.md | 33 +++++++++++++++++ bin/router/Cargo.toml | 8 ++--- lib/executor/CHANGELOG.md | 33 +++++++++++++++++ lib/executor/Cargo.toml | 6 ++-- lib/internal/CHANGELOG.md | 33 +++++++++++++++++ lib/internal/Cargo.toml | 4 +-- lib/router-config/CHANGELOG.md | 33 +++++++++++++++++ lib/router-config/Cargo.toml | 2 +- 13 files changed, 148 insertions(+), 57 deletions(-) delete mode 100644 .changeset/bump_rand_dep.md delete mode 100644 .changeset/h2c_support.md diff --git a/.changeset/bump_rand_dep.md b/.changeset/bump_rand_dep.md deleted file mode 100644 index d45d21c67..000000000 --- a/.changeset/bump_rand_dep.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -apollo-router-hive-fork: patch ---- - -Update `rand` dependency to fix a security vulnerability alert; -[GHSA-cq8v-f236-94qc](https://github.com/advisories/GHSA-cq8v-f236-94qc) diff --git a/.changeset/h2c_support.md b/.changeset/h2c_support.md deleted file mode 100644 index 200dc4b97..000000000 --- a/.changeset/h2c_support.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -hive-router-config: patch -hive-router-plan-executor: patch -hive-router: patch -hive-router-internal: patch ---- - -# HTTP/2 Cleartext (h2c) Support for Subgraph Connections - -Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. - -This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. - -## Configuration - -The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. - -### Global (all subgraphs) - -```yaml -traffic_shaping: - all: - allow_only_http2: true -``` - -### Per-subgraph - -```yaml -traffic_shaping: - subgraphs: - accounts: - allow_only_http2: true -``` - -The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. diff --git a/Cargo.lock b/Cargo.lock index 771e0ed7a..7965a30de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "hive-router" -version = "0.0.50" +version = "0.0.51" dependencies = [ "ahash", "anyhow", @@ -2589,7 +2589,7 @@ dependencies = [ [[package]] name = "hive-router-config" -version = "0.0.31" +version = "0.0.32" dependencies = [ "config", "envconfig", @@ -2612,7 +2612,7 @@ dependencies = [ [[package]] name = "hive-router-internal" -version = "0.0.18" +version = "0.0.19" dependencies = [ "ahash", "async-trait", @@ -2653,7 +2653,7 @@ dependencies = [ [[package]] name = "hive-router-plan-executor" -version = "6.12.0" +version = "6.12.1" dependencies = [ "ahash", "async-stream", diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock index 0c739cbcd..355d3b2c4 100644 --- a/apollo-router-workspace/Cargo.lock +++ b/apollo-router-workspace/Cargo.lock @@ -2701,7 +2701,7 @@ dependencies = [ [[package]] name = "hive-apollo-router-plugin" -version = "3.0.3" +version = "3.0.4" dependencies = [ "anyhow", "apollo-router", diff --git a/apollo-router-workspace/bin/router/Cargo.toml b/apollo-router-workspace/bin/router/Cargo.toml index 6bede18c5..9f47afff2 100644 --- a/apollo-router-workspace/bin/router/Cargo.toml +++ b/apollo-router-workspace/bin/router/Cargo.toml @@ -5,7 +5,7 @@ repository = "https://github.com/graphql-hive/router" edition = "2021" license = "MIT" publish = true -version = "3.0.3" +version = "3.0.4" description = "Apollo-Router Plugin for integrating with Hive Console" [[bin]] diff --git a/bin/router/CHANGELOG.md b/bin/router/CHANGELOG.md index 27817bd52..f7a3a6be7 100644 --- a/bin/router/CHANGELOG.md +++ b/bin/router/CHANGELOG.md @@ -116,6 +116,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 0.0.51 (2026-04-27) + +### Fixes + +#### HTTP/2 Cleartext (h2c) Support for Subgraph Connections + +Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. + +This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. + +### Configuration + +The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. + +#### Global (all subgraphs) + +```yaml +traffic_shaping: + all: + allow_only_http2: true +``` + +#### Per-subgraph + +```yaml +traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true +``` + +The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. + ## 0.0.50 (2026-04-20) ### Features diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index 102081ecf..4c6bdd40f 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router" -version = "0.0.50" +version = "0.0.51" edition = "2021" description = "GraphQL router/gateway for Federation" license = "MIT" @@ -24,9 +24,9 @@ graphiql = [] [dependencies] hive-router-query-planner = { path = "../../lib/query-planner", version = "2.7.1" } -hive-router-plan-executor = { path = "../../lib/executor", version = "6.12.0" } -hive-router-config = { path = "../../lib/router-config", version = "0.0.31" } -hive-router-internal = { path = "../../lib/internal", version = "0.0.18" } +hive-router-plan-executor = { path = "../../lib/executor", version = "6.12.1" } +hive-router-config = { path = "../../lib/router-config", version = "0.0.32" } +hive-router-internal = { path = "../../lib/internal", version = "0.0.19" } hive-console-sdk = { path = "../../lib/hive-console-sdk", version = "0.3.9" } graphql-tools = { path = "../../lib/graphql-tools", version = "0.5.3" } diff --git a/lib/executor/CHANGELOG.md b/lib/executor/CHANGELOG.md index d0e84ac0e..f06ed8d84 100644 --- a/lib/executor/CHANGELOG.md +++ b/lib/executor/CHANGELOG.md @@ -94,6 +94,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other - *(deps)* update release-plz/action action to v0.5.113 ([#389](https://github.com/graphql-hive/router/pull/389)) +## 6.12.1 (2026-04-27) + +### Fixes + +#### HTTP/2 Cleartext (h2c) Support for Subgraph Connections + +Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. + +This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. + +### Configuration + +The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. + +#### Global (all subgraphs) + +```yaml +traffic_shaping: + all: + allow_only_http2: true +``` + +#### Per-subgraph + +```yaml +traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true +``` + +The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. + ## 6.12.0 (2026-04-20) ### Features diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index 83a2adcb0..a8134f325 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-plan-executor" -version = "6.12.0" +version = "6.12.1" edition = "2021" description = "GraphQL query planner executor for Federation specification" license = "MIT" @@ -16,8 +16,8 @@ doctest = false [dependencies] hive-router-query-planner = { path = "../query-planner", version = "2.7.1" } -hive-router-config = { path = "../router-config", version = "0.0.31" } -hive-router-internal = { path = "../internal", version = "0.0.18" } +hive-router-config = { path = "../router-config", version = "0.0.32" } +hive-router-internal = { path = "../internal", version = "0.0.19" } graphql-tools = { path = "../graphql-tools", version = "0.5.3" } async-trait = { workspace = true } diff --git a/lib/internal/CHANGELOG.md b/lib/internal/CHANGELOG.md index bd47b2dcf..0b5fbc422 100644 --- a/lib/internal/CHANGELOG.md +++ b/lib/internal/CHANGELOG.md @@ -1,3 +1,36 @@ +## 0.0.19 (2026-04-27) + +### Fixes + +#### HTTP/2 Cleartext (h2c) Support for Subgraph Connections + +Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. + +This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. + +### Configuration + +The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. + +#### Global (all subgraphs) + +```yaml +traffic_shaping: + all: + allow_only_http2: true +``` + +#### Per-subgraph + +```yaml +traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true +``` + +The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. + ## 0.0.18 (2026-04-20) ### Features diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index e05f9f687..94a4d8594 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-internal" -version = "0.0.18" +version = "0.0.19" edition = "2021" description = "GraphQL Hive Router internal crate" license = "MIT" @@ -15,7 +15,7 @@ authors = ["The Guild"] noop_otlp_exporter = [] [dependencies] -hive-router-config = { path = "../router-config", version = "0.0.31" } +hive-router-config = { path = "../router-config", version = "0.0.32" } sonic-rs = { workspace = true } vrl = { workspace = true } diff --git a/lib/router-config/CHANGELOG.md b/lib/router-config/CHANGELOG.md index 4b7613e03..2a6447656 100644 --- a/lib/router-config/CHANGELOG.md +++ b/lib/router-config/CHANGELOG.md @@ -66,6 +66,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - *(hive-router)* fix docker image issues ([#394](https://github.com/graphql-hive/router/pull/394)) +## 0.0.32 (2026-04-27) + +### Fixes + +#### HTTP/2 Cleartext (h2c) Support for Subgraph Connections + +Adds support for HTTP/2 cleartext (h2c) connections between the router and subgraphs via the new `allow_only_http2` configuration flag. When enabled, the router uses HTTP/2 prior knowledge to communicate with subgraphs over plain HTTP without TLS. + +This is useful in environments where subgraphs support HTTP/2 but TLS is not required, such as service meshes, internal networks, or sidecar proxies. + +### Configuration + +The flag can be set globally for all subgraphs or per-subgraph. Per-subgraph settings override the global default. + +#### Global (all subgraphs) + +```yaml +traffic_shaping: + all: + allow_only_http2: true +``` + +#### Per-subgraph + +```yaml +traffic_shaping: + subgraphs: + accounts: + allow_only_http2: true +``` + +The default value is `false`, preserving the existing behavior of using HTTP/1.1 for plain HTTP connections and negotiating HTTP/2 via ALPN for TLS connections. + ## 0.0.31 (2026-04-20) ### Features diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 7bd472c02..4fb6de40e 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hive-router-config" -version = "0.0.31" +version = "0.0.32" edition = "2021" publish = true license = "MIT" From 93b0f3ab6cbb0524e7baea9f607faea5b527a54b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 28 Apr 2026 10:05:28 +0200 Subject: [PATCH 64/76] chore: migrate tests to `nextest` (#931) How to install: https://nexte.st/docs/installation/pre-built-binaries/ Closes #819 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .cargo/config.toml | 10 +-- .config/nextest.toml | 12 +++ .github/actions/cargo-install/action.yaml | 44 +++++++++++ .github/actions/install-tools/action.yaml | 11 +++ .github/workflows/ci.yaml | 10 +-- e2e/src/body_limit.rs | 92 +++++++++++------------ e2e/src/testkit/mod.rs | 26 ------- 7 files changed, 122 insertions(+), 83 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 .github/actions/cargo-install/action.yaml create mode 100644 .github/actions/install-tools/action.yaml diff --git a/.cargo/config.toml b/.cargo/config.toml index 999b0c256..5a7b6f1dc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,10 +3,10 @@ router = "run --package hive-router" dev = "run --package qp-dev-cli" subgraphs = "run --package subgraphs" -test_qp = "test --package hive-router-query-planner" -test_all = "test --workspace --exclude e2e --exclude *-plugin-example" -test_e2e = "test --package e2e -- --test-threads=5" -test_qpe = "test --package hive-router-plan-executor" -test_plugin_examples = "test --package *-plugin-example -- --test-threads=5" +test_qp = "nextest run --package hive-router-query-planner" +test_all = "nextest run --workspace --exclude e2e --exclude *-plugin-example" +test_e2e = "nextest run --package e2e --test-threads 5" +test_qpe = "nextest run --package hive-router-plan-executor" +test_plugin_examples = "nextest run --package *-plugin-example --test-threads 5" "clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged" "router-config" = "run --release -p hive-router-config router-config.schema.json" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..461fb905d --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,12 @@ +# Minimum recommended version +nextest-version = "0.9.133" + +[profile.default] +# Show output (stdout/stderr) of failed tests +failure-output = "immediate" +# Show output of successful tests only when --no-capture is passed +success-output = "never" +# Don't stop on the first failure +fail-fast = false +# Mark as slow test after 10s and terminate after 3 retries +slow-timeout = { period = "10s", terminate-after = 3 } diff --git a/.github/actions/cargo-install/action.yaml b/.github/actions/cargo-install/action.yaml new file mode 100644 index 000000000..0c70f39fa --- /dev/null +++ b/.github/actions/cargo-install/action.yaml @@ -0,0 +1,44 @@ +name: "Run `cargo install`" +description: "Run `cargo install`" + +inputs: + crate: + description: "Crate to install" + required: true + version: + description: "Version to install" + required: true + +runs: + using: composite + steps: + - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: cache-restore + with: + path: ${{ runner.tool_cache }}/${{ inputs.crate }} + key: ${{ runner.os }}-${{ runner.arch }}-${{ inputs.crate }}-bin-${{ inputs.version }} + + - name: Add installation destination to PATH + shell: bash + run: echo "${{ runner.tool_cache }}/${{ inputs.crate }}/bin" >> $GITHUB_PATH + + - name: Run cargo install + shell: bash + # Install only on cache miss + if: steps.cache-restore.outputs.cache-hit != 'true' + run: | + cargo install \ + --root ${{ runner.tool_cache }}/${{ inputs.crate }} \ + --version ${{ inputs.version }} \ + --locked \ + ${{ inputs.crate }} + + # Only save these caches for the + # main branch and for release branches, + # and only on cache miss. + - name: Save Github Actions cache + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-')) && steps.cache-restore.outputs.cache-hit != 'true' + with: + path: ${{ runner.tool_cache }}/${{ inputs.crate }} + key: ${{ steps.cache-restore.outputs.cache-primary-key }} diff --git a/.github/actions/install-tools/action.yaml b/.github/actions/install-tools/action.yaml new file mode 100644 index 000000000..819a8a36c --- /dev/null +++ b/.github/actions/install-tools/action.yaml @@ -0,0 +1,11 @@ +name: "Install tools" +description: "Install tools required by our CI jobs" + +runs: + using: composite + steps: + - name: Install cargo-nextest + uses: ./.github/actions/cargo-install + with: + crate: cargo-nextest + version: 0.9.133 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5dd556fe9..459f57cf9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: setup rust uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: ./.github/actions/install-tools - name: test all run: cargo test_all timeout-minutes: 5 @@ -37,15 +38,13 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: setup rust uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: ./.github/actions/install-tools - name: test e2e - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + run: cargo test_e2e + timeout-minutes: 10 env: RUST_BACKTRACE: full RUST_LOG: debug - with: - timeout_minutes: 10 - max_attempts: 3 - command: cargo test_e2e plugin_examples: name: test / plugin examples @@ -56,6 +55,7 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: shared-key: ${{ hashFiles('**/Cargo.lock') }} + - uses: ./.github/actions/install-tools - name: test plugin examples run: cargo test_plugin_examples timeout-minutes: 10 diff --git a/e2e/src/body_limit.rs b/e2e/src/body_limit.rs index 03921eca4..5bfbad8a9 100644 --- a/e2e/src/body_limit.rs +++ b/e2e/src/body_limit.rs @@ -2,7 +2,7 @@ mod body_limit_e2e_tests { use ntex::web::WebResponseError; - use crate::testkit::{flakey, ClientResponseExt, TestRouter}; + use crate::testkit::{ClientResponseExt, TestRouter}; #[ntex::test] async fn should_return_payload_too_large_if_limit_exceeds_while_reading_the_stream() { let router = TestRouter::builder() @@ -50,57 +50,55 @@ mod body_limit_e2e_tests { #[ntex::test] async fn should_return_payload_too_large_if_content_length_header_exceeds_the_limit() { - flakey!(async { - let router = TestRouter::builder() - .inline_config( - r#" - supergraph: - source: file - path: supergraph.graphql - limits: - max_request_body_size: 1B - "#, - ) - .build() - .start() - .await; + let router = TestRouter::builder() + .inline_config( + r#" + supergraph: + source: file + path: supergraph.graphql + limits: + max_request_body_size: 1B + "#, + ) + .build() + .start() + .await; - // use send_body instead of send_json to avoid a race condition - // where ntex's send_json may encounter a "Disconnected" error in CI - // when the server closes the connection before reading the full body - // and responding with 413 - let body = sonic_rs::to_vec(&sonic_rs::json!({ - "query": "{ __typename }", - })) - .unwrap(); + // use send_body instead of send_json to avoid a race condition + // where ntex's send_json may encounter a "Disconnected" error in CI + // when the server closes the connection before reading the full body + // and responding with 413 + let body = sonic_rs::to_vec(&sonic_rs::json!({ + "query": "{ __typename }", + })) + .unwrap(); - let res = router - .serv() - .post(router.graphql_path()) - .header(http::header::CONTENT_TYPE, "application/json") - .send_body(body) - .await; + let res = router + .serv() + .post(router.graphql_path()) + .header(http::header::CONTENT_TYPE, "application/json") + .send_body(body) + .await; - match res { - Ok(res) => { - assert_eq!(res.status(), ntex::http::StatusCode::PAYLOAD_TOO_LARGE); - insta::assert_snapshot!(res.json_body_string_pretty().await, @r#" + match res { + Ok(res) => { + assert_eq!(res.status(), ntex::http::StatusCode::PAYLOAD_TOO_LARGE); + insta::assert_snapshot!(res.json_body_string_pretty().await, @r#" + { + "errors": [ { - "errors": [ - { - "message": "Content-Length exceeds the maximum allowed size: 1", - "extensions": { - "code": "PAYLOAD_TOO_LARGE_CONTENT_LENGTH" - } - } - ] - } - "#); - } - Err(e) => { - assert_eq!(e.status_code(), ntex::http::StatusCode::PAYLOAD_TOO_LARGE); + "message": "Content-Length exceeds the maximum allowed size: 1", + "extensions": { + "code": "PAYLOAD_TOO_LARGE_CONTENT_LENGTH" + } } + ] } - }) + "#); + } + Err(e) => { + assert_eq!(e.status_code(), ntex::http::StatusCode::PAYLOAD_TOO_LARGE); + } + } } } diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index 6b71dd258..e73767f8f 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -43,32 +43,6 @@ use hive_router_config::{ use hive_router_plan_executor::executors::websocket_client; use subgraphs::{subgraphs_app, HTTPStreamingSubscriptionProtocol}; -// utilities - -/// Retries the code wrapped 3 times before reporting the last error as failure. -#[macro_export] -macro_rules! flakey { - ($body:expr) => {{ - use futures::FutureExt; - let mut last_err = None; - let attempts = 3; - for attempt in 1..=attempts { - let result = std::panic::AssertUnwindSafe($body).catch_unwind().await; - match result { - Ok(_) => return, - Err(e) => { - eprintln!("Flakey attempt {}/{} failed", attempt, attempts); - last_err = Some(e); - } - } - } - std::panic::resume_unwind(last_err.unwrap()); - }}; -} - -// #[macro_export] always hoists to the crate root so we re-export it here module level -pub use flakey; - /// Binds a TCP listener to an OS-assigned port and returns that port number. /// The listener is immediately dropped, so the port is free for the caller to use. pub fn get_available_port() -> u16 { From 1616475a5e23e12f40faa56aeb943438072dd46c Mon Sep 17 00:00:00 2001 From: Michael Skorokhodov Date: Tue, 28 Apr 2026 11:20:56 +0200 Subject: [PATCH 65/76] enhancement(router): update lab to 0.1.6 (#934) Co-authored-by: Dotan Simha Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/enhancement_update_lab_to_016.md | 9 + bin/router/package-lock.json | 836 ++++++++++++++++---- bin/router/package.json | 2 +- 3 files changed, 698 insertions(+), 149 deletions(-) create mode 100644 .changeset/enhancement_update_lab_to_016.md diff --git a/.changeset/enhancement_update_lab_to_016.md b/.changeset/enhancement_update_lab_to_016.md new file mode 100644 index 000000000..923809b59 --- /dev/null +++ b/.changeset/enhancement_update_lab_to_016.md @@ -0,0 +1,9 @@ +--- +hive-router: patch +--- + +# enhancement: update lab to 0.1.6 + +#934 by @mskorokhodov + +Update hive lab to 0.1.6 to support new query plan visualization + fetch settings diff --git a/bin/router/package-lock.json b/bin/router/package-lock.json index 1a8fdc5bc..923402b38 100644 --- a/bin/router/package-lock.json +++ b/bin/router/package-lock.json @@ -6,9 +6,132 @@ "": { "name": "hive-router-js-deps", "devDependencies": { - "@graphql-hive/laboratory": "0.1.2" + "@graphql-hive/laboratory": "0.1.6" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.4.1.tgz", + "integrity": "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.2.8", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -52,27 +175,297 @@ "license": "MIT" }, "node_modules/@graphql-hive/laboratory": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.2.tgz", - "integrity": "sha512-Memzux8zIWGzyY7FPHTmf6GUrnL+AixpWYOCJg5AnSfWCjakM7rc+yB4RHgcm04Gbcqy7C3Zb+CcPn7YOgNNJA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.6.tgz", + "integrity": "sha512-K6bCM+RekKHpxnmhvDYypJsbgbaIXZ7ZlkRWqHv7iv0C7D8YhjzPBYTlezK7oQ9AA8CJUYVVWp1tZI6Oeq+wpQ==", "dev": true, "license": "MIT", "dependencies": { + "@base-ui/react": "^1.1.0", + "@graphql-tools/url-loader": "^9.1.0", "radix-ui": "^1.4.3", + "react-zoom-pan-pinch": "^3.7.0", "uuid": "^13.0.0" }, "peerDependencies": { "@tanstack/react-form": "^1.23.8", "date-fns": "^4.1.0", - "graphql-ws": "^6.0.6", "lucide-react": "^0.548.0", "lz-string": "^1.5.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", + "subscriptions-transport-ws": "^0.11.0", "tslib": "^2.8.1", "zod": "^4.1.12" } }, + "node_modules/@graphql-hive/signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-2.0.0.tgz", + "integrity": "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@graphql-tools/batch-execute": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.0.8.tgz", + "integrity": "sha512-Kobt37qrVTFhX4HUK5/vPgMXFw/5f97AzmAlfmDBSRh/GnoAmLKCb48FrEI3gdeIwZB2fEhVHJyDqsojldnLQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/delegate": { + "version": "12.0.14", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.0.14.tgz", + "integrity": "sha512-/xCDM8zlCk1Lccww9asOIpxna9IFpIlol4yGsBD9Y2+3/Zu5k4/HzDC8LKJtw5MxdG+uJN1l9nRepr4GeBC4kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^10.0.8", + "@graphql-tools/executor": "^1.4.13", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^11.0.0", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.2.tgz", + "integrity": "sha512-V7QaW/59Dml7DK0MApMP/Z+qx2qkQ0inGJGi/n1JwBHRZehXTKDNKO7OFRA0h6V1w2afmcVso2GFwlDnPyusGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.1", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-common": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-1.0.6.tgz", + "integrity": "sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.4.0", + "@graphql-tools/utils": "^11.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-graphql-ws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.1.5.tgz", + "integrity": "sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-common": "^1.0.6", + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isows": "^1.0.7", + "tslib": "^2.8.1", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-http": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.2.1.tgz", + "integrity": "sha512-53i0TYO0cznIlZDJcnq4gQ6SOZ8efGgCDV33MYh6oqEapcp36tCMEVnVGVxcX5qRRyNHkqTY6hkA+/AyK9kicQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-hive/signal": "^2.0.0", + "@graphql-tools/executor-common": "^1.0.6", + "@graphql-tools/utils": "^11.0.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "meros": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-legacy-ws": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.27.tgz", + "integrity": "sha512-tz0K8U9VKr9G/murdPpsARM2SxrXKtaKHaFoAZQoxHpWgbTdoGgJoyT5AoY6MZkgLRi5g24X0iZOLVtYlwy/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.1", + "@types/ws": "^8.0.0", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.4.0", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.8.tgz", + "integrity": "sha512-25V7WDrODo1cPrmuUCrqf5qlMA4a/Ow4aHaqJ1MnTUaluwsV3UiqzCHWux3HSLb0H63mkoZiuOrU5xJhxRcoCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.32", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.32.tgz", + "integrity": "sha512-kJ1Qn20MPnlaEVH37639E6rzQ1tEtr6XTUhNdR4EKydl+FijtLhWX2WLZbGnvrYuG8XUcMxsZU9mRRYYNvK02w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.8", + "@graphql-tools/utils": "^11.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/url-loader": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-9.1.1.tgz", + "integrity": "sha512-mLrUnyjPbYrwbCs2GqVXB4CPGZye4aOzJlLOYNctKm3QvGaMSmEwsAVJjpuG8D+ky/1OwCklqgo2KBj3TgYoSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-graphql-ws": "^3.1.4", + "@graphql-tools/executor-http": "^3.2.1", + "@graphql-tools/executor-legacy-ws": "^1.1.27", + "@graphql-tools/utils": "^11.0.1", + "@graphql-tools/wrap": "^11.1.1", + "@types/ws": "^8.0.0", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.6.0", + "tslib": "^2.4.0", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.1.tgz", + "integrity": "sha512-pNyCOb95ab/z3zkkiPwIPYxigX7IcpyFVcgD1XACDEvg/7yGnKCESx3k/XHEeneKYx/aWKGzEh/uuf6M6Q8HOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/wrap": { + "version": "11.1.14", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.1.14.tgz", + "integrity": "sha512-ebSVT7apxr+88q3Wy0i4AyRmJ42I0SuMqjPIn1fqW14yCTQRZG8YLuIALG1gKR936+GkfMLOrADh6egJvdlN6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/delegate": "^12.0.14", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^11.0.0", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -1631,105 +2024,88 @@ "dev": true, "license": "MIT" }, - "node_modules/@tanstack/devtools-event-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", - "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "undici-types": "~7.19.0" } }, - "node_modules/@tanstack/form-core": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.29.0.tgz", - "integrity": "sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ==", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/devtools-event-client": "^0.4.1", - "@tanstack/pacer-lite": "^0.1.1", - "@tanstack/store": "^0.9.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@types/node": "*" } }, - "node_modules/@tanstack/pacer-lite": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", - "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@tanstack/react-form": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.29.0.tgz", - "integrity": "sha512-jj425NNX0QKqbUzqSNiYI3HCPHSk2df47acXCJyXczWOTmG81ECZGkgofgqamFsSU9kMiH6Di5RLUnftrlhWSw==", + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@tanstack/form-core": "1.29.0", - "@tanstack/react-store": "^0.9.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@tanstack/react-start": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@tanstack/react-store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", - "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.5.tgz", + "integrity": "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/store": "0.9.3", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" } }, "node_modules/aria-hidden": { @@ -1745,18 +2121,36 @@ "node": ">=10" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", "dev": true, "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -1764,25 +2158,51 @@ "dev": true, "license": "MIT" }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, "engines": { - "node": ">=6" + "node": "^12.20 || >= 14.13" } }, - "node_modules/graphql": { - "version": "16.13.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", - "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + "node": ">=12.20.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/graphql-ws": { @@ -1791,7 +2211,6 @@ "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -1813,26 +2232,88 @@ } } }, - "node_modules/lucide-react": { - "version": "0.548.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz", - "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==", + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "ws": "*" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/meros": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", + "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=13" + }, + "peerDependencies": { + "@types/node": ">=13" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/radix-ui": { @@ -1913,31 +2394,6 @@ } } }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -2010,20 +2466,73 @@ } } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "dev": true, "license": "MIT" }, + "node_modules/sync-fetch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0.tgz", + "integrity": "sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^3.3.2", + "timeout-signal": "^2.0.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/timeout-signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/timeout-signal/-/timeout-signal-2.0.0.tgz", + "integrity": "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -2094,15 +2603,46 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "dev": true, "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "engines": { + "node": ">= 8" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } } } diff --git a/bin/router/package.json b/bin/router/package.json index 8bce25511..934cbc871 100644 --- a/bin/router/package.json +++ b/bin/router/package.json @@ -4,6 +4,6 @@ "private": true, "packageManager": "npm@11.11.1", "devDependencies": { - "@graphql-hive/laboratory": "0.1.2" + "@graphql-hive/laboratory": "0.1.6" } } From a79f3b1748f686b7c1979da69137dc05e3927bfa Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Fri, 1 May 2026 10:04:31 +0300 Subject: [PATCH 66/76] fix(query-planner): added missing `isRepeatable` on `type __Directive` (#951) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- ..._missing_isrepeatable_on_type_directive.md | 12 +++ e2e/src/introspection.rs | 92 ++++++++++++++++++- e2e/supergraph-introspection-extended.graphql | 7 +- lib/node-addon/tests/index.ts.snapshot | 2 +- .../introspection_schema.graphql | 7 +- 5 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 .changeset/added_missing_isrepeatable_on_type_directive.md diff --git a/.changeset/added_missing_isrepeatable_on_type_directive.md b/.changeset/added_missing_isrepeatable_on_type_directive.md new file mode 100644 index 000000000..32e6f78a4 --- /dev/null +++ b/.changeset/added_missing_isrepeatable_on_type_directive.md @@ -0,0 +1,12 @@ +--- +hive-router-query-planner: patch +hive-router-plan-executor: patch +hive-router: patch +node-addon: patch +--- + +# Added missing `isRepeatable` on `type __Directive` + +The router's introspection schema was resolving `isRepeatable`, but it did not appear in the public (consumer) schema, leading to validation errors when introspection schema was executed through Laboratory. + +This change adds the missing `isRepeatable: Boolean!` to `type __Directive`, according to the [GraphQL introspection spec](https://github.com/graphql/graphql-spec/blob/main/spec/Section%204%20--%20Introspection.md). diff --git a/e2e/src/introspection.rs b/e2e/src/introspection.rs index 01c672d87..232cacb23 100644 --- a/e2e/src/introspection.rs +++ b/e2e/src/introspection.rs @@ -2,6 +2,78 @@ mod introspection_e2e_tests { use crate::testkit::{ClientResponseExt, TestRouter}; + #[ntex::test] + async fn should_work_correctly_for_repeatable_directives() { + let router = TestRouter::builder() + .inline_config(&format!( + r#"supergraph: + source: file + path: "./supergraph-introspection-extended.graphql" + "#, + )) + .build() + .start() + .await; + + let resp = router + .send_graphql_request( + r#" + query IntrospectionQuery { + __schema { + directives { + name + isRepeatable + } + } + }"#, + None, + None, + ) + .await; + + assert!(resp.status().is_success(), "Expected 200 OK"); + let response = resp.json_body_string_pretty().await; + + insta::assert_snapshot!(response, @r###" + { + "data": { + "__schema": { + "directives": [ + { + "name": "test_directive", + "isRepeatable": false + }, + { + "name": "test_repeatable_directive", + "isRepeatable": true + }, + { + "name": "skip", + "isRepeatable": false + }, + { + "name": "include", + "isRepeatable": false + }, + { + "name": "deprecated", + "isRepeatable": false + }, + { + "name": "specifiedBy", + "isRepeatable": false + }, + { + "name": "oneOf", + "isRepeatable": false + } + ] + } + } + } + "###); + } + #[ntex::test] async fn should_have_deprecated_input_values_in_introspection() { let router = TestRouter::builder() @@ -55,7 +127,7 @@ mod introspection_e2e_tests { assert!(resp.status().is_success(), "Expected 200 OK"); - insta::assert_snapshot!(resp.json_body_string_pretty().await, @r#" + insta::assert_snapshot!(resp.json_body_string_pretty().await, @r###" { "data": { "Query": { @@ -108,6 +180,21 @@ mod introspection_e2e_tests { } ] }, + { + "name": "test_repeatable_directive", + "args": [ + { + "name": "oldArg", + "isDeprecated": true, + "deprecationReason": "Use `newArg` instead" + }, + { + "name": "newArg", + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "skip", "args": [ @@ -156,8 +243,9 @@ mod introspection_e2e_tests { } } } - "#); + "###); } + #[ntex::test] async fn should_have_is_one_of_in_input_values() { let router = TestRouter::builder() diff --git a/e2e/supergraph-introspection-extended.graphql b/e2e/supergraph-introspection-extended.graphql index 110fa7c56..fc3345699 100644 --- a/e2e/supergraph-introspection-extended.graphql +++ b/e2e/supergraph-introspection-extended.graphql @@ -69,6 +69,11 @@ directive @test_directive( newArg: TestInput ) on FIELD_DEFINITION +directive @test_repeatable_directive( + oldArg: TestInput @deprecated(reason: "Use `newArg` instead") + newArg: TestInput +) repeatable on FIELD_DEFINITION + type Query @join__type(graph: ACCOUNTS) { testField( oldArg: TestInput @deprecated(reason: "Use `newArg` instead") @@ -81,4 +86,4 @@ input TestInput @oneOf { newField: MyScalar = "newFieldDefaultValue" } -scalar MyScalar @specifiedBy(url: "https://example.com/my-scalar-spec") \ No newline at end of file +scalar MyScalar @specifiedBy(url: "https://example.com/my-scalar-spec") diff --git a/lib/node-addon/tests/index.ts.snapshot b/lib/node-addon/tests/index.ts.snapshot index 95a5509f3..62a78d285 100644 --- a/lib/node-addon/tests/index.ts.snapshot +++ b/lib/node-addon/tests/index.ts.snapshot @@ -823,5 +823,5 @@ exports[`fixtures > should planAsync simple-inaccessible/test-2.graphql 1`] = ` `; exports[`should expose consumer schema without federation internals 1`] = ` -"schema @transport(subgraph: \\"age\\", kind: \\"http\\", location: \\"http://localhost/simple-inaccessible/age\\") @transport(subgraph: \\"friends\\", kind: \\"http\\", location: \\"http://localhost/simple-inaccessible/friends\\") {\\n query: Query\\n}\\n\\nenum FriendType {\\n FRIEND\\n}\\n\\ntype Query {\\n usersInAge: [User!]!\\n usersInFriends: [User!]!\\n __schema: __Schema!\\n __type(name: String!): __Type\\n}\\n\\ntype User {\\n id: ID\\n age: Int\\n friends: [User!]!\\n type: FriendType\\n}\\n\\n\\"Directs the executor to skip this field or fragment when the \`if\` argument is true.\\"\\ndirective @skip(\\"Skipped when true.\\" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT\\n\\n\\"Directs the executor to include this field or fragment only when the \`if\` argument is true.\\"\\ndirective @include(\\"Included when true.\\" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT\\n\\ndirective @deprecated(reason: String = \\"No longer supported\\") on FIELD_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION\\n\\ndirective @specifiedBy(url: String!) on SCALAR\\n\\ndirective @oneOf on OBJECT | INTERFACE | UNION\\n\\n\\"The \`Boolean\` scalar type represents \`true\` or \`false\` values.\\"\\nscalar Boolean\\n\\n\\"The \`Float\` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). Float can represent values between -(2^53 - 1) and 2^53 - 1, inclusive.\\"\\nscalar Float\\n\\n\\"The \`Int\` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.\\"\\nscalar Int\\n\\n\\"The \`ID\` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \`\\\\\\"4\\\\\\"\`) or integer (such as \`4\`) input value will be accepted as an ID.\\"\\nscalar ID\\n\\n\\"The \`String\` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.\\"\\nscalar String\\n\\ntype __Schema {\\n description: String\\n types: [__Type!]!\\n queryType: __Type!\\n mutationType: __Type\\n subscriptionType: __Type\\n directives: [__Directive!]!\\n}\\n\\ntype __Type {\\n kind: __TypeKind!\\n name: String\\n description: String\\n specifiedByURL: String\\n fields(includeDeprecated: Boolean = false): [__Field!]\\n interfaces: [__Type!]\\n possibleTypes: [__Type!]\\n enumValues(includeDeprecated: Boolean = false): [__EnumValue!]\\n inputFields(includeDeprecated: Boolean = false): [__InputValue!]\\n ofType: __Type\\n isOneOf: Boolean\\n}\\n\\ntype __Field {\\n name: String!\\n description: String\\n args(includeDeprecated: Boolean = false): [__InputValue!]!\\n type: __Type!\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\ntype __InputValue {\\n name: String!\\n description: String\\n type: __Type!\\n defaultValue: String\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\ntype __EnumValue {\\n name: String!\\n description: String\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\nenum __TypeKind {\\n SCALAR\\n OBJECT\\n INTERFACE\\n UNION\\n ENUM\\n INPUT_OBJECT\\n LIST\\n NON_NULL\\n}\\n\\ntype __Directive {\\n name: String!\\n description: String\\n locations: [__DirectiveLocation!]!\\n args(includeDeprecated: Boolean = false): [__InputValue!]!\\n}\\n\\nenum __DirectiveLocation {\\n QUERY\\n MUTATION\\n SUBSCRIPTION\\n FIELD\\n FRAGMENT_DEFINITION\\n FRAGMENT_SPREAD\\n INLINE_FRAGMENT\\n SCHEMA\\n SCALAR\\n OBJECT\\n FIELD_DEFINITION\\n ARGUMENT_DEFINITION\\n INTERFACE\\n UNION\\n ENUM\\n ENUM_VALUE\\n INPUT_OBJECT\\n INPUT_FIELD_DEFINITION\\n}\\n" +"schema @transport(subgraph: \\"age\\", kind: \\"http\\", location: \\"http://localhost/simple-inaccessible/age\\") @transport(subgraph: \\"friends\\", kind: \\"http\\", location: \\"http://localhost/simple-inaccessible/friends\\") {\\n query: Query\\n}\\n\\nenum FriendType {\\n FRIEND\\n}\\n\\ntype Query {\\n usersInAge: [User!]!\\n usersInFriends: [User!]!\\n __schema: __Schema!\\n __type(name: String!): __Type\\n}\\n\\ntype User {\\n id: ID\\n age: Int\\n friends: [User!]!\\n type: FriendType\\n}\\n\\n\\"Directs the executor to skip this field or fragment when the \`if\` argument is true.\\"\\ndirective @skip(\\"Skipped when true.\\" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT\\n\\n\\"Directs the executor to include this field or fragment only when the \`if\` argument is true.\\"\\ndirective @include(\\"Included when true.\\" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT\\n\\ndirective @deprecated(reason: String = \\"No longer supported\\") on FIELD_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION\\n\\ndirective @specifiedBy(url: String!) on SCALAR\\n\\ndirective @oneOf on OBJECT | INTERFACE | UNION\\n\\n\\"The \`Boolean\` scalar type represents \`true\` or \`false\` values.\\"\\nscalar Boolean\\n\\n\\"The \`Float\` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). Float can represent values between -(2^53 - 1) and 2^53 - 1, inclusive.\\"\\nscalar Float\\n\\n\\"The \`Int\` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.\\"\\nscalar Int\\n\\n\\"The \`ID\` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as \`\\\\\\"4\\\\\\"\`) or integer (such as \`4\`) input value will be accepted as an ID.\\"\\nscalar ID\\n\\n\\"The \`String\` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.\\"\\nscalar String\\n\\ntype __Schema {\\n description: String\\n types: [__Type!]!\\n queryType: __Type!\\n mutationType: __Type\\n subscriptionType: __Type\\n directives: [__Directive!]!\\n}\\n\\ntype __Type {\\n kind: __TypeKind!\\n name: String\\n description: String\\n specifiedByURL: String\\n fields(includeDeprecated: Boolean = false): [__Field!]\\n interfaces: [__Type!]\\n possibleTypes: [__Type!]\\n enumValues(includeDeprecated: Boolean = false): [__EnumValue!]\\n inputFields(includeDeprecated: Boolean = false): [__InputValue!]\\n ofType: __Type\\n isOneOf: Boolean\\n}\\n\\ntype __Field {\\n name: String!\\n description: String\\n args(includeDeprecated: Boolean = false): [__InputValue!]!\\n type: __Type!\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\ntype __InputValue {\\n name: String!\\n description: String\\n type: __Type!\\n defaultValue: String\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\ntype __EnumValue {\\n name: String!\\n description: String\\n isDeprecated: Boolean!\\n deprecationReason: String\\n}\\n\\nenum __TypeKind {\\n SCALAR\\n OBJECT\\n INTERFACE\\n UNION\\n ENUM\\n INPUT_OBJECT\\n LIST\\n NON_NULL\\n}\\n\\ntype __Directive {\\n name: String!\\n description: String\\n locations: [__DirectiveLocation!]!\\n args(includeDeprecated: Boolean = false): [__InputValue!]!\\n isRepeatable: Boolean!\\n}\\n\\nenum __DirectiveLocation {\\n QUERY\\n MUTATION\\n SUBSCRIPTION\\n FIELD\\n FRAGMENT_DEFINITION\\n FRAGMENT_SPREAD\\n INLINE_FRAGMENT\\n SCHEMA\\n SCALAR\\n OBJECT\\n FIELD_DEFINITION\\n ARGUMENT_DEFINITION\\n INTERFACE\\n UNION\\n ENUM\\n ENUM_VALUE\\n INPUT_OBJECT\\n INPUT_FIELD_DEFINITION\\n}\\n" `; diff --git a/lib/query-planner/src/consumer_schema/introspection_schema.graphql b/lib/query-planner/src/consumer_schema/introspection_schema.graphql index 8d5caa712..fdc7ad78e 100644 --- a/lib/query-planner/src/consumer_schema/introspection_schema.graphql +++ b/lib/query-planner/src/consumer_schema/introspection_schema.graphql @@ -8,7 +8,9 @@ directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +directive @deprecated( + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION directive @specifiedBy(url: String!) on SCALAR directive @oneOf on OBJECT | INTERFACE | UNION @@ -108,6 +110,7 @@ type __Directive { description: String locations: [__DirectiveLocation!]! args(includeDeprecated: Boolean = false): [__InputValue!]! + isRepeatable: Boolean! } enum __DirectiveLocation { @@ -129,4 +132,4 @@ enum __DirectiveLocation { ENUM_VALUE INPUT_OBJECT INPUT_FIELD_DEFINITION -} \ No newline at end of file +} From 5743db7d735e4005a7c1902ae8bd3e9770bfcf70 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 4 May 2026 15:00:41 +0200 Subject: [PATCH 67/76] Add OTel client and peer network attributes (#941) The `http.server` span now includes: - `client.address` and `client.port` from a configurable request header - `network.peer.address` and `network.peer.port` from the address of the incoming connection ```yaml telemetry: client_identification: # Default - use socket peer only ip_header: null # Header name - use the left-most valid IP from the header ip_header: x-forwarded-for # Trusted proxies - only trust the header when the socket peer is trusted ip_header: name: x-forwarded-for trusted_proxies: - 10.0.0.0/8 - 192.168.0.0/16 ``` In trusted proxies scenario, the Router scans the configured header from right to left, skips trusted proxy IP ranges, and records the first non-trusted IP as `client.address`. If no valid client IP can be resolved, the Router falls back to the socket peer address. Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/clever-pandas-tickle.md | 31 ++ Cargo.lock | 2 + Cargo.toml | 1 + bin/router/src/lib.rs | 9 +- bin/router/src/pipeline/mod.rs | 8 +- bin/router/src/pipeline/websocket_server.rs | 8 +- docs/README.md | 12 +- e2e/src/telemetry/tracing/mod.rs | 1 + e2e/src/telemetry/tracing/otlp_attributes.rs | 8 + e2e/src/telemetry/tracing/otlp_basic.rs | 8 + e2e/src/telemetry/tracing/otlp_ip.rs | 244 ++++++++++++ e2e/src/testkit/otel.rs | 4 + lib/internal/Cargo.toml | 1 + .../src/telemetry/traces/compatibility.rs | 15 +- .../src/telemetry/traces/spans/attributes.rs | 4 + .../telemetry/traces/spans/http_request.rs | 198 +++++++++- .../src/telemetry/traces/spans/tests.rs | 356 +++++++++++++++++- lib/router-config/Cargo.toml | 1 + .../src/primitives/ip_network.rs | 114 ++++++ lib/router-config/src/primitives/mod.rs | 1 + lib/router-config/src/telemetry.rs | 107 +++++- 21 files changed, 1102 insertions(+), 31 deletions(-) create mode 100644 .changeset/clever-pandas-tickle.md create mode 100644 e2e/src/telemetry/tracing/otlp_ip.rs create mode 100644 lib/router-config/src/primitives/ip_network.rs diff --git a/.changeset/clever-pandas-tickle.md b/.changeset/clever-pandas-tickle.md new file mode 100644 index 000000000..cb32b3c6d --- /dev/null +++ b/.changeset/clever-pandas-tickle.md @@ -0,0 +1,31 @@ +--- +hive-router-internal: minor +hive-router-plan-executor: minor +hive-router: minor +--- + +## Improve HTTP server request OTel tracing with client and peer network attributes. + +The `http.server` span now includes: +- `client.address` and `client.port` from a configurable request header +- `network.peer.address` and `network.peer.port` from the address of the incoming connection + +```yaml +telemetry: + client_identification: + # Default - use socket peer only + ip_header: null + + # Header name - use the left-most valid IP from the header + ip_header: x-forwarded-for + + # Trusted proxies - only trust the header when the socket peer is trusted + ip_header: + name: x-forwarded-for + trusted_proxies: + - 10.0.0.0/8 + - 192.168.0.0/16 +``` + +In trusted proxies scenario, the Router scans the configured header from right to left, skips trusted proxy IP ranges, and records the first non-trusted IP as `client.address`. +If no valid client IP can be resolved, the Router falls back to the socket peer address. diff --git a/Cargo.lock b/Cargo.lock index 7965a30de..748e38ff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2597,6 +2597,7 @@ dependencies = [ "human-size", "humantime", "humantime-serde", + "ipnet", "jsonwebtoken", "once_cell", "regex-automata", @@ -2626,6 +2627,7 @@ dependencies = [ "humantime", "hyper", "insta", + "ipnet", "lasso2", "ntex", "opentelemetry", diff --git a/Cargo.toml b/Cargo.toml index 6caa85f35..9fee7fd49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ mockito = "1.7.0" futures-util = "0.3.31" axum = "0.8.4" notify = "8.2.0" +ipnet = "2.12.0" # Telemetry opentelemetry = "0.31.0" diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index c70ee7994..c7f0cb434 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -153,7 +153,14 @@ async fn graphql_endpoint_dispatch( let parent_ctx = app_state .telemetry_context .extract_context(&HeaderExtractor(request.headers())); - let root_http_request_span = HttpServerRequestSpan::from_request(request); + let root_http_request_span = HttpServerRequestSpan::from_request( + request, + &app_state + .router_config + .telemetry + .client_identification + .ip_header, + ); let _ = root_http_request_span.set_parent(parent_ctx); async { diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 58a18745a..98c20817a 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -142,21 +142,21 @@ pub async fn graphql_request_handler( let client_name = req .headers() .get( - &shared_state + shared_state .router_config .telemetry .client_identification - .name_header, + .name_header.get_header_ref(), ) .and_then(|v| v.to_str().ok()); let client_version = req .headers() .get( - &shared_state + shared_state .router_config .telemetry .client_identification - .version_header, + .version_header.get_header_ref(), ) .and_then(|v| v.to_str().ok()); diff --git a/bin/router/src/pipeline/websocket_server.rs b/bin/router/src/pipeline/websocket_server.rs index 03c3d4394..f442ef554 100644 --- a/bin/router/src/pipeline/websocket_server.rs +++ b/bin/router/src/pipeline/websocket_server.rs @@ -327,20 +327,20 @@ async fn handle_text_frame( let client_name = headers .get( - &shared_state + shared_state .router_config .telemetry .client_identification - .name_header, + .name_header.get_header_ref(), ) .and_then(|v| v.to_str().ok()); let client_version = headers .get( - &shared_state + shared_state .router_config .telemetry .client_identification - .version_header, + .version_header.get_header_ref(), ) .and_then(|v| v.to_str().ok()); diff --git a/docs/README.md b/docs/README.md index d1668c04f..fd6108f36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| |[**subscriptions**](#subscriptions)|`object`|Configuration for subscriptions.
Default: `{"broadcast_capacity":0,"enabled":false}`
|| |[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
|| -|[**telemetry**](#telemetry)|`object`|Default: `{"client_identification":{"name_header":"graphql-client-name","version_header":"graphql-client-version"},"hive":null,"metrics":{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}},"resource":{"attributes":{}},"tracing":{"collect":{"max_attributes_per_event":16,"max_attributes_per_link":32,"max_attributes_per_span":128,"max_events_per_span":128,"parent_based_sampler":false,"sampling":1},"exporters":[],"instrumentation":{"spans":{"mode":"spec_compliant"}},"propagation":{"b3":false,"baggage":false,"jaeger":false,"trace_context":true}}}`
|| +|[**telemetry**](#telemetry)|`object`|Default: `{"client_identification":{"ip_header":null,"name_header":"graphql-client-name","version_header":"graphql-client-version"},"hive":null,"metrics":{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}},"resource":{"attributes":{}},"tracing":{"collect":{"max_attributes_per_event":16,"max_attributes_per_link":32,"max_attributes_per_span":128,"max_events_per_span":128,"parent_based_sampler":false,"sampling":1},"exporters":[],"instrumentation":{"spans":{"mode":"spec_compliant"}},"propagation":{"b3":false,"baggage":false,"jaeger":false,"trace_context":true}}}`
|| |[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"all":{"allow_only_http2":false,"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100,"router":{"dedupe":{"enabled":false,"headers":"all"},"max_long_lived_clients":128,"request_timeout":"1m"}}`
|| |[**websocket**](#websocket)|`object`|Configuration of router's WebSocket server.
Default: `{"enabled":false,"headers":{"persist":false,"source":"connection"},"path":null}`
|| @@ -134,6 +134,7 @@ subscriptions: supergraph: {} telemetry: client_identification: + ip_header: null name_header: graphql-client-name version_header: graphql-client-version hive: null @@ -2221,7 +2222,7 @@ max_retries: 10 |Name|Type|Description|Required| |----|----|-----------|--------| -|[**client\_identification**](#telemetryclient_identification)|`object`|Default: `{"name_header":"graphql-client-name","version_header":"graphql-client-version"}`
|| +|[**client\_identification**](#telemetryclient_identification)|`object`|Default: `{"ip_header":null,"name_header":"graphql-client-name","version_header":"graphql-client-version"}`
|| |[**hive**](#telemetryhive)|`object`, `null`||| |[**metrics**](#telemetrymetrics)|`object`|Configures metrics collection, processing, and export.
Default: `{"exporters":[],"instrumentation":{"common":{"histogram":{"aggregation":"explicit","bytes":{"buckets":[128,512,1024,2048,4096,8192,16384,32768,65536,131072,262144,524288,1048576,2097152,3145728,4194304,5242880],"record_min_max":false},"seconds":{"buckets":[0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1,2.5,5,7.5,10],"record_min_max":false}}},"instruments":{}}}`
|| |[**resource**](#telemetryresource)|`object`|Default: `{"attributes":{}}`
|| @@ -2232,6 +2233,7 @@ max_retries: 10 ```yaml client_identification: + ip_header: null name_header: graphql-client-name version_header: graphql-client-version hive: null @@ -2308,13 +2310,15 @@ tracing: |Name|Type|Description|Required| |----|----|-----------|--------| -|**name\_header**|`string`|Default: `"graphql-client-name"`
|| -|**version\_header**|`string`|Default: `"graphql-client-version"`
|| +|**ip\_header**||Defines how the client IP address is determined.

Important: HTTP headers like `x-forwarded-for` can be spoofed by clients.
Use it only with trusted proxies.

It's null by default and uses the socket peer address.

Use the left-most value from the specified header:
```ignore
ip_header: "x-forwarded-for"
```

If peer socket address is trusted, meaning it's part of `trusted_proxies` list,
Router evaluates values from right to left and picks the first non-trusted value.
If all values are trusted, uses the left-most value.
```ignore
ip_header:
name: "x-forwarded-for"
trusted_proxies:
- 10.0.0.0/8
- 127.0.0.1/32
```
|| +|**name\_header**|`string`|A valid HTTP header name, according to RFC 7230.
Default: `"graphql-client-name"`
Pattern: `^[A-Za-z0-9!#$%&'*+\-.^_\`\|~]+$`
|| +|**version\_header**|`string`|A valid HTTP header name, according to RFC 7230.
Default: `"graphql-client-version"`
Pattern: `^[A-Za-z0-9!#$%&'*+\-.^_\`\|~]+$`
|| **Additional Properties:** not allowed **Example** ```yaml +ip_header: null name_header: graphql-client-name version_header: graphql-client-version diff --git a/e2e/src/telemetry/tracing/mod.rs b/e2e/src/telemetry/tracing/mod.rs index a9cea533a..ccb485af7 100644 --- a/e2e/src/telemetry/tracing/mod.rs +++ b/e2e/src/telemetry/tracing/mod.rs @@ -1,5 +1,6 @@ mod hive; mod otlp_attributes; mod otlp_basic; +mod otlp_ip; mod otlp_propagation; mod otlp_sampling; diff --git a/e2e/src/telemetry/tracing/otlp_attributes.rs b/e2e/src/telemetry/tracing/otlp_attributes.rs index 74aac22fc..af4206a90 100644 --- a/e2e/src/telemetry/tracing/otlp_attributes.rs +++ b/e2e/src/telemetry/tracing/otlp_attributes.rs @@ -67,6 +67,8 @@ async fn test_deprecated_span_attributes() { Kind: Server Status: message='' code='1' Attributes: + client.address: [address] + client.port: [port] hive.kind: http.server http.flavor: 1.1 http.host: localhost @@ -78,6 +80,8 @@ async fn test_deprecated_span_attributes() { http.status_code: 200 http.target: /graphql http.url: /graphql + network.peer.address: [address] + network.peer.port: [port] server.port: [port] target: hive-router " @@ -197,6 +201,8 @@ async fn test_spec_and_deprecated_span_attributes() { Kind: Server Status: message='' code='1' Attributes: + client.address: [address] + client.port: [port] hive.kind: http.server http.flavor: 1.1 http.host: localhost @@ -212,6 +218,8 @@ async fn test_spec_and_deprecated_span_attributes() { http.status_code: 200 http.target: /graphql http.url: /graphql + network.peer.address: [address] + network.peer.port: [port] network.protocol.version: 1.1 server.address: localhost server.port: [port] diff --git a/e2e/src/telemetry/tracing/otlp_basic.rs b/e2e/src/telemetry/tracing/otlp_basic.rs index f936e3d69..c8e66378d 100644 --- a/e2e/src/telemetry/tracing/otlp_basic.rs +++ b/e2e/src/telemetry/tracing/otlp_basic.rs @@ -91,12 +91,16 @@ async fn test_otlp_http_export_with_graphql_request() { Kind: Server Status: message='' code='1' Attributes: + client.address: [address] + client.port: [port] hive.kind: http.server http.request.body.size: 45 http.request.method: POST http.response.body.size: 86 http.response.status_code: 200 http.route: /graphql + network.peer.address: [address] + network.peer.port: [port] network.protocol.version: 1.1 server.address: localhost server.port: [port] @@ -348,12 +352,16 @@ async fn test_otlp_grpc_export_with_graphql_request() { Kind: Server Status: message='' code='1' Attributes: + client.address: [address] + client.port: [port] hive.kind: http.server http.request.body.size: 45 http.request.method: POST http.response.body.size: 86 http.response.status_code: 200 http.route: /graphql + network.peer.address: [address] + network.peer.port: [port] network.protocol.version: 1.1 server.address: localhost server.port: [port] diff --git a/e2e/src/telemetry/tracing/otlp_ip.rs b/e2e/src/telemetry/tracing/otlp_ip.rs new file mode 100644 index 000000000..d47ee53ef --- /dev/null +++ b/e2e/src/telemetry/tracing/otlp_ip.rs @@ -0,0 +1,244 @@ +use crate::testkit::{otel::OtlpCollector, some_header_map, TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn test_client_ip_default_uses_peer_and_ignores_xff() { + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_traces_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + + telemetry: + tracing: + exporters: + - kind: otlp + endpoint: {otlp_endpoint} + protocol: http + batch_processor: + scheduled_delay: 50ms + max_export_timeout: 2s + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "{ users { id } }", + None, + // These values should be ignored as ip_header is null + some_header_map!( + "x-forwarded-for" => "198.51.100.7, 10.0.0.2".to_string(), + "x-real-ip" => "198.51.100.7", + "forwarded" => "for=198.51.100.7" + ), + ) + .await; + assert!(response.status().is_success()); + + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + + // Hive Router should use the peer address/port as the client address/port + // regardless of the x-forwarded-for header as it's not defined. + assert_eq!( + http_server_span.attributes.get("client.address"), + http_server_span.attributes.get("network.peer.address") + ); + assert_eq!( + http_server_span.attributes.get("client.port"), + http_server_span.attributes.get("network.peer.port") + ); +} + +#[ntex::test] +async fn test_client_ip_header_name_uses_left_most_value() { + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_traces_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + + telemetry: + client_identification: + ip_header: x-forwarded-for + tracing: + exporters: + - kind: otlp + endpoint: {otlp_endpoint} + protocol: http + batch_processor: + scheduled_delay: 50ms + max_export_timeout: 2s + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "{ users { id } }", + None, + some_header_map!("x-forwarded-for" => "198.51.100.7, 10.0.0.2".to_string()), + ) + .await; + assert!(response.status().is_success()); + + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + + assert_eq!( + http_server_span + .attributes + .get("client.address") + .map(String::as_str), + Some("198.51.100.7") + ); + assert!(http_server_span.attributes.get("client.port").is_none()); +} + +#[ntex::test] +async fn test_client_ip_trusted_proxies_uses_first_non_trusted_from_right() { + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_traces_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + + telemetry: + client_identification: + ip_header: + name: x-forwarded-for + trusted_proxies: + - 127.0.0.0/8 # localhost + - ::1/128 # localhost + - 10.0.0.0/8 # trusted proxy IP range + tracing: + exporters: + - kind: otlp + endpoint: {otlp_endpoint} + protocol: http + batch_processor: + scheduled_delay: 50ms + max_export_timeout: 2s + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "{ users { id } }", + None, + // Second IP = trusted proxy IP + some_header_map!("x-forwarded-for" => "198.51.100.7, 10.0.0.2".to_string()), + ) + .await; + assert!(response.status().is_success()); + + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + + // The client IP should be the first untrusted IP from right to left + assert_eq!( + http_server_span + .attributes + .get("client.address") + .map(String::as_str), + Some("198.51.100.7") + ); + assert!(http_server_span.attributes.get("client.port").is_none()); +} + +#[ntex::test] +async fn test_client_ip_trusted_proxies_all_trusted_falls_back_to_left_most() { + let otlp_collector = OtlpCollector::start() + .await + .expect("Failed to start OTLP collector"); + let otlp_endpoint = otlp_collector.http_traces_endpoint(); + + let subgraphs = TestSubgraphs::builder().build().start().await; + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + + telemetry: + client_identification: + ip_header: + name: x-forwarded-for + trusted_proxies: + - 127.0.0.0/8 # localhost + - ::1/128 # localhost + - 10.0.0.0/8 # trusted proxy IP range + tracing: + exporters: + - kind: otlp + endpoint: {otlp_endpoint} + protocol: http + batch_processor: + scheduled_delay: 50ms + max_export_timeout: 2s + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "{ users { id } }", + None, + // all IPs are trusted + some_header_map!("x-forwarded-for" => "10.1.1.1, 10.2.2.2".to_string()), + ) + .await; + assert!(response.status().is_success()); + + let http_server_span = otlp_collector + .wait_for_span_by_hive_kind_one("http.server") + .await; + + // Since all are trusted then we pick the left-most IP + assert_eq!( + http_server_span + .attributes + .get("client.address") + .map(String::as_str), + Some("10.1.1.1") + ); + assert!(http_server_span.attributes.get("client.port").is_none()); +} diff --git a/e2e/src/testkit/otel.rs b/e2e/src/testkit/otel.rs index 230d5f21c..826a7bbdd 100644 --- a/e2e/src/testkit/otel.rs +++ b/e2e/src/testkit/otel.rs @@ -784,6 +784,10 @@ impl OtlpCollector { // addresses and ports settings.add_filter(r"(server\.address:\s+)[\d.]+", "$1[address]"); settings.add_filter(r"(server\.port:\s+)\d+", "$1[port]"); + settings.add_filter(r"(client\.address:\s+)[\d.]+", "$1[address]"); + settings.add_filter(r"(client\.port:\s+)\d+", "$1[port]"); + settings.add_filter(r"(network\.peer\.address:\s+)[\d.]+", "$1[address]"); + settings.add_filter(r"(network\.peer\.port:\s+)\d+", "$1[port]"); settings.add_filter( r"(url\.full:\s+http:\/\/)[\d.]+:\d+(.*)", "$1[address]:[port]$2", diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index 94a4d8594..dc9e8a4e2 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -36,6 +36,7 @@ ahash = { workspace = true } dashmap = { workspace = true } tokio-stream = "0.1.18" serde = { workspace = true } +ipnet = { workspace = true } # telemetry opentelemetry = { workspace = true, features = ["trace"] } diff --git a/lib/internal/src/telemetry/traces/compatibility.rs b/lib/internal/src/telemetry/traces/compatibility.rs index 24d402eac..e1e7de5e2 100644 --- a/lib/internal/src/telemetry/traces/compatibility.rs +++ b/lib/internal/src/telemetry/traces/compatibility.rs @@ -279,6 +279,8 @@ mod tests { use crate::telemetry::traces::spans::http_request::{ HttpClientRequestSpan, HttpServerRequestSpan, }; + use hive_router_config::telemetry::ClientIpHeaderConfig; + use http::header::FORWARDED; use http_body_util::Full; use ntex::http::header::{HOST, USER_AGENT}; use ntex::util::Bytes; @@ -312,6 +314,10 @@ mod tests { (provider, memory_exporter) } + fn forwarded_ip_header_config() -> Option { + Some(ClientIpHeaderConfig::HeaderName(FORWARDED.as_str().into())) + } + fn setup_tracing_subscriber(provider: &SdkTracerProvider) -> impl Drop { let otel_tracer = provider.tracer("http-tracer"); let telemetry_layer = tracing_opentelemetry::layer().with_tracer(otel_tracer); @@ -354,7 +360,8 @@ mod tests { ntex::web::HttpResponse::build(ntex::http::StatusCode::OK).body("response body"); tracer.in_span("root", |_cx| { - let span = HttpServerRequestSpan::from_request(&http_req); + let span = + HttpServerRequestSpan::from_request(&http_req, &forwarded_ip_header_config()); span.record_body_size(body.len()); span.record_response(&http_res); }); @@ -407,7 +414,8 @@ mod tests { ntex::web::HttpResponse::build(ntex::http::StatusCode::OK).body("response body"); tracer.in_span("root", |_cx| { - let span = HttpServerRequestSpan::from_request(&http_req); + let span = + HttpServerRequestSpan::from_request(&http_req, &forwarded_ip_header_config()); span.record_body_size(body.len()); span.record_response(&http_res); }); @@ -459,7 +467,8 @@ mod tests { ntex::web::HttpResponse::build(ntex::http::StatusCode::OK).body("response body"); tracer.in_span("root", |_cx| { - let span = HttpServerRequestSpan::from_request(&http_req); + let span = + HttpServerRequestSpan::from_request(&http_req, &forwarded_ip_header_config()); span.record_body_size(body.len()); span.record_response(&http_res); }); diff --git a/lib/internal/src/telemetry/traces/spans/attributes.rs b/lib/internal/src/telemetry/traces/spans/attributes.rs index 5fcbce4e5..5b51bf229 100644 --- a/lib/internal/src/telemetry/traces/spans/attributes.rs +++ b/lib/internal/src/telemetry/traces/spans/attributes.rs @@ -19,6 +19,10 @@ pub const USER_AGENT_ORIGINAL: &str = "user_agent.original"; pub const HTTP_RESPONSE_STATUS_CODE: &str = "http.response.status_code"; pub const HTTP_RESPONSE_BODY_SIZE: &str = "http.response.body.size"; pub const HTTP_ROUTE: &str = "http.route"; +pub const CLIENT_ADDRESS: &str = "client.address"; +pub const CLIENT_PORT: &str = "client.port"; +pub const NETWORK_PEER_ADDRESS: &str = "network.peer.address"; +pub const NETWORK_PEER_PORT: &str = "network.peer.port"; /// GraphQL Attributes pub const GRAPHQL_OPERATION_NAME: &str = "graphql.operation.name"; diff --git a/lib/internal/src/telemetry/traces/spans/http_request.rs b/lib/internal/src/telemetry/traces/spans/http_request.rs index 94f994426..4aa9fc5e1 100644 --- a/lib/internal/src/telemetry/traces/spans/http_request.rs +++ b/lib/internal/src/telemetry/traces/spans/http_request.rs @@ -1,14 +1,51 @@ use bytes::Bytes; +use hive_router_config::{primitives::ip_network::IpNetwork, telemetry::ClientIpHeaderConfig}; use http::{ - header::{HOST, USER_AGENT}, - HeaderMap, Method, Response, StatusCode, Uri, + header::{FORWARDED, HOST, USER_AGENT}, + HeaderMap, HeaderName, Method, Response, StatusCode, Uri, Version, }; use http_body_util::Full; use hyper::body::Body; -use ntex::http::body::MessageBody; +use ntex::http::{body::MessageBody, HeaderMap as NtexHeaderMap}; use std::borrow::{Borrow, Cow}; +use std::net::{IpAddr, SocketAddr}; use tracing::{field::Empty, info_span, record_all, Level, Span}; +/// Minimal request interface required to build an HTTP server span. +/// +/// This keeps `HttpServerRequestSpan::from_request` decoupled from +/// `ntex::web::HttpRequest` and allows tests to provide a mock request carrying +/// a peer address. +pub trait HttpServerSpanRequest { + fn headers(&self) -> &NtexHeaderMap; + fn method(&self) -> &Method; + fn uri(&self) -> &Uri; + fn version(&self) -> Version; + fn peer_addr(&self) -> Option; +} + +impl HttpServerSpanRequest for ntex::web::HttpRequest { + fn headers(&self) -> &NtexHeaderMap { + self.headers() + } + + fn method(&self) -> &Method { + self.method() + } + + fn uri(&self) -> &Uri { + self.uri() + } + + fn version(&self) -> Version { + self.version() + } + + fn peer_addr(&self) -> Option { + self.peer_addr() + } +} + use crate::http::{HttpMethodAsStr, HttpUriAsStr, HttpVersionAsStr}; use crate::telemetry::traces::{ disabled_span, is_level_enabled, @@ -37,7 +74,10 @@ impl Borrow for HttpServerRequestSpan { } impl HttpServerRequestSpan { - pub fn from_request(request: &ntex::web::HttpRequest) -> Self { + pub fn from_request( + request: &Req, + client_ip_header_config: &Option, + ) -> Self { if !is_level_enabled(Level::INFO) { return Self { span: disabled_span(), @@ -61,6 +101,14 @@ impl HttpServerRequestSpan { let url = Cow::Borrowed(request.uri()); let protocol_version = request.version().as_static_str(); let url_scheme = url.scheme_static_str(); + let peer = request.peer_addr(); + let peer_address = peer.map(|p| p.ip().to_string()); + let peer_port = peer.map(|p| p.port()); + let (client_address, client_port) = + match resolve_client_address(request, client_ip_header_config) { + Some(client) => (Some(client.ip_raw.to_string()), client.port), + None => (peer_address.clone(), peer_port), + }; // We follow the HTTP server span conventions: // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server @@ -86,6 +134,11 @@ impl HttpServerRequestSpan { "http.response.status_code" = Empty, "http.response.body.size" = Empty, "http.route" = url.path(), + // Client + "client.address" = client_address, + "client.port" = client_port, + "network.peer.address" = peer_address, + "network.peer.port" = peer_port, ); Self { span } @@ -347,3 +400,140 @@ impl HttpInflightRequestSpan { ); } } + +fn resolve_client_address<'a, Req: HttpServerSpanRequest>( + request: &'a Req, + config: &Option, +) -> Option> { + let config = config.as_ref()?; + + match config { + ClientIpHeaderConfig::HeaderName(name) => { + let header = request + .headers() + .get(name.get_header_ref())? + .to_str() + .ok()?; + + // Finds the left-most valid address + ParsedAddr::from(name.get_header_ref(), header).next() + } + + ClientIpHeaderConfig::TrustedProxies(cfg) => { + let peer_ip = request.peer_addr()?.ip(); + + // If the peer IP is not trusted, then we can't determine the client address + if !ParsedAddr::is_trusted(peer_ip, &cfg.trusted_proxies) { + return None; + } + + let header = request + .headers() + .get(cfg.name.get_header_ref())? + .to_str() + .ok()?; + + let mut addrs = ParsedAddr::from(cfg.name.get_header_ref(), header); + let first = addrs.next()?; + + addrs + // We go from right to left + .rev() + // to find the first non-trusted address + .find(|addr| !ParsedAddr::is_trusted(addr.parsed_ip, &cfg.trusted_proxies)) + // and all are trusted, we treat the first as the client address + .unwrap_or(first) + .into() + } + } +} + +#[derive(Clone, Copy)] +struct ParsedAddr<'a> { + // We keep the str version of IP to avoid the cost of `Display::fmt` of IpAddr + ip_raw: &'a str, + parsed_ip: IpAddr, + port: Option, +} + +impl<'a> ParsedAddr<'a> { + #[inline] + fn parse_address(raw: &str) -> Option> { + let raw = raw.trim(); + + if raw.is_empty() { + return None; + } + + // Normal IP - `192.168.1.1` or `::1` + // We aim to find it first, as it's the most common case. + if let Ok(ip) = raw.parse::() { + return Some(ParsedAddr { + ip_raw: raw, + parsed_ip: ip, + port: None, + }); + } + + // IPv6 with brackets - `[::1]` or `[::1]:8080` + if let Some(rest) = raw.strip_prefix('[') { + let (addr, rest) = rest.split_once(']')?; + + let port = rest + .strip_prefix(':') + .map(str::parse::) + .transpose() + .ok()?; + + return Some(ParsedAddr { + ip_raw: addr, + parsed_ip: addr.parse().ok()?, + port, + }); + } + + // IPv4 with port + let (addr, port) = raw.rsplit_once(':')?; + + // If the address contains `:`, then it's not a valid IPv4 or IPv6 address. + if addr.contains(':') { + return None; + } + + Some(ParsedAddr { + ip_raw: addr, + parsed_ip: addr.parse().ok()?, + port: Some(port.parse().ok()?), + }) + } + + #[inline] + fn from( + name: &HeaderName, + value: &'a str, + ) -> impl DoubleEndedIterator> + 'a { + let is_forwarded = name == FORWARDED; + + value.split(',').filter_map(move |part| { + let value = if is_forwarded { + // Forwarded header values are of the form: + // `for=client-ip;proto=http;by=proxy-ip` + part.split(';').find_map(|kv| { + let (key, value) = kv.trim().split_once('=')?; + + key.eq_ignore_ascii_case("for") + .then_some(value.trim().trim_matches('"')) + })? + } else { + part + }; + + Self::parse_address(value) + }) + } + + #[inline] + fn is_trusted(ip: IpAddr, proxies: &[IpNetwork]) -> bool { + proxies.iter().any(|network| network.contains(&ip)) + } +} diff --git a/lib/internal/src/telemetry/traces/spans/tests.rs b/lib/internal/src/telemetry/traces/spans/tests.rs index 747f1ea5a..d916a499b 100644 --- a/lib/internal/src/telemetry/traces/spans/tests.rs +++ b/lib/internal/src/telemetry/traces/spans/tests.rs @@ -6,12 +6,18 @@ use super::graphql::{ }; use super::http_request::{HttpClientRequestSpan, HttpInflightRequestSpan, HttpServerRequestSpan}; use crate::graphql::ObservedError; +use crate::telemetry::traces::spans::http_request::HttpServerSpanRequest; use bytes::Bytes; -use http::header::{HOST, USER_AGENT}; -use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use hive_router_config::telemetry::{ClientIpHeaderConfig, ClientIpHeaderTrustedProxiesConfig}; +use http::header::{FORWARDED, HOST, USER_AGENT}; +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version}; use http_body_util::Full; +use ntex::http::HeaderMap as NtexHeaderMap; use ntex::util::Bytes as NtexBytes; +use ntex::web::test::TestRequest; use std::collections::{BTreeMap, HashMap}; +use std::net::SocketAddr; +use std::str::FromStr; use std::sync::{Arc, Mutex}; use tracing::field::{Field, Visit}; use tracing::subscriber::with_default; @@ -19,6 +25,53 @@ use tracing::Span; use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; use tracing_subscriber::Registry; +const XFF: HeaderName = HeaderName::from_static("x-forwarded-for"); + +struct HttpRequestMock { + req: ntex::web::HttpRequest, + // peer_addr is stored separately as TestRequest never passes it to HttpRequest. + // It's gone and we need it to test trusted_proxies thingy. + peer_addr: Option, +} + +impl HttpRequestMock { + fn with_peer_addr(mut self, peer_addr: SocketAddr) -> Self { + self.peer_addr = Some(peer_addr); + self + } +} + +impl From for HttpRequestMock { + fn from(req: TestRequest) -> Self { + Self { + req: req.to_http_request(), + peer_addr: None, + } + } +} + +impl HttpServerSpanRequest for HttpRequestMock { + fn headers(&self) -> &NtexHeaderMap { + self.req.headers() + } + + fn method(&self) -> &Method { + self.req.method() + } + + fn uri(&self) -> &Uri { + self.req.uri() + } + + fn version(&self) -> Version { + self.req.version() + } + + fn peer_addr(&self) -> Option { + self.peer_addr + } +} + #[derive(Clone, Default)] struct RecordingLayer { fields: Arc>>>, @@ -37,6 +90,14 @@ impl RecordingLayer { let id = span.id().expect("span id").into_u64(); assert_eq!(self.value(id, key).as_deref(), Some(expected)); } + + fn assert_not_recorded(&self, span: &Span, key: &str) { + let id = span.id().expect("span id").into_u64(); + assert!( + self.value(id, key).is_none(), + "Expected '{key}' not to be recorded" + ); + } } #[derive(Default)] @@ -123,6 +184,22 @@ fn assert_fields(span: &Span, expected_fields: &[&str]) { ); } +fn ip_header_config(header: &str) -> Option { + Some(ClientIpHeaderConfig::HeaderName(header.into())) +} + +fn trusted_ip_header_config( + header: &str, + trusted_proxies: Vec<&str>, +) -> Option { + Some(ClientIpHeaderConfig::TrustedProxies( + ClientIpHeaderTrustedProxiesConfig { + name: header.into(), + trusted_proxies: trusted_proxies.into_iter().map(Into::into).collect(), + }, + )) +} + #[test] fn test_http_server_request_span() { let layer = RecordingLayer::default(); @@ -130,13 +207,14 @@ fn test_http_server_request_span() { let response_body = "response body"; with_default(subscriber, || { - let req = ntex::web::test::TestRequest::with_uri("/graphql") + let req = TestRequest::with_uri("/graphql") .header(HOST, "localhost:8080") .header(USER_AGENT, "test-agent") + .header(XFF, "192.168.0.1, 192.168.0.2, 192.168.0.3:420") .to_http_request(); let body = NtexBytes::from("test body"); - let span = HttpServerRequestSpan::from_request(&req); + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(XFF.as_str())); span.record_body_size(body.len()); assert_fields( &span, @@ -157,6 +235,10 @@ fn test_http_server_request_span() { attributes::HTTP_RESPONSE_STATUS_CODE, attributes::HTTP_RESPONSE_BODY_SIZE, attributes::HTTP_ROUTE, + attributes::CLIENT_ADDRESS, + attributes::CLIENT_PORT, + attributes::NETWORK_PEER_ADDRESS, + attributes::NETWORK_PEER_PORT, ], ); @@ -170,8 +252,10 @@ fn test_http_server_request_span() { &response_body.len().to_string(), ); layer.assert_recorded_value(&span, attributes::OTEL_STATUS_CODE, "Ok"); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "192.168.0.1"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); - let span = HttpServerRequestSpan::from_request(&req); + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(XFF.as_str())); span.record_internal_server_error(); layer.assert_recorded_value(&span, attributes::HTTP_RESPONSE_STATUS_CODE, "500"); @@ -180,6 +264,268 @@ fn test_http_server_request_span() { }); } +#[test] +fn test_http_server_request_span_client_address_from_various_values() { + let layer = RecordingLayer::default(); + let subscriber = Registry::default().with(layer.clone()); + + with_default(subscriber, || { + let realistic_with_port = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "10.0.0.1:1234, 172.16.1.42:443") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &realistic_with_port, + &ip_header_config(XFF.as_str()), + ); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "10.0.0.1"); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, "1234"); + + let realistic_without_port = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "10.0.0.1, 172.16.1.42") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &realistic_without_port, + &ip_header_config(XFF.as_str()), + ); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "10.0.0.1"); + + let realistic_ipv6_with_port = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "[2001:db8::1]:8080, [2001:db8::2]:8443") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &realistic_ipv6_with_port, + &ip_header_config(XFF.as_str()), + ); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "2001:db8::1"); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, "8080"); + + let realistic_ipv6_without_port = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "2001:db8::1, 2001:db8::2") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &realistic_ipv6_without_port, + &ip_header_config(XFF.as_str()), + ); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "2001:db8::1"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let unrealistic_malformed_ipv6_with_port = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "2001:db8::2:8443") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &unrealistic_malformed_ipv6_with_port, + &ip_header_config(XFF.as_str()), + ); + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "2001:db8::2:8443"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let unrealistic_empty = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, ", ,") + .to_http_request(); + let span = HttpServerRequestSpan::from_request( + &unrealistic_empty, + &ip_header_config(XFF.as_str()), + ); + layer.assert_not_recorded(&span, attributes::CLIENT_ADDRESS); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + }); +} + +#[test] +fn test_http_server_request_span_client_address_with_trusted_proxies() { + let layer = RecordingLayer::default(); + let subscriber = Registry::default().with(layer.clone()); + let peer_addr = SocketAddr::from_str("10.0.0.2:8080").unwrap(); + let peer_ip = peer_addr.ip().to_string(); + let peer_port = peer_addr.port().to_string(); + + with_default(subscriber, || { + let req = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "198.51.100.7, 10.0.0.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &req, + &trusted_ip_header_config(XFF.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let all_trusted = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "10.1.1.1, 10.2.2.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &all_trusted, + &trusted_ip_header_config(XFF.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "10.1.1.1"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let mixed_invalid = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "garbage, 198.51.100.7, 10.0.0.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &mixed_invalid, + &trusted_ip_header_config(XFF.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let non_ip_tokens = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(XFF, "foo:300, local"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &non_ip_tokens, + &trusted_ip_header_config(XFF.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, peer_ip.as_str()); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, peer_port.as_str()); + }); +} + +#[test] +fn test_http_server_request_span_client_address_from_forwarded() { + let layer = RecordingLayer::default(); + let subscriber = Registry::default().with(layer.clone()); + + with_default(subscriber, || { + let req = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, "for=198.51.100.7;proto=https, for=10.0.0.2") + .to_http_request(); + + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(FORWARDED.as_str())); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let req = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, r#"for="198.51.100.7:1234";proto=https"#) + .to_http_request(); + + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(FORWARDED.as_str())); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, "1234"); + + let req = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, r#"for="[2001:db8::1]:8080";proto=https"#) + .to_http_request(); + + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(FORWARDED.as_str())); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "2001:db8::1"); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, "8080"); + + let req = TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, "for=_hidden, proto=https") + .to_http_request(); + + let span = HttpServerRequestSpan::from_request(&req, &ip_header_config(FORWARDED.as_str())); + + layer.assert_not_recorded(&span, attributes::CLIENT_ADDRESS); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + }); +} + +#[test] +fn test_http_server_request_span_client_address_from_forwarded_trusted_proxies() { + let layer = RecordingLayer::default(); + let subscriber = Registry::default().with(layer.clone()); + let peer_addr = SocketAddr::from_str("10.0.0.2:8080").unwrap(); + let peer_ip = peer_addr.ip().to_string(); + + with_default(subscriber, || { + let req = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, "for=198.51.100.7;proto=https, for=10.0.0.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &req, + &trusted_ip_header_config(FORWARDED.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let all_trusted = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, "for=10.1.1.1, for=10.2.2.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &all_trusted, + &trusted_ip_header_config(FORWARDED.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "10.1.1.1"); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + + let with_port = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, r#"for="198.51.100.7:4444", for=10.0.0.2"#), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &with_port, + &trusted_ip_header_config(FORWARDED.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, "198.51.100.7"); + layer.assert_recorded_value(&span, attributes::CLIENT_PORT, "4444"); + + let invalid_tokens = HttpRequestMock::from( + TestRequest::with_uri("/graphql") + .header(HOST, "localhost:8080") + .header(FORWARDED, "for=_hidden, for=10.0.0.2"), + ) + .with_peer_addr(peer_addr); + + let span = HttpServerRequestSpan::from_request( + &invalid_tokens, + &trusted_ip_header_config(FORWARDED.as_str(), vec!["10.0.0.0/8"]), + ); + + layer.assert_recorded_value(&span, attributes::CLIENT_ADDRESS, peer_ip.as_str()); + layer.assert_not_recorded(&span, attributes::CLIENT_PORT); + }); +} + #[test] fn test_http_client_request_span() { let layer = RecordingLayer::default(); diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 4fb6de40e..7d2bc942c 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -25,6 +25,7 @@ jsonwebtoken = { workspace = true } retry-policies = { workspace = true} tracing = { workspace = true } regex-automata = { workspace = true } +ipnet = { workspace = true } once_cell = "1.21.3" schemars = { version = "1.0.4", features = ["url2"] } diff --git a/lib/router-config/src/primitives/ip_network.rs b/lib/router-config/src/primitives/ip_network.rs new file mode 100644 index 000000000..30cc9ab0a --- /dev/null +++ b/lib/router-config/src/primitives/ip_network.rs @@ -0,0 +1,114 @@ +use std::{fmt, net::IpAddr, str::FromStr}; + +use ipnet::IpNet; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct IpNetwork(IpNet); + +impl IpNetwork { + pub fn get_ref(&self) -> &IpNet { + &self.0 + } + + pub fn contains(&self, ip: &IpAddr) -> bool { + self.0.contains(ip) + } +} + +impl From<&str> for IpNetwork { + fn from(value: &str) -> Self { + Self( + IpNet::from_str(value) + .unwrap_or_else(|e| panic!("Invalid IP network '{}': {}", value, e)), + ) + } +} + +impl From for IpNetwork { + fn from(value: String) -> Self { + value.as_str().into() + } +} + +impl Serialize for IpNetwork { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl JsonSchema for IpNetwork { + fn schema_name() -> std::borrow::Cow<'static, str> { + "IpNetwork".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string", + "description": "An IPv4 or IPv6 network in CIDR notation, or a single IP address.", + "examples": [ + "10.0.0.0/8", + "192.168.1.10/32", + "2001:db8::/32", + "127.0.0.1" + ] + }) + } + + fn inline_schema() -> bool { + true + } +} + +struct IpNetworkVisitor; + +impl<'de> serde::de::Visitor<'de> for IpNetworkVisitor { + type Value = IpNetwork; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "an IPv4/IPv6 CIDR network or single IP address, e.g. \"10.0.0.0/8\" or \"127.0.0.1\"", + ) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if let Ok(network) = IpNet::from_str(value) { + return Ok(IpNetwork(network)); + } + + IpAddr::from_str(value) + .map(IpNet::from) + .map(IpNetwork) + .map_err(serde::de::Error::custom) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: serde::de::Error, + { + self.visit_str(value) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + self.visit_str(&value) + } +} + +impl<'de> Deserialize<'de> for IpNetwork { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(IpNetworkVisitor) + } +} diff --git a/lib/router-config/src/primitives/mod.rs b/lib/router-config/src/primitives/mod.rs index 47f123c52..0608edae6 100644 --- a/lib/router-config/src/primitives/mod.rs +++ b/lib/router-config/src/primitives/mod.rs @@ -1,6 +1,7 @@ pub mod absolute_path; pub mod file_path; pub mod http_header; +pub mod ip_network; pub mod retry_policy; pub mod single_or_multiple; pub mod toggle; diff --git a/lib/router-config/src/telemetry.rs b/lib/router-config/src/telemetry.rs index 8cf0123e5..1a3ef2d4c 100644 --- a/lib/router-config/src/telemetry.rs +++ b/lib/router-config/src/telemetry.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::primitives::http_header::HttpHeaderName; +use crate::primitives::ip_network::IpNetwork; use crate::primitives::value_or_expression::ValueOrExpression; use crate::telemetry::{hive::HiveTelemetryConfig, metrics::MetricsConfig, tracing::TracingConfig}; @@ -47,9 +49,33 @@ pub struct ResourceConfig { #[serde(deny_unknown_fields)] pub struct ClientIdentificationConfig { #[serde(default = "default_client_name_header")] - pub name_header: String, + pub name_header: HttpHeaderName, #[serde(default = "default_client_version_header")] - pub version_header: String, + pub version_header: HttpHeaderName, + /// Defines how the client IP address is determined. + /// + /// Important: HTTP headers like `x-forwarded-for` can be spoofed by clients. + /// Use it only with trusted proxies. + /// + /// It's null by default and uses the socket peer address. + /// + /// Use the left-most value from the specified header: + /// ```ignore + /// ip_header: "x-forwarded-for" + /// ``` + /// + /// If peer socket address is trusted, meaning it's part of `trusted_proxies` list, + /// Router evaluates values from right to left and picks the first non-trusted value. + /// If all values are trusted, uses the left-most value. + /// ```ignore + /// ip_header: + /// name: "x-forwarded-for" + /// trusted_proxies: + /// - 10.0.0.0/8 + /// - 127.0.0.1/32 + /// ``` + #[serde(default)] + pub ip_header: Option, } impl Default for ClientIdentificationConfig { @@ -57,14 +83,83 @@ impl Default for ClientIdentificationConfig { Self { name_header: default_client_name_header(), version_header: default_client_version_header(), + ip_header: None, } } } -fn default_client_name_header() -> String { - "graphql-client-name".to_string() +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(untagged)] +pub enum ClientIpHeaderConfig { + HeaderName(HttpHeaderName), + TrustedProxies(ClientIpHeaderTrustedProxiesConfig), +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct ClientIpHeaderTrustedProxiesConfig { + /// Header name containing client and proxy chain values. + pub name: HttpHeaderName, + /// Trusted proxy addresses. + /// Each entry can be an IP or CIDR. + #[serde(default)] + pub trusted_proxies: Vec, +} + +fn default_client_name_header() -> HttpHeaderName { + "graphql-client-name".into() } -fn default_client_version_header() -> String { - "graphql-client-version".to_string() +fn default_client_version_header() -> HttpHeaderName { + "graphql-client-version".into() +} + +#[cfg(test)] +mod tests { + use super::{ClientIdentificationConfig, ClientIpHeaderConfig}; + + #[test] + fn client_identification_defaults_to_peer_address_source() { + let config = ClientIdentificationConfig::default(); + assert!(config.ip_header.is_none()); + } + + #[test] + fn client_identification_accepts_header_name_string() { + let config = serde_json::from_str::( + r#"{"ip_header":"x-forwarded-for"}"#, + ) + .expect("config should parse"); + + match config.ip_header { + Some(ClientIpHeaderConfig::HeaderName(name)) => { + assert_eq!(name.get_header_ref().as_str(), "x-forwarded-for"); + } + _ => panic!("expected header-name mode"), + } + } + + #[test] + fn client_identification_accepts_trusted_proxy_object() { + let config = serde_json::from_str::( + r#"{"ip_header":{"name":"x-forwarded-for","trusted_proxies":[]}}"#, + ) + .expect("config should parse"); + + match config.ip_header { + Some(ClientIpHeaderConfig::TrustedProxies(config)) => { + assert_eq!(config.name.get_header_ref().as_str(), "x-forwarded-for"); + assert_eq!(config.trusted_proxies, vec![]); + } + _ => panic!("expected trusted-proxies mode"), + } + } + + #[test] + fn client_identification_rejects_non_ip_trusted_proxy_value() { + let result = serde_json::from_str::( + r#"{"ip_header":{"name":"x-forwarded-for","trusted_proxies":["local"]}}"#, + ); + assert!(result.is_err()); + } } From 3fc3c51a128235c42f9478185029e0cf4c6f059e Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 4 May 2026 15:48:48 +0200 Subject: [PATCH 68/76] Replace Docker-based Redis with mock rust server (#954) Removes `bollard` dependency and e2e Docker test helper. Implements an in-process redis server for `response_cache` plugin tests and switch tests to inline config. --- We have a test failure from time to time and we rely on Docker yada yada, so I created a tiny redis server for mocking as we really only use it for a single plugin test. Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- Cargo.lock | 87 +----- e2e/Cargo.toml | 1 - e2e/src/testkit/docker.rs | 211 ------------- e2e/src/testkit/mod.rs | 1 - .../response_cache/router.config.yaml | 4 +- plugin_examples/response_cache/src/lib.rs | 288 +++++++++++++++++- 6 files changed, 279 insertions(+), 313 deletions(-) delete mode 100644 e2e/src/testkit/docker.rs diff --git a/Cargo.lock b/Cargo.lock index 748e38ff6..0adef73a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,49 +678,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "bollard" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" -dependencies = [ - "base64", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-util", - "hyperlocal", - "log", - "pin-project-lite", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1720,7 +1677,6 @@ dependencies = [ "async-trait", "axum", "axum-server", - "bollard", "bytes", "dashmap", "futures", @@ -2839,21 +2795,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", - "winapi", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2908,21 +2849,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -6184,17 +6110,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -6451,7 +6366,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml index be83eccdc..c1181fdaf 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -46,6 +46,5 @@ tempfile = "3.23.0" hex = "0.4" tiny_http = "0.12" futures-util = { workspace = true } -bollard = "0.20.0" axum-server = { version = "0.8.0", features = ["tls-rustls"] } rcgen = "0.14.7" diff --git a/e2e/src/testkit/docker.rs b/e2e/src/testkit/docker.rs deleted file mode 100644 index 6de6fcb81..000000000 --- a/e2e/src/testkit/docker.rs +++ /dev/null @@ -1,211 +0,0 @@ -use bollard::{ - exec::{CreateExecOptions, StartExecOptions, StartExecResults}, - models::{ContainerCreateBody, HostConfig, PortBinding}, - query_parameters::{ - CreateContainerOptionsBuilder, CreateImageOptionsBuilder, RemoveContainerOptionsBuilder, - }, - Docker, -}; -use futures_util::TryStreamExt; -use std::{collections::HashMap, marker::PhantomData}; - -use super::{Built, Started}; - -pub struct TestDockerContainerBuilder { - name: String, - image: String, - ports: HashMap, - env: Vec, -} - -impl TestDockerContainerBuilder { - pub fn new(name: impl Into, image: impl Into) -> Self { - Self { - name: name.into(), - image: image.into(), - ports: HashMap::new(), - env: vec![], - } - } - - pub fn port(mut self, container_port: u16, host_port: u16) -> Self { - self.ports.insert(container_port, host_port); - self - } - - pub fn env(mut self, env: impl Into) -> Self { - self.env.push(env.into()); - self - } - - pub fn build(self) -> TestDockerContainer { - TestDockerContainer { - name: self.name, - image: self.image, - ports: self.ports, - env: self.env, - handle: None, - _state: PhantomData, - } - } -} - -struct TestDockerContainerHandle { - name: String, - docker: Docker, -} - -impl Drop for TestDockerContainerHandle { - fn drop(&mut self) { - let docker = self.docker.clone(); - let name = self.name.clone(); - let _ = std::thread::spawn(move || { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build shutdown runtime") - .block_on(async move { - let _ = docker - .remove_container( - &name, - Some(RemoveContainerOptionsBuilder::new().force(true).build()), - ) - .await; - }) - }) - .join(); - } -} - -pub struct TestDockerContainer { - name: String, - image: String, - ports: HashMap, - env: Vec, - handle: Option, - _state: PhantomData, -} - -impl TestDockerContainer { - pub fn builder( - name: impl Into, - image: impl Into, - ) -> TestDockerContainerBuilder { - TestDockerContainerBuilder::new(name, image) - } - - pub async fn start(self) -> TestDockerContainer { - let docker = - Docker::connect_with_local_defaults().expect("failed to connect to docker daemon"); - - docker - .create_image( - Some( - CreateImageOptionsBuilder::default() - .from_image(&self.image) - .build(), - ), - None, - None, - ) - .try_collect::>() - .await - .expect("failed to pull docker image"); - - let mut port_bindings: HashMap>> = HashMap::new(); - for (container_port, host_port) in &self.ports { - port_bindings.insert( - format!("{}/tcp", container_port), - Some(vec![PortBinding { - host_ip: Some("127.0.0.1".to_string()), - host_port: Some(host_port.to_string()), - }]), - ); - } - - let host_config = HostConfig { - port_bindings: Some(port_bindings), - ..Default::default() - }; - - let _ = docker - .remove_container( - &self.name, - Some(RemoveContainerOptionsBuilder::new().force(true).build()), - ) - .await; - - docker - .create_container( - Some( - CreateContainerOptionsBuilder::new() - .name(&self.name) - .build(), - ), - ContainerCreateBody { - image: Some(self.image.clone()), - env: if self.env.is_empty() { - None - } else { - Some(self.env.clone()) - }, - host_config: Some(host_config), - ..Default::default() - }, - ) - .await - .expect("failed to create docker container"); - - docker - .start_container(&self.name, None) - .await - .expect("failed to start docker container"); - - TestDockerContainer { - name: self.name.clone(), - image: self.image, - ports: self.ports, - env: self.env, - handle: Some(TestDockerContainerHandle { - name: self.name, - docker, - }), - _state: PhantomData, - } - } -} - -impl TestDockerContainer { - pub async fn exec(&self, cmd: Vec<&str>) { - let handle = self.handle.as_ref().expect("container not started"); - - let exec = handle - .docker - .create_exec( - &handle.name, - CreateExecOptions { - attach_stdout: Some(true), - attach_stderr: Some(true), - cmd: Some(cmd), - ..Default::default() - }, - ) - .await - .expect("failed to create exec"); - - let results = handle - .docker - .start_exec(&exec.id, None::) - .await - .expect("failed to start exec"); - - if let StartExecResults::Attached { mut output, .. } = results { - while output - .try_next() - .await - .expect("exec output error") - .is_some() - {} - } - } -} diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index e73767f8f..381f23e8e 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -1,4 +1,3 @@ -pub mod docker; pub mod otel; use axum_server::{tls_rustls::RustlsConfig, Handle}; diff --git a/plugin_examples/response_cache/router.config.yaml b/plugin_examples/response_cache/router.config.yaml index 1bd6e4da2..5a13c613b 100644 --- a/plugin_examples/response_cache/router.config.yaml +++ b/plugin_examples/response_cache/router.config.yaml @@ -6,5 +6,5 @@ plugins: response_cache_plugin: enabled: true config: - redis_url: "redis://localhost:6379" - default_ttl_seconds: 2 \ No newline at end of file + redis_url: "redis://localhost:6379" + default_ttl_seconds: 2 diff --git a/plugin_examples/response_cache/src/lib.rs b/plugin_examples/response_cache/src/lib.rs index 413dc133b..67e219f8a 100644 --- a/plugin_examples/response_cache/src/lib.rs +++ b/plugin_examples/response_cache/src/lib.rs @@ -174,26 +174,290 @@ impl RouterPlugin for ResponseCachePlugin { #[cfg(test)] mod tests { - use e2e::testkit::{docker::TestDockerContainer, TestRouter, TestSubgraphs}; - use hive_router::{http::StatusCode, ntex}; + use std::{ + collections::HashMap, + fs, + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }; + + use e2e::testkit::{TestRouter, TestSubgraphs}; + use hive_router::{http::StatusCode, ntex, tokio}; + use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::{ + tcp::{OwnedReadHalf, OwnedWriteHalf}, + TcpListener, TcpStream, + }, + sync::oneshot, + }; + + type Store = Arc, Instant)>>>; + + struct TinyRedisServer { + addr: String, + stop_tx: Option>, + thread_handle: Option>, + } + + impl TinyRedisServer { + async fn start() -> Self { + let (addr_tx, addr_rx) = std::sync::mpsc::sync_channel::(1); + let (stop_tx, mut stop_rx) = oneshot::channel::<()>(); + let thread_handle = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tiny redis server should create tokio runtime"); + + rt.block_on(async move { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("tiny redis server should bind to an ephemeral port"); + + let addr = listener + .local_addr() + .expect("tiny redis server should expose local addr"); + let _ = addr_tx.send(format!("127.0.0.1:{}", addr.port())); + + let store: Store = Arc::new(Mutex::new(HashMap::new())); + + loop { + tokio::select! { + _ = &mut stop_rx => { + break; + } + accepted = listener.accept() => { + let Ok((stream, _)) = accepted else { + break; + }; + + let store = Arc::clone(&store); + tokio::spawn(async move { + handle_connection(stream, store).await; + }); + } + } + } + }); + }); + + let addr = addr_rx + .recv_timeout(Duration::from_secs(2)) + .expect("tiny redis server should send bound addr"); + + Self { + addr, + stop_tx: Some(stop_tx), + thread_handle: Some(thread_handle), + } + } + + fn redis_url(&self) -> String { + format!("redis://{}", self.addr) + } + } + + impl Drop for TinyRedisServer { + fn drop(&mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } + } + + async fn read_line(reader: &mut BufReader) -> Option> { + let mut line = Vec::new(); + if reader.read_until(b'\n', &mut line).await.ok()? == 0 { + return None; + } + + if line.last() == Some(&b'\n') { + line.pop(); + } + if line.last() == Some(&b'\r') { + line.pop(); + } + + Some(line) + } + + fn bytes_to_string(bytes: Vec) -> Option { + String::from_utf8(bytes).ok() + } + + async fn read_command(reader: &mut BufReader) -> Option>> { + let header = bytes_to_string(read_line(reader).await?)?; + if let Some(rest) = header.strip_prefix('*') { + let count: usize = rest.parse().ok()?; + + let mut parts = Vec::with_capacity(count); + + for _ in 0..count { + let len_header = bytes_to_string(read_line(reader).await?)?; + let len: usize = len_header.strip_prefix('$')?.parse().ok()?; + + let mut bytes = vec![0; len]; + reader.read_exact(&mut bytes).await.ok()?; + + let mut crlf = [0_u8; 2]; + reader.read_exact(&mut crlf).await.ok()?; + + if crlf != *b"\r\n" { + return None; + } + + parts.push(bytes); + } + + return Some(parts); + } + + let parts = header + .split_whitespace() + .map(|part| part.as_bytes().to_vec()) + .collect::>(); + + if parts.is_empty() { + None + } else { + Some(parts) + } + } + + async fn write_simple(stream: &mut OwnedWriteHalf, value: &str) { + let _ = stream.write_all(format!("+{value}\r\n").as_bytes()).await; + let _ = stream.flush().await; + } + + async fn write_error(stream: &mut OwnedWriteHalf, value: &str) { + let _ = stream + .write_all(format!("-ERR {value}\r\n").as_bytes()) + .await; + let _ = stream.flush().await; + } + + async fn write_bulk(stream: &mut OwnedWriteHalf, value: Option<&[u8]>) { + match value { + Some(bytes) => { + let _ = stream + .write_all(format!("${}\r\n", bytes.len()).as_bytes()) + .await; + let _ = stream.write_all(bytes).await; + let _ = stream.write_all(b"\r\n").await; + } + None => { + let _ = stream.write_all(b"$-1\r\n").await; + } + } + + let _ = stream.flush().await; + } + + fn clean_expired(store: &mut HashMap, Instant)>) { + let now = Instant::now(); + store.retain(|_, (_, expires_at)| *expires_at > now); + } + + fn is_command(value: &[u8], expected: &[u8]) -> bool { + value.eq_ignore_ascii_case(expected) + } + + async fn handle_connection(stream: TcpStream, store: Store) { + let (read_half, mut write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + + while let Some(parts) = read_command(&mut reader).await { + let Some(command) = parts.first() else { + write_error(&mut write_half, "empty command").await; + continue; + }; + + if is_command(command, b"PING") { + write_simple(&mut write_half, "PONG").await; + continue; + } + + if is_command(command, b"CLIENT") { + // redis-rs may send CLIENT SETINFO / CLIENT SETNAME. + // We don't care in this test. + write_simple(&mut write_half, "OK").await; + continue; + } + + if is_command(command, b"GET") { + if parts.len() != 2 { + write_error(&mut write_half, "wrong number of arguments for GET").await; + continue; + } + + let key = String::from_utf8_lossy(&parts[1]).to_string(); + + let value = { + let mut store = store.lock().expect("tiny redis store lock should succeed"); + clean_expired(&mut store); + store.get(&key).map(|(value, _)| value.clone()) + }; + write_bulk(&mut write_half, value.as_deref()).await; + + continue; + } + + if is_command(command, b"SETEX") { + if parts.len() != 4 { + write_error(&mut write_half, "wrong number of arguments for SETEX").await; + continue; + } + + let key = String::from_utf8_lossy(&parts[1]).to_string(); + + let ttl_seconds = match String::from_utf8_lossy(&parts[2]).parse::() { + Ok(value) => value, + Err(_) => { + write_error(&mut write_half, "invalid expire time").await; + continue; + } + }; + + let value = parts[3].clone(); + let expires_at = Instant::now() + Duration::from_secs(ttl_seconds); + + { + let mut store = store.lock().expect("tiny redis store lock should succeed"); + clean_expired(&mut store); + store.insert(key, (value, expires_at)); + } + + write_simple(&mut write_half, "OK").await; + continue; + } + + write_error( + &mut write_half, + &format!("unsupported command {}", String::from_utf8_lossy(command)), + ) + .await; + } + } #[ntex::test] async fn test_caching_with_default_ttl() { - let container = - TestDockerContainer::builder("redis_resp_caching_test", "redis/redis-stack:latest") - .port(6379, 6379) - .env("ALLOW_EMPTY_PASSWORD=yes") - .build() - .start() - .await; - - container.exec(vec!["redis-cli", "FLUSHALL"]).await; + let redis_server = TinyRedisServer::start().await; + let redis_url = redis_server.redis_url(); + let router_config_path = format!("{}/router.config.yaml", env!("CARGO_MANIFEST_DIR")); + let router_config = fs::read_to_string(&router_config_path) + .expect("response-cache test should read router.config.yaml") + .replace("redis://localhost:6379", &redis_url); let subgraphs = TestSubgraphs::builder().build().start().await; let router = TestRouter::builder() .with_subgraphs(&subgraphs) - .file_config("../plugin_examples/response_cache/router.config.yaml") + .inline_config(router_config) .register_plugin::() .build() .start() From ac67bb903f4ed21c2db436aa519dda1570d1ab6b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 4 May 2026 15:54:30 +0200 Subject: [PATCH 69/76] avoid propagating @include/@skip conditions to unconditional fetches (#953) Continue #948 here (as I have no push rights there) and fix issues with the merging logic. Closes: #949 and #948 --------- Co-authored-by: hgonz20 Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- ...void-include-skip-condition-propagation.md | 13 + .../simple-include-skip.supergraph.graphql | 2 + .../src/planner/fetch/fetch_step_data.rs | 87 +- .../fetch/optimize/batch_multi_type.rs | 5 +- .../src/planner/fetch/optimize/utils.rs | 101 +- .../src/planner/fetch/selections.rs | 83 +- lib/query-planner/src/tests/include_skip.rs | 943 ++++++++++++++++++ 7 files changed, 1140 insertions(+), 94 deletions(-) create mode 100644 .changeset/avoid-include-skip-condition-propagation.md diff --git a/.changeset/avoid-include-skip-condition-propagation.md b/.changeset/avoid-include-skip-condition-propagation.md new file mode 100644 index 000000000..99bc9e50b --- /dev/null +++ b/.changeset/avoid-include-skip-condition-propagation.md @@ -0,0 +1,13 @@ +--- +hive-router-query-planner: patch +hive-router-plan-executor: patch +hive-router: patch +node-addon: patch +--- + +# Avoid propagating `@include`/`@skip` conditions to unconditional fetches + +Fixed query planner condition propagation logic to avoid wrapping unconditional fetches +in conditional blocks when merging steps. This ensures that fields without directives are +not incorrectly gated by conditions from other steps, allowing for correct execution of +queries with mixed conditional and unconditional selections. diff --git a/lib/query-planner/fixture/tests/simple-include-skip.supergraph.graphql b/lib/query-planner/fixture/tests/simple-include-skip.supergraph.graphql index f14af289b..964582062 100644 --- a/lib/query-planner/fixture/tests/simple-include-skip.supergraph.graphql +++ b/lib/query-planner/fixture/tests/simple-include-skip.supergraph.graphql @@ -78,6 +78,8 @@ type Product skip: Boolean! @join__field(graph: C, requires: "isExpensive") neverCalledInclude: Boolean! @join__field(graph: C, requires: "isExpensive") neverCalledSkip: Boolean! @join__field(graph: C, requires: "isExpensive") + name: String! @join__field(graph: B, requires: "price") + isCheap: Boolean! @join__field(graph: B, requires: "price") } type Query @join__type(graph: A) @join__type(graph: B) @join__type(graph: C) { diff --git a/lib/query-planner/src/planner/fetch/fetch_step_data.rs b/lib/query-planner/src/planner/fetch/fetch_step_data.rs index b210d7ba4..75a8af055 100644 --- a/lib/query-planner/src/planner/fetch/fetch_step_data.rs +++ b/lib/query-planner/src/planner/fetch/fetch_step_data.rs @@ -5,7 +5,7 @@ use petgraph::graph::NodeIndex; use crate::{ ast::{ - merge_path::{Condition, MergePath}, + merge_path::{Condition, MergePath, Segment}, operation::VariableDefinition, safe_merge::AliasesRecords, }, @@ -128,6 +128,91 @@ impl FetchStepData { } } +// Extracts concrete type names from response-path type-condition segments. +// `products.@|[Book|Magazine]` gives back `Book` and `Magazine` +pub(crate) fn type_condition_types_from_response_path( + response_path: &MergePath, +) -> Option> { + let conditioned_types = response_path + .inner + .iter() + .filter_map(|segment| match segment { + Segment::TypeCondition(type_names, _) => Some(type_names), + _ => None, + }) + .flat_map(|type_names| type_names.iter().map(|type_name| type_name.as_str())) + .collect::>(); + + if conditioned_types.is_empty() { + None + } else { + Some(conditioned_types) + } +} + +impl FetchStepData { + // Moves a fetch-level condition down into this step's output selections. + // A fetch-level condition means "the whole HTTP request can be skipped". + // That is only correct while every output selection in the fetch has the same condition. + // Before merging a conditional fetch with an unconditional or differently-conditional fetch, + // the condition must be scoped to the affected output selections so it cannot leak to merged siblings. + fn scope_condition_to_output(&mut self, scope_by_response_path_types: bool) { + let Some(condition) = self.condition.take() else { + return; + }; + + let type_names = if scope_by_response_path_types { + type_condition_types_from_response_path(&self.response_path) + } else { + None + }; + + // If concrete type names are known, scope the condition to those types only. + // Otherwise apply it to every output type in this fetch. + if let Some(types) = type_names { + self.output.wrap_with_condition_for_types(condition, &types); + } else { + self.output.wrap_with_condition(condition); + } + } + + pub(crate) fn scope_fetch_conditions_before_merge(&mut self, source: &mut Self) { + if !self.is_entity_call() { + return; + } + + let can_scope_by_type = + self.is_fetching_multiple_types() || source.is_fetching_multiple_types(); + let condition_is_type_scoped = self.condition.is_some() + && self.is_fetching_multiple_types() + && type_condition_types_from_response_path(&self.response_path).is_some(); + + // A condition carried by a typed response path must stay on that concrete branch. + // For example, `products.@|[Book]` with `@include($showBook)` must not become an `Include` node + // around a merged Book+Magazine fetch, or Magazine data would disappear when false. + if condition_is_type_scoped { + self.scope_condition_to_output(true); + } + + // If both sides still have the same condition, keep it at fetch level, + // so the whole merged HTTP request can be skipped. + if self.condition != source.condition { + self.scope_condition_to_output(can_scope_by_type); + source.scope_condition_to_output(can_scope_by_type); + } + } + + pub(crate) fn lift_shared_output_condition_to_fetch(&mut self) { + // After a safe merge, all output selections may again be guarded by the + // same shared condition. + // Both `name` and `isCheap` may have `@include($showDetails)`. + // In that case we lift the condition back to fetch level so the router can skip the whole HTTP request. + if self.condition.is_none() { + self.condition = self.output.take_shared_top_level_fragment_condition(); + } + } +} + impl FetchStepData { pub fn into_multi_type(self) -> FetchStepData { FetchStepData:: { diff --git a/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs b/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs index 3eaac81ce..a0b86bac6 100644 --- a/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs +++ b/lib/query-planner/src/planner/fetch/optimize/batch_multi_type.rs @@ -5,8 +5,9 @@ use petgraph::{graph::NodeIndex, Direction}; use tracing::{instrument, trace}; use crate::ast::merge_path::Condition; -use crate::planner::fetch::fetch_step_data::FetchStepFlags; -use crate::planner::fetch::optimize::utils::type_condition_types_from_response_path; +use crate::planner::fetch::fetch_step_data::{ + type_condition_types_from_response_path, FetchStepFlags, +}; use crate::{ ast::merge_path::{MergePath, Segment}, planner::fetch::{ diff --git a/lib/query-planner/src/planner/fetch/optimize/utils.rs b/lib/query-planner/src/planner/fetch/optimize/utils.rs index 026a8bcd8..ceaad6841 100644 --- a/lib/query-planner/src/planner/fetch/optimize/utils.rs +++ b/lib/query-planner/src/planner/fetch/optimize/utils.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashSet, VecDeque}; +use std::collections::{HashSet, VecDeque}; use petgraph::{ graph::NodeIndex, @@ -7,8 +7,10 @@ use petgraph::{ use tracing::{instrument, trace}; use crate::{ - ast::merge_path::Segment, - ast::{merge_path::MergePath, selection_set::find_arguments_conflicts}, + ast::{ + merge_path::{MergePath, Segment}, + selection_set::find_arguments_conflicts, + }, planner::fetch::{ error::FetchGraphError, fetch_graph::FetchGraph, @@ -18,52 +20,6 @@ use crate::{ }, }; -pub fn type_condition_types_from_response_path( - response_path: &MergePath, -) -> Option> { - let conditioned_types = response_path - .inner - .iter() - .filter_map(|segment| match segment { - Segment::TypeCondition(type_names, _) => Some(type_names), - _ => None, - }) - .flat_map(|type_names| type_names.iter().map(|s| s.as_str()).clone()) - .collect::>(); - - if conditioned_types.is_empty() { - None - } else { - Some(conditioned_types) - } -} - -/// Moves a step-level condition into type-specific output branches when safe. -/// Prevents accidentally applying the condition to sibling types. -/// -/// Example: -/// If a merged fetch has Book + Magazine output and a condition from a Book path, -/// this applies the condition to Book selections only. -fn scope_target_condition(target: &mut FetchStepData) { - if !target.is_entity_call() || !target.is_fetching_multiple_types() { - return; - } - - let Some(condition) = target.condition.clone() else { - return; - }; - - let Some(conditioned_types) = type_condition_types_from_response_path(&target.response_path) - else { - return; - }; - - target - .output - .wrap_with_condition_for_types(condition, &conditioned_types); - target.condition = None; -} - /// Handles the "target is non-entity, source has step-level condition" case. /// When merging an entity fetch into a non-entity target, the condition must /// stay attached to the source branch data, not to the whole target step. @@ -109,44 +65,6 @@ fn merge_source_condition_into_non_entity_target( Ok(true) } -/// Tries to scope `source.condition` to source type branches. -/// If type scope is unclear, keeps the condition on `target` as a safe fallback. -fn preserve_or_scope_source_condition_for_entity_target( - target: &mut FetchStepData, - source: &mut FetchStepData, -) { - // This helper only applies when the merge target is an entity fetch - if !target.is_entity_call() { - return; - } - - // If source has no step-level condition, there is nothing to scope or preserve - let Some(condition) = source.condition.take() else { - return; - }; - - // We can scope condition to concrete type branches only in multi-type step. - // The response path's type-condition segments tell us which concrete types are affected. - let conditioned_types = - if target.is_fetching_multiple_types() || source.is_fetching_multiple_types() { - type_condition_types_from_response_path(&source.response_path) - } else { - None - }; - - // We know the concrete types, so apply condition only to those - // source output branches (instead of gating whole step). - if let Some(types) = conditioned_types { - source - .output - .wrap_with_condition_for_types(condition, &types); - return; - } - - // If type scope is unclear, keep condition at target step level. - target.condition = Some(condition); -} - // Return true in case an alias was applied during the merge process. #[instrument(level = "trace", skip_all)] pub(crate) fn perform_fetch_step_merge( @@ -163,11 +81,9 @@ pub(crate) fn perform_fetch_step_merge( source_index.index(), ); - scope_target_condition(target); - let source_condition_merged = merge_source_condition_into_non_entity_target(target, source)?; if !source_condition_merged { - preserve_or_scope_source_condition_for_entity_target(target, source); + target.scope_fetch_conditions_before_merge(source); } let scoped_aliases = target.output.safe_migrate_from_another( @@ -213,6 +129,11 @@ pub(crate) fn perform_fetch_step_merge( .migrate_from_another(&source.input, &MergePath::default())?; } + // Conditions may have been pushed down to keep the merge correct. + // If the merged fetch is still guarded by one shared condition, lift it back to + // step level. + target.lift_shared_output_condition_to_fetch(); + let mut children_indexes: Vec = vec![]; let mut parents_indexes: Vec = vec![]; for edge_ref in fetch_graph.children_of(source_index) { diff --git a/lib/query-planner/src/planner/fetch/selections.rs b/lib/query-planner/src/planner/fetch/selections.rs index ff91425ba..0c387c483 100644 --- a/lib/query-planner/src/planner/fetch/selections.rs +++ b/lib/query-planner/src/planner/fetch/selections.rs @@ -82,6 +82,54 @@ fn inline_fragment_condition(fragment: &InlineFragmentSelection) -> Option Option { + let mut condition = None; + + for item in selection_set.items.iter() { + let SelectionItem::InlineFragment(fragment) = item else { + return None; + }; + + if fragment.type_condition != type_name { + return None; + } + + let fragment_condition = inline_fragment_condition(fragment)?; + + match &condition { + Some(condition) => { + if condition != &fragment_condition { + return None; + } + } + None => condition = Some(fragment_condition), + } + } + + condition +} + +// Clears only the top-level wrapper condition after it has been lifted to the +// fetch step. Inner field/fragment conditions are preserved because those still +// describe selection-level behavior, not whole-fetch behavior. +fn clear_top_level_fragment_conditions(type_name: &str, selection_set: &mut SelectionSet) { + for item in selection_set.items.iter_mut() { + if let SelectionItem::InlineFragment(fragment) = item { + if fragment.type_condition == type_name { + fragment.include_if = None; + fragment.skip_if = None; + } + } + } +} + /// Attempts to lift a common condition from the top-level inline fragments for /// `type_name` into the wrapper `... on Type` fragment we build in `From`. /// @@ -459,6 +507,39 @@ impl FetchStepSelections { } } } + + // Tries to lift a shared output condition into the fetch step. + // A step-level condition means the whole fetch can be skipped. + // `Some` only when every type is guarded by the same top-level condition. + // The lifted wrapper directives are cleared from the output + // to avoid duplicating the same condition at both fetch and selection level. + pub fn take_shared_top_level_fragment_condition(&mut self) -> Option { + if self.is_empty() { + return None; + } + + let mut common_condition = None; + + for (type_name, selection_set) in self.iter() { + let condition = shared_top_level_fragment_condition(type_name, selection_set)?; + + match &common_condition { + Some(common_condition) => { + if common_condition != &condition { + return None; + } + } + None => common_condition = Some(condition), + } + } + + let condition = common_condition?; + for (type_name, selection_set) in self.selections.iter_mut() { + clear_top_level_fragment_conditions(type_name, selection_set); + } + + Some(condition) + } } impl FetchStepSelections { @@ -561,7 +642,7 @@ mod tests { } #[test] - fn lifts_uniform_condition_from_multiple_fragments() { + fn lifts_shared_condition_from_multiple_fragments() { let fetch_selections = multi_type_from_top_level_inline_fragments( r#" { diff --git a/lib/query-planner/src/tests/include_skip.rs b/lib/query-planner/src/tests/include_skip.rs index 5a758eac4..3fe4fee30 100644 --- a/lib/query-planner/src/tests/include_skip.rs +++ b/lib/query-planner/src/tests/include_skip.rs @@ -757,3 +757,946 @@ fn plans_query_with_field_level_include_skip_conditions() -> Result<(), Box Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + name @include(if: $bool) # graph B + isCheap # graph B + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @include(if: $bool) { + price + __typename + id + } + } + } + }, + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + ($bool:Boolean) { + ... on Product { + isCheap + ... on Product @include(if: $bool) { + name + } + } + } + }, + }, + }, + }, + "#); + Ok(()) +} + +#[test] +fn qp_include_field_level_unconditional_before_conditional() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + isCheap # graph B - requires `price` + name @include(if: $bool) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @include(if: $bool) { + price + __typename + id + } + } + } + }, + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + ($bool:Boolean) { + ... on Product { + ... on Product @include(if: $bool) { + name + } + isCheap + } + } + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_include_field_level_all_merged_fields_conditional() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + name @include(if: $bool) # graph B - requires `price` + isCheap @include(if: $bool) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @include(if: $bool) { + price + __typename + id + } + } + } + }, + Include(if: $bool) { + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + { + ... on Product { + isCheap + name + } + } + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_skip_field_level() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + name @skip(if: $bool) # graph B - requires `price` + isCheap # graph B - requires `price` + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @skip(if: $bool) { + price + __typename + id + } + } + } + }, + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + ($bool:Boolean) { + ... on Product { + isCheap + ... on Product @skip(if: $bool) { + name + } + } + } + }, + }, + }, + }, + "#); + Ok(()) +} + +#[test] +fn qp_skip_field_level_unconditional_before_conditional() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + isCheap # graph B - requires `price` + name @skip(if: $bool) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @skip(if: $bool) { + price + __typename + id + } + } + } + }, + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + ($bool:Boolean) { + ... on Product { + ... on Product @skip(if: $bool) { + name + } + isCheap + } + } + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_skip_field_level_all_merged_fields_conditional() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($bool: Boolean) { + product { + price # graph A + name @skip(if: $bool) # graph B - requires `price` + isCheap @skip(if: $bool) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($bool:Boolean) { + product { + __typename + price + id + ... on Product @skip(if: $bool) { + price + __typename + id + } + } + } + }, + Skip(if: $bool) { + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + { + ... on Product { + isCheap + name + } + } + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_skip_and_include_field_level_all_merged_fields_conditional() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($skip: Boolean, $include: Boolean) { + product { + price # graph A + name @skip(if: $skip) @include(if: $include) # graph B - requires `price` + isCheap @skip(if: $skip) @include(if: $include) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($include:Boolean,$skip:Boolean) { + product { + __typename + price + id + ... on Product @skip(if: $skip) @include(if: $include) { + price + __typename + id + } + } + } + }, + Skip(if: $skip) { + Include(if: $include) { + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + { + ... on Product { + isCheap + name + } + } + }, + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_mixed_include_skip_field_level_keeps_conditions_scoped() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($include: Boolean, $skip: Boolean) { + product { + price # graph A + name @include(if: $include) # graph B - requires `price` + isCheap @skip(if: $skip) # graph B - requires `price` + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/simple-include-skip.supergraph.graphql", + document, + )?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($include:Boolean,$skip:Boolean) { + product { + __typename + price + id + ... on Product @include(if: $include) { + ...a + } + ... on Product @skip(if: $skip) { + ...a + } + } + } + fragment a on Product { + price + __typename + id + } + }, + Flatten(path: "product") { + Fetch(service: "b") { + { + ... on Product { + __typename + price + id + } + } => + ($include:Boolean,$skip:Boolean) { + ... on Product { + ... on Product @skip(if: $skip) { + isCheap + } + ... on Product @include(if: $include) { + name + } + } + } + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_nested_include_skip_conditions_in_complex_products_query() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($reviews: Boolean, $skipAuthor: Boolean, $nestedReviews: Boolean, $product: Boolean) { + topProducts { + name + reviews @include(if: $reviews) { + body + author @skip(if: $skipAuthor) { + name + reviews @include(if: $nestedReviews) { + body + product @include(if: $product) { + name + shippingEstimate + } + } + } + } + } + } + "#, + ); + + let query_plan = build_query_plan("fixture/products-example.supergraph.graphql", document)?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "products") { + { + topProducts { + __typename + name + upc + } + } + }, + Include(if: $reviews) { + Flatten(path: "topProducts.@") { + Fetch(service: "reviews") { + { + ... on Product { + __typename + upc + } + } => + ($nestedReviews:Boolean,$product:Boolean,$skipAuthor:Boolean) { + ... on Product { + reviews { + body + author @skip(if: $skipAuthor) { + __typename + id + reviews @include(if: $nestedReviews) { + body + product @include(if: $product) { + __typename + upc + } + } + } + } + } + } + }, + }, + }, + Parallel { + Include(if: $product) { + Flatten(path: "topProducts.@.reviews.@.author.reviews.@.product") { + Fetch(service: "products") { + { + ... on Product { + __typename + upc + } + } => + { + ... on Product { + price + weight + name + } + } + }, + }, + }, + Skip(if: $skipAuthor) { + Flatten(path: "topProducts.@.reviews.@.author") { + Fetch(service: "accounts") { + { + ... on User { + __typename + id + } + } => + { + ... on User { + name + } + } + }, + }, + }, + }, + Include(if: $product) { + Flatten(path: "topProducts.@.reviews.@.author.reviews.@.product") { + Fetch(service: "inventory") { + { + ... on Product { + __typename + price + weight + upc + } + } => + { + ... on Product { + shippingEstimate + } + } + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_abstract_interface_mixed_conditions_stay_scoped() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($showReviews: Boolean, $hideSku: Boolean) { + products { + id + sku @skip(if: $hideSku) + reviewsCount @include(if: $showReviews) + ... on Book @include(if: $showReviews) { + title + } + ... on Magazine @skip(if: $hideSku) { + title + } + } + } + "#, + ); + + let query_plan = build_query_plan("fixture/tests/abstract-types.supergraph.graphql", document)?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "products") { + query ($hideSku:Boolean) { + products { + id + __typename + ... on Book { + __typename + sku @skip(if: $hideSku) + id + } + ... on Magazine { + __typename + sku @skip(if: $hideSku) + id + } + } + } + }, + Parallel { + Include(if: $showReviews) { + Flatten(path: "products.@") { + Fetch(service: "reviews") { + { + ... on Book { + __typename + id + } + ... on Magazine { + __typename + id + } + } => + { + ... on Book { + ... on Book { + reviewsCount + } + } + ... on Magazine { + ... on Magazine { + reviewsCount + } + } + } + }, + }, + }, + Include(if: $showReviews) { + Flatten(path: "products.@|[Book]") { + Fetch(service: "books") { + { + ... on Book { + __typename + id + } + } => + { + ... on Book { + title + } + } + }, + }, + }, + Skip(if: $hideSku) { + Flatten(path: "products.@|[Magazine]") { + Fetch(service: "magazines") { + { + ... on Magazine { + __typename + id + } + } => + { + ... on Magazine { + title + } + } + }, + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_abstract_interface_shared_condition_can_skip_remote_fetch() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($showReviews: Boolean) { + products { + id + reviewsCount @include(if: $showReviews) + reviewsScore @include(if: $showReviews) + } + } + "#, + ); + + let query_plan = build_query_plan("fixture/tests/abstract-types.supergraph.graphql", document)?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "products") { + { + products { + id + __typename + ... on Book { + __typename + id + } + ... on Magazine { + __typename + id + } + } + } + }, + Include(if: $showReviews) { + Flatten(path: "products.@") { + Fetch(service: "reviews") { + { + ... on Book { + __typename + id + } + ... on Magazine { + __typename + id + } + } => + { + ... on Book { + ... on Book { + reviewsCount + reviewsScore + } + } + ... on Magazine { + ... on Magazine { + reviewsCount + reviewsScore + } + } + } + }, + }, + }, + }, + }, + "#); + + Ok(()) +} + +#[test] +fn qp_abstract_union_member_conditions_stay_scoped() -> Result<(), Box> { + init_logger(); + + let document = parse_operation( + r#" + query ($showUserReview: Boolean, $hideAnonymousReview: Boolean) { + review { + ... on UserReview @include(if: $showUserReview) { + product { + b + } + } + ... on AnonymousReview @skip(if: $hideAnonymousReview) { + product { + c + } + } + } + } + "#, + ); + + let query_plan = build_query_plan( + "fixture/tests/union-overfetching.supergraph.graphql", + document, + )?; + let printed = format!("{}", query_plan); + + insta::assert_snapshot!(printed, @r#" + QueryPlan { + Sequence { + Fetch(service: "a") { + query ($hideAnonymousReview:Boolean,$showUserReview:Boolean) { + review { + __typename + ... on UserReview { + product @include(if: $showUserReview) { + ...a + } + } + ... on AnonymousReview { + product @skip(if: $hideAnonymousReview) { + ...a + } + } + } + } + fragment a on Product { + __typename + id + } + }, + Parallel { + Skip(if: $hideAnonymousReview) { + Flatten(path: "review|[AnonymousReview].product") { + Fetch(service: "c") { + { + ... on Product { + __typename + id + } + } => + { + ... on Product { + c + } + } + }, + }, + }, + Include(if: $showUserReview) { + Flatten(path: "review|[UserReview].product") { + Fetch(service: "b") { + { + ... on Product { + __typename + id + } + } => + { + ... on Product { + b + } + } + }, + }, + }, + }, + }, + }, + "#); + + Ok(()) +} From b870e9a700f4679a9ae218c3bfceddecb1c290d8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 20:16:55 +0200 Subject: [PATCH 70/76] chore: replace k6 benchmark validation with wrk + JSON hash comparison (#882) The benchmark suite now uses `wrk` for lower-overhead HTTP load testing while preserving response validation. This keeps throughput measurements focused on router performance and still fails the benchmark when responses have invalid status codes, GraphQL errors, or unexpected response bodies. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Kamil Kisiela Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .github/workflows/ci.yaml | 24 +- bench/README.md | 13 +- bench/ci-detect-regression.sh | 56 +- bench/expected_response.json | 1 + bench/k6.js | 2542 --------------------------------- bench/package.json | 7 +- bench/run-benchmark.sh | 180 +++ bench/wrk.lua | 239 ++++ 8 files changed, 483 insertions(+), 2579 deletions(-) mode change 100644 => 100755 bench/ci-detect-regression.sh create mode 100644 bench/expected_response.json delete mode 100644 bench/k6.js create mode 100755 bench/run-benchmark.sh create mode 100644 bench/wrk.lua diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 459f57cf9..b0e5888da 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,11 +206,10 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: setup rust uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 - - name: Setup K6 + - name: Setup wrk run: | - wget https://github.com/grafana/k6/releases/download/v0.37.0/k6-v0.37.0-linux-amd64.deb sudo apt-get update - sudo apt-get install ./k6-v0.37.0-linux-amd64.deb + sudo apt-get install -y wrk - name: Build subgraphs run: cargo build --release -p subgraphs - name: Run subgraphs @@ -224,14 +223,13 @@ jobs: sleep 5 env: ROUTER_CONFIG_FILE_PATH: ${{matrix.config}} - - name: Run k6 benchmark for ${{ github.ref }} + - name: Run benchmark for ${{ github.ref }} if: github.event_name == 'pull_request' - run: | - if [ "${{ matrix.name }}" = "persisted-documents" ]; then - k6 run -e SUMMARY_PATH=./bench/results/pr -e BENCH_PERSISTED_MODE=true -e BENCH_DOCUMENT_ID=bench_test_query bench/k6.js - else - k6 run -e SUMMARY_PATH=./bench/results/pr bench/k6.js - fi + run: ./bench/run-benchmark.sh + env: + SUMMARY_PATH: ./bench/results/pr + BENCH_PERSISTED_MODE: ${{ matrix.name == 'persisted-documents' && 'true' || '' }} + BENCH_DOCUMENT_ID: ${{ matrix.name == 'persisted-documents' && 'bench_test_query' || '' }} - name: Checkout main branch in a separate directory if: github.event_name == 'pull_request' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -265,9 +263,11 @@ jobs: env: ROUTER_CONFIG_FILE_PATH: ${{matrix.config}} - - name: Run k6 benchmark for main + - name: Run benchmark for main if: github.event_name == 'pull_request' - run: k6 run -e SUMMARY_PATH=./bench/results/main bench/k6.js + run: ./bench/run-benchmark.sh + env: + SUMMARY_PATH: ./bench/results/main - name: Compare benchmark results if: github.event_name == 'pull_request' run: | diff --git a/bench/README.md b/bench/README.md index f4d94449c..d04afbed1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -19,12 +19,17 @@ cargo build --release -p hive-router ## Load test -Defaults: 50 vus for 30s +Defaults: 50 connections for 30s ``` -k6 run k6.js +./bench/run-benchmark.sh # Custom settings -k6 run k6.js -e BENCH_VUS=69 -k6 run k6.js -e BENCH_OVER_TIME=10s +BENCH_CONNECTIONS=69 ./bench/run-benchmark.sh +BENCH_DURATION=10s ./bench/run-benchmark.sh +BENCH_PERSISTED_MODE=true BENCH_DOCUMENT_ID=bench_test_query ./bench/run-benchmark.sh + +# Backward-compatible env names +BENCH_VUS=69 ./bench/run-benchmark.sh +BENCH_OVER_TIME=10s ./bench/run-benchmark.sh ``` diff --git a/bench/ci-detect-regression.sh b/bench/ci-detect-regression.sh old mode 100644 new mode 100755 index 84f609989..7e49b0d96 --- a/bench/ci-detect-regression.sh +++ b/bench/ci-detect-regression.sh @@ -1,11 +1,16 @@ #!/bin/bash -# This script is meant to be run in CI (./github/workflows/ci.yaml#router-benchmark). +# This script is meant to be run in CI and locally. # -# It's a script to detect performance regression between two k6 benchmark summary files. -# It compares the 'http_reqs' rate metric from the PR summary against the main branch -# summary and determines if there is a regression of more than 2%. -# If a regression is detected, the script exits with a non-zero status code. +# It detects throughput regressions between two normalized benchmark summaries: +# - ./bench/results/pr/summary.json +# - ./bench/results/main/summary.json +# +# Expected summary shape: +# { +# "rate_rps": 1234.56, +# ... +# } # Ensure jq and bc are installed if ! command -v jq &> /dev/null @@ -19,8 +24,9 @@ then exit 1 fi -PR_SUMMARY="./bench/results/pr/k6_summary.json" -MAIN_SUMMARY="./bench/results/main/k6_summary.json" +PR_SUMMARY="./bench/results/pr/summary.json" +MAIN_SUMMARY="./bench/results/main/summary.json" +REGRESSION_THRESHOLD="-5" # Check if the summary files exist if [ ! -f "$PR_SUMMARY" ] || [ ! -f "$MAIN_SUMMARY" ]; then @@ -29,12 +35,24 @@ if [ ! -f "$PR_SUMMARY" ] || [ ! -f "$MAIN_SUMMARY" ]; then fi # Extract the rate values -MAIN_RATE=$(jq '.metrics.http_reqs.values.rate' "$MAIN_SUMMARY") -PR_RATE=$(jq '.metrics.http_reqs.values.rate' "$PR_SUMMARY") +MAIN_RATE=$(jq -r '.rate_rps' "$MAIN_SUMMARY") +PR_RATE=$(jq -r '.rate_rps' "$PR_SUMMARY") +MAIN_VALIDATION_FAILURES=$(jq -r '.validation_total_failures // 0' "$MAIN_SUMMARY") +PR_VALIDATION_FAILURES=$(jq -r '.validation_total_failures // 0' "$PR_SUMMARY") # Check if jq successfully extracted the rates -if [ -z "$MAIN_RATE" ] || [ -z "$PR_RATE" ] || [ "$MAIN_RATE" == "null" ] || [ "$PR_RATE" == "null" ]; then - echo "Could not extract rate from one or both summary files." +if [ -z "$MAIN_RATE" ] || [ -z "$PR_RATE" ] || [ "$MAIN_RATE" = "null" ] || [ "$PR_RATE" = "null" ]; then + echo "Could not extract rate_rps from one or both summary files." + exit 1 +fi + +if ! [[ "$MAIN_RATE" =~ ^[0-9]+([.][0-9]+)?$ ]] || ! [[ "$PR_RATE" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + echo "Invalid numeric rate_rps value(s): main=$MAIN_RATE pr=$PR_RATE" + exit 1 +fi + +if ! [[ "$MAIN_VALIDATION_FAILURES" =~ ^[0-9]+$ ]] || ! [[ "$PR_VALIDATION_FAILURES" =~ ^[0-9]+$ ]]; then + echo "Invalid validation_total_failures value(s): main=$MAIN_VALIDATION_FAILURES pr=$PR_VALIDATION_FAILURES" exit 1 fi @@ -45,18 +63,24 @@ if (( $(echo "$MAIN_RATE == 0" | bc -l) )); then exit 0 fi +if [ "$MAIN_VALIDATION_FAILURES" -gt 0 ] || [ "$PR_VALIDATION_FAILURES" -gt 0 ]; then + echo "Validation failures found in benchmark summaries." + echo "Main validation_total_failures: $MAIN_VALIDATION_FAILURES" + echo "PR validation_total_failures: $PR_VALIDATION_FAILURES" + exit 1 +fi + # Calculate the percentage difference using bc -# scale determines the number of decimal places -diff=$(echo "scale=4; (($PR_RATE - $MAIN_RATE) / $MAIN_RATE) * 100" | bc) +diff=$(echo "scale=6; (($PR_RATE - $MAIN_RATE) / $MAIN_RATE) * 100" | bc -l) # Print the results -echo "Main branch http_reqs rate: $MAIN_RATE" -echo "PR branch http_reqs rate: $PR_RATE" +echo "Main rate (rps): $MAIN_RATE" +echo "PR rate (rps): $PR_RATE" printf "Difference: %.2f%%\n" "$diff" # Check if the difference is a regression of more than 5% # bc returns 1 for true, 0 for false. We compare with a negative number. -is_regression=$(echo "$diff < -5" | bc) +is_regression=$(echo "$diff < $REGRESSION_THRESHOLD" | bc) if [ "$is_regression" -eq 1 ]; then echo "Performance regression detected! The PR is more than 5% slower than main." diff --git a/bench/expected_response.json b/bench/expected_response.json new file mode 100644 index 000000000..5527cee08 --- /dev/null +++ b/bench/expected_response.json @@ -0,0 +1 @@ +{"data":{"users":[{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]},{"id":"2","username":"dotansimha","name":"Dotan Simha","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]},{"id":"3","username":"kamilkisiela","name":"Kamil Kisiela","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]},{"id":"4","username":"ardatan","name":"Arda Tanrikulu","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]},{"id":"5","username":"gilgardosh","name":"Gil Gardosh","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]},{"id":"6","username":"laurin","name":"Laurin Quast","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]}}]}],"topProducts":[{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100,"reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"3","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"4","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]},{"inStock":false,"name":"Couch","price":1299,"shippingEstimate":0,"upc":"2","weight":1000,"reviews":[{"id":"5","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"6","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"7","body":"sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"8","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]},{"inStock":false,"name":"Glass","price":15,"shippingEstimate":10,"upc":"3","weight":20,"reviews":[{"id":"9","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]},{"inStock":false,"name":"Chair","price":499,"shippingEstimate":50,"upc":"4","weight":100,"reviews":[{"id":"10","body":"Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}},{"id":"11","body":"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.","author":{"id":"1","username":"urigo","name":"Uri Goldshtein","reviews":[{"id":"1","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}},{"id":"2","body":"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi","product":{"inStock":true,"name":"Table","price":899,"shippingEstimate":50,"upc":"1","weight":100}}]}}]},{"inStock":true,"name":"TV","price":1299,"shippingEstimate":0,"upc":"5","weight":1000,"reviews":[]}]}} \ No newline at end of file diff --git a/bench/k6.js b/bench/k6.js deleted file mode 100644 index 3c5da60bd..000000000 --- a/bench/k6.js +++ /dev/null @@ -1,2542 +0,0 @@ -import http from "k6/http"; -import { check } from "k6"; -import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js"; - -const endpoint = __ENV.ROUTER_ENDPOINT || "http://0.0.0.0:4000/graphql"; -const vus = __ENV.BENCH_VUS ? parseInt(__ENV.BENCH_VUS) : 50; -const duration = __ENV.BENCH_OVER_TIME || "30s"; -const persistedMode = __ENV.BENCH_PERSISTED_MODE === "true"; -const documentId = __ENV.BENCH_DOCUMENT_ID || "bench_test_query"; - -export const options = { - vus, - duration, -}; - -export function setup() { - for (let i = 0; i < 20; i++) { - sendGraphQLRequest(); - } -} - -export default function () { - makeGraphQLRequest(); -} - -let printIdentifiersMap = {}; -let runIdentifiersMap = {}; - -function printOnce(identifier, ...args) { - if (printIdentifiersMap[identifier]) { - return; - } - - console.log(...args); - printIdentifiersMap[identifier] = true; -} - -function runOnce(identifier, cb) { - if (runIdentifiersMap[identifier]) { - return true; - } - - runIdentifiersMap[identifier] = true; - return cb(); -} - -const graphqlQuery = `fragment User on User { - id - username - name - } - - fragment Review on Review { - id - body - } - - fragment Product on Product { - inStock - name - price - shippingEstimate - upc - weight - } - - query TestQuery { - users { - ...User - reviews { - ...Review - product { - ...Product - reviews { - ...Review - author { - ...User - reviews { - ...Review - product { - ...Product - } - } - } - } - } - } - } - topProducts { - ...Product - reviews { - ...Review - author { - ...User - reviews { - ...Review - product { - ...Product - } - } - } - } - } - }`; - -const graphqlRequest = { - payload: JSON.stringify( - persistedMode ? { documentId } : { query: graphqlQuery }, - ), - params: { - headers: { - "Content-Type": "application/json", - }, - }, -}; - -export function handleSummary(data) { - const out = { - stdout: textSummary(data, { indent: " ", enableColors: true }), - }; - - if (__ENV.SUMMARY_PATH) { - console.log( - `Writing summary to ${__ENV.SUMMARY_PATH}/k6_summary.json and .txt`, - ); - out[`${__ENV.SUMMARY_PATH}/k6_summary.json`] = JSON.stringify( - Object.assign(data, { vus, duration }), - ); - out[`${__ENV.SUMMARY_PATH}/k6_summary.txt`] = textSummary(data, { - indent: " ", - enableColors: false, - }); - } - - return out; -} - -function sendGraphQLRequest() { - const res = http.post( - endpoint, - graphqlRequest.payload, - graphqlRequest.params, - ); - - if (res.status !== 200) { - console.log(`‼️ Failed to run HTTP request:`, res); - } - - return res; -} - -function makeGraphQLRequest() { - const res = sendGraphQLRequest(); - check(res, { - "response code was 200": (res) => res.status == 200, - "no graphql errors": (resp) => { - let has_errors = resp.body.includes(`"errors"`); - if (has_errors) { - printOnce( - "graphql_errors", - `‼️ Got GraphQL errors, here's a sample:`, - res.body, - ); - } - - return !has_errors; - }, - "valid response structure": (resp) => { - return runOnce("valid response structure", () => { - const json = resp.json(); - - let isValid = checkResponseStructure(json); - - if (!isValid) { - printOnce( - "response_strcuture", - `‼️ Got invalid structure, here's a sample:`, - res.body, - ); - } - - return isValid; - }); - }, - }); -} - -function checkResponseStructure(x) { - function checkRecursive(obj, structure) { - if (obj == null) { - return false; - } - for (var key in structure) { - if ( - !obj.hasOwnProperty(key) || - typeof obj[key] !== typeof structure[key] - ) { - return false; - } - if (typeof structure[key] === "object" && structure[key] !== null) { - if (!checkRecursive(obj[key], structure[key])) { - return false; - } - } - } - return true; - } - - const expectedStructure = { - data: { - users: [ - { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - { - id: "2", - username: "dotansimha", - name: "Dotan Simha", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - { - id: "3", - username: "kamilkisiela", - name: "Kamil Kisiela", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - { - id: "4", - username: "ardatan", - name: "Arda Tanrikulu", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - { - id: "5", - username: "gilgardosh", - name: "Gil Gardosh", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - { - id: "6", - username: "laurin", - name: "Laurin Quast", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - ], - topProducts: [ - { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "3", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "4", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - { - inStock: false, - name: "Couch", - price: 1299, - shippingEstimate: 0, - upc: "2", - weight: 1000, - reviews: [ - { - id: "5", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "6", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "7", - body: "sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "8", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - { - inStock: false, - name: "Glass", - price: 15, - shippingEstimate: 10, - upc: "3", - weight: 20, - reviews: [ - { - id: "9", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - { - inStock: false, - name: "Chair", - price: 499, - shippingEstimate: 50, - upc: "4", - weight: 100, - reviews: [ - { - id: "10", - body: "Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - { - id: "11", - body: "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.", - author: { - id: "1", - username: "urigo", - name: "Uri Goldshtein", - reviews: [ - { - id: "1", - body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - { - id: "2", - body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugi", - product: { - inStock: true, - name: "Table", - price: 899, - shippingEstimate: 50, - upc: "1", - weight: 100, - }, - }, - ], - }, - }, - ], - }, - { - inStock: true, - name: "TV", - price: 1299, - shippingEstimate: 0, - upc: "5", - weight: 1000, - reviews: [], - }, - ], - }, - }; - return checkRecursive(x, expectedStructure); -} diff --git a/bench/package.json b/bench/package.json index 26a871776..8d5ecd1a9 100644 --- a/bench/package.json +++ b/bench/package.json @@ -1,7 +1,4 @@ { "name": "benchmarks", - "private": true, - "devDependencies": { - "@types/k6": "1.6.0" - } -} \ No newline at end of file + "private": true +} diff --git a/bench/run-benchmark.sh b/bench/run-benchmark.sh new file mode 100755 index 000000000..0054615c6 --- /dev/null +++ b/bench/run-benchmark.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +set -euo pipefail + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "$1 could not be found. Please install $1 to run this benchmark." + exit 1 + } +} + +duration_to_seconds() { + local value="$1" + + if [[ "$value" =~ ^([0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + + if [[ "$value" =~ ^([0-9]+)([smh])$ ]]; then + local amount="${BASH_REMATCH[1]}" + case "${BASH_REMATCH[2]}" in + s) echo "$amount" ;; + m) echo $((amount * 60)) ;; + h) echo $((amount * 3600)) ;; + *) return 1 ;; + esac + return 0 + fi + + return 1 +} + +resolve_summary_path() { + if [ -z "${SUMMARY_PATH:-}" ]; then + echo "$SCRIPT_DIR/results/pr" + elif [[ "$SUMMARY_PATH" = /* ]]; then + echo "$SUMMARY_PATH" + elif [[ "$SUMMARY_PATH" == bench/* ]] || [[ "$SUMMARY_PATH" == ./bench/* ]]; then + echo "$REPO_ROOT/${SUMMARY_PATH#./}" + else + echo "$SCRIPT_DIR/$SUMMARY_PATH" + fi +} + +show_progress() { + local wrk_pid="$1" + local total_seconds="$2" + local start_time + local now + local elapsed + local remaining + + start_time=$(date +%s) + while kill -0 "$wrk_pid" 2>/dev/null; do + if [ -n "$total_seconds" ]; then + now=$(date +%s) + elapsed=$((now - start_time)) + remaining=$((total_seconds - elapsed)) + if [ "$remaining" -lt 0 ]; then + remaining=0 + fi + printf 'wrk... %ss left\n' "$remaining" + else + printf 'wrk running...\n' + fi + sleep 5 + done +} + +parse_wrk_output() { + read -r RATE_RPS STATUS_FAILURES GRAPHQL_ERRORS RESPONSE_STRUCTURE_FAILURES <&1 | tee "$WRK_OUTPUT_FILE" & +WRK_PID=$! + +TOTAL_SECONDS="" +if TOTAL_SECONDS=$(duration_to_seconds "$BENCH_DURATION"); then + : +fi + +show_progress "$WRK_PID" "$TOTAL_SECONDS" +wait "$WRK_PID" + +parse_wrk_output + +if [ -z "$RATE_RPS" ]; then + echo "Could not parse Requests/sec from wrk output." + exit 1 +fi + +if ! [[ "$STATUS_FAILURES" =~ ^[0-9]+$ ]] || ! [[ "$GRAPHQL_ERRORS" =~ ^[0-9]+$ ]] || ! [[ "$RESPONSE_STRUCTURE_FAILURES" =~ ^[0-9]+$ ]]; then + echo "Could not parse validation counters from wrk output." + exit 1 +fi + +VALIDATION_TOTAL_FAILURES=$((STATUS_FAILURES + GRAPHQL_ERRORS + RESPONSE_STRUCTURE_FAILURES)) + +jq -n \ + --arg tool "wrk" \ + --arg duration "$BENCH_DURATION" \ + --arg endpoint "$ROUTER_ENDPOINT" \ + --arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson concurrency "$BENCH_CONNECTIONS" \ + --argjson threads "$BENCH_THREADS" \ + --argjson rate_rps "$RATE_RPS" \ + --argjson status_failures "$STATUS_FAILURES" \ + --argjson graphql_error_responses "$GRAPHQL_ERRORS" \ + --argjson response_structure_failures "$RESPONSE_STRUCTURE_FAILURES" \ + --argjson validation_total_failures "$VALIDATION_TOTAL_FAILURES" \ + '{ + tool: $tool, + rate_rps: $rate_rps, + duration: $duration, + concurrency: $concurrency, + threads: $threads, + endpoint: $endpoint, + generated_at: $generated_at, + status_failures: $status_failures, + graphql_error_responses: $graphql_error_responses, + response_structure_failures: $response_structure_failures, + validation_total_failures: $validation_total_failures + }' > "$SUMMARY_PATH/summary.json" + +echo "Wrote benchmark summary to $SUMMARY_PATH/summary.json" + +if [ "$VALIDATION_TOTAL_FAILURES" -gt 0 ]; then + echo "Validation failures found in benchmark run." + exit 1 +fi diff --git a/bench/wrk.lua b/bench/wrk.lua new file mode 100644 index 000000000..5b0f880db --- /dev/null +++ b/bench/wrk.lua @@ -0,0 +1,239 @@ +local function read_file(path) + local file = io.open(path, "r") + if file == nil then + error("Unable to open file: " .. path) + end + + local contents = file:read("*a") + file:close() + return contents +end + +local function read_file_if_exists(path) + local file = io.open(path, "r") + if file == nil then + return nil + end + + local contents = file:read("*a") + file:close() + return contents +end + +local function escape_json(value) + value = value:gsub("\\", "\\\\") + value = value:gsub('"', '\\"') + value = value:gsub("\n", "\\n") + value = value:gsub("\r", "\\r") + value = value:gsub("\t", "\\t") + return value +end + +local cjson_safe = nil +local cjson = nil + +local function build_graphql_request_body(query) + if cjson_safe ~= nil and cjson_safe.encode ~= nil then + local encoded = cjson_safe.encode({ query = query }) + if encoded ~= nil then + return encoded + end + end + + if cjson ~= nil and cjson.encode ~= nil then + local ok_encode, encoded = pcall(cjson.encode, { query = query }) + if ok_encode and encoded ~= nil then + return encoded + end + end + + local escaped_query = escape_json(query) + return "{\"query\":\"" .. escaped_query .. "\"}" +end + +local function build_persisted_document_request_body(document_id) + if cjson_safe ~= nil and cjson_safe.encode ~= nil then + local encoded = cjson_safe.encode({ documentId = document_id }) + if encoded ~= nil then + return encoded + end + end + + if cjson ~= nil and cjson.encode ~= nil then + local ok_encode, encoded = pcall(cjson.encode, { documentId = document_id }) + if ok_encode and encoded ~= nil then + return encoded + end + end + + local escaped_document_id = escape_json(document_id) + return "{\"documentId\":\"" .. escaped_document_id .. "\"}" +end + +local function hash_string(value) + local hash = 5381 + local max_u32 = 4294967296 + + for i = 1, #value do + hash = ((hash * 33) + value:byte(i)) % max_u32 + end + + return string.format("%08x", hash) +end +local ok_safe, cjson_safe_module = pcall(require, "cjson.safe") +if ok_safe then + cjson_safe = cjson_safe_module +end + +local ok_cjson, cjson_module = pcall(require, "cjson") +if ok_cjson then + cjson = cjson_module +end + +-- wrk runs response() in each worker's own Lua environment, while done() +-- runs in a separate environment. Keep counters global so done() can read +-- them from each worker via thread:get(). +threads = {} +status_failures = 0 +graphql_error_responses = 0 +response_structure_failures = 0 +checked_response_structure = false +sample_status_failure = nil +sample_graphql_error = nil +sample_structure_error = nil +local find = string.find + +local expected_response_file = os.getenv("BENCH_EXPECTED_RESPONSE_FILE") +local expected_response + +if expected_response_file ~= nil and expected_response_file ~= "" then + expected_response = read_file(expected_response_file) +else + expected_response = read_file_if_exists("expected_response.json") or read_file("bench/expected_response.json") +end + +local expected_response_hash = hash_string(expected_response) + +local function check_response_structure(body) + if body == nil then + return false + end + + local response_hash = hash_string(body) + return response_hash == expected_response_hash +end + +local operation_file = os.getenv("BENCH_OPERATION_FILE") +local persisted_mode = os.getenv("BENCH_PERSISTED_MODE") == "true" +local request_body + +if persisted_mode then + local document_id = os.getenv("BENCH_DOCUMENT_ID") + if document_id == nil or document_id == "" then + document_id = "bench_test_query" + end + request_body = build_persisted_document_request_body(document_id) +else + local query + if operation_file ~= nil and operation_file ~= "" then + query = read_file(operation_file) + else + query = read_file_if_exists("operation.graphql") or read_file("bench/operation.graphql") + end + request_body = build_graphql_request_body(query) +end + +wrk.method = "POST" +wrk.headers["Content-Type"] = "application/json" +wrk.body = request_body + +setup = function(thread) + -- Store thread handles in the done() environment so validation results can + -- be aggregated after all workers finish. + threads[#threads + 1] = thread +end + +response = function(status, headers, body) + if status ~= 200 then + status_failures = status_failures + 1 + if sample_status_failure == nil then + sample_status_failure = body + end + return + end + + if body and find(body, '"errors"', 1, true) then + graphql_error_responses = graphql_error_responses + 1 + if sample_graphql_error == nil then + sample_graphql_error = body + end + return + end + + if checked_response_structure then + return + end + + checked_response_structure = true + + if not check_response_structure(body) then + response_structure_failures = response_structure_failures + 1 + if sample_structure_error == nil then + sample_structure_error = body + end + end +end + +local function get_thread_number(thread, name) + return thread:get(name) or 0 +end + +local function get_thread_sample(thread, name) + local value = thread:get(name) + if value ~= nil and value ~= "" then + return value + end + + return nil +end + +done = function(summary, latency, requests) + local total_status_failures = 0 + local total_graphql_error_responses = 0 + local total_response_structure_failures = 0 + local status_failure_sample = nil + local graphql_error_sample = nil + local structure_error_sample = nil + + -- done() cannot see mutations performed by response() directly. Pull the + -- final global counter values from every worker environment instead. + for _, thread in ipairs(threads) do + total_status_failures = total_status_failures + get_thread_number(thread, "status_failures") + total_graphql_error_responses = total_graphql_error_responses + get_thread_number(thread, "graphql_error_responses") + total_response_structure_failures = total_response_structure_failures + get_thread_number(thread, "response_structure_failures") + + if status_failure_sample == nil then + status_failure_sample = get_thread_sample(thread, "sample_status_failure") + end + if graphql_error_sample == nil then + graphql_error_sample = get_thread_sample(thread, "sample_graphql_error") + end + if structure_error_sample == nil then + structure_error_sample = get_thread_sample(thread, "sample_structure_error") + end + end + + io.write("VALIDATION_STATUS_FAILURES=" .. total_status_failures .. "\n") + io.write("VALIDATION_GRAPHQL_ERRORS=" .. total_graphql_error_responses .. "\n") + io.write("VALIDATION_RESPONSE_STRUCTURE_FAILURES=" .. total_response_structure_failures .. "\n") + + if status_failure_sample ~= nil then + io.write("VALIDATION_STATUS_FAILURE_SAMPLE=" .. status_failure_sample .. "\n") + end + if graphql_error_sample ~= nil then + io.write("VALIDATION_GRAPHQL_ERROR_SAMPLE=" .. graphql_error_sample .. "\n") + end + if structure_error_sample ~= nil then + io.write("VALIDATION_RESPONSE_STRUCTURE_FAILURE_SAMPLE=" .. structure_error_sample .. "\n") + end +end From 9bb5c3353f86758a0303445cea5bb7c0a9779aef Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 09:17:12 +0200 Subject: [PATCH 71/76] Fix multiple inline fragments on same concrete type within interface fragment being dropped (#946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [x] Run `cargo clippy --fix` to auto-fix lint issues - [x] Run `cargo fmt` to fix formatting issues - [x] Add node-addon to changeset - [x] Fix directive semantics: propagate parent fragment directives into specific sub-fragments (only adding directives not already present) instead of wrapping in an outer fragment — avoids redundant nesting when sub-fragment already carries the same condition as the parent - [x] Fix regression test to actually exercise `expand_abstract_fragment` using a concrete parent type (`account(id:)`) with an abstract type fragment (`... on Node`) - [x] Update insta snapshots for the updated test and the affected normalization snapshot --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ardatan <20847995+ardatan@users.noreply.github.com> Co-authored-by: Kamil Kisiela Co-authored-by: Arda TANRIKULU Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .../fix_fragments_dropped_in_interface.md | 25 ++ .../pipeline/flatten_fragments.rs | 128 +++++++--- lib/query-planner/src/tests/fragments.rs | 224 ++++++++++++++++++ 3 files changed, 344 insertions(+), 33 deletions(-) create mode 100644 .changeset/fix_fragments_dropped_in_interface.md diff --git a/.changeset/fix_fragments_dropped_in_interface.md b/.changeset/fix_fragments_dropped_in_interface.md new file mode 100644 index 000000000..828ebeb32 --- /dev/null +++ b/.changeset/fix_fragments_dropped_in_interface.md @@ -0,0 +1,25 @@ +--- +hive-router-query-planner: patch +hive-router-plan-executor: patch +node-addon: patch +hive-router: patch +--- + +Fix fragments being dropped when multiple inline fragments target the same concrete type within an abstract type fragment. + +Previously, when a query contained two or more inline fragments on the same concrete type nested inside an interface or union fragment, only the first fragment's fields were included in the query plan — all subsequent ones were silently dropped. + +**Example query that previously returned only `title`:** + +```graphql +query { + films { + ... on Node { + ... on Film { title } + ... on Film { director } + } + } +} +``` + +Both fields are now correctly returned. diff --git a/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs b/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs index c0c372fa5..aeedc6beb 100644 --- a/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs +++ b/lib/query-planner/src/ast/normalization/pipeline/flatten_fragments.rs @@ -368,48 +368,109 @@ fn expand_abstract_fragment( .cloned() .collect(); - let specific_sub_fragment = fragment.selection_set.items.iter().find_map(|s| { - if let Selection::InlineFragment(f) = s { - if f.type_condition.as_ref().map(extract_type_condition) == Some(obj_type_name) { - return Some(f); + // Collect ALL matching sub-fragments for this concrete type (not just the first one). + let specific_sub_fragments: Vec<&InlineFragment<'static, String>> = fragment + .selection_set + .items + .iter() + .filter_map(|s| { + if let Selection::InlineFragment(f) = s { + if f.type_condition.as_ref().map(extract_type_condition) == Some(obj_type_name) + { + return Some(f); + } + } + None + }) + .collect(); + + if specific_sub_fragments + .iter() + .any(|f| !f.directives.is_empty()) + { + // If any sub-fragment has directives, each is treated as a distinct entity. + // A fragment for the inherited fields (with parent directives) is created first... + let mut inherited_fragment = InlineFragment { + type_condition: Some(TypeCondition::On(obj_type_name.to_string())), + directives: fragment.directives.clone(), + selection_set: SelectionSet { + span: fragment.selection_set.span, + items: inherited_fields, + }, + position: fragment.position, + }; + handle_selection_set( + state, + possible_types, + obj_type_def, + &mut inherited_fragment.selection_set, + )?; + new_items.push(Selection::InlineFragment(inherited_fragment)); + + // then a separate fragment for each sub-fragment's fields and directives. + // Propagate any parent directives (e.g. @skip/@include) into each sub-fragment + // so they remain gated by the abstract fragment's conditions. For each parent + // directive we either: + // * skip it, if a semantically equal directive (same name + args) is already + // on the sub-fragment (avoids redundant nesting for the common case); + // * merge it into the sub-fragment's directives, if no same-named directive + // exists there; + // * fall back to wrapping the sub-fragment in an outer inline fragment that + // carries the parent directives, when a same-named directive with different + // arguments is present on the sub-fragment (e.g. nested `@include` with + // different conditions) — both conditions must be preserved and `@include` + // / `@skip` are non-repeatable so they can't co-exist on the same fragment. + for sub_fragment in &specific_sub_fragments { + let mut specific_fragment = (*sub_fragment).clone(); + + let mut needs_wrapping = false; + let mut directives_to_merge: Vec<_> = Vec::new(); + for parent_directive in &fragment.directives { + let same_named = specific_fragment + .directives + .iter() + .find(|d| d.name == parent_directive.name); + match same_named { + Some(existing) if existing.arguments == parent_directive.arguments => { + // Equivalent directive already present – nothing to do. + } + Some(_) => { + // Same-named directive but with different arguments – + // can't merge safely, must wrap. + needs_wrapping = true; + break; + } + None => { + directives_to_merge.push(parent_directive.clone()); + } + } } - } - None - }); - - if let Some(sub_fragment) = specific_sub_fragment { - if !sub_fragment.directives.is_empty() { - // If the sub-fragment has directives, it's treated as a distinct entity. - // A fragment for the inherited fields (with parent directives) is created first... - let mut inherited_fragment = InlineFragment { - type_condition: Some(TypeCondition::On(obj_type_name.to_string())), - directives: fragment.directives.clone(), - selection_set: SelectionSet { - span: fragment.selection_set.span, - items: inherited_fields, - }, - position: fragment.position, - }; - handle_selection_set( - state, - possible_types, - obj_type_def, - &mut inherited_fragment.selection_set, - )?; - new_items.push(Selection::InlineFragment(inherited_fragment)); - // then a separate fragment for the sub-fragment's fields and directives. - let mut specific_fragment = sub_fragment.clone(); handle_selection_set( state, possible_types, obj_type_def, &mut specific_fragment.selection_set, )?; - new_items.push(Selection::InlineFragment(specific_fragment)); - continue; + if needs_wrapping { + let wrapper = InlineFragment { + type_condition: Some(TypeCondition::On(obj_type_name.to_string())), + directives: fragment.directives.clone(), + selection_set: SelectionSet { + span: fragment.selection_set.span, + items: vec![Selection::InlineFragment(specific_fragment)], + }, + position: fragment.position, + }; + new_items.push(Selection::InlineFragment(wrapper)); + } else { + specific_fragment.directives.extend(directives_to_merge); + new_items.push(Selection::InlineFragment(specific_fragment)); + } } + + continue; } let mut new_fragment = InlineFragment { @@ -422,7 +483,8 @@ fn expand_abstract_fragment( position: fragment.position, }; - if let Some(sub_fragment) = specific_sub_fragment { + // Merge ALL matching sub-fragments into the new fragment. + for sub_fragment in &specific_sub_fragments { new_fragment .directives .extend(sub_fragment.directives.clone()); diff --git a/lib/query-planner/src/tests/fragments.rs b/lib/query-planner/src/tests/fragments.rs index d694c766a..f5392c096 100644 --- a/lib/query-planner/src/tests/fragments.rs +++ b/lib/query-planner/src/tests/fragments.rs @@ -4,6 +4,230 @@ use crate::{ }; use std::error::Error; +/// Regression test: multiple inline fragments on the same concrete type inside an abstract type +/// fragment should all be evaluated, not just the first one. +/// Uses `account(id:)` (concrete parent type `Account`) with a `... on Node` abstract fragment +/// to force the `expand_abstract_fragment` path, where two `... on Account` inline fragments +/// must both be collected and merged so all their fields appear in the query plan. +#[test] +fn multiple_inline_fragments_on_same_concrete_type_within_interface_fragment( +) -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query { + account(id: "a1") { + ... on Node { + ... on Account { + id + } + ... on Account { + username + } + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + { + account(id: "a1") { + id + username + } + } + }, + }, + "#); + + Ok(()) +} + +/// Regression test: when an abstract-type fragment carries a directive (e.g. `@include`) +/// and a nested concrete-type fragment carries another `@include` with a *different* +/// argument, both conditions must be preserved in the query plan. Because `@include`/ +/// `@skip` are non-repeatable, the parent directive must be carried by an outer wrapper +/// fragment around the inner one. +#[test] +fn nested_same_name_directives_on_abstract_and_concrete_fragments() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($parent: Boolean!, $child: Boolean!) { + account(id: "a1") { + ... on Node @include(if: $parent) { + ... on Account @include(if: $child) { + username + } + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + query ($child:Boolean!,$parent:Boolean!) { + account(id: "a1") { + ... on Account @include(if: $parent) { + ... on Account @include(if: $child) { + username + } + } + } + } + }, + }, + "#); + + Ok(()) +} + +/// When the parent abstract fragment and the nested concrete fragment carry the +/// *same* directive with the *same* argument, the duplicate must be collapsed — +/// no redundant nesting should appear in the plan. +#[test] +fn nested_same_directive_same_arg_on_abstract_and_concrete_fragments() -> Result<(), Box> +{ + init_logger(); + let document = parse_operation( + r#" + query ($cond: Boolean!) { + account(id: "a1") { + ... on Node @include(if: $cond) { + ... on Account @include(if: $cond) { + username + } + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + query ($cond:Boolean!) { + account(id: "a1") { + ... on Account @include(if: $cond) { + username + } + } + } + }, + }, + "#); + + Ok(()) +} + +/// When the parent abstract fragment carries `@skip` and the nested concrete fragment +/// carries `@include`, both directives must be preserved (different names, so they can +/// be merged onto the same fragment). +#[test] +fn nested_different_directives_on_abstract_and_concrete_fragments() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($skip: Boolean!, $include: Boolean!) { + account(id: "a1") { + ... on Node @skip(if: $skip) { + ... on Account @include(if: $include) { + username + } + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + let plan_str = format!("{}", query_plan); + assert!( + plan_str.contains("$skip"), + "parent @skip directive must be preserved: {plan_str}" + ); + assert!( + plan_str.contains("$include"), + "child @include directive must be preserved: {plan_str}" + ); + + insta::assert_snapshot!(plan_str, @r#" + QueryPlan { + Fetch(service: "a") { + query ($include:Boolean!,$skip:Boolean!) { + account(id: "a1") { + ... on Account @skip(if: $skip) @include(if: $include) { + username + } + } + } + }, + }, + "#); + + Ok(()) +} + +/// When the parent abstract fragment carries `@include` and the nested concrete fragment +/// has *no* directive, the parent's `@include` must be merged onto the concrete fragment. +#[test] +fn parent_directive_only_on_abstract_fragment() -> Result<(), Box> { + init_logger(); + let document = parse_operation( + r#" + query ($parent: Boolean!) { + account(id: "a1") { + ... on Node @include(if: $parent) { + ... on Account { + username + } + } + } + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + query ($parent:Boolean!) { + account(id: "a1") { + ... on Account @include(if: $parent) { + username + } + } + } + }, + }, + "#); + + Ok(()) +} + #[test] fn simple_inline_fragment() -> Result<(), Box> { init_logger(); From 0e041e2e02cda83865b555996532f5fa44a46f4b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 5 May 2026 10:10:08 +0200 Subject: [PATCH 72/76] Coprocessors (#927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Coprocessor feature to allow external service to intercept and modify GraphQL requests/responses at different stages of the request pipeline. Design doc: https://guild-oss.slack.com/docs/TAYJ1FSUA/F0ASCC492KS - [x] context https://github.com/graphql-hive/router/pull/937 - [x] public schema sdl - [ ] execution stage (future - when demand) - [ ] subgraph stage (future - when demand) - [ ] tls (for http/2) (future - when demand) - [ ] different endpoints per stage (future - when demand) - [x] benchmark (~11k drops to ~9.2k rps) --- This PR introduces Coprocessor API. The goal is to let users extend Router in language agnostic way. ## Building blocks - `Stage` trait that represents a step in the request's pipeline. Every `Stage` implementation defines how to build the http request to coprocessor service, how to parse the response and how to apply mutations. Every stage can `"continue"` or `"break": ` the flow early. - `CoprocessorRuntime` wires these stages into one API that is called by Hive Router. - `RequestContext` - introduced in #937 Every stage has configurable `include` object, so users can opt into the pieces of data they need. The `include`'s set of fields is different for every stage. ```yaml include: body: true headers: true ``` The `context` (that represents `RequestContext`) in all of stages allows users to select what should be passed to the coprocessor service. ```yaml include: context: true # all context: ["hive::operation::name"] # only operation's name ``` Currently, we support those stages (they run in this order): - `router.request` - `graphql.request` - `graphql.analysis` - `graphql.response` - `router.response` Those stages are covered in the [design doc](https://guild-oss.slack.com/docs/TAYJ1FSUA/F0ASCC492KS). The design is forward‑compatible, adding new stages (like execution or subgraph stages) can be added later by implementing the Stage trait. ## Coprocessor service A coprocessor service can be accessed over http or unix domain socket with http/1, http/2 or h2c protocol. When using UDS with Router, it's in user's hand to create or truncate the socket file. Hive Router only "talks" to it. ## Observability I covered metrics, spans and logs around coprocessor runtime. Metrics: - `hive.router.coprocessor.requests_total{"coprocessor.stage"}` - Total number of coprocessor requests - `hive.router.coprocessor.duration{"coprocessor.stage"}` - Duration of coprocessor requests - `hive.router.coprocessor.errors_total{"coprocessor.stage"}` - Total number of coprocessor errors - http/uds client of coprocessor api, supports also the `http.client` metrics so users will know about http status codes, responses etc. Spans: - `coprocessor` span with `coprocessor.stage` and `coprocessor.id` attributes wraps all stage calls - `http.client` span wraps http requests to coprocessor service Logs: - Every coprocessor response, error, request action has corresponding log line (usually with `debug` level, except `error` for errors) with attributes allowing to correlate coprocessor logs with http logs and graphql logs. There's a link :) Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/coprocessors.md | 37 + .changeset/operation_kind_enum.md | 9 + Cargo.lock | 323 ++++++++- Cargo.toml | 2 + bench/README.md | 7 + bench/configs/coprocessor.config.yaml | 19 + bench/coprocessor/Cargo.toml | 16 + bench/coprocessor/main.rs | 100 +++ bin/router/benches/router_benches.rs | 3 +- bin/router/src/lib.rs | 47 +- .../src/pipeline/authorization/tests.rs | 8 +- bin/router/src/pipeline/body_read.rs | 88 --- .../src/pipeline/client_identification.rs | 58 ++ bin/router/src/pipeline/error.rs | 14 +- bin/router/src/pipeline/execution_request.rs | 7 + .../src/pipeline/introspection_policy.rs | 17 +- bin/router/src/pipeline/mod.rs | 205 ++++-- bin/router/src/pipeline/normalize.rs | 7 +- bin/router/src/pipeline/parser.rs | 38 +- .../src/pipeline/progressive_override.rs | 112 ++- bin/router/src/pipeline/query_plan.rs | 8 + bin/router/src/pipeline/request_extensions.rs | 25 - bin/router/src/pipeline/validation/mod.rs | 17 +- bin/router/src/pipeline/websocket_server.rs | 22 +- bin/router/src/plugins/plugins_service.rs | 80 ++- bin/router/src/plugins/registry.rs | 4 + bin/router/src/schema_state.rs | 7 +- bin/router/src/shared_state.rs | 18 + docs/README.md | 288 +++++++- e2e/src/coprocessor/context.rs | 625 ++++++++++++++++ e2e/src/coprocessor/failures.rs | 158 ++++ e2e/src/coprocessor/graphql_analysis.rs | 252 +++++++ e2e/src/coprocessor/graphql_request.rs | 643 +++++++++++++++++ e2e/src/coprocessor/graphql_response.rs | 156 ++++ e2e/src/coprocessor/mod.rs | 16 + e2e/src/coprocessor/router_request.rs | 175 +++++ e2e/src/coprocessor/router_response.rs | 142 ++++ e2e/src/coprocessor/unix_domain_socket.rs | 172 +++++ e2e/src/lib.rs | 2 + e2e/src/testkit/coprocessor.rs | 65 ++ e2e/src/testkit/mod.rs | 16 +- lib/executor/Cargo.toml | 8 + lib/executor/benches/coprocessor_benches.rs | 612 ++++++++++++++++ lib/executor/src/coprocessor/client.rs | 329 +++++++++ lib/executor/src/coprocessor/error.rs | 141 ++++ lib/executor/src/coprocessor/mod.rs | 11 + lib/executor/src/coprocessor/protocol.rs | 124 ++++ lib/executor/src/coprocessor/runtime.rs | 425 +++++++++++ lib/executor/src/coprocessor/stage.rs | 319 +++++++++ .../src/coprocessor/stages/graphql.rs | 545 ++++++++++++++ lib/executor/src/coprocessor/stages/mod.rs | 2 + lib/executor/src/coprocessor/stages/router.rs | 329 +++++++++ .../src/execution/client_request_details.rs | 249 ++++--- lib/executor/src/execution/plan.rs | 25 +- .../src/{context.rs => execution_context.rs} | 2 +- lib/executor/src/executors/http.rs | 32 +- lib/executor/src/executors/map.rs | 7 + lib/executor/src/headers/expression.rs | 22 +- lib/executor/src/headers/mod.rs | 31 +- lib/executor/src/headers/plan.rs | 17 - lib/executor/src/headers/request.rs | 2 +- lib/executor/src/headers/response.rs | 135 ++-- lib/executor/src/lib.rs | 4 +- lib/executor/src/plugins/hooks/mod.rs | 33 + lib/executor/src/plugins/hooks/on_execute.rs | 5 + .../src/plugins/hooks/on_graphql_params.rs | 5 + .../src/plugins/hooks/on_graphql_parse.rs | 5 + .../plugins/hooks/on_graphql_validation.rs | 5 + .../src/plugins/hooks/on_http_request.rs | 7 +- .../src/plugins/hooks/on_query_plan.rs | 5 + .../src/plugins/hooks/on_subgraph_execute.rs | 5 + .../plugins/hooks/on_subgraph_http_request.rs | 5 + .../src/plugins/hooks/on_supergraph_load.rs | 10 + lib/executor/src/plugins/plugin_context.rs | 2 + lib/executor/src/plugins/plugin_trait.rs | 7 +- .../src/request_context/api/coprocessor.rs | 87 +++ lib/executor/src/request_context/api/mod.rs | 2 + .../src/request_context/api/plugin.rs | 65 ++ lib/executor/src/request_context/deser.rs | 94 +++ .../request_context/domains/authentication.rs | 63 ++ .../src/request_context/domains/mod.rs | 258 +++++++ .../src/request_context/domains/operation.rs | 70 ++ .../domains/progressive_override.rs | 115 +++ .../src/request_context/domains/telemetry.rs | 60 ++ lib/executor/src/request_context/error.rs | 19 + lib/executor/src/request_context/mod.rs | 11 + lib/executor/src/request_context/web.rs | 39 + lib/executor/src/response/value.rs | 42 +- lib/internal/src/expressions/lib.rs | 198 +++++- lib/internal/src/expressions/mod.rs | 4 +- .../values/{header_value.rs => http.rs} | 59 +- lib/internal/src/expressions/values/mod.rs | 2 +- lib/internal/src/http.rs | 153 +++- lib/internal/src/telemetry/metrics/catalog.rs | 13 + .../telemetry/metrics/coprocessor_metrics.rs | 73 ++ lib/internal/src/telemetry/metrics/mod.rs | 5 +- lib/internal/src/telemetry/mod.rs | 6 + lib/internal/src/telemetry/propagation.rs | 25 + .../src/telemetry/traces/spans/coprocessor.rs | 34 + .../src/telemetry/traces/spans/kind.rs | 2 + .../src/telemetry/traces/spans/mod.rs | 1 + .../src/state/supergraph_state.rs | 25 +- lib/router-config/src/coprocessor.rs | 673 ++++++++++++++++++ lib/router-config/src/headers.rs | 2 +- lib/router-config/src/lib.rs | 5 + .../Cargo.toml | 21 + .../README.md | 23 + .../router.config.yaml | 9 + .../src/lib.rs | 1 + .../src/main.rs | 16 + .../src/plugin.rs | 118 +++ 111 files changed, 9379 insertions(+), 487 deletions(-) create mode 100644 .changeset/coprocessors.md create mode 100644 .changeset/operation_kind_enum.md create mode 100644 bench/configs/coprocessor.config.yaml create mode 100644 bench/coprocessor/Cargo.toml create mode 100644 bench/coprocessor/main.rs delete mode 100644 bin/router/src/pipeline/body_read.rs create mode 100644 bin/router/src/pipeline/client_identification.rs create mode 100644 e2e/src/coprocessor/context.rs create mode 100644 e2e/src/coprocessor/failures.rs create mode 100644 e2e/src/coprocessor/graphql_analysis.rs create mode 100644 e2e/src/coprocessor/graphql_request.rs create mode 100644 e2e/src/coprocessor/graphql_response.rs create mode 100644 e2e/src/coprocessor/mod.rs create mode 100644 e2e/src/coprocessor/router_request.rs create mode 100644 e2e/src/coprocessor/router_response.rs create mode 100644 e2e/src/coprocessor/unix_domain_socket.rs create mode 100644 e2e/src/testkit/coprocessor.rs create mode 100644 lib/executor/benches/coprocessor_benches.rs create mode 100644 lib/executor/src/coprocessor/client.rs create mode 100644 lib/executor/src/coprocessor/error.rs create mode 100644 lib/executor/src/coprocessor/mod.rs create mode 100644 lib/executor/src/coprocessor/protocol.rs create mode 100644 lib/executor/src/coprocessor/runtime.rs create mode 100644 lib/executor/src/coprocessor/stage.rs create mode 100644 lib/executor/src/coprocessor/stages/graphql.rs create mode 100644 lib/executor/src/coprocessor/stages/mod.rs create mode 100644 lib/executor/src/coprocessor/stages/router.rs rename lib/executor/src/{context.rs => execution_context.rs} (97%) create mode 100644 lib/executor/src/request_context/api/coprocessor.rs create mode 100644 lib/executor/src/request_context/api/mod.rs create mode 100644 lib/executor/src/request_context/api/plugin.rs create mode 100644 lib/executor/src/request_context/deser.rs create mode 100644 lib/executor/src/request_context/domains/authentication.rs create mode 100644 lib/executor/src/request_context/domains/mod.rs create mode 100644 lib/executor/src/request_context/domains/operation.rs create mode 100644 lib/executor/src/request_context/domains/progressive_override.rs create mode 100644 lib/executor/src/request_context/domains/telemetry.rs create mode 100644 lib/executor/src/request_context/error.rs create mode 100644 lib/executor/src/request_context/mod.rs create mode 100644 lib/executor/src/request_context/web.rs rename lib/internal/src/expressions/values/{header_value.rs => http.rs} (57%) create mode 100644 lib/internal/src/telemetry/metrics/coprocessor_metrics.rs create mode 100644 lib/internal/src/telemetry/propagation.rs create mode 100644 lib/internal/src/telemetry/traces/spans/coprocessor.rs create mode 100644 lib/router-config/src/coprocessor.rs create mode 100644 plugin_examples/progressive_override_launchdarkly/Cargo.toml create mode 100644 plugin_examples/progressive_override_launchdarkly/README.md create mode 100644 plugin_examples/progressive_override_launchdarkly/router.config.yaml create mode 100644 plugin_examples/progressive_override_launchdarkly/src/lib.rs create mode 100644 plugin_examples/progressive_override_launchdarkly/src/main.rs create mode 100644 plugin_examples/progressive_override_launchdarkly/src/plugin.rs diff --git a/.changeset/coprocessors.md b/.changeset/coprocessors.md new file mode 100644 index 000000000..7a3e246d6 --- /dev/null +++ b/.changeset/coprocessors.md @@ -0,0 +1,37 @@ +--- +hive-router: minor +hive-router-internal: minor +hive-router-plan-executor: minor +hive-router-config: minor +--- + +## Coprocessors + +Introduces Coprocessors as language agnostic way to extend Hive Router. + +**Supports coprocessor stages:** +- `router.request` +- `router.response` +- `graphql.request` +- `graphql.analysis` +- `graphql.response` + +**Stage capabilities:** +- include selected request/response fields in stage payloads (headers, body, context, and optional SDL depending on stage config) +- mutate request body/headers/context for downstream pipeline execution +- short-circuit and return an immediate HTTP response from a stage + +**Transport and endpoint support:** +- `http://` and `unix://` (unix socket domain) endpoints +- http/1, http/2 and h2c protocols + +**Error handling:** +- coprocessor failures map to server-side failures (500) +- client-facing GraphQL errors are masked as Internal server error +- structured error codes are preserved in GraphQL extensions.code +- detailed coprocessor failure reasons remain in server logs/telemetry only + +**Adds coprocessor metrics:** +- hive.router.coprocessor.requests_total +- hive.router.coprocessor.duration +- hive.router.coprocessor.errors_total diff --git a/.changeset/operation_kind_enum.md b/.changeset/operation_kind_enum.md new file mode 100644 index 000000000..f3b6bd7df --- /dev/null +++ b/.changeset/operation_kind_enum.md @@ -0,0 +1,9 @@ +--- +hive-router-plan-executor: patch +hive-router-internal: patch +hive-router-query-planner: patch +hive-router: patch +node-addon: patch +--- + +## Adjustments in operation's kind being Enum and not &'static str diff --git a/Cargo.lock b/Cargo.lock index 0adef73a4..846c8dfec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "alloca" version = "0.4.0" @@ -474,6 +489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -519,7 +535,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -621,6 +637,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bench-coprocessor" +version = "0.0.1" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "tokio", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -684,6 +711,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1088,6 +1136,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1148,6 +1206,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.8.2" @@ -1907,6 +1974,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-client" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2808c25d229d2f854182ba2b8098bfb8592f439f199b408d16aaf186f7b5e8" +dependencies = [ + "base64", + "bytes", + "futures", + "http", + "launchdarkly-sdk-transport", + "log", + "pin-project", + "rand 0.10.1", + "tokio", +] + [[package]] name = "fancy-regex" version = "0.17.0" @@ -2003,6 +2087,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -2404,6 +2489,21 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + [[package]] name = "headers-accept" version = "0.3.0" @@ -2616,10 +2716,12 @@ dependencies = [ "ahash", "async-stream", "async-trait", + "brotli", "bumpalo", "bytes", "criterion", "dashmap", + "flate2", "futures", "futures-util", "graphql-tools", @@ -2631,6 +2733,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "hyperlocal", "indexmap 2.14.0", "insta", "itoa", @@ -2648,6 +2751,7 @@ dependencies = [ "tokio", "tracing", "ulid", + "uuid", "xxhash-rust", "zmij", ] @@ -2795,6 +2899,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-http-proxy" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2806,7 +2930,7 @@ dependencies = [ "hyper-util", "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "tokio", "tokio-rustls", "tower-service", @@ -2849,6 +2973,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3314,6 +3453,77 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "launchdarkly-sdk-transport" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe83622d04dfcaaeac0b5e3aaa1cc156eb1e70c8b68dfcaffaee4365faa00d3" +dependencies = [ + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "log", + "no-proxy", + "tower 0.4.13", +] + +[[package]] +name = "launchdarkly-server-sdk" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7d03f7f2557dfebc24147d90b2080313fd5a60a4de5219dce1b574956f1c4e" +dependencies = [ + "aws-lc-rs", + "bitflags 2.11.1", + "bytes", + "chrono", + "crossbeam-channel", + "data-encoding", + "eventsource-client", + "flate2", + "futures", + "http", + "launchdarkly-sdk-transport", + "launchdarkly-server-sdk-evaluation", + "log", + "lru", + "moka", + "parking_lot 0.12.5", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "launchdarkly-server-sdk-evaluation" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70166742950e523236f98dad92ad55f6b2f713d84124d6a360329bfd6c44fbd4" +dependencies = [ + "base16ct", + "chrono", + "itertools 0.14.0", + "lazy_static", + "log", + "maplit", + "regex", + "semver", + "serde", + "serde_json", + "serde_with", + "sha1", +] + [[package]] name = "lazy-init" version = "0.5.1" @@ -3394,6 +3604,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru-slab" version = "0.1.2" @@ -3409,6 +3625,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matchers" version = "0.2.0" @@ -3689,6 +3911,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no-proxy" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f79c902b31ceac6856e262af5dbaffef75390cf4647c9fef7b55da69a4b912e" +dependencies = [ + "cidr", +] + [[package]] name = "node-addon" version = "0.0.23" @@ -4334,6 +4565,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4943,6 +5180,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "progressive-override-launchdarkly-plugin-example" +version = "0.0.1" +dependencies = [ + "hive-router", + "launchdarkly-server-sdk", + "serde", + "tokio", +] + [[package]] name = "prometheus" version = "0.14.0" @@ -5520,7 +5767,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -5528,7 +5775,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5644,7 +5891,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5808,16 +6055,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", ] [[package]] @@ -5839,7 +6108,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5990,6 +6259,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5997,7 +6279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6843,6 +7125,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6938,7 +7221,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -6955,6 +7238,17 @@ dependencies = [ "tonic", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -6987,7 +7281,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -7300,6 +7594,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -7350,6 +7650,7 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", + "rand 0.10.1", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 9fee7fd49..dc1ffd3af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "bin/dev-cli", "bin/router", "e2e", + "bench/coprocessor", "bench/subgraphs", "plugin_examples/apollo_sandbox", "plugin_examples/apq", @@ -26,6 +27,7 @@ members = [ "plugin_examples/plugin_template", "plugin_examples/jwt_cookie", "plugin_examples/feature_flags", + "plugin_examples/progressive_override_launchdarkly", "plugin_examples/non_standard_request", "plugin_examples/incoming_request_deduplication", "plugin_examples/error_mapping" diff --git a/bench/README.md b/bench/README.md index d04afbed1..d1ced81d1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -17,6 +17,13 @@ cargo build --release -p hive-router ./target/release/hive_router bench/supergraph.graphql ``` +## Coprocessor benchmark server (h2c over UDS) + +``` +cargo build -p bench-coprocessor --release +./target/release/bench_coprocessor +``` + ## Load test Defaults: 50 connections for 30s diff --git a/bench/configs/coprocessor.config.yaml b/bench/configs/coprocessor.config.yaml new file mode 100644 index 000000000..5c5c9c8e2 --- /dev/null +++ b/bench/configs/coprocessor.config.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +log: + level: info +coprocessor: + url: unix:///tmp/hive-bench-coprocessor.sock?path=/coprocessor + protocol: h2c + timeout: 2s + stages: + router: + request: + condition: + expression: .request.method == "POST" + include: + headers: true + method: true + context: true diff --git a/bench/coprocessor/Cargo.toml b/bench/coprocessor/Cargo.toml new file mode 100644 index 000000000..f1699c33d --- /dev/null +++ b/bench/coprocessor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bench-coprocessor" +version = "0.0.1" +edition = "2021" +publish = false + +[[bin]] +name = "bench-coprocessor" +path = "main.rs" + +[dependencies] +bytes = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true, features = ["server", "http2"] } +hyper-util = { version = "0.1.20", features = ["server", "http2", "tokio"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "net"] } diff --git a/bench/coprocessor/main.rs b/bench/coprocessor/main.rs new file mode 100644 index 000000000..726d0798f --- /dev/null +++ b/bench/coprocessor/main.rs @@ -0,0 +1,100 @@ +use std::{convert::Infallible, env, path::Path, sync::Arc}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::{ + body::Incoming, + header::{CONTENT_LENGTH, CONTENT_TYPE}, + server::conn::http2, + service::service_fn, + Method, Request, Response, StatusCode, +}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use tokio::{fs, net::UnixListener}; + +const DEFAULT_SOCKET_PATH: &str = "/tmp/hive-bench-coprocessor.sock"; +const DEFAULT_COPROCESSOR_PATH: &str = "/coprocessor"; +const CONTINUE_RESPONSE: &[u8] = + br#"{"version":1,"control":"continue","context":{"custom::a":"bench-coprocessor"}}"#; + +#[tokio::main] +async fn main() { + let socket_path = env::var("COPROCESSOR_SOCKET_PATH") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SOCKET_PATH.to_string()); + let coprocessor_path = env::var("COPROCESSOR_PATH") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_COPROCESSOR_PATH.to_string()); + let coprocessor_path: Arc = coprocessor_path.into(); + + if Path::new(&socket_path).exists() { + let _ = fs::remove_file(&socket_path).await; + } + + let listener = UnixListener::bind(&socket_path).expect("failed to bind unix socket"); + println!( + "bench-coprocessor listening on unix://{} (h2c, path {})", + socket_path, coprocessor_path + ); + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + break; + } + accepted = listener.accept() => { + let (stream, _) = match accepted { + Ok(value) => value, + Err(err) => { + eprintln!("failed to accept unix connection: {err}"); + continue; + } + }; + + let coprocessor_path = Arc::clone(&coprocessor_path); + tokio::spawn(async move { + let service = service_fn(move |req| handle_request(req, Arc::clone(&coprocessor_path))); + let io = TokioIo::new(stream); + + if let Err(err) = http2::Builder::new(TokioExecutor::new()) + .serve_connection(io, service) + .await + { + eprintln!("h2c connection error: {err}"); + } + }); + } + } + } + + let _ = fs::remove_file(&socket_path).await; +} + +async fn handle_request( + request: Request, + coprocessor_path: Arc, +) -> Result>, Infallible> { + if request.method() != Method::POST || request.uri().path() != coprocessor_path.as_ref() { + return Ok(response(StatusCode::NOT_FOUND, &[])); + } + + let _ = match request.into_body().collect().await { + Ok(collected) => collected.to_bytes(), + Err(err) => { + eprintln!("failed to read request body: {err}"); + return Ok(response(StatusCode::BAD_REQUEST, &[])); + } + }; + Ok(response(StatusCode::OK, CONTINUE_RESPONSE)) +} + +fn response(status: StatusCode, body: &'static [u8]) -> Response> { + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .header(CONTENT_LENGTH, body.len()) + .body(Full::new(Bytes::from_static(body))) + .expect("failed to build response") +} diff --git a/bin/router/benches/router_benches.rs b/bin/router/benches/router_benches.rs index 55fd0b5a8..6dfd9d46d 100644 --- a/bin/router/benches/router_benches.rs +++ b/bin/router/benches/router_benches.rs @@ -15,6 +15,7 @@ use hive_router_plan_executor::{ }, projection::plan::FieldProjectionPlan, }; +use hive_router_query_planner::state::supergraph_state::OperationKind; use hive_router_query_planner::{ ast::normalization::normalize_operation, planner::Planner, @@ -63,7 +64,7 @@ fn authorization_benchmark(c: &mut Criterion) { .map(Arc::new), operation_indentity: OperationIdentity { name: None, - operation_type: "query", + operation_type: OperationKind::Query, client_document_hash: "".to_string(), }, operation_for_plan_hash: hashes.operation_for_plan_hash, diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index c7f0cb434..2d1b00c2f 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -11,6 +11,7 @@ mod supergraph; pub mod telemetry; mod utils; +use std::ops::ControlFlow; use std::sync::Arc; use crate::{ @@ -31,7 +32,7 @@ use crate::{ persisted_documents::PersistedDocumentsRuntime, request_extensions::{ read_graphql_operation_metric_identity, read_graphql_response_metric_status, - read_request_body_size, write_graphql_response_metric_status, + write_graphql_response_metric_status, }, timeout::handle_timeout, usage_reporting::init_hive_usage_agent, @@ -57,12 +58,14 @@ pub use hive_router_config::humantime_serde; use hive_router_config::{load_config, subscriptions::CallbackConfig, HiveRouterConfig}; pub use hive_router_internal::background_tasks; use hive_router_internal::background_tasks::{BackgroundTask, CancellationToken}; -use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; use hive_router_internal::telemetry::{ otel::tracing_opentelemetry::OpenTelemetrySpanExt, traces::spans::http_request::HttpServerRequestSpan, TelemetryContext, }; pub use hive_router_internal::BoxError; +use hive_router_internal::{ + http::read_request_body_size, telemetry::metrics::catalog::values::GraphQLResponseStatus, +}; pub use hive_router_plan_executor::execution::plan::PlanExecutionOutput; pub use hive_router_plan_executor::executors::http::SubgraphHttpResponse; pub use hive_router_plan_executor::response::graphql_error::GraphQLError; @@ -109,7 +112,7 @@ impl BackgroundTask for CallbackServer { } async fn graphql_endpoint_handler( - request: HttpRequest, + mut request: HttpRequest, body_stream: web::types::Payload, schema_state: web::types::State>, app_state: web::types::State>, @@ -121,7 +124,7 @@ async fn graphql_endpoint_handler( .capture_request(&request); let response = - graphql_endpoint_dispatch(&request, body_stream, schema_state, app_state.clone()).await; + graphql_endpoint_dispatch(&mut request, body_stream, schema_state, app_state.clone()).await; let graphql_operation = read_graphql_operation_metric_identity(&request); let graphql_operation_name = graphql_operation @@ -145,7 +148,7 @@ async fn graphql_endpoint_handler( } async fn graphql_endpoint_dispatch( - request: &HttpRequest, + request: &mut HttpRequest, body_stream: web::types::Payload, schema_state: web::types::State>, app_state: web::types::State>, @@ -195,6 +198,28 @@ async fn graphql_endpoint_dispatch( cors.set_headers(request, response.headers_mut()); } + if let Some(coprocessor_runtime) = app_state.coprocessor.as_ref() { + response = match coprocessor_runtime + .on_graphql_response(response, request, || { + schema_state + .current_supergraph() + .as_ref() + .as_ref() + .map(|supergraph| supergraph.public_schema.sdl.clone()) + }) + .await + { + Ok( + ControlFlow::Break(updated_response) | ControlFlow::Continue(updated_response), + ) => updated_response, + Err(error) => { + warn!(%error, "coprocessor graphql.response stage failed"); + write_graphql_response_metric_status(request, GraphQLResponseStatus::Error); + handle_pipeline_error(error.into(), &app_state, &response_mode) + } + }; + } + root_http_request_span.record_response(&response); response @@ -270,9 +295,13 @@ pub async fn router_entrypoint(plugin_registry: PluginRegistry) -> Result<(), Ro let landing_page_path = graphql_path.clone(); let prometheus = prometheus.clone(); let long_lived_client_limit_service = long_lived_client_limit_service.clone(); + let paths_for_plugin = paths.clone(); web::App::new() .middleware(long_lived_client_limit_service) - .middleware(PluginService) + .middleware(PluginService::new( + paths_for_plugin, + prometheus.as_ref().map(|p| p.endpoint.clone()), + )) .state(shared_state.clone()) .state(schema_state.clone()) .configure(|m| configure_ntex_app(m, &paths, prometheus)) @@ -415,11 +444,11 @@ pub async fn configure_app_from_config( #[derive(Clone)] pub struct RouterPaths { - graphql: String, + pub graphql: String, websocket: Option, callback: Option, - health: String, - readiness: String, + pub health: String, + pub readiness: String, } impl RouterPaths { diff --git a/bin/router/src/pipeline/authorization/tests.rs b/bin/router/src/pipeline/authorization/tests.rs index 0c6bb4423..1a3348d88 100644 --- a/bin/router/src/pipeline/authorization/tests.rs +++ b/bin/router/src/pipeline/authorization/tests.rs @@ -11,8 +11,10 @@ use hive_router_plan_executor::{ projection::plan::FieldProjectionPlan, }; use hive_router_query_planner::{ - ast::normalization::normalize_operation, consumer_schema::ConsumerSchema, - state::supergraph_state::SupergraphState, utils::parsing::parse_schema, + ast::normalization::normalize_operation, + consumer_schema::ConsumerSchema, + state::supergraph_state::{OperationKind, SupergraphState}, + utils::parsing::parse_schema, }; use crate::pipeline::{ @@ -84,7 +86,7 @@ impl SupergraphTestData { normalized_operation_hash: hashes.combined_operation_hash, operation_indentity: OperationIdentity { name: doc.operation_name.clone(), - operation_type: "query", + operation_type: OperationKind::Query, client_document_hash: "".to_string(), }, }; diff --git a/bin/router/src/pipeline/body_read.rs b/bin/router/src/pipeline/body_read.rs deleted file mode 100644 index c82469daa..000000000 --- a/bin/router/src/pipeline/body_read.rs +++ /dev/null @@ -1,88 +0,0 @@ -use futures::TryStreamExt; -use http::header::CONTENT_LENGTH; -use ntex::{ - http::error::PayloadError, - util::{Bytes, BytesMut}, - web::{self, HttpRequest}, -}; -use strum::IntoStaticStr; - -use crate::pipeline::request_extensions::write_request_body_size; - -#[derive(Debug, thiserror::Error, IntoStaticStr)] -pub enum ReadBodyStreamError { - #[error("Failed to read request body: {0}")] - #[strum(serialize = "PAYLOAD_READ_ERROR")] - // Thrown while reading the body stream with `try_next()` - PayloadReadError(#[from] PayloadError), - - #[error("Content-Length header has invalid value")] - #[strum(serialize = "INVALID_HEADER")] - InvalidContentLengthHeader, - - #[error("Content-Length exceeds the maximum allowed size: {0}")] - #[strum(serialize = "PAYLOAD_TOO_LARGE_CONTENT_LENGTH")] - PayloadTooLargeContentLength(usize), - - #[error("Request body exceeds the maximum allowed size while reading the stream")] - #[strum(serialize = "PAYLOAD_TOO_LARGE_BODY_STREAM")] - PayloadTooLargeBodyStream, -} - -impl ReadBodyStreamError { - pub fn status_code(&self) -> http::StatusCode { - match self { - Self::PayloadReadError(_) => http::StatusCode::UNPROCESSABLE_ENTITY, - Self::InvalidContentLengthHeader => http::StatusCode::BAD_REQUEST, - Self::PayloadTooLargeContentLength(_) | Self::PayloadTooLargeBodyStream => { - http::StatusCode::PAYLOAD_TOO_LARGE - } - } - } - - pub fn error_code(&self) -> &'static str { - self.into() - } -} - -#[inline] -pub async fn read_body_stream( - req: &HttpRequest, - mut body_stream: web::types::Payload, - max_size: usize, -) -> Result { - let content_length: Option = { - let content_length_header = req.headers().get(CONTENT_LENGTH); - if let Some(content_length_header) = content_length_header { - let content_length_str = content_length_header - .to_str() - .map_err(|_| ReadBodyStreamError::InvalidContentLengthHeader)?; - let content_length: usize = content_length_str - .parse() - .map_err(|_| ReadBodyStreamError::InvalidContentLengthHeader)?; - if content_length > max_size { - write_request_body_size(req, content_length as u64); - return Err(ReadBodyStreamError::PayloadTooLargeContentLength(max_size)); - } - Some(content_length) - } else { - None - } - }; - - let mut body = if let Some(content_length) = content_length { - BytesMut::with_capacity(content_length) - } else { - BytesMut::new() - }; - - while let Some(chunk) = body_stream.try_next().await? { - // limit max size of in-memory payload - if chunk.len() > max_size.saturating_sub(body.len()) { - write_request_body_size(req, (body.len() + chunk.len()) as u64); - return Err(ReadBodyStreamError::PayloadTooLargeBodyStream); - } - body.extend_from_slice(&chunk); - } - Ok(body.freeze()) -} diff --git a/bin/router/src/pipeline/client_identification.rs b/bin/router/src/pipeline/client_identification.rs new file mode 100644 index 000000000..3761e645b --- /dev/null +++ b/bin/router/src/pipeline/client_identification.rs @@ -0,0 +1,58 @@ +use hive_router_config::telemetry::ClientIdentificationConfig; +use hive_router_plan_executor::request_context::{RequestContextError, SharedRequestContext}; +use ntex::http::HeaderMap; + +pub struct ClientIdentity { + pub(crate) name: Option, + pub(crate) version: Option, +} + +pub fn identify_client( + headers: &HeaderMap, + request_context: &SharedRequestContext, + config: &ClientIdentificationConfig, +) -> Result { + let mut client_name: Option = None; + let mut client_version: Option = None; + + request_context.update(|ctx| { + // telemetry.client_name takes precedence over the name header + match &ctx.telemetry.client_name { + Some(name) => { + client_name = Some(name.clone()); + } + None => { + if let Some(name) = headers + .get(config.name_header.get_header_ref()) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + { + ctx.telemetry.client_name = Some(name.clone()); + client_name = Some(name); + } + } + } + + // telemetry.client_version takes precedence over the version header + match &ctx.telemetry.client_version { + Some(version) => { + client_version = Some(version.clone()); + } + None => { + if let Some(version) = headers + .get(config.version_header.get_header_ref()) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + { + ctx.telemetry.client_version = Some(version.clone()); + client_version = Some(version); + } + } + } + })?; + + Ok(ClientIdentity { + name: client_name, + version: client_version, + }) +} diff --git a/bin/router/src/pipeline/error.rs b/bin/router/src/pipeline/error.rs index 5a30b56d0..cdccace4d 100644 --- a/bin/router/src/pipeline/error.rs +++ b/bin/router/src/pipeline/error.rs @@ -2,12 +2,15 @@ use std::{sync::Arc, vec}; use futures_util::stream; use graphql_tools::validation::utils::ValidationError; +use hive_router_internal::http::ReadBodyStreamError; use hive_router_plan_executor::{ + coprocessor::CoprocessorError, execution::{ error::PlanExecutionError, jwt_forward::JwtForwardingError, plan::FailedExecutionResult, }, headers::errors::HeaderRuleRuntimeError, hooks::on_graphql_error::handle_graphql_errors_with_plugins, + request_context::RequestContextError, response::graphql_error::GraphQLError, }; use hive_router_query_planner::{ @@ -25,7 +28,6 @@ use crate::{ jwt::errors::JwtError, pipeline::{ authorization::AuthorizationError, - body_read::ReadBodyStreamError, header::{ResponseMode, StreamContentType}, multipart_subscribe::{ self, APOLLO_MULTIPART_HTTP_CONTENT_TYPE, INCREMENTAL_DELIVERY_CONTENT_TYPE, @@ -161,6 +163,12 @@ pub enum PipelineError { #[error("No supergraph available yet, unable to process request")] #[strum(serialize = "NO_SUPERGRAPH_AVAILABLE")] NoSupergraphAvailable, + + #[error(transparent)] + CoprocessorError(#[from] CoprocessorError), + + #[error("Request context error")] + RequestContextError(#[from] RequestContextError), } #[derive(Clone, Debug, thiserror::Error)] @@ -193,6 +201,7 @@ impl PipelineError { Self::JwtError(err) => err.error_code(), Self::PlanExecutionError(err) => err.error_code(), Self::ReadBodyStreamError(err) => err.error_code(), + Self::CoprocessorError(err) => err.error_code(), _ => self.into(), } } @@ -200,6 +209,7 @@ impl PipelineError { pub fn graphql_error_message(&self) -> String { match self { Self::PlannerError(_) => "Unexpected error".to_string(), + Self::CoprocessorError(_) => "Internal server error".to_string(), _ => self.to_string(), } } @@ -250,6 +260,8 @@ impl PipelineError { (Self::HeaderPropagation(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::QueryPlanSerializationFailed(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::NoSupergraphAvailable, _) => StatusCode::SERVICE_UNAVAILABLE, + (Self::CoprocessorError(err), _) => err.status_code(), + (Self::RequestContextError(_), _) => StatusCode::INTERNAL_SERVER_ERROR, } } } diff --git a/bin/router/src/pipeline/execution_request.rs b/bin/router/src/pipeline/execution_request.rs index fb9ed27a0..cdb61c92a 100644 --- a/bin/router/src/pipeline/execution_request.rs +++ b/bin/router/src/pipeline/execution_request.rs @@ -9,6 +9,7 @@ use hive_router_plan_executor::hooks::on_graphql_params::{ }; use hive_router_plan_executor::plugin_context::PluginRequestState; use hive_router_plan_executor::plugin_trait::{EndControlFlow, StartControlFlow}; +use hive_router_plan_executor::plugins::hooks; use http::{header::CONTENT_TYPE, Method}; use ntex::util::Bytes; use ntex::web::types::Query; @@ -350,6 +351,9 @@ impl<'a> OperationPreparation<'a> { OnGraphQLParamsStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), body: self.body.clone(), graphql_params: None, }; @@ -407,6 +411,9 @@ impl<'a> OperationPreparation<'a> { let mut payload = OnGraphQLParamsEndHookPayload { graphql_params: operation.graphql_params, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), }; for callback in graphql_params_end_callbacks { diff --git a/bin/router/src/pipeline/introspection_policy.rs b/bin/router/src/pipeline/introspection_policy.rs index fd5caadb7..6084822aa 100644 --- a/bin/router/src/pipeline/introspection_policy.rs +++ b/bin/router/src/pipeline/introspection_policy.rs @@ -2,9 +2,9 @@ use std::collections::BTreeMap; use hive_router_config::introspection_policy::IntrospectionPermissionConfig; use hive_router_internal::expressions::{ - values::boolean::BooleanOrProgram, CompileExpression, ExpressionCompileError, + values::boolean::BooleanOrProgram, CompileExpression, ExpressionCompileError, ProgramHints, }; -use hive_router_plan_executor::execution::client_request_details; +use hive_router_plan_executor::execution::client_request_details::ClientRequestDetailsView; use tracing::debug; use vrl::core::Value as VrlValue; @@ -15,21 +15,24 @@ pub fn compile_introspection_policy( ) -> Result { match introspection_policy_cfg { Some(IntrospectionPermissionConfig::Boolean(b)) => Ok(BooleanOrProgram::Value(*b)), - Some(IntrospectionPermissionConfig::Expression { expression }) => expression - .compile_expression(None) - .map(|program| BooleanOrProgram::Program(Box::new(program))), + Some(IntrospectionPermissionConfig::Expression { expression }) => { + expression.compile_expression(None).map(|program| { + let hints = ProgramHints::from_program(&program); + BooleanOrProgram::Program(Box::new(program), hints) + }) + } None => Ok(BooleanOrProgram::Value(true)), } } pub fn handle_introspection_policy( introspection_policy_prog: &BooleanOrProgram, - client_request_details: &client_request_details::ClientRequestDetails<'_>, + client_request_details: &impl ClientRequestDetailsView, ) -> Result<(), PipelineError> { let is_enabled = introspection_policy_prog .resolve(|| { let mut context_map = BTreeMap::new(); - context_map.insert("request".into(), client_request_details.into()); + context_map.insert("request".into(), client_request_details.to_vrl_value()); VrlValue::Object(context_map) }) diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 98c20817a..026e7c0f0 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -1,20 +1,29 @@ use futures::StreamExt; -use hive_router_internal::telemetry::traces::spans::{ - graphql::GraphQLOperationSpan, http_request::HttpServerRequestSpan, +use hive_router_internal::{ + http::read_body_stream, + telemetry::traces::spans::{ + graphql::GraphQLOperationSpan, http_request::HttpServerRequestSpan, + }, }; use hive_router_plan_executor::{ + coprocessor::runtime::MutableRequestState, execution::{ - client_request_details::{ClientRequestDetails, JwtRequestDetails, OperationDetails}, - plan::QueryPlanExecutionResult, + client_request_details::{ + JwtRequestDetails, MutableClientRequestDetails, OperationDetails, + }, + plan::{PlanExecutionOutput, QueryPlanExecutionResult}, }, + headers::response::ResponseHeaderAggregator, hooks::{on_graphql_params::GraphQLParams, on_supergraph_load::SupergraphData}, plugin_context::{PluginContext, PluginRequestState}, + request_context::{RequestContextExt, SharedRequestContext}, }; use hive_router_query_planner::{ state::supergraph_state::OperationKind, utils::cancellation::CancellationToken, }; use http::{header::CONTENT_TYPE, Method}; use ntex::{ + http::body::{Body, ResponseBody}, http::HeaderMap, rt, web::{self, HttpRequest}, @@ -23,6 +32,7 @@ use sonic_rs::{JsonContainerTrait, JsonType, JsonValueTrait, Value}; use std::{ collections::HashMap, hash::{Hash, Hasher}, + ops::{ControlFlow, Deref}, sync::Arc, time::Instant, }; @@ -33,7 +43,7 @@ use crate::{ pipeline::{ active_subscriptions::SubscriptionEvent, authorization::enforce_operation_authorization, - body_read::read_body_stream, + client_identification::identify_client, coerce_variables::{coerce_request_variables, CoerceVariablesPayload}, csrf_prevention::perform_csrf_prevention, error::PipelineError, @@ -43,11 +53,10 @@ use crate::{ introspection_policy::handle_introspection_policy, normalize::{normalize_request_with_cache, GraphQLNormalizationPayload}, parser::{parse_operation_with_cache, ParseResult}, - progressive_override::request_override_context, + progressive_override::RequestOverrideContext, query_plan::{plan_operation_with_cache, QueryPlanResult}, request_extensions::{ write_graphql_operation_metric_identity, write_graphql_response_metric_status, - write_request_body_size, }, validation::validate_operation_with_cache, }, @@ -63,7 +72,7 @@ use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseSt pub mod active_subscriptions; pub mod authorization; -pub mod body_read; +mod client_identification; pub mod coerce_variables; pub mod cors; pub mod csrf_prevention; @@ -89,7 +98,7 @@ pub mod websocket_server; #[inline] pub async fn graphql_request_handler( - req: &HttpRequest, + req: &mut HttpRequest, body_stream: web::types::Payload, shared_state: &Arc, schema_state: &Arc, @@ -136,29 +145,14 @@ pub async fn graphql_request_handler( ) .await?; - write_request_body_size(req, body_bytes.len() as u64); http_server_request_span.record_body_size(body_bytes.len()); - let client_name = req - .headers() - .get( - shared_state - .router_config - .telemetry - .client_identification - .name_header.get_header_ref(), - ) - .and_then(|v| v.to_str().ok()); - let client_version = req - .headers() - .get( - shared_state - .router_config - .telemetry - .client_identification - .version_header.get_header_ref(), - ) - .and_then(|v| v.to_str().ok()); + let mut request_headers = req.headers().clone(); + let request_context = req.read_request_context()?; + + let client = identify_client(&request_headers, &request_context, &shared_state.router_config.telemetry.client_identification)?; + let client_name = client.name.as_deref(); + let client_version = client.version.as_deref(); let mut plugin_req_state = None; @@ -168,8 +162,9 @@ pub async fn graphql_request_handler( ) { plugin_req_state = Some(PluginRequestState { plugins: plugins.clone(), - router_http_request: req.into(), + router_http_request: req.deref().into(), context: plugin_context.clone(), + request_context: request_context.clone(), }); } @@ -190,14 +185,14 @@ pub async fn graphql_request_handler( } }; - let graphql_params = prepared_operation.graphql_params; + let mut graphql_params = prepared_operation.graphql_params; write_graphql_operation_metric_identity(req, graphql_params.operation_name.clone(), None); let parser_result = parse_operation_with_cache(shared_state, &graphql_params, &plugin_req_state).await?; - let parser_payload = match parser_result { + let mut parser_payload = match parser_result { ParseResult::Payload(payload) => payload, ParseResult::EarlyResponse(response) => { return Ok(response); @@ -228,6 +223,51 @@ pub async fn graphql_request_handler( return Ok(response); } + request_context.update(|ctx| { + ctx.operation.update(parser_payload.operation_name.clone(), Some(parser_payload.operation_type.clone())); + + // Set initial state for progressive overrides in request context + let progressive_overrides = &supergraph.planner.supergraph.progressive_overrides; + if !progressive_overrides.flags.is_empty() { + ctx.progressive_override.unresolved_labels = Some(progressive_overrides.flags.clone()); + } + })?; + + if let Some(coprocessor_runtime) = shared_state.coprocessor.as_ref() { + let performed_mutations = match coprocessor_runtime + .on_graphql_request( + req, + &mut request_headers, + &mut graphql_params, + || supergraph.public_schema.sdl.clone() + ) + .await? + { + ControlFlow::Break(response) => return Ok(response), + ControlFlow::Continue(performed_mutations) => performed_mutations, + }; + + if performed_mutations.body { + let parser_result = + parse_operation_with_cache(shared_state, &graphql_params, &plugin_req_state) + .await?; + + parser_payload = match parser_result { + ParseResult::Payload(payload) => payload, + ParseResult::EarlyResponse(response) => { + return Ok(response); + } + }; + + request_context.update(|ctx| { + ctx.operation.update( + parser_payload.operation_name.clone(), + Some(parser_payload.operation_type.clone()), + ); + })?; + } + } + let normalize_payload = normalize_request_with_cache( supergraph, schema_state, @@ -239,9 +279,22 @@ pub async fn graphql_request_handler( write_graphql_operation_metric_identity( req, normalize_payload.operation_indentity.name.clone(), - Some(normalize_payload.operation_indentity.operation_type), + Some(normalize_payload.operation_indentity.operation_type.as_str()), ); + // Update the request context if the operation name or type has changed + if + parser_payload.operation_name.as_ref() != normalize_payload.operation_indentity.name.as_ref() || + parser_payload.operation_type != normalize_payload.operation_indentity.operation_type + { + request_context.update(|ctx| { + ctx.operation.update( + normalize_payload.operation_indentity.name.clone(), + Some(normalize_payload.operation_indentity.operation_type.clone()) + ); + })?; + } + if req.method() == Method::GET { if let Some(OperationKind::Mutation) = normalize_payload.operation_for_plan.operation_kind @@ -282,7 +335,7 @@ pub async fn graphql_request_handler( Some(inbound_request_fingerprint( req.method(), req.path(), - req.headers(), + &request_headers, &shared_state.in_flight_requests_header_policy, schema_checksum, normalize_payload.normalized_operation_hash, @@ -293,10 +346,12 @@ pub async fn graphql_request_handler( None }; + let request_context = req.read_request_context()?; + let exec = |guard| execute_planned_request( req.method(), req.uri(), - req.headers(), + request_headers, graphql_params, &normalize_payload, supergraph, @@ -304,6 +359,7 @@ pub async fn graphql_request_handler( schema_state, operation_span, plugin_req_state, + &request_context, response_mode, guard, ); @@ -375,7 +431,7 @@ pub async fn graphql_request_handler( pub async fn execute_planned_request<'exec>( method: &'exec Method, url: &'exec http::Uri, - headers: &'exec HeaderMap, + headers: HeaderMap, mut graphql_params: GraphQLParams, normalize_payload: &Arc, supergraph: &'exec SupergraphData, @@ -383,12 +439,13 @@ pub async fn execute_planned_request<'exec>( schema_state: &'exec Arc, operation_span: GraphQLOperationSpan, plugin_req_state: Option>, + request_context: &SharedRequestContext, response_mode: &'exec ResponseMode, guard: Option, ) -> Result { let jwt_request_details = match &shared_state.jwt_auth_runtime { Some(jwt_auth_runtime) => match jwt_auth_runtime - .validate_headers(headers, &shared_state.jwt_claims_cache) + .validate_headers(&headers, &shared_state.jwt_claims_cache) .await? { Some(jwt_context) => JwtRequestDetails::Authenticated { @@ -401,11 +458,12 @@ pub async fn execute_planned_request<'exec>( }, None => JwtRequestDetails::Unauthenticated, }; + jwt_request_details.update_request_context(request_context)?; let variable_payload = coerce_request_variables(supergraph, &mut graphql_params.variables, normalize_payload)?; - let client_request_details = ClientRequestDetails { + let client_request_details = MutableClientRequestDetails { method, url, headers, @@ -420,11 +478,11 @@ pub async fn execute_planned_request<'exec>( query: graphql_params.get_query()?, }, jwt: jwt_request_details.into(), - } - .into(); + }; match execute_pipeline( - &client_request_details, + client_request_details, + &graphql_params, normalize_payload, variable_payload, supergraph, @@ -432,6 +490,7 @@ pub async fn execute_planned_request<'exec>( schema_state, operation_span, plugin_req_state, + request_context, ) .await? { @@ -505,7 +564,8 @@ pub async fn execute_planned_request<'exec>( #[inline] #[allow(clippy::too_many_arguments)] pub async fn execute_pipeline<'exec>( - client_request_details: &Arc>, + mut client_request_details: MutableClientRequestDetails<'exec>, + graphql_params: &GraphQLParams, normalize_payload: &Arc, variable_payload: CoerceVariablesPayload, supergraph: &SupergraphData, @@ -513,19 +573,15 @@ pub async fn execute_pipeline<'exec>( schema_state: &Arc, operation_span: GraphQLOperationSpan, plugin_req_state: Option>, + request_context: &SharedRequestContext, ) -> Result { if normalize_payload.operation_for_introspection.is_some() { - handle_introspection_policy(&shared_state.introspection_policy, client_request_details)?; + handle_introspection_policy(&shared_state.introspection_policy, &client_request_details)?; } let cancellation_token = CancellationToken::with_timeout(shared_state.router_config.query_planner.timeout); - let progressive_override_ctx = request_override_context( - &shared_state.override_labels_evaluator, - client_request_details, - )?; - let (normalize_payload, authorization_errors) = enforce_operation_authorization( &shared_state.router_config, normalize_payload, @@ -535,6 +591,52 @@ pub async fn execute_pipeline<'exec>( &client_request_details.jwt, )?; + let mut progressive_override_ctx = RequestOverrideContext::new( + &shared_state.override_labels_evaluator, + &client_request_details, + request_context, + )?; + + if let Some(coprocessor_runtime) = shared_state.coprocessor.as_ref() { + match coprocessor_runtime + .on_graphql_analysis( + MutableRequestState { + method: client_request_details.method, + uri: client_request_details.url, + headers: &mut client_request_details.headers, + }, + graphql_params, + request_context, + || supergraph.public_schema.sdl.clone(), + ) + .await? + { + ControlFlow::Continue(performed_mutations) => { + if performed_mutations.context { + progressive_override_ctx.update_from(request_context)?; + } + } + ControlFlow::Break(response) => { + let body = match response.body() { + ResponseBody::Body(Body::Bytes(bytes)) => bytes.to_vec(), + _ => Vec::new(), + }; + + return Ok(QueryPlanExecutionResult::Single(PlanExecutionOutput { + body, + // It's an early return, so the headers from the coprocessor response + // should all be applied to the final response. + // No header propagation rules should be applied. + response_headers_aggregator: Some( + ResponseHeaderAggregator::from_early_response(response.headers()), + ), + error_count: 0, + status_code: response.status(), + })); + } + } + } + let query_plan_result = plan_operation_with_cache( supergraph, schema_state, @@ -552,11 +654,12 @@ pub async fn execute_pipeline<'exec>( } }; + let client_request_details = client_request_details.freeze(); let planned_request = PlannedRequest { normalized_payload: normalize_payload, query_plan_payload: &query_plan_payload, variable_payload, - client_request_details: client_request_details.clone(), + client_request_details: client_request_details.into(), authorization_errors, plugin_req_state, }; @@ -566,7 +669,7 @@ pub async fn execute_pipeline<'exec>( #[allow(clippy::too_many_arguments)] pub fn inbound_request_fingerprint( - method: &http::Method, + method: &Method, path: &str, request_headers: &HeaderMap, dedupe_header_policy: &RouterRequestDedupeHeaderPolicy, diff --git a/bin/router/src/pipeline/normalize.rs b/bin/router/src/pipeline/normalize.rs index eb40060ae..a47861cc9 100644 --- a/bin/router/src/pipeline/normalize.rs +++ b/bin/router/src/pipeline/normalize.rs @@ -11,6 +11,7 @@ use hive_router_plan_executor::projection::plan::FieldProjectionPlan; use hive_router_query_planner::ast::normalization::error::NormalizationError; use hive_router_query_planner::ast::normalization::normalize_operation; use hive_router_query_planner::ast::operation::OperationDefinition; +use hive_router_query_planner::state::supergraph_state::OperationKind; use xxhash_rust::xxh3::Xxh3; use crate::cache_state::{CacheHitMiss, EntryResultHitMissExt}; @@ -35,7 +36,7 @@ pub struct GraphQLNormalizationPayload { #[derive(Debug, Clone)] pub struct OperationIdentity { pub name: Option, - pub operation_type: &'static str, + pub operation_type: OperationKind, /// Hash of the original document sent to the router, by the client. pub client_document_hash: String, } @@ -44,7 +45,7 @@ impl<'a> From<&'a OperationIdentity> for GraphQLSpanOperationIdentity<'a> { fn from(op_id: &'a OperationIdentity) -> Self { GraphQLSpanOperationIdentity { name: op_id.name.as_deref(), - operation_type: op_id.operation_type, + operation_type: op_id.operation_type.as_str(), client_document_hash: &op_id.client_document_hash, } } @@ -139,7 +140,7 @@ pub async fn normalize_request_with_cache( normalized_operation_hash: hashes.combined_operation_hash, operation_indentity: OperationIdentity { name: doc.operation_name.clone(), - operation_type: parser_payload.operation_type, + operation_type: parser_payload.operation_type.clone(), client_document_hash: parser_payload.cache_key_string.clone(), }, }; diff --git a/bin/router/src/pipeline/parser.rs b/bin/router/src/pipeline/parser.rs index 599dc92f8..ae66c2de1 100644 --- a/bin/router/src/pipeline/parser.rs +++ b/bin/router/src/pipeline/parser.rs @@ -15,6 +15,8 @@ use hive_router_plan_executor::hooks::on_graphql_parse::{ }; use hive_router_plan_executor::plugin_context::PluginRequestState; use hive_router_plan_executor::plugin_trait::{CacheHint, EndControlFlow, StartControlFlow}; +use hive_router_plan_executor::plugins::hooks; +use hive_router_query_planner::state::supergraph_state::OperationKind; use hive_router_query_planner::utils::parsing::{ safe_parse_operation, safe_parse_operation_with_token_limit, }; @@ -67,7 +69,7 @@ pub struct GraphQLParserPayload { pub parsed_operation: Arc>, pub minified_document: Arc, pub operation_name: Option, - pub operation_type: &'static str, + pub operation_type: OperationKind, pub cache_key: u64, pub cache_key_string: String, pub hive_operation_hash: Arc, @@ -77,7 +79,7 @@ impl<'a> From<&'a GraphQLParserPayload> for GraphQLSpanOperationIdentity<'a> { fn from(op_id: &'a GraphQLParserPayload) -> Self { GraphQLSpanOperationIdentity { name: op_id.operation_name.as_deref(), - operation_type: op_id.operation_type, + operation_type: op_id.operation_type.as_str(), client_document_hash: &op_id.cache_key_string, } } @@ -101,6 +103,9 @@ pub async fn parse_operation_with_cache( let mut start_payload = OnGraphQLParseStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), graphql_params, }; for plugin in plugin_req_state.plugins.as_ref() { @@ -201,6 +206,10 @@ pub async fn parse_operation_with_cache( let mut end_payload = OnGraphQLParseEndHookPayload { document: parsed_operation, cache_hint, + request_context: plugin_req_state + .as_ref() + .map(|state| state.request_context.for_plugin::()) + .unwrap(), }; for callback in on_end_callbacks { let result = callback(end_payload); @@ -228,21 +237,24 @@ pub async fn parse_operation_with_cache( Definition::Operation(op) => Some(op), _ => None, }) { - Some(OperationDefinition::Query(def)) => { - ("query", def.name.as_ref().map(|s| s.to_string())) - } - Some(OperationDefinition::Mutation(def)) => { - ("mutation", def.name.as_ref().map(|s| s.to_string())) - } - Some(OperationDefinition::Subscription(def)) => { - ("subscription", def.name.as_ref().map(|s| s.to_string())) - } - Some(OperationDefinition::SelectionSet(_)) => ("query", None), + Some(OperationDefinition::Query(def)) => ( + OperationKind::Query, + def.name.as_ref().map(|s| s.to_string()), + ), + Some(OperationDefinition::Mutation(def)) => ( + OperationKind::Mutation, + def.name.as_ref().map(|s| s.to_string()), + ), + Some(OperationDefinition::Subscription(def)) => ( + OperationKind::Subscription, + def.name.as_ref().map(|s| s.to_string()), + ), + Some(OperationDefinition::SelectionSet(_)) => (OperationKind::Query, None), None => { // This should not happen as we must have at least one operation definition // but just in case, we handle it gracefully, // the error will be caught later in the pipeline, specifically in the validation stage - ("query", None) + (OperationKind::Query, None) } }; diff --git a/bin/router/src/pipeline/progressive_override.rs b/bin/router/src/pipeline/progressive_override.rs index 7af3423ba..88cd45381 100644 --- a/bin/router/src/pipeline/progressive_override.rs +++ b/bin/router/src/pipeline/progressive_override.rs @@ -2,7 +2,9 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use hive_router_config::override_labels::{LabelOverrideValue, OverrideLabelsConfig}; use hive_router_internal::expressions::CompileExpression; -use hive_router_plan_executor::execution::client_request_details::ClientRequestDetails; +use hive_router_plan_executor::execution::client_request_details::ClientRequestDetailsView; +use hive_router_plan_executor::request_context::RequestContextError; +use hive_router_plan_executor::request_context::SharedRequestContext; use hive_router_query_planner::{ graph::{PlannerOverrideContext, PERCENTAGE_SCALE_FACTOR}, state::supergraph_state::SupergraphState, @@ -39,6 +41,8 @@ pub enum LabelEvaluationError { "VRL expression for override label '{label}' did not evaluate to a boolean. Got: {got}" )] ExpressionWrongType { label: String, got: String }, + #[error(transparent)] + RequestContext(#[from] RequestContextError), } /// Contains the request-specific context for progressive overrides. @@ -51,27 +55,69 @@ pub struct RequestOverrideContext { pub percentage_value: u64, } -#[inline] -pub fn request_override_context<'exec>( - override_labels_evaluator: &OverrideLabelsEvaluator, - client_request_details: &ClientRequestDetails<'exec>, -) -> Result { - let active_flags = override_labels_evaluator.evaluate(client_request_details)?; - - // Generate the random percentage value for this request. - // Percentage is 0 - 100_000_000_000 (100*PERCENTAGE_SCALE_FACTOR) - // 0 = 0% - // 100_000_000_000 = 100% - // 50_000_000_000 = 50% - // 50_123_456_789 = 50.12345678% - let percentage_value: u64 = rand::rng().random_range(0..=(100 * PERCENTAGE_SCALE_FACTOR)); - - let override_context = RequestOverrideContext { - active_flags, - percentage_value, - }; - - Ok(override_context) +impl RequestOverrideContext { + #[inline] + pub fn new( + override_labels_evaluator: &OverrideLabelsEvaluator, + client_request_details: &impl ClientRequestDetailsView, + request_context: &SharedRequestContext, + ) -> Result { + let progressive_override_state = request_context.snapshot()?.progressive_override; + + let active_flags = override_labels_evaluator.evaluate( + progressive_override_state.labels_to_override.as_ref(), + client_request_details, + )?; + + if !active_flags.is_empty() { + request_context.update(|ctx| { + ctx.progressive_override.labels_to_override = Some(active_flags.clone()); + ctx.progressive_override.unresolved_labels = + match ctx.progressive_override.unresolved_labels.as_ref() { + Some(labels) => { + let diff: HashSet<_> = + labels.difference(&active_flags).cloned().collect(); + + if diff.is_empty() { + None + } else { + Some(diff) + } + } + None => None, + } + })?; + } + + // Generate the random percentage value for this request. + // Percentage is 0 - 100_000_000_000 (100*PERCENTAGE_SCALE_FACTOR) + // 0 = 0% + // 100_000_000_000 = 100% + // 50_000_000_000 = 50% + // 50_123_456_789 = 50.12345678% + let percentage_value: u64 = rand::rng().random_range(0..=(100 * PERCENTAGE_SCALE_FACTOR)); + + let override_context = RequestOverrideContext { + active_flags, + percentage_value, + }; + + Ok(override_context) + } + + pub fn update_from( + &mut self, + request_context: &SharedRequestContext, + ) -> Result<(), RequestContextError> { + let active_labels = request_context + .snapshot()? + .progressive_override + .labels_to_override; + + self.active_flags = active_labels.unwrap_or_default(); + + Ok(()) + } } impl From<&RequestOverrideContext> for PlannerOverrideContext { @@ -152,18 +198,26 @@ impl OverrideLabelsEvaluator { }) } - pub(crate) fn evaluate<'exec>( + pub(crate) fn evaluate( &self, - client_request: &ClientRequestDetails<'exec>, + // Labels that have already been resolved either by plugins or coprocessors + resolved_labels: Option<&HashSet>, + client_request: &impl ClientRequestDetailsView, ) -> Result, LabelEvaluationError> { - let mut active_flags = self.static_enabled_labels.clone(); + let mut active_flags = match resolved_labels { + Some(set) => set.union(&self.static_enabled_labels).cloned().collect(), + None => self.static_enabled_labels.clone(), + }; if self.expressions.is_empty() { return Ok(active_flags); } let mut target = VrlTargetValue { - value: VrlValue::Object(BTreeMap::from([("request".into(), client_request.into())])), + value: VrlValue::Object(BTreeMap::from([( + "request".into(), + client_request.to_vrl_value(), + )])), metadata: VrlValue::Object(BTreeMap::new()), secrets: VrlSecrets::default(), }; @@ -173,6 +227,12 @@ impl OverrideLabelsEvaluator { let mut ctx = VrlContext::new(&mut target, &mut state, &timezone); for (label, expression) in &self.expressions { + // There's a chance the label was already resolved by a plugin or coprocessor + // so no need to re-evaluate it + if active_flags.contains(label) { + continue; + } + match expression.resolve(&mut ctx) { Ok(evaluated_value) => match evaluated_value { VrlValue::Boolean(true) => { diff --git a/bin/router/src/pipeline/query_plan.rs b/bin/router/src/pipeline/query_plan.rs index 2be40683c..c39a8ac4d 100644 --- a/bin/router/src/pipeline/query_plan.rs +++ b/bin/router/src/pipeline/query_plan.rs @@ -14,6 +14,7 @@ use hive_router_plan_executor::hooks::on_query_plan::{ use hive_router_plan_executor::hooks::on_supergraph_load::SupergraphData; use hive_router_plan_executor::plugin_context::PluginRequestState; use hive_router_plan_executor::plugin_trait::{CacheHint, EndControlFlow, StartControlFlow}; +use hive_router_plan_executor::plugins::hooks; use hive_router_query_planner::planner::plan_nodes::QueryPlan; use hive_router_query_planner::planner::query_plan::QUERY_PLAN_KIND; use hive_router_query_planner::utils::cancellation::CancellationToken; @@ -49,6 +50,9 @@ pub async fn plan_operation_with_cache( let mut start_payload = OnQueryPlanStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), filtered_operation_for_plan, cancellation_token, planner: &supergraph.planner, @@ -146,6 +150,10 @@ pub async fn plan_operation_with_cache( let mut end_payload = OnQueryPlanEndHookPayload { query_plan: plan, cache_hint, + request_context: plugin_req_state + .as_ref() + .map(|state| state.request_context.for_plugin::()) + .unwrap(), }; for callback in on_end_callbacks { let result = callback(end_payload); diff --git a/bin/router/src/pipeline/request_extensions.rs b/bin/router/src/pipeline/request_extensions.rs index 7c4a33924..ef4cd064e 100644 --- a/bin/router/src/pipeline/request_extensions.rs +++ b/bin/router/src/pipeline/request_extensions.rs @@ -2,21 +2,6 @@ use ntex::web::HttpRequest; use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; -/// Stores the request body size in bytes. -/// -/// The value comes from either: -/// - the `Content-Length` header -/// - the streamed payload, measured from bytes read. -/// -/// For streamed payloads, the recorded size is the number of bytes read up to -/// the configured maximum. -/// -/// Using `RequestBodySize` to store the size of the request body, -/// helps to reduce complexity in code, as otherwise, -/// we would have to return the size next within Err and Ok of `read_body_stream`. -#[derive(Debug, Clone, Copy)] -pub struct RequestBodySize(pub u64); - #[derive(Debug, Clone, Default)] pub struct GraphQLOperationMetricIdentity { pub operation_name: Option, @@ -26,16 +11,6 @@ pub struct GraphQLOperationMetricIdentity { #[derive(Debug, Clone, Copy)] pub struct GraphQLResponseMetricStatus(pub GraphQLResponseStatus); -#[inline] -pub fn write_request_body_size(req: &HttpRequest, size: u64) { - req.extensions_mut().insert(RequestBodySize(size)); -} - -#[inline] -pub fn read_request_body_size(req: &HttpRequest) -> Option { - req.extensions().get::().map(|size| size.0) -} - #[inline] pub fn write_graphql_operation_metric_identity( req: &HttpRequest, diff --git a/bin/router/src/pipeline/validation/mod.rs b/bin/router/src/pipeline/validation/mod.rs index 3f7ad2628..fe8296202 100644 --- a/bin/router/src/pipeline/validation/mod.rs +++ b/bin/router/src/pipeline/validation/mod.rs @@ -14,6 +14,7 @@ use hive_router_plan_executor::hooks::on_graphql_validation::{ use hive_router_plan_executor::hooks::on_supergraph_load::SupergraphData; use hive_router_plan_executor::plugin_context::PluginRequestState; use hive_router_plan_executor::plugin_trait::{CacheHint, EndControlFlow, StartControlFlow}; +use hive_router_plan_executor::plugins::hooks; use tracing::{error, trace, Instrument}; use xxhash_rust::xxh3::Xxh3; pub mod max_aliases_rule; @@ -42,6 +43,9 @@ pub async fn validate_operation_with_cache( let mut start_payload = OnGraphQLValidationStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), schema: validation_schema, document: validation_operation, validation_plan, @@ -113,7 +117,18 @@ pub async fn validate_operation_with_cache( }; if !on_end_callbacks.is_empty() { - let mut end_payload = OnGraphQLValidationEndHookPayload { errors, cache_hint }; + let mut end_payload = OnGraphQLValidationEndHookPayload { + errors, + cache_hint, + request_context: plugin_req_state + .as_ref() + .map(|state| { + state + .request_context + .for_plugin::() + }) + .expect("plugin state to exist as end callbacks are not empty"), + }; for callback in on_end_callbacks { let result = callback(end_payload); end_payload = result.payload; diff --git a/bin/router/src/pipeline/websocket_server.rs b/bin/router/src/pipeline/websocket_server.rs index f442ef554..587ce9aaa 100644 --- a/bin/router/src/pipeline/websocket_server.rs +++ b/bin/router/src/pipeline/websocket_server.rs @@ -26,6 +26,7 @@ use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; use hive_router_plan_executor::plugin_context::{ PluginContext, PluginRequestState, RouterHttpRequest, }; +use hive_router_plan_executor::request_context::{RequestContextExt, SharedRequestContext}; use hive_router_plan_executor::response::graphql_error::{GraphQLError, GraphQLErrorExtensions}; use hive_router_query_planner::state::supergraph_state::OperationKind; @@ -57,6 +58,10 @@ pub async fn ws_index( .map(|_| WS_SUBPROTOCOL); let plugin_context = req.extensions().get::>().cloned(); + let request_context = match req.read_request_context() { + Ok(ctx) => ctx, + Err(_) => return Ok(HttpResponse::BadRequest().finish()), + }; ws::start( req, @@ -65,6 +70,7 @@ pub async fn ws_index( let schema_state = schema_state.clone(); let shared_state = shared_state.clone(); let plugin_context = plugin_context.clone(); + let request_context = request_context.clone(); async move { ws_service( accepted_subprotocol.is_some(), @@ -72,6 +78,7 @@ pub async fn ws_index( schema_state, shared_state, plugin_context, + request_context, ) .await } @@ -86,6 +93,7 @@ async fn ws_service( schema_state: Arc, shared_state: Arc, plugin_context: Option>, + request_context: SharedRequestContext, ) -> Result, Error = io::Error>, web::Error> { if !has_accepted_subprotocol { @@ -128,6 +136,7 @@ async fn ws_service( let schema_state = schema_state.clone(); let shared_state = shared_state.clone(); let plugin_context = plugin_context.clone(); + let request_context = request_context.clone(); let ws_uri = ws_uri.clone(); let ws_path = ws_path.clone(); async move { @@ -139,6 +148,7 @@ async fn ws_service( &schema_state, &shared_state, plugin_context, + &request_context, &ws_uri, &ws_path, ) @@ -193,6 +203,7 @@ async fn handle_text_frame( schema_state: &Arc, shared_state: &Arc, plugin_context: Option>, + request_context: &SharedRequestContext, ws_uri: &http::Uri, ws_path: &Path, ) -> Option { @@ -281,6 +292,8 @@ async fn handle_text_frame( headers.insert(key.clone(), value.clone()); } + let headers = Arc::new(headers); + // store the merged headers back to init_payload if configured to do so if config.headers.persist { if let Some(ref mut init_payload) = state.borrow_mut().init_payload { @@ -314,12 +327,13 @@ async fn handle_text_frame( uri: ws_uri, method: &Method::POST, version: http::Version::HTTP_11, - headers: &headers, + headers: headers.as_ref(), path: ws_uri.path(), query_string: ws_uri.query().unwrap_or(""), match_info: ws_path, }, context: plugin_context.clone(), + request_context: request_context.clone(), }) } else { None @@ -431,7 +445,7 @@ async fn handle_text_frame( Some(inbound_request_fingerprint( &Method::POST, ws_uri.path(), - &headers, + headers.as_ref(), &shared_state.in_flight_requests_header_policy, supergraph.schema_checksum(), normalize_payload.normalized_operation_hash, @@ -447,10 +461,11 @@ async fn handle_text_frame( SingleContentType::default(), StreamContentType::default(), ); + let exec = |guard| execute_planned_request( &Method::POST, ws_uri, - &headers, + headers.as_ref().clone(), payload, &normalize_payload, supergraph, @@ -458,6 +473,7 @@ async fn handle_text_frame( schema_state, operation_span, plugin_req_state, + request_context, &response_mode, guard, ); diff --git a/bin/router/src/plugins/plugins_service.rs b/bin/router/src/plugins/plugins_service.rs index 34fcda213..1bbda0135 100644 --- a/bin/router/src/plugins/plugins_service.rs +++ b/bin/router/src/plugins/plugins_service.rs @@ -1,9 +1,11 @@ -use std::sync::Arc; +use std::{ops::ControlFlow, sync::Arc}; use hive_router_plan_executor::{ hooks::on_http_request::{OnHttpRequestHookPayload, OnHttpResponseHookPayload}, plugin_context::PluginContext, plugin_trait::{EndControlFlow, StartControlFlow}, + plugins::hooks, + request_context::{RequestContextExt, SharedRequestContext}, }; use ntex::{ service::{Service, ServiceCtx}, @@ -11,21 +13,39 @@ use ntex::{ Middleware, SharedCfg, }; -use crate::RouterSharedState; +use crate::{RouterPaths, RouterSharedState}; -pub struct PluginService; +pub struct PluginService { + paths: RouterPaths, + prometheus_endpoint: Option, +} + +impl PluginService { + pub fn new(paths: RouterPaths, prometheus_endpoint: Option) -> Self { + Self { + paths, + prometheus_endpoint, + } + } +} impl Middleware for PluginService { type Service = PluginMiddleware; fn create(&self, service: S, _cfg: SharedCfg) -> Self::Service { - PluginMiddleware { service } + PluginMiddleware { + service, + paths: self.paths.clone(), + prometheus_endpoint: self.prometheus_endpoint.clone(), + } } } pub struct PluginMiddleware { // This is special: We need this to avoid lifetime issues. service: S, + paths: RouterPaths, + prometheus_endpoint: Option, } impl Service> for PluginMiddleware @@ -42,9 +62,41 @@ where mut req: web::WebRequest, ctx: ServiceCtx<'_, Self>, ) -> Result { - let plugins = req - .app_state::>() - .and_then(|shared_state| shared_state.plugins.clone()); + let shared_state = req.app_state::>().cloned(); + + // Determine if the request should be handled by plugins. + // The exceptions are: + // - health endpoint + // - readiness endpoint + // - prometheus endpoint (if it's on the same port) + let should_run = { + let path = req.path(); + path != self.paths.health + && path != self.paths.readiness + && self.prometheus_endpoint.as_deref() != Some(path) + }; + + if !should_run { + return ctx.call(&self.service, req).await; + } + + let request_context = SharedRequestContext::default(); + req.write_request_context(request_context.clone()); + + let coprocessor_runtime = shared_state + .as_ref() + .and_then(|shared_state| shared_state.coprocessor.as_ref()); + + let plugins = shared_state + .as_ref() + .and_then(|state| state.plugins.clone()); + + if let Some(coprocessor_runtime) = coprocessor_runtime { + match coprocessor_runtime.on_router_request(req).await { + ControlFlow::Break(response) => return Ok(response), + ControlFlow::Continue(new_req) => req = new_req, + } + } if let Some(plugins) = plugins.as_ref() { let plugin_context = Arc::new(PluginContext::default()); @@ -53,6 +105,7 @@ where let mut start_payload = OnHttpRequestHookPayload { router_http_request: req, context: &plugin_context, + request_context: request_context.for_plugin::(), }; let mut on_end_callbacks = Vec::with_capacity(plugins.len()); @@ -78,10 +131,15 @@ where let mut response = ctx.call(&self.service, req).await?; + if let Some(coprocessor_runtime) = coprocessor_runtime { + response = coprocessor_runtime.on_router_response(response).await; + } + if !on_end_callbacks.is_empty() { let mut end_payload = OnHttpResponseHookPayload { response, context: &plugin_context, + request_context: request_context.for_plugin::(), }; for callback in on_end_callbacks.into_iter().rev() { @@ -104,6 +162,12 @@ where return Ok(response); } - ctx.call(&self.service, req).await + let mut response = ctx.call(&self.service, req).await?; + + if let Some(coprocessor_runtime) = coprocessor_runtime { + response = coprocessor_runtime.on_router_response(response).await; + } + + Ok(response) } } diff --git a/bin/router/src/plugins/registry.rs b/bin/router/src/plugins/registry.rs index 387efaa97..9b107b160 100644 --- a/bin/router/src/plugins/registry.rs +++ b/bin/router/src/plugins/registry.rs @@ -134,6 +134,8 @@ mod tests { }, plugin_context::{PluginContext, RouterHttpRequest}, plugin_trait::{RouterPlugin, StartHookPayload}, + plugins::hooks, + request_context::SharedRequestContext, }; use ntex::router::Path; @@ -260,11 +262,13 @@ mod tests { match_info: &path, }; let plugin_context = PluginContext::default(); + let request_context = SharedRequestContext::default(); let mut plugin_names: Vec = vec![]; for plugin in plugins.iter() { let payload = OnGraphQLParamsStartHookPayload { router_http_request: &fake_request, context: &plugin_context, + request_context: request_context.for_plugin::(), body: Default::default(), graphql_params: None, }; diff --git a/bin/router/src/schema_state.rs b/bin/router/src/schema_state.rs index 61c10ed38..391586b38 100644 --- a/bin/router/src/schema_state.rs +++ b/bin/router/src/schema_state.rs @@ -18,7 +18,8 @@ use hive_router_plan_executor::response::graphql_error::GraphQLErrorExtensions; use hive_router_plan_executor::{ executors::error::SubgraphExecutorError, hooks::on_supergraph_load::{ - OnSupergraphLoadEndHookPayload, OnSupergraphLoadStartHookPayload, SupergraphData, + OnSupergraphLoadEndHookPayload, OnSupergraphLoadStartHookPayload, PublicSchema, + SupergraphData, }, introspection::schema::SchemaWithMetadata, plugin_trait::{EndControlFlow, RouterPluginBoxed, StartControlFlow}, @@ -263,6 +264,10 @@ impl SchemaState { Ok(SupergraphData { supergraph_schema: Arc::new(parsed_supergraph_sdl), + public_schema: PublicSchema { + document: planner.consumer_schema.document.clone(), + sdl: Arc::::from(planner.consumer_schema.document.to_string()), + }, metadata, planner, authorization, diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index eb259c3d4..096d17196 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -9,6 +9,7 @@ use hive_router_internal::expressions::values::boolean::BooleanOrProgram; use hive_router_internal::expressions::ExpressionCompileError; use hive_router_internal::inflight::{InFlightCleanupGuard, InFlightMap}; use hive_router_internal::telemetry::TelemetryContext; +use hive_router_plan_executor::coprocessor::{CoprocessorError, CoprocessorRuntime}; use hive_router_plan_executor::execution::plan::FailedExecutionResult; use hive_router_plan_executor::headers::{ compile::compile_headers_plan, errors::HeaderRuleCompileError, plan::HeaderRulesPlan, @@ -285,6 +286,7 @@ pub struct RouterSharedState { pub hive_usage_agent: Option, pub introspection_policy: BooleanOrProgram, pub telemetry_context: Arc, + pub coprocessor: Option, pub plugins: Option>>, pub in_flight_requests: RouterInflightRequestsMap, pub in_flight_requests_header_policy: RouterRequestDedupeHeaderPolicy, @@ -308,6 +310,19 @@ impl RouterSharedState { active_subscriptions: ActiveSubscriptions, ) -> Result { let parse_cache = cache_state.parse_cache.clone(); + let coprocessor = router_config + .coprocessor + .as_ref() + .map(|coprocessor_config| { + CoprocessorRuntime::from_config( + coprocessor_config, + telemetry_context.clone(), + router_config.limits.max_request_body_size.to_bytes() as usize, + ) + .map_err(Box::new) + }) + .transpose()?; + Ok(Self { validation_plan: Arc::new(validation_plan), headers_plan: Arc::new(compile_headers_plan(&router_config.headers).map_err(Box::new)?), @@ -330,6 +345,7 @@ impl RouterSharedState { introspection_policy: compile_introspection_policy(&router_config.introspection) .map_err(Box::new)?, telemetry_context, + coprocessor, plugins, in_flight_requests: InFlightMap::default(), in_flight_requests_header_policy: (&router_config @@ -358,6 +374,8 @@ pub enum SharedStateError { PersistedDocuments(#[from] Box), #[error("invalid introspection config: {0}")] IntrospectionPolicyCompile(#[from] Box), + #[error("invalid coprocessor config: {0}")] + CoprocessorRuntime(#[from] Box), } #[cfg(test)] diff --git a/docs/README.md b/docs/README.md index fd6108f36..b07a07f6b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ |Name|Type|Description|Required| |----|----|-----------|--------| |[**authorization**](#authorization)|`object`|Default: `{"directives":{"enabled":true,"unauthorized":{"mode":"filter"}}}`
|yes| +|[**coprocessor**](#coprocessor)|`object`, `null`|Configuration for coprocessor.
|yes| |[**cors**](#cors)|`object`|Configuration for CORS (Cross-Origin Resource Sharing).
Default: `{"allow_any_origin":false,"allow_credentials":false,"enabled":false,"policies":[]}`
|yes| |[**csrf**](#csrf)|`object`|Configuration for CSRF prevention.
Default: `{"enabled":false,"required_headers":[]}`
|| |[**headers**](#headers)|`object`|Configuration for the headers.
Default: `{}`
|| @@ -280,6 +281,285 @@ mode: filter ``` + +## coprocessor: object,null + +Configuration for coprocessor. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**protocol**||Transport protocol used to call the coprocessor service.
|yes| +|[**stages**](#coprocessorstages)|`object`|Stage-specific configuration.
Default: `{"graphql":{},"router":{}}`
|no| +|**timeout**|`string`|Per-stage timeout for a coprocessor call.

Defaults to `1s`.
Default: `"1s"`
|no| +|**url**|`string`|Endpoint for the external coprocessor service.

Supported formats:
- `http://host[:port][/path]`
- `unix:///absolute/path/to/socket.sock`
- `unix:///absolute/path/to/socket.sock?path=/request/path`
|yes| + +**Additional Properties:** not allowed + +### coprocessor\.stages: object + +Stage-specific configuration. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**graphql**](#coprocessorstagesgraphql)|`object`|Hooks around GraphQL processing
Default: `{}`
|| +|[**router**](#coprocessorstagesrouter)|`object`|Hooks around the router HTTP boundary
Default: `{}`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +graphql: {} +router: {} + +``` + + +#### coprocessor\.stages\.graphql: object + +Hooks around GraphQL processing + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**analysis**](#coprocessorstagesgraphqlanalysis)|`object`, `null`|Configuration for `graphql.analysis` hook.
|| +|[**request**](#coprocessorstagesgraphqlrequest)|`object`, `null`|Configuration for `graphql.request` hook.
|| +|[**response**](#coprocessorstagesgraphqlresponse)|`object`, `null`|Configuration for `graphql.response` hook.
|| + +**Additional Properties:** not allowed + +##### coprocessor\.stages\.graphql\.analysis: object,null + +Configuration for `graphql.analysis` hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**condition**||Optional condition expression.

The hook runs only when this expression evaluates to `true`.
|| +|[**include**](#coprocessorstagesgraphqlanalysisinclude)|`object`|Selects which fields are included in the coprocessor payload for this hook.
|| + +**Additional Properties:** not allowed + +###### coprocessor\.stages\.graphql\.analysis\.include: object + +Selects which fields are included in the coprocessor payload for this hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**body**||Include GraphQL request body fields.

Accepts `true`, `false`, or a list of fields.
Default: `false`
|| +|**context**||Include request context.

Values:
- `false`: no context
- `true`: full context
- list: selected context keys
Default: `false`
|| +|**headers**|`boolean`|Include request headers.
Default: `false`
|| +|**method**|`boolean`|Include request method.
Default: `false`
|| +|**path**|`boolean`|Include request path.
Default: `false`
|| +|**sdl**|`boolean`|Include the current public schema SDL.
Default: `false`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +body: false +context: false +headers: false +method: false +path: false +sdl: false + +``` + + +##### coprocessor\.stages\.graphql\.request: object,null + +Configuration for `graphql.request` hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**condition**||Optional condition expression.

The hook runs only when this expression evaluates to `true`.
|| +|[**include**](#coprocessorstagesgraphqlrequestinclude)|`object`|Selects which fields are included in the coprocessor payload for this hook.
|| + +**Additional Properties:** not allowed + +###### coprocessor\.stages\.graphql\.request\.include: object + +Selects which fields are included in the coprocessor payload for this hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**body**||Include GraphQL request body fields.

Accepts `true`, `false`, or a list of fields.
Default: `false`
|| +|**context**||Include request context.

Values:
- `false`: no context
- `true`: full context
- list: selected context keys
Default: `false`
|| +|**headers**|`boolean`|Include request headers.
Default: `false`
|| +|**method**|`boolean`|Include request method.
Default: `false`
|| +|**path**|`boolean`|Include request path.
Default: `false`
|| +|**sdl**|`boolean`|Include the current public schema SDL.
Default: `false`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +body: false +context: false +headers: false +method: false +path: false +sdl: false + +``` + + +##### coprocessor\.stages\.graphql\.response: object,null + +Configuration for `graphql.response` hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**condition**||Optional condition expression.

The hook runs only when this expression evaluates to `true`.
|| +|[**include**](#coprocessorstagesgraphqlresponseinclude)|`object`|Selects which fields are included in the coprocessor payload for this hook.
|| + +**Additional Properties:** not allowed + +###### coprocessor\.stages\.graphql\.response\.include: object + +Selects which fields are included in the coprocessor payload for this hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**body**|`boolean`|Include GraphQL response body.
Default: `false`
|| +|**context**||Include request context.

Values:
- `false`: no context
- `true`: full context
- list: selected context keys
Default: `false`
|| +|**headers**|`boolean`|Include response headers.
Default: `false`
|| +|**sdl**|`boolean`|Include the current public schema SDL.
Default: `false`
|| +|**status\_code**|`boolean`|Include response status code.
Default: `false`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +body: false +context: false +headers: false +sdl: false +status_code: false + +``` + + +#### coprocessor\.stages\.router: object + +Hooks around the router HTTP boundary + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**request**](#coprocessorstagesrouterrequest)|`object`, `null`|Configuration for `router.request` hook.
|| +|[**response**](#coprocessorstagesrouterresponse)|`object`, `null`|Configuration for `router.response` hook.
|| + +**Additional Properties:** not allowed + +##### coprocessor\.stages\.router\.request: object,null + +Configuration for `router.request` hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**condition**||Optional condition expression.

The hook runs only when this expression evaluates to `true`.
|| +|[**include**](#coprocessorstagesrouterrequestinclude)|`object`|Selects which fields are included in the coprocessor payload for this hook.
|| + +**Additional Properties:** not allowed + +###### coprocessor\.stages\.router\.request\.include: object + +Selects which fields are included in the coprocessor payload for this hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**body**|`boolean`|Include the inbound HTTP request body.
Default: `false`
|| +|**context**||Include request context.

Values:
- `false`: no context
- `true`: full context
- list: selected context keys
Default: `false`
|| +|**headers**|`boolean`|Include inbound HTTP request headers.
Default: `false`
|| +|**method**|`boolean`|Include inbound HTTP request method.
Default: `false`
|| +|**path**|`boolean`|Include inbound HTTP request path.
Default: `false`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +body: false +context: false +headers: false +method: false +path: false + +``` + + +##### coprocessor\.stages\.router\.response: object,null + +Configuration for `router.response` hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**condition**||Optional condition expression.

The hook runs only when this expression evaluates to `true`.
|| +|[**include**](#coprocessorstagesrouterresponseinclude)|`object`|Selects which fields are included in the coprocessor payload for this hook.
|| + +**Additional Properties:** not allowed + +###### coprocessor\.stages\.router\.response\.include: object + +Selects which fields are included in the coprocessor payload for this hook. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**body**|`boolean`|Include outbound HTTP response body.
Default: `false`
|| +|**context**||Include request context.

Values:
- `false`: no context
- `true`: full context
- list: selected context keys
Default: `false`
|| +|**headers**|`boolean`|Include outbound HTTP response headers.
Default: `false`
|| +|**status\_code**|`boolean`|Include outbound HTTP response status code.
Default: `false`
|| + +**Additional Properties:** not allowed +**Example** + +```yaml +body: false +context: false +headers: false +status_code: false + +``` + ## cors: object @@ -770,7 +1050,7 @@ A dynamic value computed by a VRL expression. This allows you to generate header values based on the incoming request, subgraph name, and (for response rules) subgraph response headers. The expression has access to a context object with `.request`, `.subgraph`, -and `.response` fields. +and `.response.headers` fields. For more information on the available functions and syntax, see the [VRL documentation](https://vrl.dev/). @@ -992,7 +1272,7 @@ A dynamic value computed by a VRL expression. This allows you to generate header values based on the incoming request, subgraph name, and (for response rules) subgraph response headers. The expression has access to a context object with `.request`, `.subgraph`, -and `.response` fields. +and `.response.headers` fields. For more information on the available functions and syntax, see the [VRL documentation](https://vrl.dev/). @@ -1245,7 +1525,7 @@ A dynamic value computed by a VRL expression. This allows you to generate header values based on the incoming request, subgraph name, and (for response rules) subgraph response headers. The expression has access to a context object with `.request`, `.subgraph`, -and `.response` fields. +and `.response.headers` fields. For more information on the available functions and syntax, see the [VRL documentation](https://vrl.dev/). @@ -1467,7 +1747,7 @@ A dynamic value computed by a VRL expression. This allows you to generate header values based on the incoming request, subgraph name, and (for response rules) subgraph response headers. The expression has access to a context object with `.request`, `.subgraph`, -and `.response` fields. +and `.response.headers` fields. For more information on the available functions and syntax, see the [VRL documentation](https://vrl.dev/). diff --git a/e2e/src/coprocessor/context.rs b/e2e/src/coprocessor/context.rs new file mode 100644 index 000000000..1c7655fbc --- /dev/null +++ b/e2e/src/coprocessor/context.rs @@ -0,0 +1,625 @@ +use sonic_rs::JsonValueTrait; +use sonic_rs::{json, pointer}; + +use crate::testkit::{coprocessor::TestCoprocessor, ClientResponseExt, TestRouter, TestSubgraphs}; + +mod basic { + use super::*; + + #[ntex::test] + async fn context_false_omits_context_field() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| { + payload.get("context").is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "query MyContextFalse { topProducts(first: 1) { name } }", + None, + None, + ) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + request_stage_mock.assert_async().await; + } + + #[ntex::test] + async fn context_list_sends_only_selected_reserved_keys() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| { + let context = match payload.get("context") { + Some(context) => context, + None => return false, + }; + + context + .pointer(&["hive::operation::name"]) + .and_then(|value| value.as_str()) + == Some("Example") + && context.pointer(&["hive::operation::kind"]).is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: ["hive::operation::name"] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "query Example { topProducts(first: 1) { name } }", + None, + None, + ) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + request_stage_mock.assert_async().await; + } + + #[ntex::test] + async fn context_patch_updates_key_not_sent_in_request_stage() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| { + payload + .pointer(&["context", "hive::operation::name"]) + .and_then(|value| value.as_str()) + == Some("Example") + && payload.pointer(&["context", "custom::b"]).is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + "custom::b": "patched-value" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router_response_stage_mock = coprocessor + .mock_stage_with_matcher("router.response", |payload| { + println!("router.response: {:?}", payload); + payload + .pointer(&["context", "custom::b"]) + .and_then(|value| value.as_str()) + == Some("patched-value") + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: ["hive::operation::name"] + router: + response: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "query Example { topProducts(first: 1) { name } }", + None, + None, + ) + .await; + + request_stage_mock.assert_async().await; + router_response_stage_mock.assert_async().await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + } + + #[ntex::test] + async fn context_reserved_key_mutation_is_rejected() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::operation::name": "Overridden" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "query MyImmutableContext { topProducts { name } }", + None, + None, + ) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "errors": [ + { + "extensions": { + "code": "REQUEST_CONTEXT_ERROR" + }, + "message": "Internal server error" + } + ] + } + "#); + + assert!(!response.status().is_success()); + request_stage_mock.assert_async().await; + } +} + +mod progressive_override { + use super::*; + + #[ntex::test] + async fn multi_stage_update() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + // graphql.request sets `-0` flag + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::progressive_override::labels_to_override": ["my-flag-0"] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let graphql_analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + payload + .pointer(&pointer![ + "context", + "hive::progressive_override::labels_to_override", + 0, + ]) + // graphql.analysis expects `-0` flag + .is_some_and(|value| value.as_str() == Some("my-flag-0")) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + // graphql.analysis sets `-1` flag + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::progressive_override::labels_to_override": ["my-flag-1"] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router_response_stage_mock = coprocessor + .mock_stage_with_matcher("router.response", |payload| { + payload + .pointer(&pointer![ + "context", + "hive::progressive_override::labels_to_override", + 0, + ]) + // router.response expects `-1` flag + .is_some_and(|value| value.as_str() == Some("my-flag-1")) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: true + analysis: + include: + context: true + router: + response: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { name } }", None, None) + .await; + + request_stage_mock.assert_async().await; + graphql_analysis_stage_mock.assert_async().await; + router_response_stage_mock.assert_async().await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + } + + #[ntex::test] + async fn labels_propagated_to_next_stages() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::progressive_override::labels_to_override": ["my-flag"] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router_response_stage_mock = coprocessor + .mock_stage_with_matcher("router.response", |payload| { + payload + .pointer(&pointer![ + "context", + "hive::progressive_override::labels_to_override", + 0, + ]) + .is_some_and(|value| value.as_str() == Some("my-flag")) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: true + router: + response: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { name } }", None, None) + .await; + + request_stage_mock.assert_async().await; + router_response_stage_mock.assert_async().await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + } + + #[ntex::test] + async fn unresolved_labels_mutation_rejected() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::progressive_override::unresolved_labels": ["my-flag"] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "errors": [ + { + "extensions": { + "code": "REQUEST_CONTEXT_ERROR" + }, + "message": "Internal server error" + } + ] + } + "#); + + assert!(!response.status().is_success()); + request_stage_mock.assert_async().await; + } + + #[ntex::test] + async fn type_mismatch_rejected() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + "hive::progressive_override::labels_to_override": "my-flag" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "errors": [ + { + "extensions": { + "code": "REQUEST_CONTEXT_ERROR" + }, + "message": "Internal server error" + } + ] + } + "#); + + assert!(!response.status().is_success()); + request_stage_mock.assert_async().await; + } +} diff --git a/e2e/src/coprocessor/failures.rs b/e2e/src/coprocessor/failures.rs new file mode 100644 index 000000000..9a6f2efe4 --- /dev/null +++ b/e2e/src/coprocessor/failures.rs @@ -0,0 +1,158 @@ +use sonic_rs::json; + +use crate::testkit::{coprocessor::TestCoprocessor, TestRouter}; + +fn default_config(host: &str) -> String { + format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + router: + request: + include: + headers: true + "# + ) +} + +#[ntex::test] +async fn rejects_http_error_response() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(500) + .with_body("Internal Server Error") + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(default_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 500, + "router should return 500 when coprocessor fails" + ); + request_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn rejects_malformed_json_payload() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("{\"version\": 1, \"control\": ") // Incomplete JSON + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(default_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 500, + "router should return 500 when coprocessor returns malformed JSON" + ); + request_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn rejects_unsupported_version() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 420, // Unsupported version + "control": "continue" + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(default_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 500, + "router should return 500 when coprocessor returns unsupported version" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn rejects_invalid_control_value() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "jump" // Invalid control value + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(default_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 500, + "router should return 500 when coprocessor returns invalid control value" + ); + + request_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/graphql_analysis.rs b/e2e/src/coprocessor/graphql_analysis.rs new file mode 100644 index 000000000..ae3aee67e --- /dev/null +++ b/e2e/src/coprocessor/graphql_analysis.rs @@ -0,0 +1,252 @@ +use sonic_rs::{json, JsonValueTrait}; + +use crate::testkit::{coprocessor::TestCoprocessor, ClientResponseExt, TestRouter, TestSubgraphs}; + +#[ntex::test] +/// This test checks that graphql.analysis treats body as read-only, +/// so returning a body mutation from coprocessor causes the request to fail before subgraph execution. +async fn rejects_body_mutation_from_coprocessor() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "body": { + "query": "{ topProducts { name } }" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + analysis: + include: + body: [query] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + // TODO: hide those errors from the client + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "errors": [ + { + "extensions": { + "code": "COPROCESSOR_FORBIDDEN_STAGE_MUTATION_ERROR" + }, + "message": "Internal server error" + } + ] + } + "#); + + assert!( + !response.status().is_success(), + "router should reject analysis body mutation and return an error response" + ); + + analysis_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that include.headers controls outbound payload only, +/// so graphql.analysis can still mutate headers even when outbound headers are not included. +async fn applies_headers_mutation_when_include_headers_false() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + payload.get("headers").is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "headers": { + "x-coprocessor-analysis": "set-by-analysis" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-coprocessor-analysis + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + analysis: + include: + headers: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + }, + { + "name": "Couch" + }, + { + "name": "Glass" + }, + { + "name": "Chair" + }, + { + "name": "TV" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept analysis headers mutation and return successful response" + ); + + let products_requests = subgraphs.get_requests_log("products").unwrap_or_default(); + assert_eq!( + products_requests.len(), + 1, + "expected one request to products subgraph" + ); + + let propagated_header = products_requests[0] + .headers + .get("x-coprocessor-analysis") + .and_then(|value| value.to_str().ok()); + assert_eq!( + propagated_header, + Some("set-by-analysis"), + "analysis-mutated header should be propagated to subgraph request" + ); + + analysis_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn short_circuit_preserves_headers() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": { "break": 401 }, + "headers": { + "content-type": "application/json", + "x-analysis-error": "unauthorized" + }, + "body": { + "errors": [ + { + "message": "unauthorized from analysis" + } + ] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + analysis: + include: + body: [query] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 401, + "router should return the status code from the coprocessor break control" + ); + + let header = response + .headers() + .get("x-analysis-error") + .and_then(|v| v.to_str().ok()); + assert_eq!( + header, + Some("unauthorized"), + "router should apply headers provided during a break in analysis stage" + ); + + analysis_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/graphql_request.rs b/e2e/src/coprocessor/graphql_request.rs new file mode 100644 index 000000000..a850a52f9 --- /dev/null +++ b/e2e/src/coprocessor/graphql_request.rs @@ -0,0 +1,643 @@ +use sonic_rs::json; +use sonic_rs::JsonValueTrait; + +use crate::testkit::{coprocessor::TestCoprocessor, ClientResponseExt, TestRouter, TestSubgraphs}; + +#[ntex::test] +/// This test checks that graphql.request sends GraphQL inputs inside a nested body object, +/// and that only selected fields are present there. +async fn uses_nested_body_with_selected_fields() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| { + // Only `query` and `extensions` should be present + if payload.pointer(&["body", "extensions"]).is_none() { + return false; + } + if payload.pointer(&["body", "query"]).is_none() { + return false; + } + + // `variables` and `operationName` should be absent + payload.pointer(&["body", "variables"]).is_none() + && payload.pointer(&["body", "operationName"]).is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: [query, extensions] + "# + )) + .build() + .start() + .await; + + let response = router + .serv() + .post(router.graphql_path()) + .content_type("application/json") + .send_json(&json!({ + "query": "{ topProducts(first:1) { name } }", + "extensions": { "persisted": true } + })) + .await + .expect("failed to send graphql request"); + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + assert!( + response.status().is_success(), + "router should return successful response" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn does_not_send_body_when_include_body_false() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| payload.get("body").is_none()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + assert!( + response.status().is_success(), + "router should return successful response" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that graphql.request applies body mutations returned by coprocessor, +/// even when include.body is false on the outbound request payload. +async fn applies_body_mutation_when_include_body_false() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| payload.get("body").is_none()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "body": { + "query": "{ topProducts(first:1) { name } }", + "extensions":{ + "coprocessor": true + } + } + } + ) + .to_string(), + ) + .expect(1) + .create(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + payload + .pointer(&["body", "query"]) + .and_then(|query| query.as_str()) + .and_then(|query| { + let yes_top_products = query.contains("topProducts"); + let yes_name = query.contains("name"); + let no_price = !query.contains("price"); + + (yes_top_products && yes_name && no_price).into() + }) + == Some(true) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: false + analysis: + include: + body: [query, extensions] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name price } }", None, None) + .await; + + // It's up to the coprocessor to return valid response. + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept graphql.request body mutation and return successful response" + ); + + request_stage_mock.assert_async().await; + analysis_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that graphql.request applies body mutations returned by coprocessor, +/// even when `body` is a stringified JSON value, +/// and even when include.body is false on the outbound request payload. +async fn applies_body_as_string_mutation_when_include_body_false() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| payload.get("body").is_none()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "body": json!({ + "query": "{ topProducts(first:1) { name } }", + "extensions":{ + "coprocessor": true + } + }).to_string(), + } + ) + .to_string(), + ) + .expect(1) + .create(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + if payload + .pointer(&["body", "extensions", "coprocessor"]) + .and_then(|val| val.as_bool()) + != Some(true) + { + return false; + } + + payload + .pointer(&["body", "query"]) + .and_then(|query| query.as_str()) + .and_then(|query| { + let yes_top_products = query.contains("topProducts"); + let yes_name = query.contains("name"); + let no_price = !query.contains("price"); + + (yes_top_products && yes_name && no_price).into() + }) + == Some(true) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: false + analysis: + include: + body: [query, extensions] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name price } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept graphql.request body mutation and return successful response" + ); + + request_stage_mock.assert_async().await; + analysis_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that graphql.request accepts missing query when mutating body, +/// so body patches are accepted, and the query stays intact. +async fn accepts_body_mutation_without_query() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!( + { + "version": 1, + "control": "continue", + "body": { + "extensions": { + "coprocessor": true + } + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept graphql.request body mutation without query" + ); + + let products_requests = subgraphs.get_requests_log("products").unwrap_or_default(); + assert!( + !products_requests.is_empty(), + "subgraph request should not run when graphql.request body mutation is invalid" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that graphql.request rejects empty query values in body mutations, +/// so malformed body patches fail before subgraph execution. +async fn rejects_body_mutation_with_empty_query() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!( + { + "version": 1, + "control": "continue", + "body":{ + "query": " ", + "extensions": { + "coprocessor": true + } + } + } + ) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + body: false + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name } }", None, None) + .await; + + // TODO: hide those errors from the client + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "errors": [ + { + "extensions": { + "code": "COPROCESSOR_INVALID_STAGE_BODY_ERROR" + }, + "message": "Internal server error" + } + ] + } + "#); + + assert!( + !response.status().is_success(), + "router should reject graphql.request body mutation with empty query" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that include.headers controls outbound payload only, +/// so graphql.request can still mutate headers even when outbound headers are not included. +async fn applies_headers_mutation_when_include_headers_false() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.request", |payload| { + payload.get("headers").is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "headers": { + "x-coprocessor-stage": "request-mutated" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + let headers = payload.get("headers"); + headers + .and_then(|headers| headers.get("x-coprocessor-stage")) + .and_then(|value| sonic_rs::to_string(value).ok()) + .is_some_and(|value| { + value == "\"request-mutated\"" || value == "[\"request-mutated\"]" + }) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + include: + headers: false + analysis: + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept graphql.request headers mutation and return successful response" + ); + + request_stage_mock.assert_async().await; + analysis_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn condition_check() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + request: + condition: + expression: .request.method == "GET" + include: + body: [query, extensions] + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first:1) { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + } + ] + } + } + "#); + assert!( + response.status().is_success(), + "router should return successful response" + ); +} diff --git a/e2e/src/coprocessor/graphql_response.rs b/e2e/src/coprocessor/graphql_response.rs new file mode 100644 index 000000000..158a0374c --- /dev/null +++ b/e2e/src/coprocessor/graphql_response.rs @@ -0,0 +1,156 @@ +use sonic_rs::json; + +use crate::testkit::coprocessor::TestCoprocessor; +use crate::testkit::{ClientResponseExt, TestRouter, TestSubgraphs}; + +#[ntex::test] +/// This test checks that graphql.response accepts json body +async fn graphql_response_accepts_json_body() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.response") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "body": { + "data": null, + "errors": [{ + "message": "hello from coprocessor" + }] + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + response: + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .serv() + .post(router.graphql_path()) + .content_type("application/json") + .send_json(&json!({ + "query": "{ topProducts { name } }" + })) + .await + .expect("failed to send graphql request"); + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": null, + "errors": [ + { + "message": "hello from coprocessor" + } + ] + } + "#); + assert!( + response.status().is_success(), + "router should return successful response" + ); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +/// This test checks that graphql.response accepts string body +async fn graphql_response_accepts_string_body() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("graphql.response") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "body": json!({ + "data": null, + "errors": [{ + "message": "hello from coprocessor" + }] + }).to_string() + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + response: + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .serv() + .post(router.graphql_path()) + .content_type("application/json") + .send_json(&json!({ + "query": "{ topProducts { name } }" + })) + .await + .expect("failed to send graphql request"); + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": null, + "errors": [ + { + "message": "hello from coprocessor" + } + ] + } + "#); + assert!( + response.status().is_success(), + "router should return successful response" + ); + + request_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/mod.rs b/e2e/src/coprocessor/mod.rs new file mode 100644 index 000000000..66fa014c4 --- /dev/null +++ b/e2e/src/coprocessor/mod.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod context; +#[cfg(test)] +mod failures; +#[cfg(test)] +mod graphql_analysis; +#[cfg(test)] +mod graphql_request; +#[cfg(test)] +mod graphql_response; +#[cfg(test)] +mod router_request; +#[cfg(test)] +mod router_response; +#[cfg(test)] +mod unix_domain_socket; diff --git a/e2e/src/coprocessor/router_request.rs b/e2e/src/coprocessor/router_request.rs new file mode 100644 index 000000000..8d67670ae --- /dev/null +++ b/e2e/src/coprocessor/router_request.rs @@ -0,0 +1,175 @@ +use sonic_rs::json; + +use crate::testkit::{coprocessor::TestCoprocessor, ClientResponseExt, TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn short_circuit() { + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": { + "break": 401 + }, + "headers": { + "x-custom-error": "unauthorized", + "content-type": "application/json" + }, + "body": "{\"error\": \"Unauthorized from coprocessor\"}" + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + router: + request: + condition: + expression: .request.method == "POST" + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert_eq!( + response.status().as_u16(), + 401, + "router should return the status code from the coprocessor break control" + ); + + let header = response + .headers() + .get("x-custom-error") + .and_then(|v| v.to_str().ok()); + assert_eq!( + header, + Some("unauthorized"), + "router should apply headers provided during a break" + ); + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "error": "Unauthorized from coprocessor" + } + "#); + + request_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn mutates_headers() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let request_stage_mock = coprocessor + .mock_stage("router.request") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "headers": { + "x-coprocessor-router": "set-by-router-request", + "content-type": "application/json" + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + headers: + all: + request: + - propagate: + named: x-coprocessor-router + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + router: + request: + condition: + expression: .request.method == "POST" + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table" + }, + { + "name": "Couch" + }, + { + "name": "Glass" + }, + { + "name": "Chair" + }, + { + "name": "TV" + } + ] + } + } + "#); + + assert!( + response.status().is_success(), + "router should accept router request headers mutation and return successful response" + ); + + let products_requests = subgraphs.get_requests_log("products").unwrap_or_default(); + assert_eq!( + products_requests.len(), + 1, + "expected one request to products subgraph" + ); + + request_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/router_response.rs b/e2e/src/coprocessor/router_response.rs new file mode 100644 index 000000000..1e8fff029 --- /dev/null +++ b/e2e/src/coprocessor/router_response.rs @@ -0,0 +1,142 @@ +use sonic_rs::json; +use sonic_rs::JsonValueTrait; + +use crate::testkit::{coprocessor::TestCoprocessor, ClientResponseExt, TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn mutates_headers_and_body() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let response_stage_mock = coprocessor + .mock_stage("router.response") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "headers": { + "x-coprocessor-response": "set-by-router-response", + "content-type": "application/json" + }, + "body": "{\"data\": {\"topProducts\": [{\"name\": \"Intercepted\"}]}}" + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + router: + response: + condition: + expression: .request.method == "POST" + include: + headers: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert!( + response.status().is_success(), + "response status should be success" + ); + + let header = response + .headers() + .get("x-coprocessor-response") + .and_then(|v| v.to_str().ok()); + assert_eq!( + header, + Some("set-by-router-response"), + "router should apply headers provided by router.response stage" + ); + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Intercepted" + } + ] + } + } + "#); + + response_stage_mock.assert_async().await; +} + +#[ntex::test] +async fn includes_graphql_operation_context() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let response_stage_mock = coprocessor + .mock_stage_with_matcher("router.response", |payload| { + payload + .pointer(&["context", "hive::operation::name"]) + .and_then(|value| value.as_str()) + == Some("MyRouterResponseContext") + && payload + .pointer(&["context", "hive::operation::kind"]) + .and_then(|value| value.as_str()) + == Some("query") + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + router: + response: + include: + context: true + "# + )) + .build() + .start() + .await; + + let response = router + .send_graphql_request( + "query MyRouterResponseContext { topProducts { name } }", + None, + None, + ) + .await; + + let _ = response; + response_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/unix_domain_socket.rs b/e2e/src/coprocessor/unix_domain_socket.rs new file mode 100644 index 000000000..fde6ff1cb --- /dev/null +++ b/e2e/src/coprocessor/unix_domain_socket.rs @@ -0,0 +1,172 @@ +use std::path::PathBuf; + +use axum::{routing::post, Json, Router}; +use serde_json::{json, Value}; +use tempfile::Builder; +use tokio::fs::remove_file; +use tokio::net::UnixListener; +use tokio::sync::oneshot; + +use crate::testkit::{TestRouter, TestSubgraphs}; + +#[ntex::test] +async fn works_over_unix_domain_socket() { + run_unix_domain_socket_test("http1").await; +} + +#[ntex::test] +async fn works_over_unix_domain_socket_h2c() { + run_unix_domain_socket_test("h2c").await; +} + +async fn run_unix_domain_socket_test(protocol: &str) { + let subgraphs = TestSubgraphs::builder().build().start().await; + + let socket_dir = Builder::new() + .prefix("hive-coprocessor-") + .tempdir_in("/tmp") + .expect("failed to create temporary socket directory"); + let socket_path_buf = socket_dir.path().join("coprocessor.sock"); + let request_path = "/coprocessor"; + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let server_handle = tokio::spawn(run_unix_coprocessor( + socket_path_buf.clone(), + request_path, + shutdown_rx, + )); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(format!( + r#" + supergraph: + source: file + path: supergraph.graphql + coprocessor: + url: unix://{}?path={request_path} + protocol: {protocol} + stages: + graphql: + analysis: + include: + body: [query] + router: + response: + include: + status_code: true + "#, + socket_path_buf.display() + )) + .build() + .start() + .await; + + let ok_response = router + .send_graphql_request("{ topProducts { name } }", None, None) + .await; + + assert!( + ok_response.status().is_success(), + "expected successful response" + ); + assert_eq!( + ok_response + .headers() + .get("x-coprocessor-response") + .and_then(|v| v.to_str().ok()), + Some("injected-by-router-response") + ); + + let blocked_response = router + .send_graphql_request("{ __schema { queryType { name } } }", None, None) + .await; + + assert_eq!(blocked_response.status().as_u16(), 403); + assert_eq!( + blocked_response + .headers() + .get("x-coprocessor-reason") + .and_then(|v| v.to_str().ok()), + Some("blocked-by-graphql-analysis") + ); + + let _ = shutdown_tx.send(()); + let _ = server_handle.await; +} + +async fn run_unix_coprocessor( + socket_path: PathBuf, + request_path: &'static str, + shutdown_rx: oneshot::Receiver<()>, +) { + if socket_path.exists() { + let _ = remove_file(&socket_path); + } + + let listener = UnixListener::bind(&socket_path).expect("failed to bind unix socket"); + + let app = Router::new().route(request_path, post(coprocessor_handler)); + + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .expect("failed to serve unix coprocessor"); + + let _ = remove_file(&socket_path); +} + +async fn coprocessor_handler(Json(payload): Json) -> Json { + let stage = payload + .get("stage") + .and_then(Value::as_str) + .unwrap_or_default(); + + match stage { + "graphql.analysis" => { + let query = payload + .pointer("/body/query") + .and_then(Value::as_str) + .unwrap_or_default(); + + if query.contains("__schema") { + return Json(json!({ + "version": 1, + "control": { "break": 403 }, + "headers": { + "content-type": "application/json", + "x-coprocessor-reason": "blocked-by-graphql-analysis" + }, + "body": { + "errors": [ + { "message": "Operation rejected by policy" } + ] + } + })); + } + } + "router.response" => { + let status_code = payload + .get("status_code") + .and_then(Value::as_u64) + .unwrap_or_default(); + + if status_code == 200 { + return Json(json!({ + "version": 1, + "control": "continue", + "headers": { + "content-type": "application/json", + "x-coprocessor-response": "injected-by-router-response" + } + })); + } + } + _ => {} + }; + + Json(json!({ "version": 1, "control": "continue" })) +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 2f640afa3..6112d65aa 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -7,6 +7,8 @@ mod body_limit; #[cfg(test)] mod conditional_directives; #[cfg(test)] +mod coprocessor; +#[cfg(test)] mod disable_introspection; #[cfg(test)] mod entity_batching; diff --git a/e2e/src/testkit/coprocessor.rs b/e2e/src/testkit/coprocessor.rs new file mode 100644 index 000000000..50f7f2cc5 --- /dev/null +++ b/e2e/src/testkit/coprocessor.rs @@ -0,0 +1,65 @@ +use mockito::{Mock, ServerGuard}; +use sonic_rs::JsonValueTrait; + +pub struct TestCoprocessor { + pub server: ServerGuard, +} + +impl TestCoprocessor { + pub async fn new() -> Self { + Self { + server: mockito::Server::new_async().await, + } + } + + pub fn host_with_port(&self) -> String { + self.server.host_with_port() + } + + /// Creates a mock that matches a specific coprocessor stage. + pub fn mock_stage(&mut self, stage_name: impl Into) -> Mock { + let stage_name = stage_name.into(); + self.server + .mock("POST", "/coprocessor") + .match_request(move |request| { + let Ok(body) = request.body() else { + return false; + }; + let Ok(payload) = sonic_rs::from_slice::(body) else { + return false; + }; + payload + .get("stage") + .and_then(|value| value.as_str()) + .is_some_and(|stage| stage == stage_name) + }) + } + + /// Creates a mock that matches a specific coprocessor stage and an additional predicate on the parsed JSON body. + pub fn mock_stage_with_matcher( + &mut self, + stage_name: impl Into, + predicate: F, + ) -> Mock + where + F: Fn(&sonic_rs::Value) -> bool + Send + Sync + 'static, + { + let stage_name = stage_name.into(); + self.server + .mock("POST", "/coprocessor") + .match_request(move |request| { + let Ok(body) = request.body() else { + return false; + }; + let Ok(payload) = sonic_rs::from_slice::(body) else { + return false; + }; + if payload.get("stage").and_then(|value| value.as_str()) + != Some(stage_name.as_str()) + { + return false; + } + predicate(&payload) + }) + } +} diff --git a/e2e/src/testkit/mod.rs b/e2e/src/testkit/mod.rs index 381f23e8e..7f7e89340 100644 --- a/e2e/src/testkit/mod.rs +++ b/e2e/src/testkit/mod.rs @@ -1,3 +1,4 @@ +pub mod coprocessor; pub mod otel; use axum_server::{tls_rustls::RustlsConfig, Handle}; @@ -859,7 +860,10 @@ impl TestRouter { async move { web::App::new() .middleware(long_lived_limit) - .middleware(PluginService) + .middleware(PluginService::new( + paths.clone(), + prometheus.as_ref().map(|p| p.endpoint.clone()), + )) .state(shared_state) .state(schema_state) .state(callback_subs) @@ -1032,6 +1036,9 @@ pub trait ClientResponseExt { fn string_body(&self) -> impl Future; fn json_body(&self) -> impl Future; fn json_body_string_pretty(&self) -> impl Future; + /// The difference from [`json_body_string_pretty`] is that this method uses a stable + /// pretty-printer that does not depend on the order of fields in the JSON object. + fn json_body_string_pretty_stable(&self) -> impl Future; } impl ClientResponseExt for ClientResponse { @@ -1051,6 +1058,13 @@ impl ClientResponseExt for ClientResponse { sonic_rs::to_string_pretty(&self.json_body().await) .expect("failed to pretty print JSON body") } + + async fn json_body_string_pretty_stable(&self) -> String { + let body = self.body().await.expect("failed to read request body"); + let stable_json: serde_json::Value = + sonic_rs::from_slice(&body).expect("failed to parse request body to JSON"); + serde_json::to_string_pretty(&stable_json).expect("failed to pretty print canonical JSON") + } } pub async fn wait_until_mock_matched(mock: &Mock) -> Result<(), String> { diff --git a/lib/executor/Cargo.toml b/lib/executor/Cargo.toml index a8134f325..534707c1c 100644 --- a/lib/executor/Cargo.toml +++ b/lib/executor/Cargo.toml @@ -25,6 +25,9 @@ futures = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true, features = ["client"] } +hyperlocal = "0.9.1" +brotli = "8.0.2" +flate2 = { version = "1.1.9", default-features = false, features = ["zlib-rs"] } # Use zlib-rs for performance serde = { workspace = true } sonic-rs = { workspace = true } tracing = { workspace = true } @@ -57,6 +60,7 @@ sonic-simd = "0.1.2" async-stream = "0.3.6" futures-util = "0.3.31" ulid = "1.2.1" +uuid = { version = "1.23.0", features = ["v4", "fast-rng"] } [dev-dependencies] subgraphs = { path = "../../bench/subgraphs" } @@ -69,3 +73,7 @@ rustls = { workspace = true } [[bench]] name = "executor_benches" harness = false + +[[bench]] +name = "coprocessor_benches" +harness = false diff --git a/lib/executor/benches/coprocessor_benches.rs b/lib/executor/benches/coprocessor_benches.rs new file mode 100644 index 000000000..e17e04487 --- /dev/null +++ b/lib/executor/benches/coprocessor_benches.rs @@ -0,0 +1,612 @@ +use bytes::Bytes as HyperBytes; +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; +use hive_router_config::coprocessor::{ + ContextSelection, CoprocessorGraphqlRequestIncludeConfig, + CoprocessorGraphqlResponseIncludeConfig, CoprocessorHookConfig, + CoprocessorRouterRequestIncludeConfig, CoprocessorRouterResponseIncludeConfig, + GraphqlBodySelection, +}; +use hive_router_plan_executor::coprocessor::stage::Stage; +use hive_router_plan_executor::coprocessor::stages::graphql::{ + GraphqlRequestInput, GraphqlRequestStage, GraphqlResponseInput, GraphqlResponseStage, +}; +use hive_router_plan_executor::coprocessor::stages::router::{ + RouterRequestInput, RouterRequestStage, RouterResponseInput, RouterResponseStage, +}; +use hive_router_plan_executor::hooks::on_graphql_params::GraphQLParams; +use hive_router_plan_executor::request_context::SharedRequestContext; +use ntex::http::header::{HeaderName, HeaderValue}; +use ntex::http::StatusCode; +use ntex::util::Bytes as NtexBytes; +use ntex::web::{self, test, DefaultError}; +use std::hint::black_box; +use std::ops::ControlFlow; + +const CONTINUE_RESPONSE_JSON: &[u8] = br#"{"version":1,"control":"continue","method":"POST","path":"/next","headers":{"x-coprocessor":["true"]},"body":"hello"}"#; +const BREAK_RESPONSE_JSON: &[u8] = + br#"{"version":1,"control":{"break":200},"headers":{"content-type":["application/json"]},"body":"{\"ok\":true}"}"#; +const MINIMAL_CONTINUE_RESPONSE_JSON: &[u8] = br#"{"version":1,"control":"continue"}"#; +const MINIMAL_BREAK_RESPONSE_JSON: &[u8] = br#"{"version":1,"control":{"break":200}}"#; +const GRAPHQL_CONTINUE_RESPONSE_JSON: &[u8] = + br#"{"version":1,"control":"continue","headers":{"x-coprocessor":["true"]}}"#; +const GRAPHQL_BREAK_RESPONSE_JSON: &[u8] = br#"{"version":1,"control":{"break":200},"headers":{"content-type":["application/json"]},"body":"{\"errors\":[{\"message\":\"blocked\"}]}"}"#; + +fn run_stage<'a, 'b, S>( + stage: &S, + mut input: S::Input<'a>, + context: &'a SharedRequestContext, + response_bytes: &'b HyperBytes, + id: &'a str, +) where + S: Stage, +{ + let request = ::build_request( + black_box(stage), + black_box(&input), + black_box(id), + black_box(context), + ) + .expect("build_request should succeed"); + black_box(request); + + let parsed = ::parse_response(black_box(stage), black_box(response_bytes)) + .expect("parse_response should succeed"); + + let break_decision = ::break_output(black_box(stage), black_box(parsed)) + .expect("break_output should succeed"); + + match break_decision { + ControlFlow::Continue(parsed) => { + ::apply_mutations(stage, black_box(parsed), black_box(&mut input)) + .expect("apply_mutations should succeed"); + let _ = black_box(input); + } + ControlFlow::Break(response) => { + let _ = black_box(response); + } + } +} + +fn make_web_request( + method: ntex::http::Method, + path: &str, + body: &'static [u8], + header_count: usize, +) -> web::WebRequest { + let mut req = test::TestRequest::default() + .method(method) + .uri(path) + .set_payload(body); + + for i in 0..header_count { + req = req.header(format!("x-bench-{i}"), "value"); + } + + req.to_srv_request() +} + +fn make_web_response( + status_code: ntex::http::StatusCode, + body: &'static str, + header_count: usize, +) -> web::WebResponse { + let req = test::TestRequest::default().to_srv_request(); + let mut builder = web::HttpResponse::build(status_code); + for i in 0..header_count { + let name = HeaderName::from_bytes(format!("x-resp-{i}").as_bytes()).unwrap(); + builder.set_header(name, HeaderValue::from_static("value")); + } + + req.into_response(builder.body(body)) +} + +fn make_http_request( + method: ntex::http::Method, + path: &str, + header_count: usize, +) -> web::HttpRequest { + let mut req = test::TestRequest::default().method(method).uri(path); + for i in 0..header_count { + req = req.header(format!("x-bench-{i}"), "value"); + } + + req.to_http_request() +} + +fn make_graphql_params() -> GraphQLParams { + GraphQLParams { + query: Some("query Bench{me{id}}".to_string()), + operation_name: Some("Bench".to_string()), + variables: Default::default(), + extensions: Some(Default::default()), + } +} + +fn make_graphql_http_response( + status: StatusCode, + body: &'static [u8], + header_count: usize, +) -> web::HttpResponse { + let mut response = web::HttpResponse::build(status); + for i in 0..header_count { + let name = HeaderName::from_bytes(format!("x-graphql-resp-{i}").as_bytes()).unwrap(); + response.set_header(name, HeaderValue::from_static("value")); + } + + response.body(NtexBytes::from_static(body)) +} + +fn bench_router_request_stage(c: &mut Criterion) { + let minimal_stage = RouterRequestStage::from_config(&CoprocessorHookConfig { + condition: None, + include: CoprocessorRouterRequestIncludeConfig::default(), + }) + .expect("router.request stage should compile"); + let full_stage = RouterRequestStage::from_config(&CoprocessorHookConfig { + condition: None, + include: CoprocessorRouterRequestIncludeConfig { + body: true, + context: ContextSelection::all(), + headers: true, + method: true, + path: true, + }, + }) + .expect("router.request stage should compile"); + + let mut group = c.benchmark_group("coprocessor/router.request"); + + group.bench_function("end_to_end/minimal_continue", |b| { + b.iter_batched( + || make_web_request(ntex::http::Method::GET, "/graphql", b"", 8), + |req| { + let input = RouterRequestInput::new(req, None); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(MINIMAL_CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_continue", |b| { + const BODY: &[u8] = b"{\"query\":\"{ me { id } }\"}"; + b.iter_batched( + || make_web_request(ntex::http::Method::POST, "/graphql", BODY, 32), + |req| { + let input = RouterRequestInput::new(req, Some(NtexBytes::from_static(BODY))); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/minimal_break", |b| { + b.iter_batched( + || make_web_request(ntex::http::Method::GET, "/graphql", b"", 8), + |req| { + let input = RouterRequestInput::new(req, None); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(MINIMAL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_break", |b| { + const BODY: &[u8] = b"{\"query\":\"{ me { id } }\"}"; + b.iter_batched( + || make_web_request(ntex::http::Method::POST, "/graphql", BODY, 32), + |req| { + let input = RouterRequestInput::new(req, Some(NtexBytes::from_static(BODY))); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +fn bench_router_response_stage(c: &mut Criterion) { + let minimal_stage = RouterResponseStage::from_config(&CoprocessorHookConfig { + condition: None, + include: Default::default(), + }) + .expect("router.response stage should compile"); + + let full_stage = RouterResponseStage::from_config(&CoprocessorHookConfig { + condition: None, + include: CoprocessorRouterResponseIncludeConfig { + body: true, + context: ContextSelection::all(), + headers: true, + status_code: true, + }, + }) + .expect("router.response stage should compile"); + + let mut group = c.benchmark_group("coprocessor/router.response"); + + group.bench_function("end_to_end/minimal_continue", |b| { + b.iter_batched( + || make_web_response(ntex::http::StatusCode::OK, "", 8), + |response| { + let input = RouterResponseInput::new(response); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(MINIMAL_CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_continue", |b| { + b.iter_batched( + || { + make_web_response( + ntex::http::StatusCode::OK, + "{\"data\":{\"hello\":\"world\"}}", + 32, + ) + }, + |response| { + let input = RouterResponseInput::new(response); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/minimal_break", |b| { + b.iter_batched( + || make_web_response(ntex::http::StatusCode::OK, "", 8), + |response| { + let input = RouterResponseInput::new(response); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(MINIMAL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_break", |b| { + b.iter_batched( + || { + make_web_response( + ntex::http::StatusCode::OK, + "{\"data\":{\"hello\":\"world\"}}", + 32, + ) + }, + |response| { + let input = RouterResponseInput::new(response); + let context = SharedRequestContext::default(); + let response_bytes = HyperBytes::from_static(BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +fn bench_graphql_request_stage(c: &mut Criterion) { + let minimal_stage = GraphqlRequestStage::from_config(&CoprocessorHookConfig { + condition: None, + include: Default::default(), + }) + .expect("graphql.request stage should compile"); + let full_stage = GraphqlRequestStage::from_config(&CoprocessorHookConfig { + condition: None, + include: CoprocessorGraphqlRequestIncludeConfig { + body: GraphqlBodySelection::all(), + context: ContextSelection::all(), + headers: true, + method: true, + path: true, + sdl: false, + }, + }) + .expect("graphql.request stage should compile"); + + let mut group = c.benchmark_group("coprocessor/graphql.request"); + + group.bench_function("end_to_end/minimal_continue", |b| { + b.iter_batched( + || make_http_request(ntex::http::Method::GET, "/graphql", 8), + |request| { + let mut request_headers = request.headers().clone(); + let mut graphql_params = GraphQLParams::default(); + let context = SharedRequestContext::default(); + let input = GraphqlRequestInput::new( + &request, + &mut request_headers, + &mut graphql_params, + None, + ); + + let response_bytes = HyperBytes::from_static(MINIMAL_CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_continue", |b| { + b.iter_batched( + || make_http_request(ntex::http::Method::POST, "/graphql", 32), + |request| { + let mut request_headers = request.headers().clone(); + let mut graphql_params = make_graphql_params(); + let context = SharedRequestContext::default(); + let input = GraphqlRequestInput::new( + &request, + &mut request_headers, + &mut graphql_params, + None, + ); + + let response_bytes = HyperBytes::from_static(GRAPHQL_CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/minimal_break", |b| { + b.iter_batched( + || make_http_request(ntex::http::Method::GET, "/graphql", 8), + |request| { + let mut request_headers = request.headers().clone(); + let mut graphql_params = GraphQLParams::default(); + let context = SharedRequestContext::default(); + let input = GraphqlRequestInput::new( + &request, + &mut request_headers, + &mut graphql_params, + None, + ); + + let response_bytes = HyperBytes::from_static(MINIMAL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_break", |b| { + b.iter_batched( + || make_http_request(ntex::http::Method::POST, "/graphql", 32), + |request| { + let mut request_headers = request.headers().clone(); + let mut graphql_params = make_graphql_params(); + let context = SharedRequestContext::default(); + let input = GraphqlRequestInput::new( + &request, + &mut request_headers, + &mut graphql_params, + None, + ); + + let response_bytes = HyperBytes::from_static(GRAPHQL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +fn bench_graphql_response_stage(c: &mut Criterion) { + let minimal_stage = GraphqlResponseStage::from_config(&CoprocessorHookConfig { + condition: None, + include: Default::default(), + }) + .expect("graphql.response stage should compile"); + let full_stage = GraphqlResponseStage::from_config(&CoprocessorHookConfig { + condition: None, + include: CoprocessorGraphqlResponseIncludeConfig { + body: true, + context: ContextSelection::all(), + headers: true, + sdl: false, + status_code: true, + }, + }) + .expect("graphql.response stage should compile"); + + let mut group = c.benchmark_group("coprocessor/graphql.response"); + + let request = make_http_request(ntex::http::Method::POST, "/graphql", 32); + + group.bench_function("end_to_end/minimal_continue", |b| { + b.iter_batched( + || make_graphql_http_response(StatusCode::OK, b"{\"data\":{}}", 8), + |graphql_response| { + let context = SharedRequestContext::default(); + let input = GraphqlResponseInput::new(graphql_response, &request, None); + + let response_bytes = HyperBytes::from_static(MINIMAL_CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_continue", |b| { + b.iter_batched( + || make_graphql_http_response(StatusCode::OK, b"{\"data\":{\"hello\":\"world\"}}", 32), + |graphql_response| { + let context = SharedRequestContext::default(); + let input = GraphqlResponseInput::new(graphql_response, &request, None); + + let response_bytes = HyperBytes::from_static(CONTINUE_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/minimal_break", |b| { + b.iter_batched( + || make_graphql_http_response(StatusCode::OK, b"{\"data\":{}}", 8), + |graphql_response| { + let context = SharedRequestContext::default(); + let input = GraphqlResponseInput::new(graphql_response, &request, None); + + let response_bytes = HyperBytes::from_static(MINIMAL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&minimal_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.bench_function("end_to_end/full_break", |b| { + b.iter_batched( + || make_graphql_http_response(StatusCode::OK, b"{\"data\":{\"hello\":\"world\"}}", 32), + |graphql_response| { + let context = SharedRequestContext::default(); + let input = GraphqlResponseInput::new(graphql_response, &request, None); + + let response_bytes = HyperBytes::from_static(GRAPHQL_BREAK_RESPONSE_JSON); + let id = "id"; + run_stage( + black_box(&full_stage), + black_box(input), + black_box(&context), + black_box(&response_bytes), + black_box(id), + ); + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +fn all_benchmarks(c: &mut Criterion) { + bench_router_request_stage(c); + bench_router_response_stage(c); + bench_graphql_request_stage(c); + bench_graphql_response_stage(c); +} + +criterion_group!(benches, all_benchmarks); +criterion_main!(benches); diff --git a/lib/executor/src/coprocessor/client.rs b/lib/executor/src/coprocessor/client.rs new file mode 100644 index 000000000..6eee54251 --- /dev/null +++ b/lib/executor/src/coprocessor/client.rs @@ -0,0 +1,329 @@ +use bytes::Bytes; +use hive_router_config::coprocessor::{ + CoprocessorConfig, CoprocessorEndpoint, CoprocessorProtocol, +}; +use hive_router_internal::telemetry::metrics::catalog::values::GraphQLResponseStatus; +use hive_router_internal::telemetry::traces::spans::http_request::HttpClientRequestSpan; +use hive_router_internal::telemetry::TelemetryContext; +use http::{HeaderMap, HeaderValue, Method, Request, Response, Uri}; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_util::client::legacy::{connect::HttpConnector, Client as HyperClient}; +use hyper_util::rt::{TokioExecutor, TokioTimer}; +use hyperlocal::{UnixConnector, Uri as HyperlocalUri}; +use std::io::Read; +use std::sync::Arc; +use std::time::Duration; +use tracing::Instrument; + +use super::error::CoprocessorError; + +type HttpClient = HyperClient>; +type UnixClient = HyperClient>; + +const ACCEPT_ENCODING_VALUE: HeaderValue = HeaderValue::from_static("gzip, br, deflate"); +const CONTENT_TYPE_VALUE: HeaderValue = HeaderValue::from_static("application/json"); + +enum Client { + Http { client: Arc }, + Unix { client: Arc }, +} + +enum Compression { + GZip, + Brotli, + Deflate, + None, +} + +impl Compression { + pub fn decompress(&self, body: Bytes) -> Result { + match self { + Compression::None => Ok(body), + Compression::GZip => Self::gzip(body), + Compression::Brotli => Self::brotli(body), + Compression::Deflate => Self::deflate(body), + } + } + + fn decompress_with( + mut decoder: R, + capacity: usize, + encoding: &'static str, + ) -> Result { + let mut out = Vec::with_capacity(capacity * 4); + decoder.read_to_end(&mut out).map_err(|source| { + CoprocessorError::ResponseDecompressionFailure { encoding, source } + })?; + + Ok(Bytes::from(out)) + } + + fn gzip(body: Bytes) -> Result { + let decoder = flate2::read::GzDecoder::new(body.as_ref()); + Self::decompress_with(decoder, body.len(), "gzip") + } + + fn brotli(body: Bytes) -> Result { + let decoder = brotli::Decompressor::new(body.as_ref(), 4096); + Self::decompress_with(decoder, body.len(), "br") + } + + fn deflate(body: Bytes) -> Result { + let decoder = flate2::read::ZlibDecoder::new(body.as_ref()); + Self::decompress_with(decoder, body.len(), "deflate") + } +} + +impl TryFrom<&HeaderMap> for Compression { + type Error = CoprocessorError; + + fn try_from(headers: &HeaderMap) -> Result { + let Some(content_encoding) = headers.get(http::header::CONTENT_ENCODING) else { + return Ok(Compression::None); + }; + + let content_encoding = content_encoding + .to_str() + .map_err(CoprocessorError::InvalidContentEncodingHeader)? + .trim(); + + if content_encoding.is_empty() || content_encoding.eq_ignore_ascii_case("identity") { + return Ok(Compression::None); + } + + if content_encoding.as_bytes().contains(&b',') { + // We intentionally reject multi encodings. + // Receiving `gzip, deflate` and having to decompress with `deflate` and then `gzip` + // is super weird and very very rare. + return Err(CoprocessorError::UnsupportedStackedContentEncoding( + content_encoding.to_string(), + )); + } + + if content_encoding.eq_ignore_ascii_case("gzip") { + return Ok(Compression::GZip); + } + + if content_encoding.eq_ignore_ascii_case("br") { + return Ok(Compression::Brotli); + } + + if content_encoding.eq_ignore_ascii_case("deflate") { + return Ok(Compression::Deflate); + } + + Err(CoprocessorError::UnsupportedContentEncoding( + content_encoding.to_string(), + )) + } +} + +impl Client { + async fn request( + &self, + request: Request>, + ) -> Result, CoprocessorError> { + let res = match self { + Client::Http { client } => client + .request(request) + .await + .map_err(CoprocessorError::RequestExecutionFailure)?, + Client::Unix { client } => client + .request(request) + .await + .map_err(CoprocessorError::RequestExecutionFailure)?, + }; + + Ok(res) + } +} + +pub struct CoprocessorClient { + client: Client, + endpoint: Uri, + timeout: Duration, + telemetry_context: Arc, +} + +impl CoprocessorClient { + pub fn new( + config: CoprocessorConfig, + telemetry_context: Arc, + ) -> Result { + let timeout = config.timeout; + + if config.protocol == CoprocessorProtocol::Http2 { + return Err(CoprocessorError::UnsupportedProtocol( + CoprocessorProtocol::Http2, + )); + } + + match (&config.url, config.protocol) { + (CoprocessorEndpoint::Http { url }, protocol) => { + let endpoint = url + .parse::() + .map_err(|error| CoprocessorError::EndpointParseFailure(url.clone(), error))?; + + let client = Arc::new(build_http_client(protocol)?); + + Ok(Self { + client: Client::Http { client }, + endpoint, + timeout, + telemetry_context, + }) + } + ( + CoprocessorEndpoint::Unix { + socket_path, + request_path, + }, + protocol, + ) => { + if !request_path.starts_with('/') { + return Err(CoprocessorError::InvalidUnixRequestPath( + request_path.clone(), + )); + } + + let endpoint: Uri = HyperlocalUri::new(socket_path, request_path.as_str()).into(); + let client = Arc::new(build_unix_client(protocol)?); + + Ok(Self { + client: Client::Unix { client }, + endpoint, + timeout, + telemetry_context, + }) + } + } + } + + pub async fn send(&self, body: Bytes) -> Result, CoprocessorError> { + let request_body_size = body.len() as u64; + let mut request = Request::builder() + .method(Method::POST) + .uri(&self.endpoint) + .header(http::header::CONTENT_TYPE, CONTENT_TYPE_VALUE) + .header(http::header::ACCEPT_ENCODING, ACCEPT_ENCODING_VALUE) + .body(Full::new(body)) + .map_err(CoprocessorError::RequestBuildFailure)?; + + let mut request_capture = self.telemetry_context.metrics.http_client.capture_request( + &request, + request_body_size, + None, + ); + let http_request_span = HttpClientRequestSpan::from_request(&request); + + let response = async { + // Forward trace context so coprocessor spans/logs can be correlated. + self.telemetry_context + .inject_context_into_http_headers(request.headers_mut()); + + let start = std::time::Instant::now(); + let response = tokio::time::timeout(self.timeout, self.client.request(request)) + .await + .map_err(|_| CoprocessorError::RequestTimeout { + endpoint: self.endpoint.to_string(), + timeout_ms: self.timeout.as_millis(), + }); + + let response = match response { + Ok(Ok(res)) => res, + Ok(Err(err)) => { + request_capture.finish_error(err.error_code(), start.elapsed()); + return Err(err); + } + Err(err) => { + request_capture.finish_error(err.error_code(), start.elapsed()); + return Err(err); + } + }; + + http_request_span.record_response(&response); + request_capture.set_status_code(response.status().as_u16()); + + if !response.status().is_success() { + let error = CoprocessorError::UnexpectedStatus(response.status()); + request_capture.finish( + 0, + start.elapsed(), + GraphQLResponseStatus::Error, + Some(error.error_code()), + ); + return Err(error); + } + + let (parts, response_body) = response.into_parts(); + let response_body = match response_body.collect().await { + Ok(body) => body.to_bytes(), + Err(err) => { + let error = CoprocessorError::ResponseBodyReadFailure(err); + request_capture.finish_error(error.error_code(), start.elapsed()); + return Err(error); + } + }; + + request_capture.finish( + response_body.len() as u64, + start.elapsed(), + GraphQLResponseStatus::Ok, + None, + ); + + let compression = Compression::try_from(&parts.headers)?; + let decompressed = compression.decompress(response_body)?; + + Ok(Response::from_parts(parts, decompressed)) + } + .instrument(http_request_span.clone()) + .await; + + if response.is_err() { + http_request_span.record_internal_server_error(); + } + + response + } +} + +fn build_http_client(protocol: CoprocessorProtocol) -> Result { + // `http2` config value is reserved for future (https) and explicitly rejected for now. + if protocol == CoprocessorProtocol::Http2 { + return Err(CoprocessorError::UnsupportedProtocol(protocol)); + } + + let mut connector = HttpConnector::new(); + connector.enforce_http(true); + connector.set_keepalive(Some(Duration::from_secs(60))); + + let builder = client_builder(protocol); + + Ok(builder.build(connector)) +} + +fn build_unix_client(protocol: CoprocessorProtocol) -> Result { + // `http2` config value is reserved for future (https) and explicitly rejected for now. + if protocol == CoprocessorProtocol::Http2 { + return Err(CoprocessorError::UnsupportedProtocol(protocol)); + } + + let connector = UnixConnector; + let builder = client_builder(protocol); + + Ok(builder.build(connector)) +} + +fn client_builder(protocol: CoprocessorProtocol) -> hyper_util::client::legacy::Builder { + let mut builder = HyperClient::builder(TokioExecutor::new()); + builder.pool_timer(TokioTimer::new()); + builder.pool_idle_timeout(Duration::from_secs(60)); + + if protocol == CoprocessorProtocol::H2c { + builder.http2_only(true); + } + + builder +} diff --git a/lib/executor/src/coprocessor/error.rs b/lib/executor/src/coprocessor/error.rs new file mode 100644 index 000000000..9213d9ed4 --- /dev/null +++ b/lib/executor/src/coprocessor/error.rs @@ -0,0 +1,141 @@ +use hive_router_config::coprocessor::CoprocessorProtocol; +use hive_router_internal::expressions::ExpressionCompileError; +use http::header::ToStrError; +use http::uri::InvalidUri; +use ntex::http::error::PayloadError; +use strum::IntoStaticStr; + +use crate::request_context::RequestContextError; + +#[derive(thiserror::Error, Debug, IntoStaticStr)] +pub enum CoprocessorError { + #[error("coprocessor protocol '{0:?}' is not supported")] + #[strum(serialize = "COPROCESSOR_UNSUPPORTED_PROTOCOL")] + UnsupportedProtocol(CoprocessorProtocol), + + #[error("coprocessor unix:// request path must start with '/', received '{0}'")] + #[strum(serialize = "COPROCESSOR_INVALID_UNIX_REQUEST_PATH")] + InvalidUnixRequestPath(String), + + #[error("failed to parse coprocessor endpoint URI '{0}': {1}")] + #[strum(serialize = "COPROCESSOR_ENDPOINT_PARSE_FAILURE")] + EndpointParseFailure(String, InvalidUri), + + #[error("failed to build coprocessor request: {0}")] + #[strum(serialize = "COPROCESSOR_REQUEST_BUILD_FAILURE")] + RequestBuildFailure(#[source] http::Error), + + #[error("coprocessor request execution failed: {0}")] + #[strum(serialize = "COPROCESSOR_REQUEST_EXECUTION_FAILURE")] + RequestExecutionFailure(#[source] hyper_util::client::legacy::Error), + + #[error("coprocessor returned non-success status: {0}")] + #[strum(serialize = "COPROCESSOR_UNEXPECTED_STATUS")] + UnexpectedStatus(http::StatusCode), + + #[error("coprocessor request to '{endpoint}' timed out after {timeout_ms}ms")] + #[strum(serialize = "COPROCESSOR_REQUEST_TIMEOUT")] + RequestTimeout { endpoint: String, timeout_ms: u128 }, + + #[error("failed reading coprocessor response body: {0}")] + #[strum(serialize = "COPROCESSOR_RESPONSE_BODY_READ_FAILURE")] + ResponseBodyReadFailure(#[source] hyper::Error), + + #[error("invalid coprocessor content-encoding header: {0}")] + #[strum(serialize = "COPROCESSOR_INVALID_CONTENT_ENCODING_HEADER")] + InvalidContentEncodingHeader(#[source] ToStrError), + + #[error("unsupported stacked content-encoding from coprocessor: '{0}'")] + #[strum(serialize = "COPROCESSOR_UNSUPPORTED_STACKED_CONTENT_ENCODING")] + UnsupportedStackedContentEncoding(String), + + #[error("unsupported content-encoding from coprocessor: '{0}'")] + #[strum(serialize = "COPROCESSOR_UNSUPPORTED_CONTENT_ENCODING")] + UnsupportedContentEncoding(String), + + #[error("failed to decompress coprocessor response using '{encoding}': {source}")] + #[strum(serialize = "COPROCESSOR_RESPONSE_DECOMPRESSION_FAILURE")] + ResponseDecompressionFailure { + encoding: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("failed to compile coprocessor condition expression: {0}")] + #[strum(serialize = "COPROCESSOR_CONDITION_COMPILE_ERROR")] + ConditionCompile(#[from] ExpressionCompileError), + + #[error("failed to evaluate coprocessor condition: {0}")] + #[strum(serialize = "COPROCESSOR_CONDITION_EVALUATION_ERROR")] + ConditionEvaluation(String), + + #[error("failed to read router request body for coprocessor: {0}")] + #[strum(serialize = "COPROCESSOR_REQUEST_BODY_READ_ERROR")] + RequestBodyRead(#[from] PayloadError), + + #[error("invalid UTF-8 body bytes in {context}: {source}")] + #[strum(serialize = "COPROCESSOR_INVALID_UTF8_BODY_ERROR")] + InvalidUtf8Body { + context: &'static str, + #[source] + source: std::str::Utf8Error, + }, + + #[error("failed to deserialize coprocessor response payload: {0}")] + #[strum(serialize = "COPROCESSOR_RESPONSE_DESERIALIZE_ERROR")] + ResponseDeserialize(#[from] sonic_rs::Error), + + #[error("coprocessor returned unsupported version {0}")] + #[strum(serialize = "COPROCESSOR_UNSUPPORTED_VERSION_ERROR")] + UnsupportedVersion(u8), + + #[error("invalid HTTP header name in coprocessor payload: {0}")] + #[strum(serialize = "COPROCESSOR_INVALID_HEADER_NAME_ERROR")] + InvalidHeaderName(String), + + #[error("invalid HTTP header value in coprocessor payload: {0}")] + #[strum(serialize = "COPROCESSOR_INVALID_HEADER_VALUE_ERROR")] + InvalidHeaderValue(String), + + #[error("invalid HTTP method in coprocessor payload: {0}")] + #[strum(serialize = "COPROCESSOR_INVALID_METHOD_ERROR")] + InvalidMethod(String), + + #[error("invalid request path in coprocessor payload: {0}")] + #[strum(serialize = "COPROCESSOR_INVALID_PATH_ERROR")] + InvalidPath(String), + + #[error("coprocessor {stage} stage cannot mutate '{field}'")] + #[strum(serialize = "COPROCESSOR_FORBIDDEN_STAGE_MUTATION_ERROR")] + ForbiddenStageMutation { + stage: &'static str, + field: &'static str, + }, + + #[error("coprocessor {stage} stage cannot mutate context key '{key}'")] + #[strum(serialize = "COPROCESSOR_FORBIDDEN_CONTEXT_MUTATION_ERROR")] + ForbiddenContextMutation { stage: &'static str, key: String }, + + #[error("request context error: {0}")] + #[strum(serialize = "REQUEST_CONTEXT_ERROR")] + RequestContextError(#[from] RequestContextError), + + #[error("invalid body returned by coprocessor {stage} stage, expected {expected}: {reason}")] + #[strum(serialize = "COPROCESSOR_INVALID_STAGE_BODY_ERROR")] + InvalidStageBody { + stage: &'static str, + expected: &'static str, + reason: String, + }, +} + +impl CoprocessorError { + pub fn status_code(&self) -> ntex::http::StatusCode { + // Let's use the same status code for all errors. + // This way we won't leak anything to the client. + ntex::http::StatusCode::INTERNAL_SERVER_ERROR + } + pub fn error_code(&self) -> &'static str { + self.into() + } +} diff --git a/lib/executor/src/coprocessor/mod.rs b/lib/executor/src/coprocessor/mod.rs new file mode 100644 index 000000000..620a3d08b --- /dev/null +++ b/lib/executor/src/coprocessor/mod.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod error; +pub mod protocol; +pub mod runtime; +pub mod stage; +pub mod stages; + +// TODO: Allow adding jwt claims via coprocessor. + +pub use error::CoprocessorError; +pub use runtime::CoprocessorRuntime; diff --git a/lib/executor/src/coprocessor/protocol.rs b/lib/executor/src/coprocessor/protocol.rs new file mode 100644 index 000000000..fc3fbdf90 --- /dev/null +++ b/lib/executor/src/coprocessor/protocol.rs @@ -0,0 +1,124 @@ +use std::fmt; + +use ntex::http::StatusCode; +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub const COPROCESSOR_VERSION: u8 = 1; +const CONTINUE_KEY: &str = "continue"; +const BREAK_KEY: &str = "break"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoprocessorControl { + Continue, + Break(StatusCode), +} + +impl CoprocessorControl { + pub fn break_status(&self) -> Option { + match self { + CoprocessorControl::Break(status_code) => Some(*status_code), + CoprocessorControl::Continue => None, + } + } +} + +impl Serialize for CoprocessorControl { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + CoprocessorControl::Continue => serializer.serialize_str(CONTINUE_KEY), + CoprocessorControl::Break(status_code) => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry(BREAK_KEY, &status_code.as_u16())?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for CoprocessorControl { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(CoprocessorControlVisitor) + } +} + +struct CoprocessorControlVisitor; + +impl CoprocessorControlVisitor { + fn parse_continue(&self, value: &str) -> Result + where + E: de::Error, + { + if value == CONTINUE_KEY { + Ok(CoprocessorControl::Continue) + } else { + Err(E::invalid_value( + de::Unexpected::Str(value), + &"\"continue\"", + )) + } + } +} + +impl<'de> Visitor<'de> for CoprocessorControlVisitor { + type Value = CoprocessorControl; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("\"continue\" or {\"break\": }") + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: de::Error, + { + self.parse_continue(value) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + self.parse_continue(value) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + self.parse_continue(&value) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let Some(key) = map.next_key::<&str>()? else { + return Err(de::Error::custom( + "coprocessor control object cannot be empty", + )); + }; + + if key != BREAK_KEY { + return Err(de::Error::custom("unsupported coprocessor control field")); + } + + let status_code = map.next_value::()?; + let status_code = StatusCode::from_u16(status_code) + .map_err(|error| de::Error::custom(error.to_string()))?; + + if map.next_key::<&str>()?.is_some() { + return Err(de::Error::custom( + "coprocessor control object must contain only one field", + )); + } + + Ok(CoprocessorControl::Break(status_code)) + } +} diff --git a/lib/executor/src/coprocessor/runtime.rs b/lib/executor/src/coprocessor/runtime.rs new file mode 100644 index 000000000..183260632 --- /dev/null +++ b/lib/executor/src/coprocessor/runtime.rs @@ -0,0 +1,425 @@ +use hive_router_config::coprocessor::CoprocessorConfig; +use hive_router_internal::http::read_body_stream; +use hive_router_internal::telemetry::traces::spans::coprocessor::CoprocessorSpan; +use hive_router_internal::telemetry::TelemetryContext; +use http::{Method as HttpMethod, Uri}; +use ntex::http::HeaderMap; +use ntex::web::{self, DefaultError}; +use std::ops::ControlFlow; +use std::sync::Arc; +use tracing::{debug, error, info, Instrument}; + +use crate::coprocessor::client::CoprocessorClient; +use crate::coprocessor::error::CoprocessorError; +use crate::coprocessor::stage::Stage; +use crate::coprocessor::stages::graphql::{ + GraphqlAnalysisInput, GraphqlAnalysisStage, GraphqlRequestInput, GraphqlRequestStage, + GraphqlResponseInput, GraphqlResponseStage, +}; +use crate::coprocessor::stages::router::{ + RouterRequestInput, RouterRequestStage, RouterResponseInput, RouterResponseStage, +}; +use crate::execution::plan::FailedExecutionResult; +use crate::plugins::hooks::on_graphql_params::GraphQLParams; +use crate::request_context::{ + RequestContextError, RequestContextExt, RequestContextPatch, SharedRequestContext, +}; +use crate::response::graphql_error::GraphQLError; + +pub struct CoprocessorRuntime { + router_request: Option>, + router_response: Option>, + graphql_request: Option>, + graphql_analysis: Option>, + graphql_response: Option>, + body_size_limit: usize, +} + +#[derive(Default)] +pub struct PerformedMutations { + pub body: bool, + pub headers: bool, + pub context: bool, +} + +struct StageRuntime { + client: Arc, + stage: S, + telemetry_context: Arc, +} + +pub struct MutableRequestState<'a> { + pub method: &'a HttpMethod, + pub uri: &'a Uri, + pub headers: &'a mut HeaderMap, +} + +impl StageRuntime { + fn new( + client: Arc, + stage: A, + telemetry_context: Arc, + ) -> Self { + Self { + client, + stage, + telemetry_context, + } + } + + async fn execute<'a>( + &self, + input: &mut A::Input<'a>, + shared_context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let result = self.execute_internal(input, shared_context).await; + + if result.is_err() { + let stage_name = self.stage.stage_name(); + let metrics = &self.telemetry_context.metrics.coprocessor; + metrics.record_error(stage_name); + } + + result + } + + async fn execute_internal<'a>( + &self, + input: &mut A::Input<'a>, + shared_context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let mut performed_mutations = PerformedMutations::default(); + + // Skip remote call when condition says stage should not run + if !self.stage.should_run(input)? { + return Ok(ControlFlow::Continue(performed_mutations)); + } + + let stage_name = self.stage.stage_name(); + let metrics = &self.telemetry_context.metrics.coprocessor; + metrics.record_request(stage_name); + + let start = std::time::Instant::now(); + let id = uuid::Uuid::new_v4().to_string(); + let span = CoprocessorSpan::new(stage_name, &id).span; + + async { + // Build stage payload and call the coprocessor + // TODO: Include `request_id` in coprocessor payloads for log correlation + // once Dotan's logging PR is merged. + let request = self.stage.build_request(input, &id, shared_context)?; + debug!( + coprocessor.id = %id, + coprocessor.stage = stage_name, + "Sending coprocessor request" + ); + + let response = self.client.send(request.body).await?; + + metrics.record_duration(stage_name, start.elapsed().as_secs_f64()); + + if !response.status().is_success() { + return Err(CoprocessorError::UnexpectedStatus(response.status())); + } + + // Parse the response + let mut parsed = self.stage.parse_response(response.body())?; + + if parsed.body.is_some() { + performed_mutations.body = true; + } + if parsed.headers.is_some() { + performed_mutations.headers = true; + } + // Handle possible break decision first + match self.stage.break_output(parsed) { + Ok(ControlFlow::Continue(p)) => { + parsed = p; + } + Ok(ControlFlow::Break(response)) => { + info!( + coprocessor.id = %id, + coprocessor.stage = stage_name, + status_code = %response.status(), + "Coprocessor short-circuited the request" + ); + return Ok(ControlFlow::Break(response)); + } + Err(err) => { + return Err(err); + } + } + + if let Some(context_patch_json) = parsed.context.take() { + performed_mutations.context = true; + let context_patch = A::parse_json_body(&context_patch_json)?; + let patch: RequestContextPatch = + sonic_rs::from_str(context_patch.as_ref()).map_err(RequestContextError::Json)?; + let mut context = shared_context.read_lock()?; + context.for_coprocessor().apply_patch(patch)?; + } + + if performed_mutations.body || performed_mutations.headers || performed_mutations.context { + debug!( + coprocessor.id = %id, + coprocessor.stage = stage_name, + body = performed_mutations.body, + headers = performed_mutations.headers, + context = performed_mutations.context, + "Coprocessor mutated the request" + ); + } + + // Apply mutations only when flow continues + self.stage.apply_mutations(parsed, input)?; + + // Continue the pipeline. + Ok(ControlFlow::Continue(performed_mutations)) + } + .instrument(span) + .await + .inspect_err(|err| { + error!(%err, coprocessor.id = %id, coprocessor.stage = stage_name, "Coprocessor failure"); + }) + } +} + +impl CoprocessorRuntime { + pub fn from_config( + config: &CoprocessorConfig, + telemetry_context: Arc, + body_size_limit: usize, + ) -> Result { + let client = Arc::new(CoprocessorClient::new( + config.clone(), + telemetry_context.clone(), + )?); + + let router_request = config + .stages + .router + .request + .as_ref() + .map(RouterRequestStage::from_config) + .transpose()? + .map(|adapter| StageRuntime::new(client.clone(), adapter, telemetry_context.clone())); + + let router_response = config + .stages + .router + .response + .as_ref() + .map(RouterResponseStage::from_config) + .transpose()? + .map(|adapter| StageRuntime::new(client.clone(), adapter, telemetry_context.clone())); + + let graphql_request = config + .stages + .graphql + .request + .as_ref() + .map(GraphqlRequestStage::from_config) + .transpose()? + .map(|adapter| StageRuntime::new(client.clone(), adapter, telemetry_context.clone())); + + let graphql_response = config + .stages + .graphql + .response + .as_ref() + .map(GraphqlResponseStage::from_config) + .transpose()? + .map(|adapter| StageRuntime::new(client.clone(), adapter, telemetry_context.clone())); + + let graphql_analysis = config + .stages + .graphql + .analysis + .as_ref() + .map(GraphqlAnalysisStage::from_config) + .transpose()? + .map(|adapter| StageRuntime::new(client, adapter, telemetry_context)); + + Ok(Self { + router_request, + router_response, + graphql_request, + graphql_analysis, + graphql_response, + body_size_limit, + }) + } + + pub async fn on_router_request( + &self, + mut req: web::WebRequest, + ) -> ControlFlow> { + let Some(stage) = &self.router_request else { + return ControlFlow::Continue(req); + }; + + // We read the request body only when this stage needs to include body + let request_body = if stage.stage.include_body() { + let body_stream = web::types::Payload(req.take_payload()); + let new_body = match read_body_stream(&req, body_stream, self.body_size_limit).await { + Ok(body) => body, + // We deliberately do not map to CoprocessorError here, + // to follow the same logic for status codes + Err(err) => { + error!(%err, "coprocessor {} stage failed", stage.stage.stage_name()); + let response = + build_router_stage_error_response(err.status_code(), err.error_code()); + return ControlFlow::Break(req.into_response(response)); + } + }; + + Some(new_body) + } else { + None + }; + + let shared_context = match req.read_request_context() { + Ok(context) => context, + Err(error) => { + let error = CoprocessorError::from(error); + let response = + build_router_stage_error_response(error.status_code(), error.error_code()); + return ControlFlow::Break(req.into_response(response)); + } + }; + + let mut input = RouterRequestInput::new(req, request_body); + match stage + .execute(&mut input, &shared_context) + .await + .unwrap_or_else(|err| { + error!(%err, coprocessor.stage = stage.stage.stage_name(), "coprocessor stage failed"); + ControlFlow::Break(build_router_stage_error_response( + err.status_code(), + err.error_code(), + )) + }) { + ControlFlow::Continue(_) => { + // On continue, restore the original body only when coprocessor did not replace it + input.restore_request_body_if_unchanged(); + ControlFlow::Continue(input.request) + } + ControlFlow::Break(response) => { + // On break, we return immediately and skip body restoration to avoid unnecessary work + ControlFlow::Break(input.request.into_response(response)) + } + } + } + + pub async fn on_router_response(&self, response: web::WebResponse) -> web::WebResponse { + let Some(stage) = &self.router_response else { + return response; + }; + + let shared_context = match response.request().read_request_context() { + Ok(context) => context, + Err(error) => { + let error = CoprocessorError::from(error); + let fallback = + build_router_stage_error_response(error.status_code(), error.error_code()); + return response.into_response(fallback); + } + }; + + let mut input = RouterResponseInput::new(response); + + match stage + .execute(&mut input, &shared_context) + .await + .unwrap_or_else(|err| error_to_break(stage, err)) + { + ControlFlow::Continue(_) => input.response, + ControlFlow::Break(response) => input.response.into_response(response), + } + } + + pub async fn on_graphql_request( + &self, + request: &web::HttpRequest, + request_headers: &mut HeaderMap, + graphql_request: &mut GraphQLParams, + sdl_fn: impl FnOnce() -> Arc, + ) -> Result, CoprocessorError> { + let Some(stage) = &self.graphql_request else { + return Ok(ControlFlow::Continue(Default::default())); + }; + + let shared_context = request.read_request_context()?; + let sdl = stage.stage.include_sdl().then(sdl_fn); + let mut input = + GraphqlRequestInput::new(request, request_headers, graphql_request, sdl.as_deref()); + stage.execute(&mut input, &shared_context).await + } + + pub async fn on_graphql_analysis( + &self, + request: MutableRequestState<'_>, + graphql_request: &GraphQLParams, + context: &SharedRequestContext, + sdl_fn: impl FnOnce() -> Arc, + ) -> Result, CoprocessorError> { + let Some(stage) = &self.graphql_analysis else { + return Ok(ControlFlow::Continue(Default::default())); + }; + + let sdl = stage.stage.include_sdl().then(sdl_fn); + let mut input = GraphqlAnalysisInput::new(request, graphql_request, sdl.as_deref()); + stage.execute(&mut input, context).await + } + + pub async fn on_graphql_response( + &self, + response: web::HttpResponse, + request: &web::HttpRequest, + // We return `Option` instead of `T`, because of error handling of the caller. + // If I would return `T`, the caller would have to be wrapped with duplicated + // error handling logic. + // With `Option`, the coprocessor runtime handles the case where sdl is not available. + sdl_fn: impl FnOnce() -> Option>, + ) -> Result, CoprocessorError> { + let Some(stage) = &self.graphql_response else { + return Ok(ControlFlow::Continue(response)); + }; + + let sdl = stage.stage.include_sdl().then(sdl_fn).flatten(); + let shared_context = request.read_request_context()?; + let mut input = GraphqlResponseInput::new(response, request, sdl.as_deref()); + Ok(stage + .execute(&mut input, &shared_context) + .await? + .map_continue(|_| input.response)) + } +} + +fn error_to_break( + stage: &StageRuntime, + err: CoprocessorError, +) -> ControlFlow { + // Stage error metrics and specific logs are already recorded in execute() where possible, + // but keeping a fallback log here correctly reports the failure that causes short-circuit + // in case it's not caught earlier, and always breaks the request. + error!(%err, coprocessor.stage = stage.stage.stage_name(), "coprocessor stage failed"); + ControlFlow::Break(web::HttpResponse::new(err.status_code())) +} + +fn build_router_stage_error_response( + status: http::StatusCode, + code: &'static str, +) -> web::HttpResponse { + let body = FailedExecutionResult { + errors: vec![GraphQLError::from_message_and_code( + "Internal server error", + code, + )], + } + .serialize(); + + web::HttpResponse::build(status) + .header(http::header::CONTENT_TYPE, "application/json") + .body(body) +} diff --git a/lib/executor/src/coprocessor/stage.rs b/lib/executor/src/coprocessor/stage.rs new file mode 100644 index 000000000..4cd1ec912 --- /dev/null +++ b/lib/executor/src/coprocessor/stage.rs @@ -0,0 +1,319 @@ +use bytes::Bytes; +use hive_router_config::headers::HOP_BY_HOP_HEADERS; +use hive_router_config::primitives::value_or_expression::ValueOrExpression; +use hive_router_internal::expressions::values::boolean::BooleanOrProgram; +use hive_router_internal::expressions::vrl::core::Value as VrlValue; +use hive_router_internal::expressions::{CompileExpression, ProgramHints, ValueOrProgram}; +use ntex::http::header::{HeaderName, HeaderValue}; +use ntex::http::HeaderMap; +use ntex::util::Bytes as NtexBytes; +use ntex::web; +use serde::Deserialize; +use serde::{ser::SerializeMap, Serialize, Serializer}; +use sonic_rs::LazyValue; +use std::borrow::Cow; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::ops::ControlFlow; +use std::str::FromStr; + +use crate::coprocessor::error::CoprocessorError; +use crate::coprocessor::protocol::{CoprocessorControl, COPROCESSOR_VERSION}; +use crate::request_context::SharedRequestContext; + +/// Outbound HTTP request sent to coprocessor service. +pub struct CoprocessorRequest<'a> { + pub id: &'a str, + pub body: Bytes, +} + +pub trait Stage { + /// Input data for the stage. + type Input<'a>; + + const STAGE_NAME: &'static str; + + fn stage_name(&self) -> &'static str { + Self::STAGE_NAME + } + + /// We accept both stringified JSON (wrapped in quotes) and raw JSON + fn parse_json_body<'a>(body: &'a LazyValue<'a>) -> Result, CoprocessorError> { + let raw = body.as_raw_str(); + if raw.as_bytes().first() == Some(&b'"') { + // JSON string token -> decode/unescape and use decoded text + Ok(sonic_rs::from_str(raw)?) + } else { + // Non-string JSON token (object/array/number/bool/null) -> use raw JSON text + Ok(Cow::Borrowed(raw)) + } + } + + fn should_run(&self, input: &Self::Input<'_>) -> Result; + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError>; + + fn parse_response<'a>( + &self, + body: &'a Bytes, + ) -> Result, CoprocessorError> { + let payload: StageResponsePayload<'a> = sonic_rs::from_slice(body)?; + // Version check guarantees both sides speak the same protocol contract. + if payload.version != COPROCESSOR_VERSION { + return Err(CoprocessorError::UnsupportedVersion(payload.version)); + } + + Ok(payload) + } + + fn break_output<'b>( + &self, + parsed: StageResponsePayload<'b>, + ) -> Result>, CoprocessorError> { + // No break control means normal flow continues and mutations can be applied. + let Some(status) = parsed.control.break_status() else { + return Ok(ControlFlow::Continue(parsed)); + }; + + let mut response = web::HttpResponse::Ok(); + response.status(status); + + if let Some(headers) = parsed.headers.as_ref() { + headers.apply_to_response_builder(&mut response)?; + } + + let response = if let Some(body) = parsed.body { + let body = Self::parse_json_body(&body)?; + response.body(CoprocessorResponseBody::from(body).into_ntex_bytes()) + } else { + response.finish() + }; + + // Break short-circuits the pipeline with an immediate HTTP response. + Ok(ControlFlow::Break(response)) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError>; +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum OneOrMore<'a> { + One(#[serde(borrow)] Cow<'a, str>), + More(#[serde(borrow)] Vec>), +} + +#[derive(Deserialize)] +#[serde(transparent, bound(deserialize = "'de: 'a"))] +pub(crate) struct StageResponseHeaders<'a>(#[serde(borrow)] HashMap, OneOrMore<'a>>); + +impl<'a> StageResponseHeaders<'a> { + pub(crate) fn replace_into(&self, headers_mut: &mut HeaderMap) -> Result<(), CoprocessorError> { + // Replace all the headers except for hop-by-hop headers + let keys_to_remove: Vec<_> = headers_mut + .keys() + .filter(|name| !HOP_BY_HOP_HEADERS.contains(&name.as_str())) + .cloned() + .collect(); + + for key in keys_to_remove { + headers_mut.remove(&key); + } + + self.try_for_each_parsed(|name, value| { + headers_mut.append(name.clone(), value); + }) + } + + pub(crate) fn apply_to_response_builder( + &self, + response: &mut web::HttpResponseBuilder, + ) -> Result<(), CoprocessorError> { + self.try_for_each_parsed(|name, value| { + response.set_header(name.clone(), value); + }) + } + + fn try_for_each_parsed(&self, mut f: F) -> Result<(), CoprocessorError> + where + F: FnMut(&HeaderName, HeaderValue), + { + for (name, values) in &self.0 { + let header_name = HeaderName::from_str(name.as_ref()) + .map_err(|error| CoprocessorError::InvalidHeaderName(error.to_string()))?; + + if HOP_BY_HOP_HEADERS.contains(&header_name.as_str()) { + continue; + } + + match values { + OneOrMore::One(value) => { + let header_value = parse_header_value(value)?; + f(&header_name, header_value); + } + OneOrMore::More(values) => { + for value in values { + let header_value = parse_header_value(value)?; + f(&header_name, header_value); + } + } + } + } + + Ok(()) + } +} + +#[derive(Deserialize)] +pub struct StageResponsePayload<'a> { + /// Coprocessor protocol version. Must match router runtime version. + pub(crate) version: u8, + /// Continue/break decision for stage execution. + pub(crate) control: CoprocessorControl, + #[serde(borrow)] + pub(crate) headers: Option>, + #[serde(borrow)] + pub(crate) body: Option>, + #[serde(borrow)] + pub(crate) context: Option>, +} + +pub fn compile_condition( + condition: Option<&ValueOrExpression>, +) -> Result, CoprocessorError> { + match condition { + Some(ValueOrExpression::Value(value)) => Ok(Some(ValueOrProgram::Value(*value))), + Some(ValueOrExpression::Expression { expression }) => { + let program = expression.compile_expression(None)?; + let hints = ProgramHints::from_program(&program); + Ok(Some(ValueOrProgram::Program(Box::new(program), hints))) + } + None => Ok(None), + } +} + +pub fn evaluate_condition( + condition: Option<&BooleanOrProgram>, + vrl_context_fn: F, +) -> Result +where + F: FnOnce(&ProgramHints) -> VrlValue, +{ + let Some(condition) = condition else { + return Ok(true); + }; + + condition + .resolve_with_hints(vrl_context_fn) + .map_err(|error| CoprocessorError::ConditionEvaluation(error.to_string())) +} + +fn parse_header_value(value: &str) -> Result { + HeaderValue::from_str(value) + .map_err(|error| CoprocessorError::InvalidHeaderValue(error.to_string())) +} + +/// Borrowed raw bytes used to build coprocessor request payload bodies. +/// Bytes must be valid UTF-8 before being written into JSON stage payloads. +pub struct CoprocessorRequestBody<'a>(&'a [u8]); + +impl<'a> CoprocessorRequestBody<'a> { + /// Converts raw bytes into UTF-8 text for JSON payload fields. + pub fn try_to_utf8(self, context: &'static str) -> Result<&'a str, CoprocessorError> { + std::str::from_utf8(self.0) + .map_err(|source| CoprocessorError::InvalidUtf8Body { context, source }) + } +} + +impl<'a> From<&'a [u8]> for CoprocessorRequestBody<'a> { + fn from(value: &'a [u8]) -> Self { + Self(value) + } +} + +impl<'a> From<&'a NtexBytes> for CoprocessorRequestBody<'a> { + fn from(value: &'a NtexBytes) -> Self { + Self(value.as_ref()) + } +} + +/// Text body received from coprocessor stage responses. +pub struct CoprocessorResponseBody<'a>(Cow<'a, str>); + +impl<'a> CoprocessorResponseBody<'a> { + /// Converts stage text into `ntex::Bytes` for http server APIs. + pub fn into_ntex_bytes(self) -> NtexBytes { + match self.0 { + Cow::Borrowed(value) => NtexBytes::copy_from_slice(value.as_bytes()), + Cow::Owned(value) => NtexBytes::from(value), + } + } +} + +impl<'a> From> for CoprocessorResponseBody<'a> { + fn from(value: Cow<'a, str>) -> Self { + Self(value) + } +} + +/// Borrowed view used to serialize ntex headers as protocol JSON. +pub struct HeaderMapJsonRef<'a>(pub &'a HeaderMap); + +impl Serialize for HeaderMapJsonRef<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Protocol requires header values as arrays: {"name": ["value1", ...]} + enum HeaderValues<'a> { + One(&'a str), + Many(Vec<&'a str>), + } + + // Group duplicate header names so multi-value headers are serialized once. + let mut grouped_headers: HashMap<&str, HeaderValues<'_>> = + HashMap::with_capacity(self.0.len()); + for (name, value) in self.0.iter() { + let Ok(value) = value.to_str() else { + continue; + }; + + match grouped_headers.entry(name.as_str()) { + Entry::Vacant(entry) => { + entry.insert(HeaderValues::One(value)); + } + Entry::Occupied(mut entry) => match entry.get_mut() { + HeaderValues::One(previous) => { + let previous = *previous; + entry.insert(HeaderValues::Many(vec![previous, value])); + } + HeaderValues::Many(values) => { + values.push(value); + } + }, + } + } + + let mut map = serializer.serialize_map(Some(grouped_headers.len()))?; + for (name, values) in grouped_headers { + match values { + HeaderValues::One(value) => { + map.serialize_entry(name, &[value])?; + } + HeaderValues::Many(values) => { + map.serialize_entry(name, &values)?; + } + } + } + map.end() + } +} diff --git a/lib/executor/src/coprocessor/stages/graphql.rs b/lib/executor/src/coprocessor/stages/graphql.rs new file mode 100644 index 000000000..c66f0c47d --- /dev/null +++ b/lib/executor/src/coprocessor/stages/graphql.rs @@ -0,0 +1,545 @@ +use std::borrow::Cow; +use std::collections::HashMap; + +use hive_router_config::coprocessor::{ + CoprocessorGraphqlAnalysisIncludeConfig, CoprocessorGraphqlRequestIncludeConfig, + CoprocessorGraphqlResponseIncludeConfig, CoprocessorHookConfig, +}; +use hive_router_internal::expressions::lib::ToVrlValue; +use hive_router_internal::expressions::values::boolean::BooleanOrProgram; +use ntex::http::body::{Body, ResponseBody}; +use ntex::http::HeaderMap; +use ntex::web; + +use crate::coprocessor::error::CoprocessorError; +use crate::coprocessor::protocol::COPROCESSOR_VERSION; +use crate::coprocessor::runtime::MutableRequestState; +use crate::coprocessor::stage::{ + compile_condition, evaluate_condition, CoprocessorRequest, CoprocessorRequestBody, + CoprocessorResponseBody, HeaderMapJsonRef, Stage, StageResponsePayload, +}; +use crate::plugins::hooks::on_graphql_params::GraphQLParams; +use crate::request_context::{SelectedRequestContext, SharedRequestContext}; + +pub struct GraphqlRequestStage { + condition: Option, + include: CoprocessorGraphqlRequestIncludeConfig, +} + +pub struct GraphqlResponseStage { + condition: Option, + include: CoprocessorGraphqlResponseIncludeConfig, +} + +pub struct GraphqlAnalysisStage { + condition: Option, + include: CoprocessorGraphqlAnalysisIncludeConfig, +} + +pub struct GraphqlRequestInput<'a> { + request: &'a web::HttpRequest, + request_headers: &'a mut HeaderMap, + graphql_params: &'a mut GraphQLParams, + sdl: Option<&'a str>, +} + +pub struct GraphqlResponseInput<'a> { + pub(crate) response: web::HttpResponse, + request: &'a web::HttpRequest, + sdl: Option<&'a str>, +} + +pub struct GraphqlAnalysisInput<'a> { + request: MutableRequestState<'a>, + graphql_params: &'a GraphQLParams, + sdl: Option<&'a str>, +} + +impl GraphqlRequestStage { + pub fn from_config( + config: &CoprocessorHookConfig, + ) -> Result { + Ok(Self { + condition: compile_condition(config.condition.as_ref())?, + include: config.include.clone(), + }) + } + + pub fn include_sdl(&self) -> bool { + self.include.sdl + } +} + +impl GraphqlResponseStage { + pub fn from_config( + config: &CoprocessorHookConfig, + ) -> Result { + Ok(Self { + condition: compile_condition(config.condition.as_ref())?, + include: config.include.clone(), + }) + } + + pub fn include_sdl(&self) -> bool { + self.include.sdl + } +} + +impl GraphqlAnalysisStage { + pub fn from_config( + config: &CoprocessorHookConfig, + ) -> Result { + Ok(Self { + condition: compile_condition(config.condition.as_ref())?, + include: config.include.clone(), + }) + } + + pub fn include_sdl(&self) -> bool { + self.include.sdl + } +} + +impl<'a> GraphqlRequestInput<'a> { + pub fn new( + request: &'a web::HttpRequest, + request_headers: &'a mut HeaderMap, + graphql_params: &'a mut GraphQLParams, + sdl: Option<&'a str>, + ) -> Self { + Self { + request, + request_headers, + graphql_params, + sdl, + } + } +} + +impl<'a> GraphqlAnalysisInput<'a> { + pub fn new( + request: MutableRequestState<'a>, + graphql_params: &'a GraphQLParams, + sdl: Option<&'a str>, + ) -> Self { + Self { + request, + graphql_params, + sdl, + } + } +} + +impl<'a> GraphqlResponseInput<'a> { + pub fn new( + response: web::HttpResponse, + request: &'a web::HttpRequest, + sdl: Option<&'a str>, + ) -> Self { + Self { + response, + request, + sdl, + } + } +} + +impl Stage for GraphqlRequestStage { + type Input<'a> = GraphqlRequestInput<'a>; + + const STAGE_NAME: &'static str = "graphql.request"; + + fn should_run(&self, input: &Self::Input<'_>) -> Result { + evaluate_condition(self.condition.as_ref(), |hints| { + hints.context_builder(|root| { + root.insert_object("request", |req| { + req.insert_lazy("method", || input.request.method().as_str().into()) + .insert_lazy("headers", || input.request.headers().to_vrl_value()) + .insert_lazy("url", || input.request.uri().to_vrl_value()) + .insert_object("operation", |op| { + op.insert_lazy("name", || { + input.graphql_params.operation_name.clone().into() + }) + .insert_lazy("query", || input.graphql_params.query.clone().into()); + }); + }); + }) + }) + } + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let context_snapshot = if self.include.context.is_none() { + None + } else { + Some(context.snapshot()?) + }; + + let payload = GraphqlRequestPayload { + version: COPROCESSOR_VERSION, + stage: Self::STAGE_NAME, + id, + method: self + .include + .method + .then(|| input.request.method().as_str().into()), + path: self.include.path.then(|| input.request.path().into()), + headers: self + .include + .headers + .then_some(HeaderMapJsonRef(input.request_headers)), + body: build_graphql_body_payload_ref(self.include.body, input.graphql_params), + context: context_snapshot + .as_ref() + .map(|ctx| ctx.as_selected(&self.include.context)), + sdl: self.include.sdl.then_some(input.sdl).flatten(), + }; + + Ok(CoprocessorRequest { + id, + body: sonic_rs::to_vec(&payload)?.into(), + }) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError> { + // GraphQL request stage mutates headers and body. Method/path stay read-only. + if let Some(headers) = parsed.headers { + headers.replace_into(input.request_headers)?; + } + + if let Some(body) = parsed.body { + let fields: GraphqlBodyPayload = sonic_rs::from_str(&Self::parse_json_body(&body)?)?; + fields.apply_to(input.graphql_params, Self::STAGE_NAME)?; + return Ok(()); + } + + Ok(()) + } +} + +impl Stage for GraphqlAnalysisStage { + type Input<'a> = GraphqlAnalysisInput<'a>; + + const STAGE_NAME: &'static str = "graphql.analysis"; + + fn should_run(&self, input: &Self::Input<'_>) -> Result { + evaluate_condition(self.condition.as_ref(), |hints| { + hints.context_builder(|root| { + root.insert_object("request", |req| { + req.insert_lazy("method", || input.request.method.as_str().into()) + .insert_lazy("headers", || input.request.headers.to_vrl_value()) + .insert_lazy("url", || input.request.uri.to_vrl_value()) + .insert_object("operation", |op| { + op.insert_lazy("name", || { + input.graphql_params.operation_name.clone().into() + }) + .insert_lazy("query", || input.graphql_params.query.clone().into()); + }); + }); + }) + }) + } + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let context_snapshot = if self.include.context.is_none() { + None + } else { + Some(context.snapshot()?) + }; + let payload = GraphqlAnalysisPayload { + version: COPROCESSOR_VERSION, + stage: Self::STAGE_NAME, + id, + method: self + .include + .method + .then(|| input.request.method.as_str().into()), + path: self.include.path.then(|| input.request.uri.path().into()), + headers: self + .include + .headers + .then_some(HeaderMapJsonRef(input.request.headers)), + body: build_graphql_body_payload_ref(self.include.body, input.graphql_params), + context: context_snapshot + .as_ref() + .map(|ctx| ctx.as_selected(&self.include.context)), + sdl: self.include.sdl.then_some(input.sdl).flatten(), + }; + + Ok(CoprocessorRequest { + id, + body: sonic_rs::to_vec(&payload)?.into(), + }) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError> { + if let Some(headers) = parsed.headers { + headers.replace_into(input.request.headers)?; + } + + if parsed.body.is_some() { + return Err(CoprocessorError::ForbiddenStageMutation { + stage: Self::STAGE_NAME, + field: "body", + }); + } + + Ok(()) + } +} + +impl Stage for GraphqlResponseStage { + type Input<'a> = GraphqlResponseInput<'a>; + + const STAGE_NAME: &'static str = "graphql.response"; + + fn should_run(&self, input: &Self::Input<'_>) -> Result { + evaluate_condition(self.condition.as_ref(), |hints| { + hints.context_builder(|root| { + root.insert_object("request", |req| { + req.insert_lazy("method", || input.request.method().as_str().into()) + .insert_lazy("headers", || input.request.headers().to_vrl_value()) + .insert_lazy("url", || input.request.uri().to_vrl_value()); + }); + root.insert_object("response", |res| { + res.insert_lazy("headers", || input.response.headers().to_vrl_value()) + .insert_lazy("status_code", || input.response.status().as_u16().into()); + }); + }) + }) + } + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let context_snapshot = if self.include.context.is_none() { + None + } else { + Some(context.snapshot()?) + }; + let body = if self.include.body { + match input.response.body() { + ResponseBody::Body(Body::Bytes(bytes)) => Some( + CoprocessorRequestBody::from(bytes) + .try_to_utf8(Self::STAGE_NAME)? + .into(), + ), + _ => None, + } + } else { + None + }; + + let payload = GraphqlResponsePayload { + version: COPROCESSOR_VERSION, + stage: Self::STAGE_NAME, + id, + headers: self + .include + .headers + .then(|| HeaderMapJsonRef(input.response.headers())), + body, + context: context_snapshot + .as_ref() + .map(|ctx| ctx.as_selected(&self.include.context)), + status_code: self + .include + .status_code + .then_some(input.response.status().as_u16()), + sdl: self.include.sdl.then_some(input.sdl).flatten(), + }; + + Ok(CoprocessorRequest { + id, + body: sonic_rs::to_vec(&payload)?.into(), + }) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError> { + // GraphQL response stage mutates headers and body only. The status property is controlled by break. + if let Some(headers) = parsed.headers { + headers.replace_into(input.response.headers_mut())?; + } + + if let Some(body) = parsed.body { + let body = Self::parse_json_body(&body)?; + let new_body = ntex::http::body::Body::Bytes( + CoprocessorResponseBody::from(body).into_ntex_bytes(), + ); + input.response = std::mem::replace( + &mut input.response, + web::HttpResponse::new(ntex::http::StatusCode::OK), + ) + .set_body(new_body); + } + + Ok(()) + } +} + +#[derive(serde::Serialize)] +struct GraphqlRequestPayload<'a> { + version: u8, + stage: &'static str, + id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + method: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + sdl: Option<&'a str>, +} + +#[derive(serde::Serialize)] +struct GraphqlResponsePayload<'a> { + version: u8, + stage: &'static str, + id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sdl: Option<&'a str>, +} + +#[derive(serde::Serialize)] +struct GraphqlAnalysisPayload<'a> { + version: u8, + stage: &'static str, + id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + method: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + sdl: Option<&'a str>, +} + +impl GraphqlBodyPayload { + fn apply_to( + self, + target: &mut GraphQLParams, + stage_name: &'static str, + ) -> Result<(), CoprocessorError> { + if self + .query + .as_ref() + .is_some_and(|query| query.trim().is_empty()) + { + return Err(CoprocessorError::InvalidStageBody { + stage: stage_name, + expected: "'query' must be a non-empty string", + reason: "query is empty".to_string(), + }); + } + + if let Some(query) = self.query { + target.query = Some(query); + } + + if let Some(operation_name) = self.operation_name { + target.operation_name = Some(operation_name); + } + + if let Some(variables) = self.variables { + target.variables = variables; + } + + if let Some(extensions) = self.extensions { + target.extensions = Some(extensions); + } + + Ok(()) + } +} + +#[derive(serde::Serialize)] +struct GraphqlBodyPayloadRef<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + query: Option<&'a str>, + #[serde(rename = "operationName", skip_serializing_if = "Option::is_none")] + operation_name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + variables: Option<&'a HashMap>, + #[serde(skip_serializing_if = "Option::is_none")] + extensions: Option<&'a HashMap>, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct GraphqlBodyPayload { + #[serde(default)] + query: Option, + #[serde(rename = "operationName", default)] + operation_name: Option, + #[serde(default)] + variables: Option>, + #[serde(default)] + extensions: Option>, +} + +fn build_graphql_body_payload_ref<'a>( + selection: hive_router_config::coprocessor::GraphqlBodySelection, + graphql_params: &'a GraphQLParams, +) -> Option> { + if selection.is_empty() { + return None; + } + + Some(GraphqlBodyPayloadRef { + query: selection + .query + .then_some(graphql_params.query.as_deref()) + .flatten(), + operation_name: selection + .operation_name + .then_some(graphql_params.operation_name.as_deref()) + .flatten(), + variables: selection.variables.then_some(&graphql_params.variables), + extensions: selection + .extensions + .then_some(graphql_params.extensions.as_ref()) + .flatten(), + }) +} diff --git a/lib/executor/src/coprocessor/stages/mod.rs b/lib/executor/src/coprocessor/stages/mod.rs new file mode 100644 index 000000000..50a1ff8f4 --- /dev/null +++ b/lib/executor/src/coprocessor/stages/mod.rs @@ -0,0 +1,2 @@ +pub mod graphql; +pub mod router; diff --git a/lib/executor/src/coprocessor/stages/router.rs b/lib/executor/src/coprocessor/stages/router.rs new file mode 100644 index 000000000..3d31b54a9 --- /dev/null +++ b/lib/executor/src/coprocessor/stages/router.rs @@ -0,0 +1,329 @@ +use std::borrow::Cow; + +use futures::stream; +use hive_router_config::coprocessor::{ + CoprocessorHookConfig, CoprocessorRouterRequestIncludeConfig, + CoprocessorRouterResponseIncludeConfig, +}; +use hive_router_internal::expressions::{lib::ToVrlValue, values::boolean::BooleanOrProgram}; +use ntex::http::{ + body::{Body, ResponseBody}, + error::PayloadError, + Payload, Response, StatusCode, +}; +use ntex::util::Bytes as NtexBytes; +use ntex::web::{self, DefaultError}; + +use crate::coprocessor::protocol::COPROCESSOR_VERSION; +use crate::coprocessor::stage::{ + compile_condition, evaluate_condition, CoprocessorRequest, CoprocessorRequestBody, + CoprocessorResponseBody, HeaderMapJsonRef, Stage, StageResponsePayload, +}; +use crate::request_context::SelectedRequestContext; +use crate::{coprocessor::error::CoprocessorError, request_context::SharedRequestContext}; + +pub struct RouterRequestStage { + condition: Option, + include: CoprocessorRouterRequestIncludeConfig, +} + +pub struct RouterResponseStage { + condition: Option, + include: CoprocessorRouterResponseIncludeConfig, +} + +pub struct RouterRequestInput { + pub(crate) request: web::WebRequest, + request_body: Option, + /// This tells runtime whether it should restore original body bytes after stage execution + body_replaced: bool, +} + +pub struct RouterResponseInput { + pub(crate) response: web::WebResponse, +} + +impl RouterRequestStage { + pub fn from_config( + config: &CoprocessorHookConfig, + ) -> Result { + Ok(Self { + condition: compile_condition(config.condition.as_ref())?, + include: config.include.clone(), + }) + } + + pub fn include_body(&self) -> bool { + self.include.body + } +} + +impl RouterResponseStage { + pub fn from_config( + config: &CoprocessorHookConfig, + ) -> Result { + Ok(Self { + condition: compile_condition(config.condition.as_ref())?, + include: config.include.clone(), + }) + } +} + +impl RouterRequestInput { + pub fn new(request: web::WebRequest, request_body: Option) -> Self { + Self { + request, + request_body, + body_replaced: false, + } + } + + pub(crate) fn restore_request_body_if_unchanged(&mut self) { + // If coprocessor replaced body, restoring old bytes would be wasted and wrong + if self.body_replaced { + return; + } + + if let Some(body) = self.request_body.as_ref() { + // We restore payload from saved bytes so downstream handlers can still read the body. + // TODO: avoid rebuilding payload stream when we can preserve original payload state. + self.request.set_payload(payload_from_bytes(body.clone())); + } + } +} + +impl RouterResponseInput { + pub fn new(response: web::WebResponse) -> Self { + Self { response } + } +} + +impl Stage for RouterRequestStage { + type Input<'a> = RouterRequestInput; + + const STAGE_NAME: &'static str = "router.request"; + + fn should_run(&self, input: &Self::Input<'_>) -> Result { + evaluate_condition(self.condition.as_ref(), |hints| { + hints.context_builder(|root| { + root.insert_object("request", |req| { + req.insert_lazy("method", || input.request.method().as_str().into()) + .insert_lazy("headers", || input.request.headers().to_vrl_value()) + .insert_lazy("url", || input.request.uri().to_vrl_value()); + }); + }) + }) + } + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let context_snapshot = if self.include.context.is_none() { + None + } else { + Some(context.snapshot()?) + }; + let body = if self.include.body { + Some( + CoprocessorRequestBody::from(input.request_body.as_deref().unwrap_or_default()) + .try_to_utf8(Self::STAGE_NAME)? + .into(), + ) + } else { + None + }; + + let payload = RouterRequestPayload { + version: COPROCESSOR_VERSION, + stage: Self::STAGE_NAME, + id, + method: self + .include + .method + .then(|| input.request.method().as_str().into()), + path: self.include.path.then(|| input.request.path().into()), + headers: self + .include + .headers + .then(|| HeaderMapJsonRef(input.request.headers())), + body, + context: context_snapshot + .as_ref() + .map(|ctx| ctx.as_selected(&self.include.context)), + }; + + Ok(CoprocessorRequest { + id, + body: sonic_rs::to_vec(&payload)?.into(), + }) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError> { + // Router request stage can mutate request headers and body properties before downstream processing + if let Some(headers) = parsed.headers { + headers.replace_into(&mut input.request.head_mut().headers)?; + } + + if let Some(body) = parsed.body { + input.body_replaced = true; + let body_str = Self::parse_json_body(&body)?; + if body_str.is_empty() { + input.request.set_payload(Payload::None); + } else { + input.request.set_payload(payload_from_bytes( + CoprocessorResponseBody::from(body_str).into_ntex_bytes(), + )); + } + } + + Ok(()) + } +} + +impl Stage for RouterResponseStage { + type Input<'a> = RouterResponseInput; + + const STAGE_NAME: &'static str = "router.response"; + + fn should_run(&self, input: &Self::Input<'_>) -> Result { + evaluate_condition(self.condition.as_ref(), |hints| { + hints.context_builder(|root| { + root.insert_object("request", |req| { + req.insert_lazy("method", || { + input.response.request().method().as_str().into() + }) + .insert_lazy("headers", || { + input.response.request().headers().to_vrl_value() + }) + .insert_lazy("url", || input.response.request().uri().to_vrl_value()); + }) + .insert_object("response", |res| { + res.insert_lazy("headers", || input.response.headers().to_vrl_value()) + .insert_lazy("status_code", || input.response.status().as_u16().into()); + }); + }) + }) + } + + fn build_request<'a>( + &self, + input: &Self::Input<'_>, + id: &'a str, + context: &SharedRequestContext, + ) -> Result, CoprocessorError> { + let context_snapshot = if self.include.context.is_none() { + None + } else { + Some(context.snapshot()?) + }; + let body = if self.include.body { + match input.response.response().body() { + ResponseBody::Body(Body::Bytes(bytes)) => Some( + CoprocessorRequestBody::from(bytes) + .try_to_utf8(Self::STAGE_NAME)? + .into(), + ), + _ => None, + } + } else { + None + }; + + let payload = RouterResponsePayload { + version: COPROCESSOR_VERSION, + stage: Self::STAGE_NAME, + id, + headers: self + .include + .headers + .then(|| HeaderMapJsonRef(input.response.response().headers())), + body, + context: context_snapshot + .as_ref() + .map(|ctx| ctx.as_selected(&self.include.context)), + status_code: self + .include + .status_code + .then_some(input.response.response().status().as_u16()), + }; + + Ok(CoprocessorRequest { + id, + body: sonic_rs::to_vec(&payload)?.into(), + }) + } + + fn apply_mutations<'b>( + &self, + parsed: StageResponsePayload<'b>, + input: &mut Self::Input<'_>, + ) -> Result<(), CoprocessorError> { + // Router response stage can mutate outgoing headers and body before plugin::on_end run + if let Some(headers) = parsed.headers { + headers.replace_into(input.response.response_mut().headers_mut())?; + } + + if let Some(body) = parsed.body { + let previous_response = + std::mem::replace(input.response.response_mut(), Response::new(StatusCode::OK)); + + let body_str = Self::parse_json_body(&body)?; + if body_str.is_empty() { + *input.response.response_mut() = previous_response.set_body(Body::None); + } else { + let body_bytes = CoprocessorResponseBody::from(body_str).into_ntex_bytes(); + *input.response.response_mut() = + previous_response.set_body(Body::Bytes(body_bytes)); + } + } + + Ok(()) + } +} + +fn payload_from_bytes(body: NtexBytes) -> Payload { + if body.is_empty() { + return Payload::None; + } + + Payload::from_stream(stream::iter([Ok::(body)])) +} + +#[derive(serde::Serialize)] +struct RouterRequestPayload<'a> { + version: u8, + stage: &'static str, + id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + method: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option>, +} + +#[derive(serde::Serialize)] +struct RouterResponsePayload<'a> { + version: u8, + stage: &'static str, + id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, +} diff --git a/lib/executor/src/execution/client_request_details.rs b/lib/executor/src/execution/client_request_details.rs index aba579055..f17ce80e1 100644 --- a/lib/executor/src/execution/client_request_details.rs +++ b/lib/executor/src/execution/client_request_details.rs @@ -5,20 +5,106 @@ use hive_router_internal::expressions::{lib::ToVrlValue, vrl::core::Value}; use http::Method; use ntex::http::HeaderMap as NtexHeaderMap; +use crate::request_context::{RequestContextError, SharedRequestContext}; + pub struct OperationDetails<'exec> { pub name: Option<&'exec str>, pub query: &'exec str, pub kind: &'static str, } +pub struct MutableClientRequestDetails<'exec> { + pub method: &'exec Method, + pub url: &'exec http::Uri, + pub headers: NtexHeaderMap, + pub operation: OperationDetails<'exec>, + pub jwt: Arc, +} + pub struct ClientRequestDetails<'exec> { pub method: &'exec Method, pub url: &'exec http::Uri, - pub headers: &'exec NtexHeaderMap, + pub headers: Arc, pub operation: OperationDetails<'exec>, pub jwt: Arc, } +// Trait for accessing read-only client request details. +// It's created to do not leak the mutable implementation details. +pub trait ClientRequestDetailsView { + fn method(&self) -> &Method; + fn url(&self) -> &http::Uri; + fn headers(&self) -> &NtexHeaderMap; + fn operation<'a>(&'a self) -> &'a OperationDetails<'a>; + fn jwt(&self) -> &JwtRequestDetails; + + fn to_vrl_value(&self) -> Value { + request_details_to_vrl_value(self) + } +} + +impl ClientRequestDetailsView for MutableClientRequestDetails<'_> { + fn method(&self) -> &Method { + self.method + } + + fn url(&self) -> &http::Uri { + self.url + } + + fn headers(&self) -> &NtexHeaderMap { + &self.headers + } + + fn operation<'a>(&'a self) -> &'a OperationDetails<'a> { + &self.operation + } + + fn jwt(&self) -> &JwtRequestDetails { + &self.jwt + } +} + +impl ClientRequestDetailsView for ClientRequestDetails<'_> { + fn method(&self) -> &Method { + self.method + } + + fn url(&self) -> &http::Uri { + self.url + } + + fn headers(&self) -> &NtexHeaderMap { + &self.headers + } + + fn operation<'a>(&'a self) -> &'a OperationDetails<'a> { + &self.operation + } + + fn jwt(&self) -> &JwtRequestDetails { + &self.jwt + } +} + +impl<'exec> MutableClientRequestDetails<'exec> { + pub fn freeze(self) -> ClientRequestDetails<'exec> { + ClientRequestDetails { + method: self.method, + url: self.url, + headers: self.headers.into(), + operation: self.operation, + jwt: self.jwt, + } + } +} + +impl From<&MutableClientRequestDetails<'_>> for Value { + fn from(details: &MutableClientRequestDetails) -> Self { + request_details_to_vrl_value(details) + } +} + pub enum JwtRequestDetails { Authenticated { token: String, @@ -29,97 +115,88 @@ pub enum JwtRequestDetails { Unauthenticated, } +impl JwtRequestDetails { + pub fn update_request_context( + &self, + request_context: &SharedRequestContext, + ) -> Result<(), RequestContextError> { + request_context.update(|ctx| match self { + JwtRequestDetails::Authenticated { scopes, .. } => { + ctx.authentication.jwt_status = Some(true); + ctx.authentication.jwt_scopes = scopes + .as_ref() + .map(|scopes| scopes.iter().cloned().collect()); + } + JwtRequestDetails::Unauthenticated => { + ctx.authentication.jwt_scopes = None; + ctx.authentication.jwt_status = Some(false); + } + }) + } +} + impl From<&ClientRequestDetails<'_>> for Value { fn from(details: &ClientRequestDetails) -> Self { - // .request.headers - let headers_value = client_header_map_to_vrl_value(details.headers); + request_details_to_vrl_value(details) + } +} + +fn request_details_to_vrl_value(details: &(impl ClientRequestDetailsView + ?Sized)) -> Value { + // .request.headers + let headers_value = details.headers().to_vrl_value(); - // .request.url - let url_value = Self::Object(BTreeMap::from([ + // .request.url + let url_value = details.url().to_vrl_value(); + + // .request.operation + let operation_value = Value::Object(BTreeMap::from([ + ("name".into(), details.operation().name.into()), + ("type".into(), details.operation().kind.into()), + ("query".into(), details.operation().query.into()), + ])); + + // .request.jwt + let jwt_value = match details.jwt() { + JwtRequestDetails::Authenticated { + token, + prefix, + claims, + scopes, + } => Value::Object(BTreeMap::from([ + ("authenticated".into(), Value::Boolean(true)), + ("token".into(), token.to_string().into()), ( - "host".into(), - details.url.host().unwrap_or("unknown").into(), + "prefix".into(), + prefix.as_deref().unwrap_or_default().into(), ), - ("path".into(), details.url.path().into()), + ("claims".into(), claims.to_vrl_value()), ( - "port".into(), - details - .url - .port_u16() - .unwrap_or_else(|| { - if details.url.scheme() == Some(&http::uri::Scheme::HTTPS) { - 443 - } else { - 80 - } - }) - .into(), + "scopes".into(), + match scopes { + Some(scopes) => Value::Array( + scopes + .iter() + .map(|v| Value::Bytes(Bytes::from(v.clone()))) + .collect(), + ), + None => Value::Array(vec![]), + }, ), - ])); - - // .request.operation - let operation_value = Self::Object(BTreeMap::from([ - ("name".into(), details.operation.name.into()), - ("type".into(), details.operation.kind.into()), - ("query".into(), details.operation.query.into()), - ])); - - // .request.jwt - let jwt_value = match details.jwt.as_ref() { - JwtRequestDetails::Authenticated { - token, - prefix, - claims, - scopes, - } => Self::Object(BTreeMap::from([ - ("authenticated".into(), Value::Boolean(true)), - ("token".into(), token.to_string().into()), - ( - "prefix".into(), - prefix.as_deref().unwrap_or_default().into(), - ), - ("claims".into(), claims.to_vrl_value()), - ( - "scopes".into(), - match scopes { - Some(scopes) => Value::Array( - scopes - .iter() - .map(|v| Value::Bytes(Bytes::from(v.clone()))) - .collect(), - ), - None => Value::Array(vec![]), - }, - ), - ])), - JwtRequestDetails::Unauthenticated => Self::Object(BTreeMap::from([ - ("authenticated".into(), Value::Boolean(false)), - ("token".into(), Value::Null), - ("prefix".into(), Value::Null), - ("claims".into(), Value::Object(BTreeMap::new())), - ("scopes".into(), Value::Array(vec![])), - ])), - }; - - Self::Object(BTreeMap::from([ - ("method".into(), details.method.as_str().into()), - ("headers".into(), headers_value), - ("url".into(), url_value), - ("operation".into(), operation_value), - ("jwt".into(), jwt_value), - ])) - } -} + ])), + JwtRequestDetails::Unauthenticated => Value::Object(BTreeMap::from([ + ("authenticated".into(), Value::Boolean(false)), + ("token".into(), Value::Null), + ("prefix".into(), Value::Null), + ("claims".into(), Value::Object(BTreeMap::new())), + ("scopes".into(), Value::Array(vec![])), + ])), + }; -fn client_header_map_to_vrl_value(headers: &ntex::http::HeaderMap) -> Value { - let mut obj = BTreeMap::new(); - for (header_name, header_value) in headers.iter() { - if let Ok(value) = header_value.to_str() { - obj.insert( - header_name.as_str().into(), - Value::Bytes(Bytes::from(value.to_owned())), - ); - } - } - Value::Object(obj) + Value::Object(BTreeMap::from([ + ("method".into(), details.method().as_str().into()), + ("headers".into(), headers_value), + ("url".into(), url_value), + ("operation".into(), operation_value), + ("jwt".into(), jwt_value), + ])) } diff --git a/lib/executor/src/execution/plan.rs b/lib/executor/src/execution/plan.rs index 540ff80e8..93421b2e9 100644 --- a/lib/executor/src/execution/plan.rs +++ b/lib/executor/src/execution/plan.rs @@ -32,18 +32,18 @@ use tracing::Instrument; use crate::execution::client_request_details::OperationDetails; use crate::{ - context::ExecutionContext, execution::{ client_request_details::ClientRequestDetails, error::{IntoPlanExecutionError, LazyPlanContext, PlanExecutionError}, jwt_forward::JwtAuthForwardingPlan, rewrites::FetchRewriteExt, }, + execution_context::ExecutionContext, executors::{common::SubgraphExecutionRequest, map::SubgraphExecutorMap}, headers::{ - plan::{HeaderRulesPlan, ResponseHeaderAggregator}, + plan::HeaderRulesPlan, request::modify_subgraph_request_headers, - response::apply_subgraph_response_headers, + response::{apply_subgraph_response_headers, ResponseHeaderAggregator}, }, hooks::{ on_execute::{OnExecuteEndHookPayload, OnExecuteStartHookPayload}, @@ -55,6 +55,7 @@ use crate::{ }, plugin_context::PluginRequestState, plugin_trait::{EndControlFlow, StartControlFlow}, + plugins::hooks, projection::{ plan::FieldProjectionPlan, request::project_requires, response::project_by_operation, }, @@ -284,7 +285,7 @@ pub async fn execute_query_plan<'exec>( client_request: ClientRequestDetails { method: &client_method, url: &client_url, - headers: &client_headers, + headers: client_headers.clone(), operation: OperationDetails { query: &client_operation_query, name: client_operation_name.as_deref(), @@ -350,10 +351,14 @@ async fn execute_query_plan_with_data<'exec>( let mut on_end_callbacks = vec![]; + // TODO: coprocessor.on_execution_request if let Some(plugin_req_state) = opts.plugin_req_state.as_ref() { let mut start_payload = OnExecuteStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), query_plan: opts.query_plan, operation_for_plan: &opts.operation_for_plan, data, @@ -422,12 +427,18 @@ async fn execute_query_plan_with_data<'exec>( let mut errors = exec_ctx.errors; let mut response_size_estimate = exec_ctx.response_storage.estimate_final_response_size(); + // TODO: coprocessor.on_execution_response if !on_end_callbacks.is_empty() { let mut end_payload = OnExecuteEndHookPayload { data, errors, extensions, response_size_estimate, + request_context: opts + .plugin_req_state + .as_ref() + .map(|state| state.request_context.for_plugin::()) + .expect("plugin state not available, but on_end_callbacks are present"), }; for callback in on_end_callbacks { @@ -1388,11 +1399,11 @@ fn select_fetch_variables<'a>( #[cfg(test)] mod tests { use crate::{ - context::ExecutionContext, execution::{ client_request_details::{ClientRequestDetails, JwtRequestDetails, OperationDetails}, plan::Executor, }, + execution_context::ExecutionContext, headers::plan::HeaderRulesPlan, introspection::schema::SchemaMetadata, response::graphql_error::{GraphQLErrorExtensions, GraphQLErrorPath}, @@ -1565,7 +1576,7 @@ mod tests { client_request: &ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &HeaderMap::new(), + headers: HeaderMap::new().into(), operation: OperationDetails { name: None, query: "{ products { upc } }", @@ -1677,7 +1688,7 @@ mod tests { client_request: &ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &HeaderMap::new(), + headers: HeaderMap::new().into(), operation: OperationDetails { name: None, query: "{ from_a from_b }", diff --git a/lib/executor/src/context.rs b/lib/executor/src/execution_context.rs similarity index 97% rename from lib/executor/src/context.rs rename to lib/executor/src/execution_context.rs index b234f8ca6..efa323da9 100644 --- a/lib/executor/src/context.rs +++ b/lib/executor/src/execution_context.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use hive_router_query_planner::planner::plan_nodes::FlattenNodePath; use crate::{ - headers::plan::ResponseHeaderAggregator, + headers::response::ResponseHeaderAggregator, response::{ graphql_error::{GraphQLError, GraphQLErrorPath}, storage::ResponsesStorage, diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index 5d659614c..0ef7c9813 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -10,6 +10,7 @@ use crate::hooks::on_subgraph_http_request::{ }; use crate::plugin_context::PluginRequestState; use crate::plugin_trait::{EndControlFlow, StartControlFlow}; +use crate::plugins::hooks; use crate::response::subgraph_response::SubgraphResponse; use futures::stream::BoxStream; use hive_router_config::HiveRouterConfig; @@ -41,8 +42,6 @@ use crate::utils::consts::QUOTE; use crate::{executors::common::SubgraphExecutor, json_writer::write_and_escape_string}; use hive_router_internal::telemetry::traces::spans::http_request::HttpClientRequestSpan; use hive_router_internal::telemetry::traces::spans::http_request::HttpInflightRequestSpan; -use hive_router_internal::telemetry::Injector; -use http::HeaderName; use tracing::Instrument; pub struct HTTPSubgraphExecutor { @@ -217,7 +216,7 @@ async fn send_request<'a>( let response: Result = async { // TODO: let's decide at some point if the tracing headers // should be part of the fingerprint or not. - telemetry_context.inject_context(&mut TraceHeaderInjector(req.headers_mut())); + telemetry_context.inject_context_into_http_headers(req.headers_mut()); let res_fut = http_client.request(req); @@ -320,6 +319,9 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { execution_request, deduplicate_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), }; for plugin in plugin_req_state.plugins.as_ref() { let result = plugin.on_subgraph_http_request(start_payload).await; @@ -441,8 +443,14 @@ impl SubgraphExecutor for HTTPSubgraphExecutor { }; if !on_end_callbacks.is_empty() { + let plugin_state_ref = plugin_req_state + .as_ref() + .expect("plugin state not available, but on_end_callbacks are present"); let mut end_payload = OnSubgraphHttpResponseHookPayload { - context: &plugin_req_state.as_ref().unwrap().context, + context: &plugin_state_ref.context, + request_context: plugin_state_ref + .request_context + .for_plugin::(), response, deduplication_hint, }; @@ -645,19 +653,3 @@ impl SubgraphHttpResponse { ) } } - -struct TraceHeaderInjector<'a>(pub &'a mut HeaderMap); - -impl<'a> Injector for TraceHeaderInjector<'a> { - fn set(&mut self, key: &str, value: String) { - let Ok(name) = HeaderName::from_bytes(key.as_bytes()) else { - return; - }; - - let Ok(val) = HeaderValue::from_str(&value) else { - return; - }; - - self.0.insert(name, val); - } -} diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index 5a78786b7..ad0bbfba9 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -38,6 +38,7 @@ use crate::{ }, plugin_context::PluginRequestState, plugin_trait::{EndControlFlow, StartControlFlow}, + plugins::hooks, response::subgraph_response::SubgraphResponse, }; @@ -177,6 +178,9 @@ impl SubgraphExecutorMap { let mut start_payload = OnSubgraphExecuteStartHookPayload { router_http_request: &plugin_req_state.router_http_request, context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), subgraph_name, executor, execution_request, @@ -215,6 +219,9 @@ impl SubgraphExecutorMap { if let Some(plugin_req_state) = plugin_req_state.as_ref() { let mut end_payload = OnSubgraphExecuteEndHookPayload { context: &plugin_req_state.context, + request_context: plugin_req_state + .request_context + .for_plugin::(), execution_result, }; diff --git a/lib/executor/src/headers/expression.rs b/lib/executor/src/headers/expression.rs index a3a45da3c..187aae596 100644 --- a/lib/executor/src/headers/expression.rs +++ b/lib/executor/src/headers/expression.rs @@ -1,8 +1,8 @@ use std::collections::BTreeMap; use bytes::Bytes; -use hive_router_internal::expressions::vrl::core::Value; -use http::{HeaderMap, HeaderValue}; +use hive_router_internal::expressions::{lib::ToVrlValue, vrl::core::Value}; +use http::HeaderValue; use crate::headers::{request::RequestExpressionContext, response::ResponseExpressionContext}; use hive_router_internal::expressions::FromVrlValue; @@ -16,19 +16,6 @@ pub fn vrl_value_to_header_value(value: Value) -> Option { HeaderValue::from_vrl_value(value).ok() } -fn header_map_to_vrl_value(headers: &HeaderMap) -> Value { - let mut obj = BTreeMap::new(); - for (header_name, header_value) in headers.iter() { - if let Ok(value) = header_value.to_str() { - obj.insert( - header_name.as_str().into(), - Value::Bytes(Bytes::from(value.to_owned())), - ); - } - } - Value::Object(obj) -} - impl From<&RequestExpressionContext<'_>> for Value { /// NOTE: If performance becomes an issue, consider pre-computing parts of this context that do not change fn from(ctx: &RequestExpressionContext) -> Self { @@ -57,7 +44,10 @@ impl From<&ResponseExpressionContext<'_>> for Value { Self::Bytes(Bytes::from(ctx.subgraph_name.to_owned())), )])); // .response - let response_value = header_map_to_vrl_value(ctx.subgraph_headers); + let response_value = Self::Object(BTreeMap::from([( + "headers".into(), + ctx.subgraph_headers.to_vrl_value(), + )])); // .request let request_value: Self = ctx.client_request.into(); diff --git a/lib/executor/src/headers/mod.rs b/lib/executor/src/headers/mod.rs index 425419c6d..e76a0cdc2 100644 --- a/lib/executor/src/headers/mod.rs +++ b/lib/executor/src/headers/mod.rs @@ -13,8 +13,9 @@ mod tests { ClientRequestDetails, JwtRequestDetails, OperationDetails, }, headers::{ - compile::compile_headers_plan, plan::ResponseHeaderAggregator, - request::modify_subgraph_request_headers, response::apply_subgraph_response_headers, + compile::compile_headers_plan, + request::modify_subgraph_request_headers, + response::{apply_subgraph_response_headers, ResponseHeaderAggregator}, }, }; use hive_router_config::parse_yaml_config; @@ -90,7 +91,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -124,7 +125,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -171,7 +172,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -209,7 +210,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: Some("MyQuery"), query: "{ __typename }", @@ -243,7 +244,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -283,7 +284,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -327,7 +328,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -396,7 +397,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -464,7 +465,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -525,7 +526,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -579,7 +580,7 @@ mod tests { response: - insert: name: x-original-forwarded-for - expression: '.response."x-forwarded-for"' + expression: '.response.headers."x-forwarded-for"' "#; let config = parse_yaml_config(String::from(yaml_str)).unwrap(); let plan = compile_headers_plan(&config.headers).unwrap(); @@ -587,7 +588,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", @@ -650,7 +651,7 @@ mod tests { let client_details = ClientRequestDetails { method: &http::Method::POST, url: &"http://example.com".parse().unwrap(), - headers: &client_headers, + headers: client_headers.into(), operation: OperationDetails { name: None, query: "{ __typename }", diff --git a/lib/executor/src/headers/plan.rs b/lib/executor/src/headers/plan.rs index c6c8ec0d2..7544033e5 100644 --- a/lib/executor/src/headers/plan.rs +++ b/lib/executor/src/headers/plan.rs @@ -123,20 +123,3 @@ pub enum HeaderAggregationStrategy { Last, Append, } - -type AggregatedHeader = (HeaderAggregationStrategy, Vec); - -#[derive(Default)] -pub struct ResponseHeaderAggregator { - pub entries: HashMap, -} - -impl ResponseHeaderAggregator { - pub fn none_if_empty(self) -> Option { - if self.entries.is_empty() { - None - } else { - Some(self) - } - } -} diff --git a/lib/executor/src/headers/request.rs b/lib/executor/src/headers/request.rs index a455565fd..2423b7cb6 100644 --- a/lib/executor/src/headers/request.rs +++ b/lib/executor/src/headers/request.rs @@ -110,7 +110,7 @@ impl ApplyRequestHeader for RequestPropagateRegex { ctx: &RequestExpressionContext, output_headers: &mut HeaderMap, ) -> Result<(), HeaderRuleRuntimeError> { - for (header_name, header_value) in ctx.client_request.headers { + for (header_name, header_value) in ctx.client_request.headers.iter() { if is_denied_header(header_name) { continue; } diff --git a/lib/executor/src/headers/response.rs b/lib/executor/src/headers/response.rs index 581880213..44284b96c 100644 --- a/lib/executor/src/headers/response.rs +++ b/lib/executor/src/headers/response.rs @@ -1,20 +1,20 @@ -use std::iter::once; - use crate::{ execution::client_request_details::ClientRequestDetails, headers::{ errors::HeaderRuleRuntimeError, expression::vrl_value_to_header_value, plan::{ - HeaderAggregationStrategy, HeaderRulesPlan, ResponseHeaderAggregator, - ResponseHeaderRule, ResponseInsertExpression, ResponseInsertStatic, - ResponsePropagateNamed, ResponsePropagateRegex, ResponseRemoveNamed, - ResponseRemoveRegex, + HeaderAggregationStrategy, HeaderRulesPlan, ResponseHeaderRule, + ResponseInsertExpression, ResponseInsertStatic, ResponsePropagateNamed, + ResponsePropagateRegex, ResponseRemoveNamed, ResponseRemoveRegex, }, sanitizer::is_denied_header, }, }; +use ahash::HashMap; use hive_router_internal::expressions::ExecutableProgram; +use ntex::http::HeaderMap as NtexHeaderMap; +use std::iter::once; use super::sanitizer::is_never_join_header; use http::{header::InvalidHeaderValue, HeaderMap, HeaderName, HeaderValue}; @@ -97,8 +97,7 @@ impl ApplyResponseHeader for ResponsePropagateNamed { if let Some(header_value) = ctx.subgraph_headers.get(header_name) { matched = true; - write_agg( - accumulator, + accumulator.write( self.rename.as_ref().unwrap_or(header_name), header_value, self.strategy, @@ -114,7 +113,7 @@ impl ApplyResponseHeader for ResponsePropagateNamed { return Ok(()); } - write_agg(accumulator, destination_name, default_value, self.strategy); + accumulator.write(destination_name, default_value, self.strategy); } } @@ -151,7 +150,7 @@ impl ApplyResponseHeader for ResponsePropagateRegex { continue; } - write_agg(accumulator, header_name, header_value, self.strategy); + accumulator.write(header_name, header_value, self.strategy); } Ok(()) @@ -174,7 +173,7 @@ impl ApplyResponseHeader for ResponseInsertStatic { self.strategy }; - write_agg(accumulator, &self.name, &self.value, strategy); + accumulator.write(&self.name, &self.value, strategy); Ok(()) } @@ -199,7 +198,7 @@ impl ApplyResponseHeader for ResponseInsertExpression { self.strategy }; - write_agg(accumulator, &self.name, &header_value, strategy); + accumulator.write(&self.name, &header_value, strategy); } Ok(()) @@ -243,42 +242,6 @@ impl ApplyResponseHeader for ResponseRemoveRegex { } } -/// Write a header to the aggregator according to the specified strategy. -fn write_agg( - agg: &mut ResponseHeaderAggregator, - name: &HeaderName, - value: &HeaderValue, - strategy: HeaderAggregationStrategy, -) { - let strategy = if is_never_join_header(name) { - HeaderAggregationStrategy::Append - } else { - strategy - }; - - if !agg.entries.contains_key(name) { - agg.entries - .insert(name.clone(), (strategy, once(value.clone()).collect())); - return; - } - - // The `expect` is safe because we just inserted the entry if it didn't exist - let (strategy, values) = agg.entries.get_mut(name).expect("Expected entry to exist"); - - match (strategy, values.len()) { - (HeaderAggregationStrategy::First, 0) => { - values.push(value.clone()); - } - (HeaderAggregationStrategy::Last, _) => { - values.clear(); - values.push(value.clone()); - } - (HeaderAggregationStrategy::Append, _) => { - values.push(value.clone()); - } - (_, _) => {} - } -} impl ResponseHeaderAggregator { /// Modify the outgoing client response headers based on the aggregated headers from subgraphs. #[inline] @@ -337,3 +300,79 @@ fn join_with_comma(values: &[HeaderValue]) -> Result); + +#[derive(Default)] +pub struct ResponseHeaderAggregator { + pub entries: HashMap, +} + +impl ResponseHeaderAggregator { + pub fn none_if_empty(self) -> Option { + if self.entries.is_empty() { + None + } else { + Some(self) + } + } + + /// Write a header to the aggregator according to the specified strategy. + pub fn write( + &mut self, + name: &HeaderName, + value: &HeaderValue, + strategy: HeaderAggregationStrategy, + ) { + let strategy = if is_never_join_header(name) { + HeaderAggregationStrategy::Append + } else { + strategy + }; + + if !self.entries.contains_key(name) { + self.entries + .insert(name.clone(), (strategy, once(value.clone()).collect())); + return; + } + + // The `expect` is safe because we just inserted the entry if it didn't exist + let (strategy, values) = self.entries.get_mut(name).expect("Expected entry to exist"); + + match (strategy, values.len()) { + (HeaderAggregationStrategy::First, 0) => { + values.push(value.clone()); + } + (HeaderAggregationStrategy::Last, _) => { + values.clear(); + values.push(value.clone()); + } + (HeaderAggregationStrategy::Append, _) => { + values.push(value.clone()); + } + (_, _) => {} + } + } + + // I deliberately chose to have a dedicated funtion over From + // to convert headers from "early return responses" (from coprocessor and plugins), + // to prevent us from accidentally using First, Last or Append strategies, + // when converting from ntex's HeaderMap to the ResponseHeaderAggregator. + pub fn from_early_response(headers: &NtexHeaderMap) -> Self { + let mut aggregator = Self::default(); + for (name, value) in headers.iter() { + aggregator.write( + name, + // SAFETY: Awkward but since ntex's HeaderValue was built, + // then the http's HeaderValue should be safe to convert to. + &HeaderValue::from_bytes(value.as_bytes()).expect("Failed to convert header value"), + // Why Last and not First or Append? + // Coprocessors return headers in `{: [,] | }` format, + // meaning there's always a single entry, and when it has multiple values, + // it's handled already by ntex's HeaderMap. + HeaderAggregationStrategy::Last, + ); + } + aggregator + } +} diff --git a/lib/executor/src/lib.rs b/lib/executor/src/lib.rs index bdcbdadc0..419a55bd1 100644 --- a/lib/executor/src/lib.rs +++ b/lib/executor/src/lib.rs @@ -1,11 +1,13 @@ -pub mod context; +pub mod coprocessor; pub mod execution; +pub mod execution_context; pub mod executors; pub mod headers; pub mod introspection; pub mod json_writer; pub mod plugins; pub mod projection; +pub mod request_context; pub mod response; pub mod utils; pub mod variables; diff --git a/lib/executor/src/plugins/hooks/mod.rs b/lib/executor/src/plugins/hooks/mod.rs index c3d502126..87bac9053 100644 --- a/lib/executor/src/plugins/hooks/mod.rs +++ b/lib/executor/src/plugins/hooks/mod.rs @@ -9,3 +9,36 @@ pub mod on_query_plan; pub mod on_subgraph_execute; pub mod on_subgraph_http_request; pub mod on_supergraph_load; + +mod sealed { + pub trait Sealed {} +} + +pub trait HookMarker: sealed::Sealed {} + +pub struct OnHttpRequest; +pub struct OnGraphqlParams; +pub struct OnGraphqlParse; +pub struct OnGraphqlValidation; +pub struct OnQueryPlan; +pub struct OnExecute; +pub struct OnSubgraphExecute; +pub struct OnSubgraphHttp; + +impl sealed::Sealed for OnHttpRequest {} +impl sealed::Sealed for OnGraphqlParams {} +impl sealed::Sealed for OnGraphqlParse {} +impl sealed::Sealed for OnGraphqlValidation {} +impl sealed::Sealed for OnQueryPlan {} +impl sealed::Sealed for OnExecute {} +impl sealed::Sealed for OnSubgraphExecute {} +impl sealed::Sealed for OnSubgraphHttp {} + +impl HookMarker for OnHttpRequest {} +impl HookMarker for OnGraphqlParams {} +impl HookMarker for OnGraphqlParse {} +impl HookMarker for OnGraphqlValidation {} +impl HookMarker for OnQueryPlan {} +impl HookMarker for OnExecute {} +impl HookMarker for OnSubgraphExecute {} +impl HookMarker for OnSubgraphHttp {} diff --git a/lib/executor/src/plugins/hooks/on_execute.rs b/lib/executor/src/plugins/hooks/on_execute.rs index 5f623b312..c44c7013a 100644 --- a/lib/executor/src/plugins/hooks/on_execute.rs +++ b/lib/executor/src/plugins/hooks/on_execute.rs @@ -10,9 +10,12 @@ use crate::plugin_context::{PluginContext, RouterHttpRequest}; use crate::plugin_trait::{ EndHookPayload, EndHookResult, FromGraphQLErrorToResponse, StartHookPayload, StartHookResult, }; +use crate::request_context::RequestContextPluginApi; use crate::response::graphql_error::GraphQLError; use crate::response::value::Value; +type RequestContextApi = RequestContextPluginApi; + pub struct OnExecuteStartHookPayload<'exec> { /// The incoming HTTP request to the router for which the GraphQL execution is happening. /// It includes all the details of the request such as headers, body, etc. @@ -29,6 +32,7 @@ pub struct OnExecuteStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The query plan generated for the incoming GraphQL request. /// It includes the details of how the router plans to execute the request across the subgraphs. pub query_plan: &'exec QueryPlan, @@ -137,6 +141,7 @@ pub struct OnExecuteEndHookPayload<'exec> { /// This will be sent to the client as the "extensions" field in the GraphQL response. /// Plugins can modify this map before proceeding, and the modified map will be sent to the client. pub extensions: HashMap, + pub request_context: RequestContextApi, /// An estimate of the response size in bytes. /// This is calculated based on the subgraph responses diff --git a/lib/executor/src/plugins/hooks/on_graphql_params.rs b/lib/executor/src/plugins/hooks/on_graphql_params.rs index c04639915..69fd9f915 100644 --- a/lib/executor/src/plugins/hooks/on_graphql_params.rs +++ b/lib/executor/src/plugins/hooks/on_graphql_params.rs @@ -17,8 +17,11 @@ use crate::plugin_trait::EndHookPayload; use crate::plugin_trait::EndHookResult; use crate::plugin_trait::StartHookPayload; use crate::plugin_trait::StartHookResult; +use crate::request_context::RequestContextPluginApi; use ntex::http::Response; +type RequestContextApi = RequestContextPluginApi; + #[derive(Debug, Default, Serialize)] /// The GraphQL parameters parsed from the HTTP request body by the router. /// This includes the `query`, `operationName`, `variables`, and `extensions` @@ -115,6 +118,7 @@ pub struct OnGraphQLParamsStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The raw body of the incoming HTTP request. /// This is useful for plugins that want to parse the body in a custom way, /// or want to access the raw body for logging or other purposes. @@ -165,6 +169,7 @@ pub struct OnGraphQLParamsEndHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, } impl<'exec> EndHookPayload for OnGraphQLParamsEndHookPayload<'exec> {} diff --git a/lib/executor/src/plugins/hooks/on_graphql_parse.rs b/lib/executor/src/plugins/hooks/on_graphql_parse.rs index 25660a3fa..562e4ff8c 100644 --- a/lib/executor/src/plugins/hooks/on_graphql_parse.rs +++ b/lib/executor/src/plugins/hooks/on_graphql_parse.rs @@ -7,8 +7,11 @@ use crate::{ hooks::on_graphql_params::GraphQLParams, plugin_context::{PluginContext, RouterHttpRequest}, plugin_trait::{CacheHint, EndHookPayload, EndHookResult, StartHookPayload, StartHookResult}, + request_context::RequestContextPluginApi, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnGraphQLParseStartHookPayload<'exec> { /// The incoming HTTP request to the router for which the GraphQL execution is happening. /// It includes all the details of the request such as headers, body, etc. @@ -25,6 +28,7 @@ pub struct OnGraphQLParseStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The GraphQL parameters parsed from the HTTP request body by the router. /// This includes the `query`, `operationName`, `variables`, and `extensions` /// [Learn more about GraphQL-over-HTTP params](https://graphql.org/learn/serving-over-http/#request-format) @@ -52,6 +56,7 @@ pub struct OnGraphQLParseEndHookPayload { /// - If this is `CacheHint::Hit`, it means the parsing process didn't happen because the result was retrieved from the cache. /// - If this is `CacheHint::Miss`, it means the parsing process happened and the result was not retrieved from the cache. pub cache_hint: CacheHint, + pub request_context: RequestContextApi, } impl EndHookPayload for OnGraphQLParseEndHookPayload {} diff --git a/lib/executor/src/plugins/hooks/on_graphql_validation.rs b/lib/executor/src/plugins/hooks/on_graphql_validation.rs index 2f0b5a250..0159bce9c 100644 --- a/lib/executor/src/plugins/hooks/on_graphql_validation.rs +++ b/lib/executor/src/plugins/hooks/on_graphql_validation.rs @@ -10,8 +10,11 @@ use ntex::http::Response; use crate::{ plugin_context::{PluginContext, RouterHttpRequest}, plugin_trait::{CacheHint, EndHookPayload, EndHookResult, StartHookPayload, StartHookResult}, + request_context::RequestContextPluginApi, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnGraphQLValidationStartHookPayload<'exec> { /// The incoming HTTP request to the router for which the GraphQL execution is happening. /// It includes all the details of the request such as headers, body, etc. @@ -28,6 +31,7 @@ pub struct OnGraphQLValidationStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The GraphQL Schema that the document will be validated against. /// This is not the same with the supergraph. This is the public schema exposed by the router to the clients, which is generated from the supergraph and can be modified by the plugins. /// The plugins can replace the input schema to be used for validation @@ -116,6 +120,7 @@ impl<'exec> OnGraphQLValidationStartHookPayload<'exec> { pub struct OnGraphQLValidationEndHookPayload { pub errors: Arc>, pub cache_hint: CacheHint, + pub request_context: RequestContextApi, } impl EndHookPayload for OnGraphQLValidationEndHookPayload {} diff --git a/lib/executor/src/plugins/hooks/on_http_request.rs b/lib/executor/src/plugins/hooks/on_http_request.rs index 3911ce3cc..f7f4c4da5 100644 --- a/lib/executor/src/plugins/hooks/on_http_request.rs +++ b/lib/executor/src/plugins/hooks/on_http_request.rs @@ -6,8 +6,11 @@ use ntex::{ use crate::{ plugin_context::PluginContext, plugin_trait::{EndHookPayload, EndHookResult, StartHookPayload, StartHookResult}, + request_context::RequestContextPluginApi, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnHttpRequestHookPayload<'req> { /// The raw incoming HTTP request to the router /// It includes all the details of the request such as headers, body, etc. @@ -35,7 +38,7 @@ pub struct OnHttpRequestHookPayload<'req> { /// use hive_router::{ /// plugins::hooks::{ /// on_http_request::{OnHttpRequestHookPayload, OnHttpRequestHookResult}, - /// on_execute::{OnExecuteStartHookPayload, OnExecuteStartHookResult} + /// on_execute::{OnExecuteStartHookPayload, OnExecuteStartHookResult} /// }, /// plugin_context::PluginContext, /// async_trait::async_trait, @@ -65,6 +68,7 @@ pub struct OnHttpRequestHookPayload<'req> { /// } /// ``` pub context: &'req PluginContext, + pub request_context: RequestContextApi, } impl<'req> StartHookPayload, Response> @@ -82,6 +86,7 @@ pub type OnHttpRequestHookResult<'req> = StartHookResult< pub struct OnHttpResponseHookPayload<'req> { pub response: web::WebResponse, pub context: &'req PluginContext, + pub request_context: RequestContextApi, } impl<'req> OnHttpResponseHookPayload<'req> { diff --git a/lib/executor/src/plugins/hooks/on_query_plan.rs b/lib/executor/src/plugins/hooks/on_query_plan.rs index b3c22b8c5..f3315d70f 100644 --- a/lib/executor/src/plugins/hooks/on_query_plan.rs +++ b/lib/executor/src/plugins/hooks/on_query_plan.rs @@ -10,8 +10,11 @@ use crate::{ execution::plan::PlanExecutionOutput, plugin_context::{PluginContext, RouterHttpRequest}, plugin_trait::{CacheHint, EndHookPayload, EndHookResult, StartHookPayload, StartHookResult}, + request_context::RequestContextPluginApi, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnQueryPlanStartHookPayload<'exec> { /// The incoming HTTP request to the router for which the GraphQL execution is happening. /// It includes all the details of the request such as headers, body, etc. @@ -28,6 +31,7 @@ pub struct OnQueryPlanStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The GraphQL Document AST that will be used for query planning. pub filtered_operation_for_plan: &'exec OperationDefinition, /// The cancellation token that can be used to check if the request has been cancelled by the client or not. @@ -55,6 +59,7 @@ pub struct OnQueryPlanEndHookPayload { /// - If this is `CacheHint::Hit`, it means the query planning process didn't happen because the result was retrieved from the cache. /// - If this is `CacheHint::Miss`, it means the query planning process happened and the result was not retrieved from the cache. pub cache_hint: CacheHint, + pub request_context: RequestContextApi, } impl EndHookPayload for OnQueryPlanEndHookPayload {} diff --git a/lib/executor/src/plugins/hooks/on_subgraph_execute.rs b/lib/executor/src/plugins/hooks/on_subgraph_execute.rs index 5ae88840d..a1e733ed7 100644 --- a/lib/executor/src/plugins/hooks/on_subgraph_execute.rs +++ b/lib/executor/src/plugins/hooks/on_subgraph_execute.rs @@ -5,9 +5,12 @@ use crate::{ EndHookPayload, EndHookResult, FromGraphQLErrorToResponse, StartHookPayload, StartHookResult, }, + request_context::RequestContextPluginApi, response::{graphql_error::GraphQLError, subgraph_response::SubgraphResponse}, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnSubgraphExecuteStartHookPayload<'exec> { /// The incoming HTTP request to the router for which the GraphQL execution is happening. /// It includes all the details of the request such as headers, body, etc. @@ -24,6 +27,7 @@ pub struct OnSubgraphExecuteStartHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The name of the subgraph for which the execution is happening. pub subgraph_name: &'exec str, @@ -55,6 +59,7 @@ pub struct OnSubgraphExecuteEndHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, } impl<'exec> EndHookPayload> for OnSubgraphExecuteEndHookPayload<'exec> {} diff --git a/lib/executor/src/plugins/hooks/on_subgraph_http_request.rs b/lib/executor/src/plugins/hooks/on_subgraph_http_request.rs index 9be4d6833..ecabd1cd4 100644 --- a/lib/executor/src/plugins/hooks/on_subgraph_http_request.rs +++ b/lib/executor/src/plugins/hooks/on_subgraph_http_request.rs @@ -9,9 +9,12 @@ use crate::{ plugin_trait::{ from_graphql_error_to_bytes, EndHookPayload, FromGraphQLErrorToResponse, StartHookPayload, }, + request_context::RequestContextPluginApi, response::graphql_error::GraphQLError, }; +type RequestContextApi = RequestContextPluginApi; + pub struct OnSubgraphHttpRequestHookPayload<'exec> { /// The name of the subgraph for which the HTTP request is being sent. pub subgraph_name: &'exec str, @@ -37,6 +40,7 @@ pub struct OnSubgraphHttpRequestHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, } impl<'exec> StartHookPayload, SubgraphHttpResponse> @@ -57,6 +61,7 @@ pub struct OnSubgraphHttpResponseHookPayload<'exec> { /// /// [Learn more about the context data sharing in the docs](https://the-guild.dev/graphql/hive/docs/router/extensibility/plugin_system#context-data-sharing) pub context: &'exec PluginContext, + pub request_context: RequestContextApi, /// The HTTP response received from the subgraph for the HTTP request sent by the router. /// Plugins can modify the response before it is sent back to the client. pub response: SubgraphHttpResponse, diff --git a/lib/executor/src/plugins/hooks/on_supergraph_load.rs b/lib/executor/src/plugins/hooks/on_supergraph_load.rs index 4d951b04f..061bdfbef 100644 --- a/lib/executor/src/plugins/hooks/on_supergraph_load.rs +++ b/lib/executor/src/plugins/hooks/on_supergraph_load.rs @@ -11,6 +11,13 @@ use crate::{ SubgraphExecutorMap, }; +pub struct PublicSchema { + /// The AST of the public schema document exposed by the router. + pub document: Arc, + /// The SDL string of the public schema document exposed by the router. + pub sdl: Arc, +} + pub struct SupergraphData { /// The metadata of the supergraph schema, /// which includes the list of subgraphs, their relationships, and other relevant information about the supergraph. @@ -23,6 +30,9 @@ pub struct SupergraphData { pub subgraph_executor_map: Arc, /// The AST of the supergraph schema document that was loaded and parsed by the router. pub supergraph_schema: Arc, + /// The public schema exposed by the router. + /// It is generated from the supergraph schema and stripped from federation internals. + pub public_schema: PublicSchema, } impl SupergraphData { diff --git a/lib/executor/src/plugins/plugin_context.rs b/lib/executor/src/plugins/plugin_context.rs index 16755334f..774800929 100644 --- a/lib/executor/src/plugins/plugin_context.rs +++ b/lib/executor/src/plugins/plugin_context.rs @@ -13,6 +13,7 @@ use ntex::router::Path; use ntex::{http::HeaderMap, web::HttpRequest}; use crate::plugin_trait::RouterPluginBoxed; +use crate::request_context::SharedRequestContext; pub struct RouterHttpRequest<'req> { pub uri: &'req Uri, @@ -202,6 +203,7 @@ pub struct PluginRequestState<'req> { pub plugins: Arc>, pub router_http_request: RouterHttpRequest<'req>, pub context: Arc, + pub request_context: SharedRequestContext, } #[cfg(test)] diff --git a/lib/executor/src/plugins/plugin_trait.rs b/lib/executor/src/plugins/plugin_trait.rs index 2a053680c..4ee1d224e 100644 --- a/lib/executor/src/plugins/plugin_trait.rs +++ b/lib/executor/src/plugins/plugin_trait.rs @@ -1,6 +1,7 @@ use crate::{ execution::plan::PlanExecutionOutput, - headers::plan::{HeaderAggregationStrategy, ResponseHeaderAggregator}, + headers::plan::HeaderAggregationStrategy, + headers::response::ResponseHeaderAggregator, hooks::{ on_execute::{OnExecuteStartHookPayload, OnExecuteStartHookResult}, on_graphql_error::{OnGraphQLErrorHookPayload, OnGraphQLErrorHookResult}, @@ -94,7 +95,7 @@ where /// StatusCode::UNAUTHORIZED, /// ); /// } - /// + /// /// payload.proceed() /// } /// ``` @@ -197,7 +198,7 @@ where /// StatusCode::UNAUTHORIZED, /// ); /// } - /// + /// /// payload.proceed() /// } /// ``` diff --git a/lib/executor/src/request_context/api/coprocessor.rs b/lib/executor/src/request_context/api/coprocessor.rs new file mode 100644 index 000000000..179da0ca3 --- /dev/null +++ b/lib/executor/src/request_context/api/coprocessor.rs @@ -0,0 +1,87 @@ +use super::super::domains::{RequestContext, HIVE_PREFIX}; +use super::super::error::RequestContextError; + +use crate::response::value::Value as ResponseValue; +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::fmt; + +impl RequestContext { + /// Returns a coprocessor-facing API handle. + /// This API supports untyped key-value patching from external JSON payloads. + pub fn for_coprocessor(&mut self) -> RequestContextCoprocessorApi<'_> { + RequestContextCoprocessorApi::new(self) + } +} + +/// An API for external coprocessors to mutate the request context. +/// Unlike the Plugin API, this uses string keys and dynamic JSON values. +/// It performs runtime validation and prefix-routing for reserved keys. +pub struct RequestContextCoprocessorApi<'a> { + context: &'a mut RequestContext, +} + +impl RequestContextCoprocessorApi<'_> { + pub fn new(context: &mut RequestContext) -> RequestContextCoprocessorApi<'_> { + RequestContextCoprocessorApi { context } + } + + /// Sets a value for a specific key in the context. + /// If the key starts with `hive::`, it is routed to a reserved domain. + /// Otherwise, it is stored in the custom context. + fn set(&mut self, key: &str, value: ResponseValue<'_>) -> Result<(), RequestContextError> { + if !key.starts_with(HIVE_PREFIX) { + self.context.custom.apply(key, value); + return Ok(()); + } + + self.context + .try_set_reserved_key(key, value.as_ref().into()) + } + + /// Applies multiple context updates from an external patch object. + pub fn apply_patch(&mut self, patch: RequestContextPatch) -> Result<(), RequestContextError> { + for (key, value) in patch.entries { + self.set(key, value)?; + } + + Ok(()) + } +} + +/// A collection of context updates received from an external coprocessor. +#[derive(Debug, Default)] +pub struct RequestContextPatch<'a> { + pub(crate) entries: Vec<(&'a str, ResponseValue<'a>)>, +} + +impl<'a, 'de: 'a> Deserialize<'de> for RequestContextPatch<'a> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RequestContextPatchVisitor; + + impl<'de> Visitor<'de> for RequestContextPatchVisitor { + type Value = RequestContextPatch<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a flat request-context patch object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut patch = RequestContextPatch::default(); + while let Some((key, value)) = map.next_entry::<&'de str, ResponseValue<'de>>()? { + patch.entries.push((key, value)); + } + + Ok(patch) + } + } + + deserializer.deserialize_map(RequestContextPatchVisitor) + } +} diff --git a/lib/executor/src/request_context/api/mod.rs b/lib/executor/src/request_context/api/mod.rs new file mode 100644 index 000000000..6c499054f --- /dev/null +++ b/lib/executor/src/request_context/api/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod coprocessor; +pub(crate) mod plugin; diff --git a/lib/executor/src/request_context/api/plugin.rs b/lib/executor/src/request_context/api/plugin.rs new file mode 100644 index 000000000..14ccbdfb5 --- /dev/null +++ b/lib/executor/src/request_context/api/plugin.rs @@ -0,0 +1,65 @@ +use std::{marker::PhantomData, sync::MutexGuard}; + +use super::super::domains::{RequestContext, SharedRequestContext}; +use super::super::RequestContextError; +use crate::hooks::HookMarker; + +impl SharedRequestContext { + /// Returns a scoped API handle for plugins. + /// The `Hook` generic determines the available capabilities (read/write permissions) + /// based on the current plugin's hook. + pub fn for_plugin(&self) -> RequestContextPluginApi { + RequestContextPluginApi::new(self.clone()) + } +} + +/// A type-safe API for router plugins to interact with the request context. +/// The `Hook` generic is a marker type (`OnQueryPlan`) used to enforce +/// hook-specific permissions at compile time. +#[derive(Clone)] +pub struct RequestContextPluginApi { + context: SharedRequestContext, + _hook: PhantomData, +} + +/// A read-only snapshot of the request context. +/// Holding this struct allows plugins to perform async work (across `.await`) +/// without blocking other plugins or the main execution flow. +pub struct RequestContextPluginRead { + pub(crate) snapshot: RequestContext, + pub(crate) _hook: PhantomData, +} + +/// A synchronous write guard for the request context. +/// This struct holds a [MutexGuard] in the context. +/// To avoid deadlocks or long-lived pipeline blocks, +/// it should never be held across `.await`. +pub struct RequestContextPluginWrite<'a, Hook> { + pub(crate) context: MutexGuard<'a, RequestContext>, + pub(crate) _hook: PhantomData, +} + +impl RequestContextPluginApi { + fn new(context: SharedRequestContext) -> RequestContextPluginApi { + RequestContextPluginApi { + context, + _hook: PhantomData, + } + } + + /// Creates a read-only snapshot of the current context. + pub fn read(&self) -> Result, RequestContextError> { + Ok(RequestContextPluginRead { + snapshot: self.context.snapshot()?, + _hook: PhantomData, + }) + } + + /// Acquires a write lock on the context. + pub fn write(&self) -> Result, RequestContextError> { + Ok(RequestContextPluginWrite { + context: self.context.read_lock()?, + _hook: PhantomData, + }) + } +} diff --git a/lib/executor/src/request_context/deser.rs b/lib/executor/src/request_context/deser.rs new file mode 100644 index 000000000..5230af901 --- /dev/null +++ b/lib/executor/src/request_context/deser.rs @@ -0,0 +1,94 @@ +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; +use sonic_rs::{JsonContainerTrait, JsonValueTrait, Value}; + +use super::error::RequestContextError; +use super::{RequestContext, SelectedRequestContext}; + +pub trait RequestContextValueExt { + fn expect_str<'a>( + &'a self, + key: &'static str, + expected: &'static str, + ) -> Result<&'a str, RequestContextError>; + fn expect_array<'a>( + &'a self, + key: &'static str, + expected: &'static str, + ) -> Result<&'a [Value], RequestContextError>; +} + +impl RequestContextValueExt for Value { + fn expect_str<'a>( + &'a self, + key: &'static str, + allowed_types: &'static str, + ) -> Result<&'a str, RequestContextError> { + let value = self + .as_str() + .ok_or_else(|| RequestContextError::ReservedKeyTypeMismatch { + key: key.to_string(), + expected: allowed_types, + })?; + + Ok(value) + } + + fn expect_array<'a>( + &'a self, + key: &'static str, + allowed_types: &'static str, + ) -> Result<&'a [Value], RequestContextError> { + let value = + self.as_array() + .ok_or_else(|| RequestContextError::ReservedKeyTypeMismatch { + key: key.to_string(), + expected: allowed_types, + })?; + + Ok(value.as_slice()) + } +} + +impl Serialize for RequestContext { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let len = self.custom.size() + self.reserved_serialized_len(); + + let mut map = serializer.serialize_map(Some(len))?; + self.serialize_all_reserved(&mut map)?; + for (key, value) in self.custom.iter() { + map.serialize_entry(key, value)?; + } + + map.end() + } +} + +impl Serialize for SelectedRequestContext<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.selection.is_all() { + return self.context.serialize(serializer); + } + + let keys = self.selection.keys(); + let mut map = serializer.serialize_map(Some(keys.len()))?; + + for key in keys { + if self.context.try_serialize_reserved_entry(key, &mut map)? { + continue; + } + + if let Some(value) = self.context.custom.get(key) { + map.serialize_entry(key, value)?; + } + } + + map.end() + } +} diff --git a/lib/executor/src/request_context/domains/authentication.rs b/lib/executor/src/request_context/domains/authentication.rs new file mode 100644 index 000000000..46867e451 --- /dev/null +++ b/lib/executor/src/request_context/domains/authentication.rs @@ -0,0 +1,63 @@ +use std::collections::HashSet; + +use serde::ser::SerializeMap; +use sonic_rs::Value; + +use super::super::api::plugin::RequestContextPluginRead; +use super::RequestContextDomain; +use super::RequestContextError; + +pub(crate) const JWT_SCOPES_KEY: &str = "hive::authentication::jwt_scopes"; +pub(crate) const JWT_STATUS_KEY: &str = "hive::authentication::jwt_status"; + +/// Context domain for authentication state. +#[derive(Debug, Clone, Default)] +pub struct AuthenticationContext { + /// Scopes extracted from the current authenticated user's JWT. + pub jwt_scopes: Option>, + /// Authentication status. If `Some(true)`, the request has been verified as authenticated. + pub jwt_status: Option, +} + +/// A read-only view of authentication state for plugins. +pub struct RequestContextAuthenticationRead<'a> { + context: &'a AuthenticationContext, +} + +impl RequestContextAuthenticationRead<'_> { + /// Returns the authenticated user's scopes if present. + pub fn jwt_scopes(&self) -> Option<&HashSet> { + self.context.jwt_scopes.as_ref() + } + + /// Returns the authentication status if known. + pub fn jwt_status(&self) -> Option<&bool> { + self.context.jwt_status.as_ref() + } +} + +impl RequestContextPluginRead { + /// Returns the authentication read API. + pub fn authentication(&self) -> RequestContextAuthenticationRead<'_> { + RequestContextAuthenticationRead { + context: &self.snapshot.authentication, + } + } +} + +impl RequestContextDomain for AuthenticationContext { + const DOMAIN_PREFIX: &'static str = "hive::authentication::"; + + fn set_key_value(&mut self, key: &str, _value: Value) -> Result<(), RequestContextError> { + match key { + JWT_SCOPES_KEY => self.forbidden_mutation(key), + JWT_STATUS_KEY => self.forbidden_mutation(key), + _ => self.unknown_key(key), + } + } + + super::impl_domain_serde!( + JWT_SCOPES_KEY => jwt_scopes, + JWT_STATUS_KEY => jwt_status, + ); +} diff --git a/lib/executor/src/request_context/domains/mod.rs b/lib/executor/src/request_context/domains/mod.rs new file mode 100644 index 000000000..7ec21c41a --- /dev/null +++ b/lib/executor/src/request_context/domains/mod.rs @@ -0,0 +1,258 @@ +use super::domains::authentication::AuthenticationContext; +use super::domains::operation::OperationContext; +use super::domains::progressive_override::ProgressiveOverrideContext; +use super::domains::telemetry::TelemetryContext; +use super::RequestContextError; + +use crate::response::value::Value as ResponseValue; + +use hive_router_config::coprocessor::ContextSelection; +use serde::{ser::SerializeMap, Serialize}; +use sonic_rs::Value; +use std::sync::{Arc, Mutex, MutexGuard}; + +mod authentication; +mod operation; +mod progressive_override; +mod telemetry; + +/// The standard prefix used for all Hive-reserved context keys. +pub(crate) const HIVE_PREFIX: &str = "hive::"; + +/// A trait implemented by internal context domains (operation, authentication and so on). +/// It provides a common interface for key-value mapping for coprocessor patches, and de/serialization. +pub(crate) trait RequestContextDomain { + /// The prefix for keys belonging to this domain (like `hive::operation::`) + const DOMAIN_PREFIX: &'static str; + + /// Checks if a key belongs to this domain based on its prefix. + fn is_applicable(&self, key: &str) -> bool { + key.starts_with(Self::DOMAIN_PREFIX) + } + + /// Updates a value in this domain from a coprocessor patch. + /// This is where per-key mutability policies are enforced. + fn set_key_value(&mut self, key: &str, value: Value) -> Result<(), RequestContextError>; + + /// Serializes all non-null fields in this domain into the provided map. + fn serialize_all(&self, map: &mut S) -> Result<(), S::Error>; + + /// Serializes a specific key from this domain if it exists. + fn serialize_entry(&self, key: &str, map: &mut S) -> Result<(), S::Error>; + + /// Returns the number of non-null fields currently stored in this domain. + /// Some serialization libraries require to define length explicitly, + /// prior to serializing the map. + /// We don't really need that for sonic_rs, but I added it just in case we + /// ever move to a different JSON library. + fn serialized_len(&self) -> usize; + + fn forbidden_mutation(&self, key: &str) -> Result<(), RequestContextError> { + Err(RequestContextError::ForbiddenReservedMutation { + key: key.to_string(), + }) + } + + fn unknown_key(&self, key: &str) -> Result<(), RequestContextError> { + Err(RequestContextError::UnknownReservedKey { + key: key.to_string(), + }) + } + + /// Utility to serialize an optional field into a map only if it is present. + fn serialize_optional_entry( + &self, + map: &mut S, + key: &str, + value: Option<&T>, + ) -> Result<(), S::Error> { + if let Some(value) = value { + map.serialize_entry(key, &value) + } else { + Ok(()) + } + } +} + +/// Implements serialization for a domain struct using the provided key-value pairs. +macro_rules! impl_domain_serde { + ($( $key_const:ident => $field:ident ),+ $(,)?) => { + // Goes over each field and counts how many are present + fn serialized_len(&self) -> usize { + 0 $(+ usize::from(self.$field.is_some()))+ + } + + // Serializes all present fields into the map. + fn serialize_all(&self, map: &mut S) -> Result<(), S::Error> { + $( + self.serialize_optional_entry(map, $key_const, self.$field.as_ref())?; + )+ + Ok(()) + } + + // Serializes a single field by key, if present. + fn serialize_entry(&self, key: &str, map: &mut S) -> Result<(), S::Error> { + match key { + $( + $key_const => self.serialize_optional_entry(map, key, self.$field.as_ref()), + )+ + _ => Ok(()), + } + } + }; +} + +pub(crate) use impl_domain_serde; + +macro_rules! reserved_domains { + ($($name:ident: $type:ty),* $(,)?) => { + /// The root request context structure. + /// + /// It contains typed reserved domains (hive-owned) and a custom context + /// for arbitrary plugin/coprocessor data. + #[derive(Debug, Clone, Default)] + pub struct RequestContext { + $( + /// Reserved context domain for $name. + pub $name: $type, + )* + /// A collection of arbitrary keys and values stored by plugins and coprocessors. + pub custom: CustomContext, + } + + impl RequestContext { + /// Attempts to route a reserved key to the appropriate domain for mutation. + /// Returns `Ok` if the key was handled by a domain, or `Err` + /// if the key does not match any reserved domain keys. + pub(crate) fn try_set_reserved_key( + &mut self, + key: &str, + value: Value, + ) -> Result<(), RequestContextError> { + $( + if self.$name.is_applicable(key) { + self.$name.set_key_value(key, value)?; + return Ok(()); + } + )* + Err(RequestContextError::UnknownReservedKey { + key: key.to_string(), + }) + } + + /// Returns the total number of non-null reserved keys across all domains. + pub(crate) fn reserved_serialized_len(&self) -> usize { + // 0 is the initial value, then we add the serialized length of each domain + 0 $(+ self.$name.serialized_len())* + } + + /// Serializes every active reserved key across all domains into a map. + pub(crate) fn serialize_all_reserved(&self, map: &mut S) -> Result<(), S::Error> { + $(self.$name.serialize_all(map)?;)* + Ok(()) + } + + /// Attempts to serialize a specific reserved key by routing it to its domain. + /// Returns: + /// `true` if the key belonged to a domain (even if it was null and nothing was written) + /// `false` if it matches no domain + pub(crate) fn try_serialize_reserved_entry( + &self, + key: &str, + map: &mut S, + ) -> Result { + $( + if self.$name.is_applicable(key) { + self.$name.serialize_entry(key, map)?; + return Ok(true); + } + )* + Ok(false) + } + } + }; +} + +reserved_domains! { + operation: OperationContext, + progressive_override: ProgressiveOverrideContext, + authentication: AuthenticationContext, + telemetry: TelemetryContext, +} + +#[derive(Debug, Clone, Default)] +pub struct CustomContext(Vec<(String, Value)>); + +impl CustomContext { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0 + .iter() + .find(|(custom_key, _)| custom_key == key) + .map(|(_, value)| value) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.0.iter().map(|(key, value)| (key, value)) + } + + pub(crate) fn apply(&mut self, key: &str, value: ResponseValue<'_>) { + if value.is_null() { + // Remove + self.0.retain(|(current_key, _)| current_key != key); + return; + } + + let value = value.as_ref().into(); + if let Some((_, current)) = self.0.iter_mut().find(|(name, _)| *name == key) { + // Update existing value + *current = value; + return; + } + // Insert new value + self.0.push((key.to_string(), value)); + } + + pub(crate) fn size(&self) -> usize { + self.0.len() + } +} + +#[derive(Default, Debug, Clone)] +pub struct SharedRequestContext(Arc>); + +impl SharedRequestContext { + pub fn read_lock(&self) -> Result, RequestContextError> { + self.0.lock().map_err(|_| RequestContextError::LockPoison) + } + + pub fn snapshot(&self) -> Result { + Ok(self.read_lock()?.clone()) + } + + pub fn update(&self, f: impl FnOnce(&mut RequestContext)) -> Result<(), RequestContextError> { + let mut context = self.read_lock()?; + f(&mut context); + Ok(()) + } +} + +pub struct SelectedRequestContext<'a> { + pub(crate) context: &'a RequestContext, + pub(crate) selection: &'a ContextSelection, +} + +impl RequestContext { + pub fn new() -> Self { + Self::default() + } + + pub fn as_selected<'a>( + &'a self, + selection: &'a ContextSelection, + ) -> SelectedRequestContext<'a> { + SelectedRequestContext { + context: self, + selection, + } + } +} diff --git a/lib/executor/src/request_context/domains/operation.rs b/lib/executor/src/request_context/domains/operation.rs new file mode 100644 index 000000000..7eff6b7a7 --- /dev/null +++ b/lib/executor/src/request_context/domains/operation.rs @@ -0,0 +1,70 @@ +use hive_router_query_planner::state::supergraph_state::OperationKind; +use serde::ser::SerializeMap; +use sonic_rs::Value; + +use super::super::api::plugin::RequestContextPluginRead; +use super::RequestContextDomain; +use super::RequestContextError; + +pub(crate) const OPERATION_NAME_KEY: &str = "hive::operation::name"; +pub(crate) const OPERATION_KIND_KEY: &str = "hive::operation::kind"; + +/// Context domain for GraphQL operation metadata. +/// +/// This domain stores details about the operation extracted from the request. +#[derive(Debug, Clone, Default)] +pub struct OperationContext { + /// The name of the GraphQL operation. + pub name: Option, + /// The kind of the GraphQL operation ("query", "mutation", "subscription"). + pub kind: Option, +} + +impl OperationContext { + /// Updates the operation metadata in a single call. + pub fn update(&mut self, name: Option, kind: Option) { + self.name = name; + self.kind = kind; + } +} + +/// A read-only view of the operation metadata for plugins. +pub struct RequestContextOperationRead<'a> { + context: &'a OperationContext, +} + +impl RequestContextOperationRead<'_> { + pub fn name(&self) -> Option<&String> { + self.context.name.as_ref() + } + + pub fn kind(&self) -> Option<&OperationKind> { + self.context.kind.as_ref() + } +} + +impl RequestContextPluginRead { + /// Returns the operation metadata for reads. + pub fn operation(&self) -> RequestContextOperationRead<'_> { + RequestContextOperationRead { + context: &self.snapshot.operation, + } + } +} + +impl RequestContextDomain for OperationContext { + const DOMAIN_PREFIX: &'static str = "hive::operation::"; + + fn set_key_value(&mut self, key: &str, _value: Value) -> Result<(), RequestContextError> { + match key { + OPERATION_NAME_KEY => self.forbidden_mutation(key), + OPERATION_KIND_KEY => self.forbidden_mutation(key), + _ => self.unknown_key(key), + } + } + + super::impl_domain_serde!( + OPERATION_NAME_KEY => name, + OPERATION_KIND_KEY => kind, + ); +} diff --git a/lib/executor/src/request_context/domains/progressive_override.rs b/lib/executor/src/request_context/domains/progressive_override.rs new file mode 100644 index 000000000..4843fd586 --- /dev/null +++ b/lib/executor/src/request_context/domains/progressive_override.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; + +use serde::ser::SerializeMap; +use sonic_rs::{JsonValueTrait, Value}; + +use super::super::api::plugin::{RequestContextPluginRead, RequestContextPluginWrite}; +use super::super::deser::RequestContextValueExt; +use super::RequestContextDomain; +use super::RequestContextError; +use crate::hooks; + +pub trait CanWriteProgressiveOverride {} +impl CanWriteProgressiveOverride for hooks::OnQueryPlan {} +impl CanWriteProgressiveOverride for hooks::OnHttpRequest {} +impl CanWriteProgressiveOverride for hooks::OnGraphqlParams {} +impl CanWriteProgressiveOverride for hooks::OnGraphqlParse {} +impl CanWriteProgressiveOverride for hooks::OnGraphqlValidation {} + +pub(crate) const UNRESOLVED_LABELS_KEY: &str = "hive::progressive_override::unresolved_labels"; +pub(crate) const LABELS_TO_OVERRIDE_KEY: &str = "hive::progressive_override::labels_to_override"; + +/// Context domain for progressive overrides. +#[derive(Debug, Clone, Default)] +pub struct ProgressiveOverrideContext { + /// The set of labels that require an external decision + pub unresolved_labels: Option>, + /// The set of labels that should be overridden for this request + pub labels_to_override: Option>, +} + +impl ProgressiveOverrideContext { + fn set_labels_to_override_value(&mut self, value: Value) -> Result<(), RequestContextError> { + if value.is_null() { + self.labels_to_override = None; + return Ok(()); + } + + let array = value.expect_array(LABELS_TO_OVERRIDE_KEY, "array of strings or null")?; + let mut labels = HashSet::with_capacity(array.len()); + for item in array { + let label = item.expect_str(LABELS_TO_OVERRIDE_KEY, "array of strings or null")?; + labels.insert(label.to_string()); + } + + self.labels_to_override = Some(labels); + Ok(()) + } +} + +/// A read-only view of progressive override state for plugins. +pub struct RequestContextProgressiveOverrideRead<'a> { + context: &'a ProgressiveOverrideContext, +} + +impl RequestContextProgressiveOverrideRead<'_> { + /// Returns the set of unresolved labels that require a decision. + pub fn unresolved_labels(&self) -> Option<&HashSet> { + self.context.unresolved_labels.as_ref() + } + + /// Returns the set of labels currently marked to be overridden. + pub fn labels_to_override(&self) -> Option<&HashSet> { + self.context.labels_to_override.as_ref() + } +} + +/// A writable interface for progressive override state for plugins. +pub struct RequestContextProgressiveOverrideWrite<'a> { + context: &'a mut ProgressiveOverrideContext, +} + +impl RequestContextProgressiveOverrideWrite<'_> { + /// Sets the labels that should be overridden for the current request. + /// Providing `None` is equivalent to an empty set, so no overrides. + pub fn set_labels_to_override(&mut self, labels: Option>) -> &mut Self { + self.context.labels_to_override = labels; + self + } +} + +impl RequestContextPluginRead { + /// Returns the progressive override read API. + pub fn progressive_override(&self) -> RequestContextProgressiveOverrideRead<'_> { + RequestContextProgressiveOverrideRead { + context: &self.snapshot.progressive_override, + } + } +} + +impl RequestContextPluginWrite<'_, Hook> { + /// Returns the progressive override write API. + /// Only available in hooks that implement `CanWriteProgressiveOverride`. + pub fn progressive_override(&mut self) -> RequestContextProgressiveOverrideWrite<'_> { + RequestContextProgressiveOverrideWrite { + context: &mut self.context.progressive_override, + } + } +} + +impl RequestContextDomain for ProgressiveOverrideContext { + const DOMAIN_PREFIX: &'static str = "hive::progressive_override::"; + + fn set_key_value(&mut self, key: &str, value: Value) -> Result<(), RequestContextError> { + match key { + UNRESOLVED_LABELS_KEY => self.forbidden_mutation(key), + LABELS_TO_OVERRIDE_KEY => self.set_labels_to_override_value(value), + _ => self.unknown_key(key), + } + } + + super::impl_domain_serde!( + UNRESOLVED_LABELS_KEY => unresolved_labels, + LABELS_TO_OVERRIDE_KEY => labels_to_override, + ); +} diff --git a/lib/executor/src/request_context/domains/telemetry.rs b/lib/executor/src/request_context/domains/telemetry.rs new file mode 100644 index 000000000..3a87229c4 --- /dev/null +++ b/lib/executor/src/request_context/domains/telemetry.rs @@ -0,0 +1,60 @@ +use serde::ser::SerializeMap; +use sonic_rs::Value; + +use super::super::api::plugin::RequestContextPluginRead; +use super::RequestContextDomain; +use super::RequestContextError; + +pub(crate) const CLIENT_NAME_KEY: &str = "hive::telemetry::client_name"; +pub(crate) const CLIENT_VERSION_KEY: &str = "hive::telemetry::client_version"; + +/// Context domain for telemetry metadata. +/// This domain stores client identification. +#[derive(Debug, Clone, Default)] +pub struct TelemetryContext { + /// The name of the client application + pub client_name: Option, + /// The version of the client application + pub client_version: Option, +} + +/// A read-only view of telemetry metadata for plugins. +pub struct RequestContextTelemetryRead<'a> { + context: &'a TelemetryContext, +} + +impl RequestContextTelemetryRead<'_> { + pub fn client_name(&self) -> Option<&String> { + self.context.client_name.as_ref() + } + + pub fn client_version(&self) -> Option<&String> { + self.context.client_version.as_ref() + } +} + +impl RequestContextPluginRead { + /// Returns the telemetry metadata for reads. + pub fn telemetry(&self) -> RequestContextTelemetryRead<'_> { + RequestContextTelemetryRead { + context: &self.snapshot.telemetry, + } + } +} + +impl RequestContextDomain for TelemetryContext { + const DOMAIN_PREFIX: &'static str = "hive::telemetry::"; + + fn set_key_value(&mut self, key: &str, _value: Value) -> Result<(), RequestContextError> { + match key { + CLIENT_NAME_KEY => self.forbidden_mutation(key), + CLIENT_VERSION_KEY => self.forbidden_mutation(key), + _ => self.unknown_key(key), + } + } + + super::impl_domain_serde!( + CLIENT_NAME_KEY => client_name, + CLIENT_VERSION_KEY => client_version, + ); +} diff --git a/lib/executor/src/request_context/error.rs b/lib/executor/src/request_context/error.rs new file mode 100644 index 000000000..dc13c5b91 --- /dev/null +++ b/lib/executor/src/request_context/error.rs @@ -0,0 +1,19 @@ +#[derive(Debug, thiserror::Error)] +pub enum RequestContextError { + #[error("request context is missing")] + Missing, + #[error("request context lock is poisoned")] + LockPoison, + #[error("unknown reserved request-context key: {key}")] + UnknownReservedKey { key: String }, + #[error("request-context key '{key}' cannot be mutated externally")] + ForbiddenReservedMutation { key: String }, + #[error("reserved request-context key '{key}' has an invalid type: expected {expected}")] + ReservedKeyTypeMismatch { key: String, expected: &'static str }, + #[error("invalid operation kind: {value}")] + InvalidOperationKind { value: String }, + #[error("reserved prefix in custom key: {key}")] + ReservedPrefixInCustomKey { key: String }, + #[error("json error: {0}")] + Json(sonic_rs::Error), +} diff --git a/lib/executor/src/request_context/mod.rs b/lib/executor/src/request_context/mod.rs new file mode 100644 index 000000000..a7feadff9 --- /dev/null +++ b/lib/executor/src/request_context/mod.rs @@ -0,0 +1,11 @@ +mod api; +mod deser; +mod domains; +mod error; +mod web; + +pub use api::coprocessor::RequestContextPatch; +pub use api::plugin::RequestContextPluginApi; +pub use domains::{RequestContext, SelectedRequestContext, SharedRequestContext}; +pub use error::RequestContextError; +pub use web::RequestContextExt; diff --git a/lib/executor/src/request_context/web.rs b/lib/executor/src/request_context/web.rs new file mode 100644 index 000000000..94a8269b1 --- /dev/null +++ b/lib/executor/src/request_context/web.rs @@ -0,0 +1,39 @@ +use ntex::web; + +use super::domains::SharedRequestContext; +use super::error::RequestContextError; + +pub trait RequestContextExt { + fn read_request_context(&self) -> Result; + fn write_request_context(&mut self, context: SharedRequestContext); +} + +impl RequestContextExt for web::HttpRequest { + #[inline] + fn read_request_context(&self) -> Result { + self.extensions() + .get::() + .cloned() + .ok_or(RequestContextError::Missing) + } + + #[inline] + fn write_request_context(&mut self, context: SharedRequestContext) { + self.extensions_mut().insert(context); + } +} + +impl RequestContextExt for web::WebRequest { + #[inline] + fn read_request_context(&self) -> Result { + self.extensions() + .get::() + .cloned() + .ok_or(RequestContextError::Missing) + } + + #[inline] + fn write_request_context(&mut self, context: SharedRequestContext) { + self.extensions_mut().insert(context); + } +} diff --git a/lib/executor/src/response/value.rs b/lib/executor/src/response/value.rs index aee57fca6..b652fdca3 100644 --- a/lib/executor/src/response/value.rs +++ b/lib/executor/src/response/value.rs @@ -4,7 +4,7 @@ use serde::{ de::{self, Deserializer, MapAccess, SeqAccess, Visitor}, ser::{SerializeMap, SerializeSeq}, }; -use sonic_rs::{JsonNumberTrait, ValueRef}; +use sonic_rs::{JsonNumberTrait, Value as SonicValue, ValueRef}; use std::{ borrow::Cow, fmt::Display, @@ -27,6 +27,12 @@ pub enum Value<'a> { Object(Vec<(&'a str, Value<'a>)>), } +impl<'a> AsRef> for Value<'a> { + fn as_ref(&self) -> &Value<'a> { + self + } +} + impl Hash for Value<'_> { fn hash(&self, state: &mut H) { match self { @@ -381,6 +387,40 @@ impl serde::Serialize for Value<'_> { } } +impl From<&Value<'_>> for SonicValue { + fn from(value: &Value) -> Self { + match value { + Value::Null => SonicValue::new_null(), + Value::Bool(b) => (*b).into(), + Value::F64(f) => match SonicValue::new_f64(*f) { + Some(num) => num, + None => SonicValue::new_null(), + }, + Value::I64(n) => (*n).into(), + Value::U64(n) => (*n).into(), + Value::Array(l) => { + let mut array_value = SonicValue::new_array_with(l.len()); + + for val in l.iter() { + array_value.append_value(val.into()); + } + + array_value + } + Value::Object(o) => { + let mut object_value = SonicValue::new_object_with(o.len()); + + for (k, v) in o.iter() { + object_value.insert(k, v.into()); + } + + object_value + } + Value::String(s) => SonicValue::from(s.as_ref()), + } + } +} + #[cfg(test)] mod tests { use super::Value; diff --git a/lib/internal/src/expressions/lib.rs b/lib/internal/src/expressions/lib.rs index c9fdafd9d..71637e342 100644 --- a/lib/internal/src/expressions/lib.rs +++ b/lib/internal/src/expressions/lib.rs @@ -6,6 +6,7 @@ use hive_router_config::traffic_shaping::DurationOrExpression; use vrl::{ compiler::{compile as vrl_compile, Program as VrlProgram, TargetValue as VrlTargetValue}, core::Value as VrlValue, + path::OwnedSegment, prelude::{ state::RuntimeState as VrlState, Context as VrlContext, Function, TimeZone as VrlTimeZone, }, @@ -105,13 +106,11 @@ impl ExecutableProgram for VrlProgram { } } -/// Generic enum for a value that can be either static or computed via VRL expression -#[derive(Clone)] pub enum ValueOrProgram { /// A statically-known value Value(T), /// A VRL program that computes the value at runtime - Program(Box), + Program(Box, ProgramHints), } impl ValueOrProgram @@ -131,7 +130,7 @@ where { match self { ValueOrProgram::Value(v) => Ok(v.clone()), - ValueOrProgram::Program(vrl_program) => { + ValueOrProgram::Program(vrl_program, _) => { let vrl_context = vrl_context_fn(); let result_value = vrl_program .execute(vrl_context) @@ -141,6 +140,31 @@ where } } } + + /// Resolve this ValueOrProgram to a concrete value, providing target query hints + /// to the context function so it can optimize the VRL context structure. + /// + /// - `vrl_context_fn` - A function that returns the VRL value context, given the query hints + #[inline] + pub fn resolve_with_hints( + &self, + vrl_context_fn: F, + ) -> Result> + where + F: FnOnce(&ProgramHints) -> VrlValue, + { + match self { + ValueOrProgram::Value(v) => Ok(v.clone()), + ValueOrProgram::Program(vrl_program, hints) => { + let vrl_context = vrl_context_fn(hints); + let result_value = vrl_program + .execute(vrl_context) + .map_err(ProgramResolutionError::ExecutionFailed)?; + + T::from_vrl_value(result_value).map_err(ProgramResolutionError::ConversionFailed) + } + } + } } impl ValueOrProgram { @@ -152,8 +176,172 @@ impl ValueOrProgram { DurationOrExpression::Duration(dur) => Ok(ValueOrProgram::Value(*dur)), DurationOrExpression::Expression { expression } => { let program = expression.as_str().compile_expression(fns)?; - Ok(ValueOrProgram::Program(Box::new(program))) + let hints = ProgramHints::from_program(&program); + Ok(ValueOrProgram::Program(Box::new(program), hints)) } } } } + +#[derive(Debug, Default)] +struct HintNode { + is_terminal: bool, + children: Vec<(String, HintNode)>, +} + +impl HintNode { + fn insert(&mut self, path: &[OwnedSegment]) { + if path.is_empty() { + self.is_terminal = true; + return; + } + + let OwnedSegment::Field(ref f) = path[0] else { + return; // Ignore index segments + }; + let key = f.as_str(); + + let child_idx = if let Some(idx) = self.children.iter().position(|(k, _)| k == key) { + idx + } else { + self.children.push((key.to_string(), HintNode::default())); + self.children.len() - 1 + }; + + self.children[child_idx].1.insert(&path[1..]); + } + + fn get_child(&self, key: &str) -> Option<&HintNode> { + self.children.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } +} + +/// This struct analyzes a VRL program to determine which variables are accessed +/// during execution. +/// The purpose of this struct is to selectively build context for expressions. +#[derive(Debug, Default)] +pub struct ProgramHints { + root: HintNode, +} + +impl ProgramHints { + pub fn from_program(program: &VrlProgram) -> Self { + let mut root = HintNode::default(); + for q in &program.info().target_queries { + root.insert(&q.path.segments); + } + Self { root } + } + + pub fn context_builder<'a>( + &'a self, + build_fn: impl FnOnce(&mut VrlObjectBuilder<'a, '_>), + ) -> VrlValue { + VrlContextBuilder::new(self).build_root(build_fn) + } +} + +pub struct VrlContextBuilder<'a> { + hints: &'a ProgramHints, +} + +impl<'a> VrlContextBuilder<'a> { + fn new(hints: &'a ProgramHints) -> Self { + Self { hints } + } + + /// Entry point to build the root object + fn build_root(&self, build_fn: impl FnOnce(&mut VrlObjectBuilder<'a, '_>)) -> VrlValue { + let mut map = BTreeMap::new(); + let mut obj_builder = VrlObjectBuilder { + node: Some(&self.hints.root), + force_build: self.hints.root.is_terminal, + map: &mut map, + }; + build_fn(&mut obj_builder); + VrlValue::Object(map) + } +} + +enum ChildState<'a> { + Skip, + Force, + Explore(&'a HintNode), +} + +/// A builder tied to a specific depth in the object tree. +pub struct VrlObjectBuilder<'a, 'b> { + node: Option<&'a HintNode>, + force_build: bool, + map: &'b mut BTreeMap, +} + +impl<'a, 'b> VrlObjectBuilder<'a, 'b> { + /// Inserts a lazy value if the path is requested. + pub fn insert_lazy(&mut self, key: &'static str, value_fn: F) -> &mut Self + where + F: FnOnce() -> VrlValue, + { + if !matches!(self.evaluate_child(key), ChildState::Skip) { + self.map.insert(key.into(), value_fn()); + } + self + } + + /// Nests a new object. The inner builder will be skipped entirely if the parent key + /// is not requested. + pub fn insert_object( + &mut self, + key: &'static str, + build_fn: impl FnOnce(&mut VrlObjectBuilder<'a, '_>), + ) -> &mut Self { + let child_state = self.evaluate_child(key); + + if matches!(child_state, ChildState::Skip) { + return self; + } + + let mut inner_map = BTreeMap::new(); + + let force_build = matches!(child_state, ChildState::Force); + let child_node = match child_state { + ChildState::Explore(n) => Some(n), + _ => None, + }; + + let mut sub_builder = VrlObjectBuilder { + node: child_node, + force_build, + map: &mut inner_map, + }; + build_fn(&mut sub_builder); + + // Only insert the object if children were added or it was explicitly requested + if !inner_map.is_empty() || force_build { + self.map.insert(key.into(), VrlValue::Object(inner_map)); + } + self + } + + /// Evaluates a key to determine how its children should be built. + #[inline] + fn evaluate_child(&self, key: &str) -> ChildState<'a> { + if self.force_build { + return ChildState::Force; + } + + let Some(node) = self.node else { + return ChildState::Skip; + }; + + let Some(child) = node.get_child(key) else { + return ChildState::Skip; + }; + + if child.is_terminal { + return ChildState::Force; + } + + ChildState::Explore(child) + } +} diff --git a/lib/internal/src/expressions/mod.rs b/lib/internal/src/expressions/mod.rs index 194ba91d9..d3e6cbacd 100644 --- a/lib/internal/src/expressions/mod.rs +++ b/lib/internal/src/expressions/mod.rs @@ -6,7 +6,7 @@ pub mod values; pub use vrl; pub use error::{ExpressionCompileError, ExpressionExecutionError, ProgramResolutionError}; -pub use lib::{CompileExpression, ExecutableProgram, FromVrlValue, ValueOrProgram}; +pub use lib::{CompileExpression, ExecutableProgram, FromVrlValue, ProgramHints, ValueOrProgram}; pub use values::duration::{DurationConversionError, DurationOrProgram}; -pub use values::header_value::HeaderValueConversionError; +pub use values::http::HeaderValueConversionError; pub use values::string::{StringConversionError, StringOrProgram}; diff --git a/lib/internal/src/expressions/values/header_value.rs b/lib/internal/src/expressions/values/http.rs similarity index 57% rename from lib/internal/src/expressions/values/header_value.rs rename to lib/internal/src/expressions/values/http.rs index c1ed2b29f..a2ae9ae49 100644 --- a/lib/internal/src/expressions/values/header_value.rs +++ b/lib/internal/src/expressions/values/http.rs @@ -1,5 +1,9 @@ -use crate::expressions::FromVrlValue; -use http::HeaderValue; +use std::collections::BTreeMap; + +use crate::expressions::{lib::ToVrlValue, FromVrlValue}; +use bytes::Bytes; +use http::{HeaderMap, HeaderValue, Uri}; +use ntex::http::HeaderMap as NtexHeaderMap; use vrl::core::Value as VrlValue; /// Error type for HeaderValue conversion failures @@ -61,3 +65,54 @@ impl FromVrlValue for HeaderValue { } } } + +impl ToVrlValue for NtexHeaderMap { + fn to_vrl_value(&self) -> VrlValue { + let mut obj = BTreeMap::new(); + for (header_name, header_value) in self.iter() { + if let Ok(value) = header_value.to_str() { + obj.insert( + header_name.as_str().into(), + VrlValue::Bytes(Bytes::from(value.to_owned())), + ); + } + } + VrlValue::Object(obj) + } +} + +impl ToVrlValue for HeaderMap { + fn to_vrl_value(&self) -> VrlValue { + let mut obj = BTreeMap::new(); + for (header_name, header_value) in self.iter() { + if let Ok(value) = header_value.to_str() { + obj.insert( + header_name.as_str().into(), + VrlValue::Bytes(Bytes::from(value.to_owned())), + ); + } + } + VrlValue::Object(obj) + } +} + +impl ToVrlValue for Uri { + fn to_vrl_value(&self) -> VrlValue { + VrlValue::Object(BTreeMap::from([ + ("host".into(), self.host().unwrap_or("unknown").into()), + ("path".into(), self.path().into()), + ( + "port".into(), + self.port_u16() + .unwrap_or_else(|| { + if self.scheme() == Some(&http::uri::Scheme::HTTPS) { + 443 + } else { + 80 + } + }) + .into(), + ), + ])) + } +} diff --git a/lib/internal/src/expressions/values/mod.rs b/lib/internal/src/expressions/values/mod.rs index 556f22c19..7c4115ddf 100644 --- a/lib/internal/src/expressions/values/mod.rs +++ b/lib/internal/src/expressions/values/mod.rs @@ -1,5 +1,5 @@ pub mod boolean; pub mod duration; -pub mod header_value; +pub mod http; pub mod sonic; pub mod string; diff --git a/lib/internal/src/http.rs b/lib/internal/src/http.rs index 373454464..74280c5a7 100644 --- a/lib/internal/src/http.rs +++ b/lib/internal/src/http.rs @@ -1,4 +1,13 @@ -use http::{uri::Scheme, Method, Uri, Version}; +use std::cell::{Ref, RefMut}; + +use futures::TryStreamExt; +use http::{header::CONTENT_LENGTH, uri::Scheme, Method, Uri, Version}; +use ntex::{ + http::{error::PayloadError, HeaderMap}, + util::{Bytes, BytesMut, Extensions}, + web::{self, DefaultError, HttpRequest, WebRequest}, +}; +use strum::IntoStaticStr; pub trait HttpUriAsStr { fn scheme_static_str(&self) -> &'static str; @@ -70,3 +79,145 @@ pub fn normalize_route_path(path: &str) -> String { format!("/{trimmed}") } } + +/// Stores the request body size in bytes. +/// +/// The value comes from either: +/// - the `Content-Length` header +/// - the streamed payload, measured from bytes read. +/// +/// For streamed payloads, the recorded size is the number of bytes read up to +/// the configured maximum. +/// +/// Using `RequestBodySize` to store the size of the request body, +/// helps to reduce complexity in code, as otherwise, +/// we would have to return the size next within Err and Ok of `read_body_stream`. +#[derive(Debug, Clone, Copy)] +pub struct RequestBodySize(pub u64); + +pub struct RequestBodyBytes(pub Bytes); + +#[derive(Debug, thiserror::Error, IntoStaticStr)] +pub enum ReadBodyStreamError { + #[error("Failed to read request body: {0}")] + #[strum(serialize = "PAYLOAD_READ_ERROR")] + // Thrown while reading the body stream with `try_next()` + PayloadReadError(#[from] PayloadError), + + #[error("Content-Length header has invalid value")] + #[strum(serialize = "INVALID_HEADER")] + InvalidContentLengthHeader, + + #[error("Content-Length exceeds the maximum allowed size: {0}")] + #[strum(serialize = "PAYLOAD_TOO_LARGE_CONTENT_LENGTH")] + PayloadTooLargeContentLength(usize), + + #[error("Request body exceeds the maximum allowed size while reading the stream")] + #[strum(serialize = "PAYLOAD_TOO_LARGE_BODY_STREAM")] + PayloadTooLargeBodyStream, +} + +impl ReadBodyStreamError { + pub fn status_code(&self) -> http::StatusCode { + match self { + Self::PayloadReadError(_) => http::StatusCode::UNPROCESSABLE_ENTITY, + Self::InvalidContentLengthHeader => http::StatusCode::BAD_REQUEST, + Self::PayloadTooLargeContentLength(_) | Self::PayloadTooLargeBodyStream => { + http::StatusCode::PAYLOAD_TOO_LARGE + } + } + } + + pub fn error_code(&self) -> &'static str { + self.into() + } +} + +#[inline] +fn write_request_body_size(req: &R, size: u64) { + req.extensions_mut().insert(RequestBodySize(size)); +} + +#[inline] +pub fn read_request_body_size(req: &HttpRequest) -> Option { + req.extensions().get::().map(|size| size.0) +} + +#[inline] +pub async fn read_body_stream( + req: &R, + mut body_stream: web::types::Payload, + max_size: usize, +) -> Result { + let content_length: Option = { + let content_length_header = req.headers().get(CONTENT_LENGTH); + if let Some(content_length_header) = content_length_header { + let content_length_str = content_length_header + .to_str() + .map_err(|_| ReadBodyStreamError::InvalidContentLengthHeader)?; + let content_length: usize = content_length_str + .parse() + .map_err(|_| ReadBodyStreamError::InvalidContentLengthHeader)?; + if content_length > max_size { + write_request_body_size(req, content_length as u64); + return Err(ReadBodyStreamError::PayloadTooLargeContentLength(max_size)); + } + Some(content_length) + } else { + None + } + }; + + let mut body = if let Some(content_length) = content_length { + BytesMut::with_capacity(content_length) + } else { + BytesMut::new() + }; + + while let Some(chunk) = body_stream.try_next().await? { + // limit max size of in-memory payload + if chunk.len() > max_size.saturating_sub(body.len()) { + write_request_body_size(req, (body.len() + chunk.len()) as u64); + return Err(ReadBodyStreamError::PayloadTooLargeBodyStream); + } + body.extend_from_slice(&chunk); + } + + write_request_body_size(req, body.len() as u64); + + Ok(body.freeze()) +} + +pub trait RequestLike { + fn headers(&self) -> &HeaderMap; + fn extensions(&self) -> Ref<'_, Extensions>; + fn extensions_mut(&self) -> RefMut<'_, Extensions>; +} + +impl RequestLike for HttpRequest { + fn headers(&self) -> &HeaderMap { + self.headers() + } + + fn extensions(&self) -> Ref<'_, Extensions> { + self.extensions() + } + + fn extensions_mut(&self) -> RefMut<'_, Extensions> { + self.extensions_mut() + } +} + +impl RequestLike for WebRequest { + fn headers(&self) -> &HeaderMap { + self.headers() + } + + fn extensions(&self) -> Ref<'_, Extensions> { + self.extensions() + } + + fn extensions_mut(&self) -> RefMut<'_, Extensions> { + self.extensions_mut() + } +} diff --git a/lib/internal/src/telemetry/metrics/catalog.rs b/lib/internal/src/telemetry/metrics/catalog.rs index 6b6fd5230..98f9141fb 100644 --- a/lib/internal/src/telemetry/metrics/catalog.rs +++ b/lib/internal/src/telemetry/metrics/catalog.rs @@ -80,6 +80,7 @@ pub mod labels { pub const GRAPHQL_OPERATION_TYPE: &str = "graphql.operation.type"; pub const GRAPHQL_OPERATION_NAME: &str = "graphql.operation.name"; pub const GRAPHQL_RESPONSE_STATUS: &str = "graphql.response.status"; + pub const COPROCESSOR_STAGE: &str = "coprocessor.stage"; } pub mod names { @@ -111,6 +112,9 @@ pub mod names { "hive.router.persisted_documents.storage.failures_total"; pub const PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL: &str = "hive.router.persisted_documents.extract.missing_id_total"; + pub const COPROCESSOR_REQUESTS_TOTAL: &str = "hive.router.coprocessor.requests_total"; + pub const COPROCESSOR_DURATION: &str = "hive.router.coprocessor.duration"; + pub const COPROCESSOR_ERRORS_TOTAL: &str = "hive.router.coprocessor.errors_total"; } pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ @@ -240,6 +244,15 @@ pub(crate) const METRIC_SPECS: &[(&str, &[&str])] = &[ (names::PLAN_CACHE_SIZE, &[]), (names::PERSISTED_DOCUMENTS_STORAGE_FAILURES_TOTAL, &[]), (names::PERSISTED_DOCUMENTS_EXTRACT_MISSING_ID_TOTAL, &[]), + ( + names::COPROCESSOR_REQUESTS_TOTAL, + &[labels::COPROCESSOR_STAGE], + ), + (names::COPROCESSOR_DURATION, &[labels::COPROCESSOR_STAGE]), + ( + names::COPROCESSOR_ERRORS_TOTAL, + &[labels::COPROCESSOR_STAGE], + ), ]; pub fn labels_for(metric_name: &str) -> Option<&'static [&'static str]> { diff --git a/lib/internal/src/telemetry/metrics/coprocessor_metrics.rs b/lib/internal/src/telemetry/metrics/coprocessor_metrics.rs new file mode 100644 index 000000000..d442315fe --- /dev/null +++ b/lib/internal/src/telemetry/metrics/coprocessor_metrics.rs @@ -0,0 +1,73 @@ +use opentelemetry::metrics::{Counter, Histogram, Meter}; +use opentelemetry::KeyValue; + +use crate::telemetry::metrics::catalog; +#[cfg(debug_assertions)] +use crate::telemetry::metrics::catalog::debug_assert_attrs; + +pub struct CoprocessorMetrics { + pub requests_total: Option>, + pub duration: Option>, + pub errors_total: Option>, +} + +impl CoprocessorMetrics { + pub fn new(meter: Option<&Meter>) -> Self { + let Some(meter) = meter else { + return Self { + requests_total: None, + duration: None, + errors_total: None, + }; + }; + + Self { + requests_total: Some( + meter + .u64_counter(catalog::names::COPROCESSOR_REQUESTS_TOTAL) + .with_description("Total number of coprocessor requests") + .build(), + ), + duration: Some( + meter + .f64_histogram(catalog::names::COPROCESSOR_DURATION) + .with_description("Duration of coprocessor requests") + .with_unit("s") + .build(), + ), + errors_total: Some( + meter + .u64_counter(catalog::names::COPROCESSOR_ERRORS_TOTAL) + .with_description("Total number of coprocessor errors") + .build(), + ), + } + } + + pub fn record_request(&self, stage: &'static str) { + if let Some(metric) = &self.requests_total { + let attrs = [KeyValue::new(catalog::labels::COPROCESSOR_STAGE, stage)]; + #[cfg(debug_assertions)] + debug_assert_attrs(catalog::names::COPROCESSOR_REQUESTS_TOTAL, &attrs); + metric.add(1, &attrs); + } + } + + pub fn record_duration(&self, stage: &'static str, duration: f64) { + if let Some(metric) = &self.duration { + let attrs = [KeyValue::new(catalog::labels::COPROCESSOR_STAGE, stage)]; + #[cfg(debug_assertions)] + debug_assert_attrs(catalog::names::COPROCESSOR_DURATION, &attrs); + metric.record(duration, &attrs); + } + } + + pub fn record_error(&self, stage: &'static str) { + if let Some(metric) = &self.errors_total { + let attrs = [KeyValue::new(catalog::labels::COPROCESSOR_STAGE, stage)]; + #[cfg(debug_assertions)] + debug_assert_attrs(catalog::names::COPROCESSOR_ERRORS_TOTAL, &attrs); + metric.add(1, &attrs); + } + } +} diff --git a/lib/internal/src/telemetry/metrics/mod.rs b/lib/internal/src/telemetry/metrics/mod.rs index 15643faae..97e6a322f 100644 --- a/lib/internal/src/telemetry/metrics/mod.rs +++ b/lib/internal/src/telemetry/metrics/mod.rs @@ -1,19 +1,20 @@ pub mod cache_metrics; mod capture; pub mod catalog; +pub mod coprocessor_metrics; pub mod graphql_metrics; pub mod http_client_metrics; pub mod http_server_metrics; pub mod persisted_documents_metrics; pub mod setup; pub mod supergraph_metrics; - pub use opentelemetry::metrics::ObservableGauge; pub use setup::{build_meter_provider_from_config, MetricsSetup, PrometheusRuntimeConfig}; use opentelemetry::metrics::Meter; use crate::telemetry::metrics::cache_metrics::CacheMetrics; +use crate::telemetry::metrics::coprocessor_metrics::CoprocessorMetrics; use crate::telemetry::metrics::graphql_metrics::GraphQLMetrics; use crate::telemetry::metrics::http_client_metrics::HttpClientMetrics; use crate::telemetry::metrics::http_server_metrics::HttpServerMetrics; @@ -27,6 +28,7 @@ pub struct Metrics { pub supergraph: SupergraphMetrics, pub cache: CacheMetrics, pub persisted_documents: PersistedDocumentsMetrics, + pub coprocessor: CoprocessorMetrics, } impl Metrics { @@ -38,6 +40,7 @@ impl Metrics { supergraph: SupergraphMetrics::new(meter), cache: CacheMetrics::new(meter), persisted_documents: PersistedDocumentsMetrics::new(meter), + coprocessor: CoprocessorMetrics::new(meter), } } } diff --git a/lib/internal/src/telemetry/mod.rs b/lib/internal/src/telemetry/mod.rs index 859f5b207..89477b357 100644 --- a/lib/internal/src/telemetry/mod.rs +++ b/lib/internal/src/telemetry/mod.rs @@ -16,11 +16,13 @@ use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; use crate::telemetry::metrics::Metrics; +use crate::telemetry::propagation::HeaderMapInjector; use crate::telemetry::traces::build_trace_provider; pub mod error; pub mod metrics; pub mod otel; +pub mod propagation; pub mod traces; pub mod utils; @@ -105,6 +107,10 @@ impl TelemetryContext { } } + pub fn inject_context_into_http_headers(&self, headers: &mut http::HeaderMap) { + self.inject_context(&mut HeaderMapInjector::from(headers)); + } + pub fn extract_context(&self, extractor: &E) -> otel::opentelemetry::Context where E: otel::opentelemetry::propagation::Extractor, diff --git a/lib/internal/src/telemetry/propagation.rs b/lib/internal/src/telemetry/propagation.rs new file mode 100644 index 000000000..2692c75da --- /dev/null +++ b/lib/internal/src/telemetry/propagation.rs @@ -0,0 +1,25 @@ +use http::{HeaderMap, HeaderName, HeaderValue}; + +use crate::telemetry::Injector; + +pub struct HeaderMapInjector<'a>(&'a mut HeaderMap); + +impl<'a> From<&'a mut HeaderMap> for HeaderMapInjector<'a> { + fn from(value: &'a mut HeaderMap) -> Self { + Self(value) + } +} + +impl Injector for HeaderMapInjector<'_> { + fn set(&mut self, key: &str, value: String) { + let Ok(name) = HeaderName::from_bytes(key.as_bytes()) else { + return; + }; + + let Ok(val) = HeaderValue::from_str(&value) else { + return; + }; + + self.0.insert(name, val); + } +} diff --git a/lib/internal/src/telemetry/traces/spans/coprocessor.rs b/lib/internal/src/telemetry/traces/spans/coprocessor.rs new file mode 100644 index 000000000..c013b8b0f --- /dev/null +++ b/lib/internal/src/telemetry/traces/spans/coprocessor.rs @@ -0,0 +1,34 @@ +use tracing::{info_span, Level, Span}; + +use crate::telemetry::traces::{disabled_span, is_level_enabled, spans::TARGET_NAME}; + +pub struct CoprocessorSpan { + pub span: Span, +} + +impl std::ops::Deref for CoprocessorSpan { + type Target = Span; + fn deref(&self) -> &Self::Target { + &self.span + } +} + +impl CoprocessorSpan { + pub fn new(stage: &'static str, id: &str) -> Self { + if !is_level_enabled(Level::INFO) { + return Self { + span: disabled_span(), + }; + } + + let span = info_span!( + target: TARGET_NAME, + "coprocessor", + "hive.kind" = "coprocessor", + "otel.kind" = "Internal", + "coprocessor.stage" = stage, + "coprocessor.id" = id, + ); + CoprocessorSpan { span } + } +} diff --git a/lib/internal/src/telemetry/traces/spans/kind.rs b/lib/internal/src/telemetry/traces/spans/kind.rs index 4abcdc725..4513df7ab 100644 --- a/lib/internal/src/telemetry/traces/spans/kind.rs +++ b/lib/internal/src/telemetry/traces/spans/kind.rs @@ -33,6 +33,8 @@ pub enum HiveSpanKind { GraphqlOperation, #[strum(serialize = "graphql.subgraph.operation")] GraphQLSubgraphOperation, + #[strum(serialize = "coprocessor")] + Coprocessor, } impl HiveSpanKind { diff --git a/lib/internal/src/telemetry/traces/spans/mod.rs b/lib/internal/src/telemetry/traces/spans/mod.rs index a0de4fe94..faaf8555f 100644 --- a/lib/internal/src/telemetry/traces/spans/mod.rs +++ b/lib/internal/src/telemetry/traces/spans/mod.rs @@ -14,6 +14,7 @@ pub const TARGET_NAME: &str = "hive-router"; pub mod attributes; +pub mod coprocessor; pub mod graphql; pub mod http_request; pub mod kind; diff --git a/lib/query-planner/src/state/supergraph_state.rs b/lib/query-planner/src/state/supergraph_state.rs index 7fb97fdb6..6f8562861 100644 --- a/lib/query-planner/src/state/supergraph_state.rs +++ b/lib/query-planner/src/state/supergraph_state.rs @@ -610,7 +610,7 @@ impl SupergraphState { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub enum OperationKind { #[serde(rename = "query")] @@ -621,6 +621,16 @@ pub enum OperationKind { Subscription, } +impl OperationKind { + pub fn as_str(&self) -> &'static str { + match self { + OperationKind::Query => "query", + OperationKind::Mutation => "mutation", + OperationKind::Subscription => "subscription", + } + } +} + impl Display for OperationKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -631,6 +641,19 @@ impl Display for OperationKind { } } +impl TryFrom<&str> for OperationKind { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "query" => Ok(OperationKind::Query), + "mutation" => Ok(OperationKind::Mutation), + "subscription" => Ok(OperationKind::Subscription), + _ => Err("invalid operation kind".to_string()), + } + } +} + #[derive(Debug)] pub enum SupergraphDefinition { Object(SupergraphObjectType), diff --git a/lib/router-config/src/coprocessor.rs b/lib/router-config/src/coprocessor.rs new file mode 100644 index 000000000..2c93ab82f --- /dev/null +++ b/lib/router-config/src/coprocessor.rs @@ -0,0 +1,673 @@ +use std::{collections::HashSet, fmt, time::Duration}; + +use schemars::{json_schema, JsonSchema}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, +}; + +use crate::primitives::value_or_expression::ValueOrExpression; + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorConfig { + /// Endpoint for the external coprocessor service. + /// + /// Supported formats: + /// - `http://host[:port][/path]` + /// - `unix:///absolute/path/to/socket.sock` + /// - `unix:///absolute/path/to/socket.sock?path=/request/path` + pub url: CoprocessorEndpoint, + + /// Transport protocol used to call the coprocessor service. + pub protocol: CoprocessorProtocol, + + #[serde( + default = "default_coprocessor_timeout", + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize" + )] + #[schemars(with = "String")] + /// Per-stage timeout for a coprocessor call. + /// + /// Defaults to `1s`. + pub timeout: Duration, + + #[serde(default)] + /// Stage-specific configuration. + pub stages: CoprocessorStagesConfig, +} + +fn default_coprocessor_timeout() -> Duration { + Duration::from_secs(1) +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoprocessorProtocol { + /// HTTP/1.1 over TCP. + Http1, + /// HTTP/2 over TLS (currently unsupported and rejected). + Http2, + /// HTTP/2 cleartext over TCP. + H2c, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorStagesConfig { + #[serde(default)] + /// Hooks around the router HTTP boundary + pub router: CoprocessorRouterStageConfig, + #[serde(default)] + /// Hooks around GraphQL processing + pub graphql: CoprocessorGraphqlStageConfig, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorRouterStageConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Configuration for `router.request` hook. + pub request: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Configuration for `router.response` hook. + pub response: Option>, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorGraphqlStageConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Configuration for `graphql.request` hook. + pub request: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Configuration for `graphql.analysis` hook. + pub analysis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Configuration for `graphql.response` hook. + pub response: Option>, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorHookConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional condition expression. + /// + /// The hook runs only when this expression evaluates to `true`. + pub condition: Option>, + #[serde(default)] + /// Selects which fields are included in the coprocessor payload for this hook. + pub include: I, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorRouterRequestIncludeConfig { + #[serde(default)] + /// Include the inbound HTTP request body. + pub body: bool, + #[serde(default)] + /// Include request context. + /// + /// Values: + /// - `false`: no context + /// - `true`: full context + /// - list: selected context keys + pub context: ContextSelection, + #[serde(default)] + /// Include inbound HTTP request headers. + pub headers: bool, + #[serde(default)] + /// Include inbound HTTP request method. + pub method: bool, + #[serde(default)] + /// Include inbound HTTP request path. + pub path: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorRouterResponseIncludeConfig { + #[serde(default)] + /// Include outbound HTTP response body. + pub body: bool, + #[serde(default)] + /// Include request context. + /// + /// Values: + /// - `false`: no context + /// - `true`: full context + /// - list: selected context keys + pub context: ContextSelection, + #[serde(default)] + /// Include outbound HTTP response headers. + pub headers: bool, + #[serde(default)] + /// Include outbound HTTP response status code. + pub status_code: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorGraphqlRequestIncludeConfig { + #[serde(default)] + /// Include GraphQL request body fields. + /// + /// Accepts `true`, `false`, or a list of fields. + pub body: GraphqlBodySelection, + #[serde(default)] + /// Include request context. + /// + /// Values: + /// - `false`: no context + /// - `true`: full context + /// - list: selected context keys + pub context: ContextSelection, + #[serde(default)] + /// Include request headers. + pub headers: bool, + #[serde(default)] + /// Include request method. + pub method: bool, + #[serde(default)] + /// Include request path. + pub path: bool, + #[serde(default)] + /// Include the current public schema SDL. + pub sdl: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorGraphqlResponseIncludeConfig { + #[serde(default)] + /// Include GraphQL response body. + pub body: bool, + #[serde(default)] + /// Include request context. + /// + /// Values: + /// - `false`: no context + /// - `true`: full context + /// - list: selected context keys + pub context: ContextSelection, + #[serde(default)] + /// Include response headers. + pub headers: bool, + #[serde(default)] + /// Include response status code. + pub status_code: bool, + #[serde(default)] + /// Include the current public schema SDL. + pub sdl: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct CoprocessorGraphqlAnalysisIncludeConfig { + #[serde(default)] + /// Include GraphQL request body fields. + /// + /// Accepts `true`, `false`, or a list of fields. + pub body: GraphqlBodySelection, + #[serde(default)] + /// Include request context. + /// + /// Values: + /// - `false`: no context + /// - `true`: full context + /// - list: selected context keys + pub context: ContextSelection, + #[serde(default)] + /// Include request headers. + pub headers: bool, + #[serde(default)] + /// Include request method. + pub method: bool, + #[serde(default)] + /// Include request path. + pub path: bool, + #[serde(default)] + /// Include the current public schema SDL. + pub sdl: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum GraphqlBodyField { + /// Include the GraphQL query string. + Query, + /// Include the GraphQL operation name. + OperationName, + /// Include GraphQL variables. + Variables, + /// Include GraphQL extensions. + Extensions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// Selection set for GraphQL body fields included in coprocessor payloads. +/// +/// Serialized forms: +/// - `true` => all body fields +/// - `false` => no body fields +/// - list => selected body fields +pub struct GraphqlBodySelection { + /// Include `query`. + pub query: bool, + /// Include `operationName`. + pub operation_name: bool, + /// Include `variables`. + pub variables: bool, + /// Include `extensions`. + pub extensions: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(untagged)] +enum GraphqlBodySelectionRepr { + Bool(bool), + List(Vec), +} + +impl GraphqlBodySelection { + pub const fn all() -> Self { + Self { + query: true, + operation_name: true, + variables: true, + extensions: true, + } + } + + pub const fn none() -> Self { + Self { + query: false, + operation_name: false, + variables: false, + extensions: false, + } + } + + pub const fn is_empty(&self) -> bool { + !self.query && !self.operation_name && !self.variables && !self.extensions + } + + pub const fn is_all(&self) -> bool { + self.query && self.operation_name && self.variables && self.extensions + } +} + +impl Serialize for GraphqlBodySelection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let repr = if self.is_all() { + GraphqlBodySelectionRepr::Bool(true) + } else if self.is_empty() { + GraphqlBodySelectionRepr::Bool(false) + } else { + let mut fields = Vec::with_capacity(4); + if self.query { + fields.push(GraphqlBodyField::Query); + } + if self.operation_name { + fields.push(GraphqlBodyField::OperationName); + } + if self.variables { + fields.push(GraphqlBodyField::Variables); + } + if self.extensions { + fields.push(GraphqlBodyField::Extensions); + } + + GraphqlBodySelectionRepr::List(fields) + }; + + repr.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for GraphqlBodySelection { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let repr = GraphqlBodySelectionRepr::deserialize(deserializer)?; + + Ok(match repr { + GraphqlBodySelectionRepr::Bool(true) => GraphqlBodySelection::all(), + GraphqlBodySelectionRepr::Bool(false) => GraphqlBodySelection::none(), + GraphqlBodySelectionRepr::List(fields) => { + let mut selection = GraphqlBodySelection::none(); + for field in fields { + match field { + GraphqlBodyField::Query => selection.query = true, + GraphqlBodyField::OperationName => selection.operation_name = true, + GraphqlBodyField::Variables => selection.variables = true, + GraphqlBodyField::Extensions => selection.extensions = true, + } + } + selection + } + }) + } +} + +impl JsonSchema for GraphqlBodySelection { + fn schema_name() -> std::borrow::Cow<'static, str> { + "GraphqlBodySelection".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + ::json_schema(generator) + } + + fn inline_schema() -> bool { + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// Selection of request-context entries included in coprocessor payloads. +/// +/// Serialized forms: +/// - `true` => include full context +/// - `false` => include no context +/// - list => include only selected context keys +pub struct ContextSelection { + all: bool, + keys: HashSet, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(untagged)] +enum ContextSelectionRepr { + Bool(bool), + List(Vec), +} + +impl ContextSelection { + pub fn all() -> Self { + Self { + all: true, + keys: HashSet::new(), + } + } + + pub fn none() -> Self { + Self { + all: false, + keys: HashSet::new(), + } + } + + pub fn list(keys: Vec) -> Self { + Self { + all: false, + keys: HashSet::from_iter(keys), + } + } + + pub const fn is_all(&self) -> bool { + self.all + } + + pub fn is_none(&self) -> bool { + !self.all && self.keys.is_empty() + } + + pub fn is_some(&self) -> bool { + !self.is_none() + } + + pub fn keys(&self) -> impl ExactSizeIterator + '_ { + self.keys.iter().map(|k| k.as_str()) + } +} + +impl Serialize for ContextSelection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let repr = if self.is_all() { + ContextSelectionRepr::Bool(true) + } else if self.is_none() { + ContextSelectionRepr::Bool(false) + } else { + ContextSelectionRepr::List(Vec::from_iter(self.keys.iter().cloned())) + }; + + repr.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ContextSelection { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let repr = ContextSelectionRepr::deserialize(deserializer)?; + + Ok(match repr { + ContextSelectionRepr::Bool(true) => ContextSelection::all(), + ContextSelectionRepr::Bool(false) => ContextSelection::none(), + ContextSelectionRepr::List(keys) => ContextSelection::list(keys), + }) + } +} + +impl JsonSchema for ContextSelection { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ContextSelection".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + ::json_schema(generator) + } + + fn inline_schema() -> bool { + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Target endpoint for coprocessor communication. +pub enum CoprocessorEndpoint { + Http { + /// HTTP endpoint URL in `http://host[:port][/path]` form. + url: String, + }, + Unix { + /// Absolute path to Unix domain socket file. + socket_path: String, + /// Request path to use when talking over Unix socket. + request_path: String, + }, +} + +impl CoprocessorEndpoint { + fn parse(value: &str) -> Result { + if value.starts_with("https://") { + return Err("coprocessor.url with https scheme is not supported yet".to_string()); + } + + if value.starts_with("http://") { + let parsed = value + .parse::() + .map_err(|error| format!("invalid http URL: {error}"))?; + + if parsed.scheme_str() != Some("http") { + return Err("coprocessor.url must use http scheme".to_string()); + } + + if parsed.authority().is_none() { + return Err("coprocessor.url must include host (and optional port)".to_string()); + } + + return Ok(Self::Http { + url: value.to_string(), + }); + } + + if let Some(rest) = value.strip_prefix("unix://") { + if !rest.starts_with('/') { + return Err("unix coprocessor.url must include an absolute socket path".to_string()); + } + + let (socket_path, query) = match rest.split_once('?') { + Some((socket_path, query)) => (socket_path, Some(query)), + None => (rest, None), + }; + + if socket_path.len() <= 1 { + return Err("unix coprocessor.url socket path cannot be empty".to_string()); + } + + let mut request_path = "/".to_string(); + + if let Some(query) = query { + if query.is_empty() { + return Err("unix coprocessor.url query cannot be empty".to_string()); + } + + let mut path_found = false; + + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + + let (key, value) = pair.split_once('=').ok_or_else(|| { + "unix coprocessor.url query parameters must use key=value format" + .to_string() + })?; + + if key != "path" { + return Err(format!( + "unsupported unix coprocessor.url query parameter '{key}'" + )); + } + + if path_found { + return Err( + "unix coprocessor.url query parameter 'path' can be provided only once" + .to_string(), + ); + } + + request_path = value.to_string(); + path_found = true; + } + + if !request_path.starts_with('/') { + return Err( + "unix coprocessor.url query parameter 'path' must start with '/'" + .to_string(), + ); + } + + if request_path.is_empty() { + return Err( + "unix coprocessor.url query parameter 'path' cannot be empty".to_string(), + ); + } + } + + return Ok(Self::Unix { + socket_path: socket_path.to_string(), + request_path, + }); + } + + Err("coprocessor.url must use one of the supported schemes: http:// or unix://".to_string()) + } + + fn to_config_string(&self) -> String { + match self { + CoprocessorEndpoint::Http { url } => url.clone(), + CoprocessorEndpoint::Unix { + socket_path, + request_path, + } => { + if request_path == "/" { + format!("unix://{socket_path}") + } else { + format!("unix://{socket_path}?path={request_path}") + } + } + } + } +} + +impl Serialize for CoprocessorEndpoint { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_config_string()) + } +} + +impl JsonSchema for CoprocessorEndpoint { + fn schema_name() -> std::borrow::Cow<'static, str> { + "CoprocessorEndpoint".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string", + "description": "Coprocessor endpoint URL. Supported forms: http://host[:port][/path], unix:///path/to/socket.sock, unix:///path/to/socket.sock?path=/api/v1" + }) + } + + fn inline_schema() -> bool { + true + } +} + +struct CoprocessorEndpointVisitor; + +impl<'de> Visitor<'de> for CoprocessorEndpointVisitor { + type Value = CoprocessorEndpoint; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "a coprocessor endpoint URL (http://... or unix:///... with optional ?path=/...)", + ) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + CoprocessorEndpoint::parse(value).map_err(E::custom) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: de::Error, + { + self.visit_str(value) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + self.visit_str(&value) + } +} + +impl<'de> Deserialize<'de> for CoprocessorEndpoint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(CoprocessorEndpointVisitor) + } +} diff --git a/lib/router-config/src/headers.rs b/lib/router-config/src/headers.rs index bd0913f6f..b496277a1 100644 --- a/lib/router-config/src/headers.rs +++ b/lib/router-config/src/headers.rs @@ -236,7 +236,7 @@ pub enum InsertSource { /// This allows you to generate header values based on the incoming request, /// subgraph name, and (for response rules) subgraph response headers. /// The expression has access to a context object with `.request`, `.subgraph`, - /// and `.response` fields. + /// and `.response.headers` fields. /// /// For more information on the available functions and syntax, see the /// [VRL documentation](https://vrl.dev/). diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index bd74267b3..d0513412b 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -1,4 +1,5 @@ pub mod authorization; +pub mod coprocessor; pub mod cors; pub mod csrf; mod env_overrides; @@ -132,6 +133,10 @@ pub struct HiveRouterConfig { /// Configuration for persisted documents extraction and resolution. #[serde(default)] pub persisted_documents: persisted_documents::PersistedDocumentsConfig, + + /// Configuration for coprocessor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coprocessor: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] diff --git a/plugin_examples/progressive_override_launchdarkly/Cargo.toml b/plugin_examples/progressive_override_launchdarkly/Cargo.toml new file mode 100644 index 000000000..570e9f768 --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "progressive-override-launchdarkly-plugin-example" +version = "0.0.1" +edition = "2021" +license = "MIT" +authors = ["The Guild"] +repository = "https://github.com/graphql-hive/router" +homepage = "https://github.com/graphql-hive/router" +publish = false + +[lib] + +[[bin]] +name = "hive_router_with_progressive_override_launchdarkly" +path = "src/main.rs" + +[dependencies] +hive-router = { version = "*", path = "../../bin/router" } +launchdarkly-server-sdk = "3" +serde = { workspace = true } +tokio = { workspace = true } diff --git a/plugin_examples/progressive_override_launchdarkly/README.md b/plugin_examples/progressive_override_launchdarkly/README.md new file mode 100644 index 000000000..8fd50a78b --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/README.md @@ -0,0 +1,23 @@ +# Progressive Override + LaunchDarkly plugin example + +This example shows how to resolve `hive::progressive_override::unresolved_labels` +with LaunchDarkly and set `hive::progressive_override::labels_to_override` from a plugin. + +## Configure + +Set your LaunchDarkly server-side SDK key: + +```bash +export LD_SDK_KEY="your-launchdarkly-server-sdk-key" +``` + +## Run + +```bash +cargo run -p progressive-override-launchdarkly-plugin-example -- \ + --config ./plugin_examples/progressive_override_launchdarkly/router.config.yaml +``` + +The plugin reads the context key from the `x-user-id` header by default +(configurable via `context_key_header`) and evaluates each unresolved override +label as a LaunchDarkly boolean flag key. diff --git a/plugin_examples/progressive_override_launchdarkly/router.config.yaml b/plugin_examples/progressive_override_launchdarkly/router.config.yaml new file mode 100644 index 000000000..6a0d2ccc1 --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/router.config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../../e2e/supergraph.graphql +plugins: + progressive_override_launchdarkly: + enabled: true + config: + context_key_header: "x-user-id" diff --git a/plugin_examples/progressive_override_launchdarkly/src/lib.rs b/plugin_examples/progressive_override_launchdarkly/src/lib.rs new file mode 100644 index 000000000..962cb1bb9 --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/src/lib.rs @@ -0,0 +1 @@ +pub mod plugin; diff --git a/plugin_examples/progressive_override_launchdarkly/src/main.rs b/plugin_examples/progressive_override_launchdarkly/src/main.rs new file mode 100644 index 000000000..be55c83a6 --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/src/main.rs @@ -0,0 +1,16 @@ +use hive_router::{ + configure_global_allocator, error::RouterInitError, init_rustls_crypto_provider, ntex, + router_entrypoint, PluginRegistry, RouterGlobalAllocator, +}; + +use progressive_override_launchdarkly_plugin_example::plugin::ProgressiveOverrideLaunchDarklyPlugin; + +configure_global_allocator!(); + +#[hive_router::main] +async fn main() -> Result<(), RouterInitError> { + init_rustls_crypto_provider(); + + router_entrypoint(PluginRegistry::new().register::()) + .await +} diff --git a/plugin_examples/progressive_override_launchdarkly/src/plugin.rs b/plugin_examples/progressive_override_launchdarkly/src/plugin.rs new file mode 100644 index 000000000..d454330ba --- /dev/null +++ b/plugin_examples/progressive_override_launchdarkly/src/plugin.rs @@ -0,0 +1,118 @@ +use std::collections::HashSet; +use std::env::var; + +use hive_router::{ + async_trait, + plugins::{ + hooks::{ + on_plugin_init::{OnPluginInitPayload, OnPluginInitResult}, + on_query_plan::{OnQueryPlanStartHookPayload, OnQueryPlanStartHookResult}, + }, + plugin_trait::{RouterPlugin, StartHookPayload}, + }, + tracing::warn, +}; +use launchdarkly_server_sdk::{Client, ConfigBuilder, Context, ContextBuilder}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ProgressiveOverrideLaunchDarklyConfig { + #[serde(default)] + pub context_key_header: Option, +} + +pub struct ProgressiveOverrideLaunchDarklyPlugin { + client: Client, + context_key_header: String, +} + +#[async_trait] +impl RouterPlugin for ProgressiveOverrideLaunchDarklyPlugin { + type Config = ProgressiveOverrideLaunchDarklyConfig; + + fn plugin_name() -> &'static str { + "progressive_override_launchdarkly" + } + + fn on_plugin_init(payload: OnPluginInitPayload) -> OnPluginInitResult { + let config = payload.config()?; + let sdk_key = var("LD_SDK_KEY")?; + + let ld_config = ConfigBuilder::new(sdk_key.as_str()).build()?; + let client = Client::build(ld_config)?; + client.start_with_default_executor(); + + payload.initialize_plugin(Self { + client, + context_key_header: config + .context_key_header + .unwrap_or_else(|| "x-user-id".to_string()), + }) + } + + async fn on_query_plan<'exec>( + &'exec self, + start_payload: OnQueryPlanStartHookPayload<'exec>, + ) -> OnQueryPlanStartHookResult<'exec> { + let snapshot = match start_payload.request_context.read() { + Ok(snapshot) => snapshot, + Err(err) => { + warn!(error = %err, "failed to read request context"); + return start_payload.proceed(); + } + }; + + let progressive_override = snapshot.progressive_override(); + let Some(unresolved_labels) = progressive_override.unresolved_labels() else { + return start_payload.proceed(); + }; + + if unresolved_labels.is_empty() { + return start_payload.proceed(); + } + + let context = build_ld_context(&start_payload, &self.context_key_header); + let mut labels_to_override = HashSet::new(); + + for label in unresolved_labels { + let is_enabled = self.client.bool_variation(&context, label, false); + + if is_enabled { + labels_to_override.insert(label.clone()); + } + } + + if let Ok(mut write) = start_payload.request_context.write() { + write + .progressive_override() + .set_labels_to_override(Some(labels_to_override)); + } + + start_payload.proceed() + } + + async fn on_shutdown<'exec>(&'exec self) { + self.client.close(); + } +} + +fn build_ld_context( + payload: &OnQueryPlanStartHookPayload<'_>, + context_key_header: &str, +) -> Context { + let context_key = payload + .router_http_request + .headers + .get(context_key_header) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .unwrap_or("anonymous"); + + ContextBuilder::new(context_key) + .build() + .unwrap_or_else(|_| { + ContextBuilder::new("anonymous") + .build() + .expect("valid context") + }) +} From 6c7df6879feebe8a754640c0fc23ef63370fa6f2 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 5 May 2026 11:44:44 +0200 Subject: [PATCH 73/76] uuid upgrade (#955) GHSA-w5hq-g745-h8pq The `uuid` library is used by `@graphql-hive/laboratory` so it's part of Hive Router, but UUIDs are never user-controlled. The second instance of `uuid` is inside fed audit's dependencies within the apollo/* libs, that are not user-controlled and also a dev dependency so it has zero impact. Closes #950 Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/lab_upgrade.md | 5 + bin/router/package-lock.json | 288 ++++++++++++++++++++++++++++++++++- bin/router/package.json | 2 +- package-lock.json | 99 +++++------- 4 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 .changeset/lab_upgrade.md diff --git a/.changeset/lab_upgrade.md b/.changeset/lab_upgrade.md new file mode 100644 index 000000000..4d3295e25 --- /dev/null +++ b/.changeset/lab_upgrade.md @@ -0,0 +1,5 @@ +--- +hive-router: patch +--- + +# Upgrade Laboratory to v0.1.7 diff --git a/bin/router/package-lock.json b/bin/router/package-lock.json index 923402b38..a318b8ee5 100644 --- a/bin/router/package-lock.json +++ b/bin/router/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "hive-router-js-deps", "devDependencies": { - "@graphql-hive/laboratory": "0.1.6" + "@graphql-hive/laboratory": "0.1.7" } }, "node_modules/@babel/runtime": { @@ -175,9 +175,9 @@ "license": "MIT" }, "node_modules/@graphql-hive/laboratory": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.6.tgz", - "integrity": "sha512-K6bCM+RekKHpxnmhvDYypJsbgbaIXZ7ZlkRWqHv7iv0C7D8YhjzPBYTlezK7oQ9AA8CJUYVVWp1tZI6Oeq+wpQ==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@graphql-hive/laboratory/-/laboratory-0.1.7.tgz", + "integrity": "sha512-DEqy+LxlZ86D092wMf6MryxaUHP+s1o1mfqrLkPHBQssy2g6xOJG44Enc3/Qg1KCx+OkBSQQnUJPk0T5p5+YvA==", "dev": true, "license": "MIT", "dependencies": { @@ -185,7 +185,7 @@ "@graphql-tools/url-loader": "^9.1.0", "radix-ui": "^1.4.3", "react-zoom-pan-pinch": "^3.7.0", - "uuid": "^13.0.0" + "uuid": "^14.0.0" }, "peerDependencies": { "@tanstack/react-form": "^1.23.8", @@ -2031,6 +2031,112 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.29.1.tgz", + "integrity": "sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.29.1.tgz", + "integrity": "sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/form-core": "1.29.1", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -2121,6 +2227,14 @@ "node": ">=10" } }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/cross-inspect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", @@ -2151,6 +2265,18 @@ "dev": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -2158,6 +2284,14 @@ "dev": true, "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -2205,6 +2339,17 @@ "node": ">=6" } }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/graphql-ws": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", @@ -2258,6 +2403,36 @@ "ws": "*" } }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lucide-react": { + "version": "0.548.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz", + "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/meros": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", @@ -2394,6 +2569,31 @@ } } }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -2488,6 +2688,67 @@ "dev": true, "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/subscriptions-transport-ws": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz", + "integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==", + "deprecated": "The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^5.2.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/subscriptions-transport-ws/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sync-fetch": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0.tgz", @@ -2590,9 +2851,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -2644,6 +2905,17 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/bin/router/package.json b/bin/router/package.json index 934cbc871..52f3b3a43 100644 --- a/bin/router/package.json +++ b/bin/router/package.json @@ -4,6 +4,6 @@ "private": true, "packageManager": "npm@11.11.1", "devDependencies": { - "@graphql-hive/laboratory": "0.1.6" + "@graphql-hive/laboratory": "0.1.7" } } diff --git a/package-lock.json b/package-lock.json index 082ef9101..6d7f7b5d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,10 +37,7 @@ } }, "bench": { - "name": "benchmarks", - "devDependencies": { - "@types/k6": "1.6.0" - } + "name": "benchmarks" }, "docs/generator": { "name": "docs-md-generator", @@ -73,14 +70,14 @@ } }, "node_modules/@apollo/composition": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.13.3.tgz", - "integrity": "sha512-0/vr6vbk1BynEKY6/UV0SZUHBV33p5Ok0y6RhkNnX5BA+ysyOkcyTLPlEHo+ce62nqWx4ml3iOxeKIEHqbKelQ==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@apollo/composition/-/composition-2.14.0.tgz", + "integrity": "sha512-IPxU1zZkoUlVRWCZFQ4n2Ela+F8euHualZNdQ3vaeBIGLYVJDMcfL3/CaXZ717Taa/6DrxkjfKQiuJd12oL7rQ==", "dev": true, "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.13.3", - "@apollo/query-graphs": "2.13.3" + "@apollo/federation-internals": "2.14.0", + "@apollo/query-graphs": "2.14.0" }, "engines": { "node": ">=18" @@ -90,9 +87,9 @@ } }, "node_modules/@apollo/federation-internals": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.13.3.tgz", - "integrity": "sha512-4zHgqznZza5Qx2CZy1qlrh83BB/3Yd8BqD/dmhGzLCvHJQ1LBTyZbWN7xwKs5z5FbxdSPkL5TvPoLm5Rek8/4g==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@apollo/federation-internals/-/federation-internals-2.14.0.tgz", + "integrity": "sha512-Kw1vfyTU3oG38HkmYym+QGtebdTg1fcEfsjdIjJFKQXgYa/fTwr+NZcfT4FHli8ws9WNvCp+f5pUuklM2e9OHQ==", "dev": true, "license": "Elastic-2.0", "dependencies": { @@ -108,28 +105,14 @@ "graphql": "^16.5.0" } }, - "node_modules/@apollo/federation-internals/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@apollo/query-graphs": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.13.3.tgz", - "integrity": "sha512-X/bgsIVmamAzd8IFMDo9upO5Oi/uhAZlcBmna6yEFlN0fzdQZMHL5pp1fyzRG1wxCFi3lVjDXw2dc/380uWB9g==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@apollo/query-graphs/-/query-graphs-2.14.0.tgz", + "integrity": "sha512-s88QftWzFCFfUq+7Tzje5I8Rrh/ntX2E0Mq8uOh625ffPE1m92kRga3Zmxouca0ancJwymeVQ4w4hif2UbLEXA==", "dev": true, "license": "Elastic-2.0", "dependencies": { - "@apollo/federation-internals": "2.13.3", + "@apollo/federation-internals": "2.14.0", "deep-equal": "^2.0.5", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" @@ -141,29 +124,15 @@ "graphql": "^16.5.0" } }, - "node_modules/@apollo/query-graphs/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@apollo/subgraph": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.13.3.tgz", - "integrity": "sha512-nmdACpdTe+kgmNF0Yow3MoDkE0SMfvK6tTsEs3h3M9GLYCgBWZWBoDWQfNdr7kWRW6aan2SylU0K0ktF643h3g==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@apollo/subgraph/-/subgraph-2.14.0.tgz", + "integrity": "sha512-hG/KFcC9FMihf8Zy181EZIioW26e7iqsOojEoB74kmLaBRXs+cYqb7Eba8u1mfI34S2F1VzbODSvWNQn4J3W0Q==", "dev": true, "license": "MIT", "dependencies": { "@apollo/cache-control-types": "^1.0.2", - "@apollo/federation-internals": "2.13.3" + "@apollo/federation-internals": "2.14.0" }, "engines": { "node": ">=14.15.0" @@ -2086,13 +2055,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/k6": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.6.0.tgz", - "integrity": "sha512-koixvaHqP241ymqEEgl068kkXek+LhM5YTjysI1ikKUAnmbIKbt8Lngw3nhMHmg3Cihh7viUP/WCIF+FKi4DYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -2344,15 +2306,15 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -4672,6 +4634,21 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/wait-on": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", From b970b9262f88666a14d34ef4e6d31eaa6882d306 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 5 May 2026 14:18:21 +0200 Subject: [PATCH 74/76] fix fragment spread normalization across field boundaries and preserve spread directives (#956) Closes #939 Fixes query normalization for fragment spreads and preserves `@include` and `@skip` directives on fragment spreads. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/norm_fix.md | 10 + .../fixture/issues/939.supergraph.graphql | 109 ++++ .../src/ast/normalization/mod.rs | 488 ++++++++++++++++++ .../pipeline/inline_fragment_spreads.rs | 10 +- lib/query-planner/src/tests/fragments.rs | 98 ++++ lib/query-planner/src/tests/issues.rs | 110 ++++ 6 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 .changeset/norm_fix.md create mode 100644 lib/query-planner/fixture/issues/939.supergraph.graphql diff --git a/.changeset/norm_fix.md b/.changeset/norm_fix.md new file mode 100644 index 000000000..e794bec93 --- /dev/null +++ b/.changeset/norm_fix.md @@ -0,0 +1,10 @@ +--- +hive-router-query-planner: patch +hive-router-plan-executor: patch +hive-router: patch +node-addon: patch +--- + +# Fix fragment handling + +Fix fragment handling for some queries that use reusable fragments with conditional directives diff --git a/lib/query-planner/fixture/issues/939.supergraph.graphql b/lib/query-planner/fixture/issues/939.supergraph.graphql new file mode 100644 index 000000000..704dce712 --- /dev/null +++ b/lib/query-planner/fixture/issues/939.supergraph.graphql @@ -0,0 +1,109 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +scalar join__FieldSet + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +enum join__Graph { + CONTENT @join__graph(name: "content", url: "") +} + +type Query @join__type(graph: CONTENT) { + node(id: ID!): INode +} + +type MyNode implements INode + @join__type(graph: CONTENT) + @join__implements(graph: CONTENT, interface: "INode") { + id: ID! + content: Content +} + +type TextContent implements ITextContent + @join__type(graph: CONTENT) + @join__implements(graph: CONTENT, interface: "ITextContent") { + id: ID! + fragments: [TextContentFragment!]! +} + +type TextGroupContent implements ITextContent + @join__type(graph: CONTENT) + @join__implements(graph: CONTENT, interface: "ITextContent") { + id: ID! + fragments: [TextContentFragment!]! +} + +type TextContentFragment @join__type(graph: CONTENT) { + contentNode: MyNode! +} + +interface INode @join__type(graph: CONTENT) { + id: ID! +} + +interface ITextContent @join__type(graph: CONTENT) { + id: ID! + fragments: [TextContentFragment!]! +} + +union Content + @join__type(graph: CONTENT) + @join__unionMember(graph: CONTENT, member: "TextContent") + @join__unionMember(graph: CONTENT, member: "TextGroupContent") = + | TextContent + | TextGroupContent diff --git a/lib/query-planner/src/ast/normalization/mod.rs b/lib/query-planner/src/ast/normalization/mod.rs index b730841c3..8d853b943 100644 --- a/lib/query-planner/src/ast/normalization/mod.rs +++ b/lib/query-planner/src/ast/normalization/mod.rs @@ -1407,4 +1407,492 @@ mod tests { " ); } + + #[test] + fn fragment_spread_on_interface_across_field_boundary_preserves_type_context() { + let schema_str = std::fs::read_to_string("./fixture/issues/939.supergraph.graphql") + .expect("Unable to read supergraph"); + let schema = parse_schema(&schema_str); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query SingleNode($id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on ITextContent { + fragments { + contentNode { + content { + ...ITextContentPreview + } + } + } + } + } + } + } + } + + fragment ITextContentPreview on ITextContent { + id + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query SingleNode($id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on TextContent { + fragments { + contentNode { + content { + ... on TextContent { + id + } + ... on TextGroupContent { + id + } + } + } + } + } + ... on TextGroupContent { + fragments { + contentNode { + content { + ... on TextContent { + id + } + ... on TextGroupContent { + id + } + } + } + } + } + } + } + } + } + "# + ); + } + + #[test] + fn same_type_fragment_spread_directives_are_preserved() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + interface Node { + id: ID! + } + + type Account implements Node { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($cond: Boolean!) { + account { + ...AccountFields @include(if: $cond) + } + } + + fragment AccountFields on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($cond: Boolean!) { + account { + ... on Account @include(if: $cond) { + name + } + } + } + "# + ); + } + + #[test] + fn same_type_fragment_spread_skip_is_preserved() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + type Account { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($cond: Boolean!) { + account { + ...AccountFields @skip(if: $cond) + } + } + + fragment AccountFields on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($cond: Boolean!) { + account { + ... on Account @skip(if: $cond) { + name + } + } + } + "# + ); + } + + #[test] + fn same_type_fragment_spread_include_and_skip_are_preserved() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + type Account { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($include: Boolean!, $skip: Boolean!) { + account { + ...AccountFields @include(if: $include) @skip(if: $skip) + } + } + + fragment AccountFields on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($include: Boolean!, $skip: Boolean!) { + account { + ... on Account @skip(if: $skip) @include(if: $include) { + name + } + } + } + "# + ); + } + + #[test] + fn outer_same_type_fragment_spread_directive_is_preserved_through_nested_spreads() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + type Account { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($cond: Boolean!) { + account { + ...AccountFields1 @include(if: $cond) + } + } + + fragment AccountFields1 on Account { + ...AccountFields2 + } + + fragment AccountFields2 on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($cond: Boolean!) { + account { + ... on Account @include(if: $cond) { + name + } + } + } + "# + ); + } + + #[test] + fn inner_same_type_fragment_spread_directive_is_preserved_through_nested_spreads() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + type Account { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($cond: Boolean!) { + account { + ...AccountFields1 + } + } + + fragment AccountFields1 on Account { + ...AccountFields2 @include(if: $cond) + } + + fragment AccountFields2 on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($cond: Boolean!) { + account { + ... on Account @include(if: $cond) { + name + } + } + } + "# + ); + } + + #[test] + fn field_boundary_fragment_spread_include_is_preserved() { + let schema_str = std::fs::read_to_string("./fixture/issues/939.supergraph.graphql") + .expect("Unable to read supergraph"); + let schema = parse_schema(&schema_str); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query SingleNode($cond: Boolean!, $id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on ITextContent { + fragments { + contentNode { + content { + ...ITextContentPreview @include(if: $cond) + } + } + } + } + } + } + } + } + + fragment ITextContentPreview on ITextContent { + id + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query SingleNode($cond: Boolean!, $id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on TextContent { + fragments { + contentNode { + content { + ... on TextContent @include(if: $cond) { + id + } + ... on TextGroupContent @include(if: $cond) { + id + } + } + } + } + } + ... on TextGroupContent { + fragments { + contentNode { + content { + ... on TextContent @include(if: $cond) { + id + } + ... on TextGroupContent @include(if: $cond) { + id + } + } + } + } + } + } + } + } + } + "# + ); + } + + #[test] + fn reusable_fragment_with_mixed_include_conditions_stays_separate() { + let schema = parse_schema( + r#" + type Query { + account: Account + } + + type Account { + id: ID! + name: String! + } + "#, + ); + let supergraph = SupergraphState::new(&schema); + + insta::assert_snapshot!( + pretty_query( + normalize_operation( + &supergraph, + &parse_query( + r#" + query($first: Boolean!, $second: Boolean!) { + account { + ...AccountFields @include(if: $first) + ...AccountFields @include(if: $second) + } + } + + fragment AccountFields on Account { + name + } + "#, + ) + .expect("to parse"), + None, + ) + .expect("to normalize") + .to_string() + ), + @r#" + query($first: Boolean!, $second: Boolean!) { + account { + ... on Account @include(if: $first) { + name + } + ... on Account @include(if: $second) { + name + } + } + } + "# + ); + } } diff --git a/lib/query-planner/src/ast/normalization/pipeline/inline_fragment_spreads.rs b/lib/query-planner/src/ast/normalization/pipeline/inline_fragment_spreads.rs index 99f8e85ff..387f4db04 100644 --- a/lib/query-planner/src/ast/normalization/pipeline/inline_fragment_spreads.rs +++ b/lib/query-planner/src/ast/normalization/pipeline/inline_fragment_spreads.rs @@ -60,7 +60,8 @@ fn handle_selection_set<'a>( handle_selection_set( &mut field.selection_set, fragment_map, - parent_type_condition, + // Crossing a field boundary resets the type condition context + None, )?; new_items.push(Selection::Field(field)); } @@ -71,7 +72,12 @@ fn handle_selection_set<'a>( } })?; - if parent_type_condition == Some(&fragment_def.type_condition) { + if parent_type_condition == Some(&fragment_def.type_condition) + // `...Frag @include(...)` stores `@include` on the spread itself. + // In the code below, we inline the fragment's selections, + // so any directives would be lost. + && spread.directives.is_empty() + { // If the fragment's type condition matches the top type condition, // we can inline its selections directly. let mut selection_set = fragment_def.selection_set.clone(); diff --git a/lib/query-planner/src/tests/fragments.rs b/lib/query-planner/src/tests/fragments.rs index f5392c096..c2306c97d 100644 --- a/lib/query-planner/src/tests/fragments.rs +++ b/lib/query-planner/src/tests/fragments.rs @@ -228,6 +228,104 @@ fn parent_directive_only_on_abstract_fragment() -> Result<(), Box> { Ok(()) } +/// Reusing the same named fragment with different `@include` conditions on the same +/// concrete parent must preserve both conditions as separate wrappers. +#[test] +fn reusable_fragment_with_mixed_include_conditions_on_concrete_parent() -> Result<(), Box> +{ + init_logger(); + let document = parse_operation( + r#" + query ($first: Boolean!, $second: Boolean!) { + account(id: "a1") { + ...AccountFields @include(if: $first) + ...AccountFields @include(if: $second) + } + } + + fragment AccountFields on Account { + username + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + query ($first:Boolean!,$second:Boolean!) { + account(id: "a1") { + ... on Account @include(if: $first) { + ...a + } + ... on Account @include(if: $second) { + ...a + } + } + } + fragment a on Account { + username + } + }, + }, + "#); + + Ok(()) +} + +/// Reusing the same named fragment with different `@include` conditions under an +/// abstract parent must keep both conditions through type expansion. +#[test] +fn reusable_fragment_with_mixed_include_conditions_on_abstract_parent() -> Result<(), Box> +{ + init_logger(); + let document = parse_operation( + r#" + query ($first: Boolean!, $second: Boolean!) { + account(id: "a1") { + ... on Node { + ...AccountFields @include(if: $first) + ...AccountFields @include(if: $second) + } + } + } + + fragment AccountFields on Account { + username + } + "#, + ); + let query_plan = build_query_plan( + "fixture/tests/corrupted-supergraph-node-id.supergraph.graphql", + document, + )?; + + insta::assert_snapshot!(format!("{}", query_plan), @r#" + QueryPlan { + Fetch(service: "a") { + query ($first:Boolean!,$second:Boolean!) { + account(id: "a1") { + ... on Account @include(if: $first) { + ...a + } + ... on Account @include(if: $second) { + ...a + } + } + } + fragment a on Account { + username + } + }, + }, + "#); + + Ok(()) +} + #[test] fn simple_inline_fragment() -> Result<(), Box> { init_logger(); diff --git a/lib/query-planner/src/tests/issues.rs b/lib/query-planner/src/tests/issues.rs index 5fb932341..120a147c0 100644 --- a/lib/query-planner/src/tests/issues.rs +++ b/lib/query-planner/src/tests/issues.rs @@ -258,3 +258,113 @@ fn issue_190_test() -> Result<(), Box> { Ok(()) } + +#[test] +fn issue_939_test() -> Result<(), Box> { + init_logger(); + + let named_fragment_document = parse_operation( + r#" + query SingleNode($id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on ITextContent { + fragments { + contentNode { + content { + ...ITextContentPreview + } + } + } + } + } + } + } + } + + fragment ITextContentPreview on ITextContent { + id + } + "#, + ); + let named_fragment_plan = build_query_plan( + "fixture/issues/939.supergraph.graphql", + named_fragment_document, + )?; + + let inline_fragment_document = parse_operation( + r#" + query SingleNode($id: ID!) { + node(id: $id) { + ... on MyNode { + content { + ... on ITextContent { + fragments { + contentNode { + content { + ... on ITextContent { + id + } + } + } + } + } + } + } + } + } + "#, + ); + let inline_fragment_plan = build_query_plan( + "fixture/issues/939.supergraph.graphql", + inline_fragment_document, + )?; + + assert_eq!( + format!("{}", named_fragment_plan), + format!("{}", inline_fragment_plan) + ); + + insta::assert_snapshot!(format!("{}", inline_fragment_plan), @r#" + QueryPlan { + Fetch(service: "content") { + query ($id:ID!) { + node(id: $id) { + __typename + ... on MyNode { + content { + __typename + ... on TextContent { + fragments { + ...a + } + } + ... on TextGroupContent { + fragments { + ...a + } + } + } + } + } + } + fragment a on TextContentFragment { + contentNode { + content { + __typename + ... on TextContent { + id + } + ... on TextGroupContent { + id + } + } + } + } + }, + }, + "#); + + Ok(()) +} From 165419172db4079f6ac8977777e8d9058d8dd165 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Tue, 5 May 2026 17:26:53 +0300 Subject: [PATCH 75/76] feat(hive-console-sdk): dynamic exclusions via VRL expressions (#932) Ref ROUTER-282 --------- Co-authored-by: Kamil Kisiela Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/dynamic-exclusions.md | 43 + Cargo.lock | 18 + apollo-router-workspace/Cargo.lock | 1935 ++++++++++++++++- .../bin/router/src/usage.rs | 115 +- .../src/pipeline/introspection_policy.rs | 2 +- bin/router/src/pipeline/mod.rs | 3 + bin/router/src/pipeline/usage_reporting.rs | 36 +- bin/router/src/pipeline/websocket_server.rs | 21 +- bin/router/src/shared_state.rs | 3 +- docs/README.md | 16 +- e2e/src/telemetry/mod.rs | 1 + e2e/src/telemetry/usage_reporting.rs | 578 +++++ lib/executor/src/coprocessor/stage.rs | 2 +- .../src/coprocessor/stages/graphql.rs | 4 +- lib/executor/src/coprocessor/stages/router.rs | 2 +- .../src/execution/client_request_details.rs | 2 +- lib/executor/src/executors/map.rs | 36 +- lib/executor/src/headers/expression.rs | 2 +- lib/hive-console-sdk/Cargo.toml | 7 + lib/hive-console-sdk/src/agent/builder.rs | 16 + lib/hive-console-sdk/src/agent/usage_agent.rs | 803 ++++++- .../src/expressions/error.rs | 0 .../src/expressions/functions/env.rs | 0 .../src/expressions/functions/mod.rs | 0 .../src/expressions/lib.rs | 84 +- .../src/expressions/mod.rs | 6 +- .../src/expressions/values/boolean.rs | 5 +- .../src/expressions/values/duration.rs | 5 +- .../src/expressions/values/http.rs | 0 .../src/expressions/values/mod.rs | 0 .../src/expressions/values/sonic.rs | 10 +- .../src/expressions/values/string.rs | 7 +- lib/hive-console-sdk/src/lib.rs | 1 + lib/internal/Cargo.toml | 3 +- lib/internal/src/expressions.rs | 87 + lib/internal/src/telemetry/utils.rs | 6 +- lib/router-config/src/usage_reporting.rs | 82 +- 37 files changed, 3672 insertions(+), 269 deletions(-) create mode 100644 .changeset/dynamic-exclusions.md create mode 100644 e2e/src/telemetry/usage_reporting.rs rename lib/{internal => hive-console-sdk}/src/expressions/error.rs (100%) rename lib/{internal => hive-console-sdk}/src/expressions/functions/env.rs (100%) rename lib/{internal => hive-console-sdk}/src/expressions/functions/mod.rs (100%) rename lib/{internal => hive-console-sdk}/src/expressions/lib.rs (73%) rename lib/{internal => hive-console-sdk}/src/expressions/mod.rs (65%) rename lib/{internal => hive-console-sdk}/src/expressions/values/boolean.rs (81%) rename lib/{internal => hive-console-sdk}/src/expressions/values/duration.rs (90%) rename lib/{internal => hive-console-sdk}/src/expressions/values/http.rs (100%) rename lib/{internal => hive-console-sdk}/src/expressions/values/mod.rs (100%) rename lib/{internal => hive-console-sdk}/src/expressions/values/sonic.rs (76%) rename lib/{internal => hive-console-sdk}/src/expressions/values/string.rs (79%) create mode 100644 lib/internal/src/expressions.rs diff --git a/.changeset/dynamic-exclusions.md b/.changeset/dynamic-exclusions.md new file mode 100644 index 000000000..04f94cadb --- /dev/null +++ b/.changeset/dynamic-exclusions.md @@ -0,0 +1,43 @@ +--- +hive-router: minor +hive-console-sdk: minor +hive-apollo-router-plugin: minor +--- + +# Dynamic Exclusions + +## Dynamic Exclusions in Hive Router + +Hive Router now supports dynamic exclusions, allowing you to exclude specific requests from usage reporting based on custom logic. This feature is useful for scenarios where you want to skip telemetry for certain requests, such as health checks or specific endpoints. + +The previous operation-name list format is still supported for backward compatibility. + +### Usage +```diff +- exclude: ['ExcludedOp'] ++ exclude: ++ expression: '.request.operation.name == "ExcludedOp"' +``` + +Both of the following are valid and supported: + +```yaml +# legacy format +exclude: + - ExcludedOp + +# dynamic expression format +exclude: + expression: '.request.operation.name == "ExcludedOp"' +``` + +The details about expression context is documented in the [Hive Router documentation](https://the-guild.dev/graphql/hive/docs/router/configuration/expressions). + +## Dynamic Exclusions in Apollo Router + +As in Hive Router, Apollo Router used to support only operation name based exclusions. With the new dynamic exclusions feature, you can now specify custom logic to exclude requests from usage reporting. + + +# New `add_report_with_request` method in Hive Console SDK + +In order to support exclusions based on request properties, a new method `add_report_with_request` has been added to the Hive Console SDK. This method allows you to include the request information in the report, which can then be used in the dynamic exclusion logic. diff --git a/Cargo.lock b/Cargo.lock index 846c8dfec..202e7dec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2555,12 +2555,17 @@ dependencies = [ "anyhow", "async-dropper-simple", "async-trait", + "bytes", "futures-util", "graphql-tools", + "http", + "http-serde", + "humantime", "lazy_static", "md5", "mockito", "moka", + "ntex", "recloser", "regex-automata", "regress 0.11.1", @@ -2571,11 +2576,13 @@ dependencies = [ "serde", "serde_json", "sha2", + "sonic-rs", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", "typify", + "vrl", ] [[package]] @@ -2677,6 +2684,7 @@ dependencies = [ "criterion", "dashmap", "futures", + "hive-console-sdk", "hive-router-config", "http", "http-body-util", @@ -2840,6 +2848,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + [[package]] name = "httparse" version = "1.10.1" diff --git a/apollo-router-workspace/Cargo.lock b/apollo-router-workspace/Cargo.lock index 355d3b2c4..75f6d8693 100644 --- a/apollo-router-workspace/Cargo.lock +++ b/apollo-router-workspace/Cargo.lock @@ -24,6 +24,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-siv" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e08d0cdb774acd1e4dac11478b1a0c0d203134b2aab0ba25eb430de9b18f8b9" +dependencies = [ + "aead", + "aes", + "cipher", + "cmac", + "ctr", + "dbl", + "digest", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -140,9 +177,9 @@ dependencies = [ "mime", "multi_try", "multimap 0.10.1", - "nom", + "nom 7.1.3", "nom_locate", - "parking_lot", + "parking_lot 0.12.5", "percent-encoding", "petgraph 0.8.3", "regex", @@ -260,14 +297,14 @@ dependencies = [ "opentelemetry-semantic-conventions", "opentelemetry-zipkin", "opentelemetry_sdk", - "parking_lot", + "parking_lot 0.12.5", "paste", "pin-project-lite", "prometheus", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "proteus", - "rand 0.9.4", + "rand 0.9.3", "regex", "reqwest", "rhai", @@ -286,7 +323,7 @@ dependencies = [ "serde_json_bytes", "serde_regex", "serde_urlencoded", - "serde_yaml", + "serde_yaml 0.8.26", "sha1", "sha2", "shellexpand", @@ -362,7 +399,16 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" dependencies = [ - "term", + "term 0.7.0", +] + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term 1.2.1", ] [[package]] @@ -950,12 +996,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + [[package]] name = "base64" version = "0.13.1" @@ -996,8 +1069,8 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" dependencies = [ - "lalrpop", - "lalrpop-util", + "lalrpop 0.20.2", + "lalrpop-util 0.20.2", "regex", ] @@ -1066,6 +1139,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1182,6 +1273,15 @@ dependencies = [ "serde", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.52" @@ -1194,6 +1294,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1206,6 +1315,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -1217,6 +1337,29 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.42" @@ -1231,6 +1374,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "ci_info" version = "0.14.14" @@ -1242,6 +1395,50 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.5.50" @@ -1280,6 +1477,28 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -1290,6 +1509,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "community-id" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48629740a3480b865d4083ff45f826a253bd5ce28db618d89359b0e95dc750c3" +dependencies = [ + "base64 0.22.1", + "hex", + "sha1", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -1445,6 +1675,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + [[package]] name = "countme" version = "3.0.1" @@ -1478,6 +1719,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc16" version = "0.4.0" @@ -1517,6 +1773,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1548,9 +1813,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -1561,6 +1863,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1669,7 +1991,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", "serde", ] @@ -1679,6 +2001,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1842,6 +2173,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1853,6 +2196,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dns-lookup" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5597a4b7fe5275fc9dcf88ce26326bc8e4cb87d0130f33752d4c5f717793cf" +dependencies = [ + "cfg-if", + "libc", + "socket2 0.6.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "docker_credential" version = "1.3.2" @@ -1865,18 +2226,51 @@ dependencies = [ ] [[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - -[[package]] -name = "dunce" -version = "1.0.5" +name = "domain" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] +checksum = "7f7ff15f82df7d5086fb15dfc1c1e96598a6ded9829840a9bcfa1fa3ccd8d01d" +dependencies = [ + "bumpalo", + "bytes", + "domain-macros", + "futures-util", + "hashbrown 0.14.5", + "log", + "moka", + "octseq", + "rand 0.8.5", + "serde", + "smallvec", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "domain-macros" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d1a6796ad411f6812d691955066ad27450196bfb181bb91b66a643cc3e8f5b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1992,6 +2386,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "envmnt" version = "0.10.4" @@ -2082,12 +2495,35 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "faststr" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca7d44d22004409a61c393afb3369c8f7bb74abcae49fe249ee01dcc3002113" +dependencies = [ + "bytes", + "rkyv", + "serde", + "simdutf8", +] + [[package]] name = "ff" version = "0.13.1" @@ -2141,6 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -2232,7 +2669,7 @@ dependencies = [ "fred-macros", "futures", "log", - "parking_lot", + "parking_lot 0.12.5", "rand 0.8.5", "redis-protocol", "rustls", @@ -2353,6 +2790,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -2456,6 +2899,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -2554,6 +3003,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "grok" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddab6a9c8bb998cb2fc3101fde8ef561b7c4970db3957be7a8eee1e168f666b" +dependencies = [ + "glob", + "onig", +] + [[package]] name = "group" version = "0.13.0" @@ -2584,6 +3043,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2595,6 +3065,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "allocator-api2", +] [[package]] name = "hashbrown" @@ -2669,7 +3142,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.4", + "rand 0.9.3", "ring", "thiserror 2.0.18", "tinyvec", @@ -2690,8 +3163,8 @@ dependencies = [ "ipconfig", "moka", "once_cell", - "parking_lot", - "rand 0.9.4", + "parking_lot 0.12.5", + "rand 0.9.3", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -2731,26 +3204,33 @@ dependencies = [ "anyhow", "async-dropper-simple", "async-trait", + "bytes", "futures-util", "graphql-tools", + "http 1.3.1", + "http-serde", + "humantime", "lazy_static", "md5", "moka", + "ntex", "recloser", "regex-automata", "regress 0.11.1", "reqwest", "reqwest-middleware", - "reqwest-retry", - "retry-policies", + "reqwest-retry 0.8.0", + "retry-policies 0.5.1", "serde", "serde_json", "sha2", + "sonic-rs", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", "typify", + "vrl", ] [[package]] @@ -2771,6 +3251,17 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "http" version = "0.2.12" @@ -3185,6 +3676,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "influxdb-line-protocol" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fa7ee6be451ea0b1912b962c91c8380835e97cf1584a77e18264e908448dcb" +dependencies = [ + "bytes", + "log", + "nom 7.1.3", + "smallvec", + "snafu 0.7.5", +] + [[package]] name = "inotify" version = "0.11.0" @@ -3205,6 +3718,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3212,6 +3735,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -3236,6 +3762,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "ipcrypt-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e4f67dbfc0f75d7b65953ecf0be3fd84ee0cb1ae72a00a4aa9a2f5518a2c80" +dependencies = [ + "aes", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3382,6 +3917,33 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonschema" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f50532ce4a0ba3ae930212908d8ec50e7806065c059fe9c75da2ece6132294" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.17.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing 0.38.1", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -3420,6 +3982,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -3455,22 +4026,43 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" dependencies = [ - "ascii-canvas", + "ascii-canvas 3.0.0", "bit-set 0.5.3", "ena", "itertools 0.11.0", - "lalrpop-util", + "lalrpop-util 0.20.2", "petgraph 0.6.5", "pico-args", "regex", "regex-syntax", "string_cache", - "term", + "term 0.7.0", "tiny-keccak", "unicode-xid", "walkdir", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas 4.0.0", + "bit-set 0.8.0", + "ena", + "itertools 0.14.0", + "lalrpop-util 0.22.2", + "petgraph 0.7.1", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term 1.2.1", + "unicode-xid", + "walkdir", +] + [[package]] name = "lalrpop-util" version = "0.20.2" @@ -3480,6 +4072,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3503,9 +4105,9 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libm" @@ -3521,7 +4123,16 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.5.18", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", ] [[package]] @@ -3602,6 +4213,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3617,6 +4237,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "md5" version = "0.8.0" @@ -3727,7 +4357,7 @@ dependencies = [ "equivalent", "event-listener 5.4.1", "futures-util", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "rustc_version", "smallvec", @@ -3776,12 +4406,50 @@ dependencies = [ "serde", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nanorand" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3d189da485332e96ba8a5ef646a311871abd7915bf06ac848a9117f19cf6e4" + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -3801,6 +4469,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "nom_locate" version = "4.2.0" @@ -3809,7 +4495,7 @@ checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" dependencies = [ "bytecount", "memchr", - "nom", + "nom 7.1.3", ] [[package]] @@ -3845,17 +4531,330 @@ dependencies = [ ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "ntex" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "f4735978b410d8496a1d89bac416af3277f6d36e9ae56d1e3977b96b81ab8048" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" + "base64 0.22.1", + "bitflags 2.10.0", + "derive_more", + "encoding_rs", + "env_logger", + "httparse", + "httpdate", + "log", + "mime", + "nanorand", + "ntex-bytes", + "ntex-codec", + "ntex-dispatcher", + "ntex-error", + "ntex-h2", + "ntex-http", + "ntex-io", + "ntex-macros", + "ntex-net", + "ntex-router", + "ntex-rt", + "ntex-server", + "ntex-service", + "ntex-tls", + "ntex-util", + "percent-encoding", + "pin-project-lite", + "regex", + "rustls", + "serde", + "serde_json", + "serde_urlencoded", + "sha1", + "thiserror 2.0.18", + "uuid", + "variadics_please", + "webpki-roots", +] + +[[package]] +name = "ntex-bytes" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f03e42e23f0ab33b86f4af78f19c39a0cc253b20c073e5e9b92408ba0e91ff" +dependencies = [ + "bytes", + "serde", +] + +[[package]] +name = "ntex-codec" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f071b0c1daa379de93b4bbaf8ed822f12539901bdb15a5901cd15168f5e8eca" +dependencies = [ + "ntex-bytes", +] + +[[package]] +name = "ntex-dispatcher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc658d0b4caced7d7cfeefaeddd50f6c80265dc98e944beae3ac6601337b267" +dependencies = [ + "bitflags 2.10.0", + "log", + "ntex-codec", + "ntex-io", + "ntex-service", + "ntex-util", + "pin-project-lite", +] + +[[package]] +name = "ntex-error" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614a4c1c3cd23c231172fe20ab42bb66e8572e8c4cf282285723fc423b64834e" +dependencies = [ + "backtrace", + "foldhash 0.2.0", + "ntex-bytes", + "thiserror 2.0.18", +] + +[[package]] +name = "ntex-h2" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e400e7ea01ad28eb530e1a34840f700718cb0e7191cfb9160554b04f464f88b" +dependencies = [ + "bitflags 2.10.0", + "foldhash 0.2.0", + "log", + "nanorand", + "ntex-bytes", + "ntex-codec", + "ntex-dispatcher", + "ntex-error", + "ntex-http", + "ntex-io", + "ntex-net", + "ntex-server", + "ntex-service", + "ntex-util", + "pin-project-lite", + "thiserror 2.0.18", +] + +[[package]] +name = "ntex-http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a02a55ca3286342030ba369d4176e280989e97958455963efce846e8abdca6" +dependencies = [ + "foldhash 0.2.0", + "futures-core", + "http 1.3.1", + "itoa", + "log", + "ntex-bytes", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "ntex-io" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c1d3d9a67a9abcad8981967bb1f3c59ec2c34e442771d16ed33acf663f0361" +dependencies = [ + "bitflags 2.10.0", + "log", + "ntex-bytes", + "ntex-codec", + "ntex-service", + "ntex-util", + "pin-project-lite", +] + +[[package]] +name = "ntex-io-uring" +version = "0.7.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e60f4a52aae6b07b8a4c560f05802be74c10c2924838f1007f48684233aaaa" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "libc", + "sc", +] + +[[package]] +name = "ntex-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51138717dfe591b9b4063bf167ddcdc6fa8e3552157316f29f12c321493e3710" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ntex-net" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c7c631404d704766913028124c60712457b1157923d85156aaf49fbd2551e9" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "libc", + "log", + "ntex-bytes", + "ntex-error", + "ntex-http", + "ntex-io", + "ntex-io-uring", + "ntex-polling", + "ntex-rt", + "ntex-service", + "ntex-util", + "scoped-tls", + "slab", + "socket2 0.6.1", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "ntex-polling" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad417300c371ebb585b3b67e94d16f5843ea4d59e93e8b59d5c979a363b76bb7" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "ntex-router" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203fc4ba8fd22db656cde62cfbfe705f776aa652ce8d7ce5c6093b7c37185f70" +dependencies = [ + "http 1.3.1", + "log", + "ntex-bytes", + "regex", + "serde", +] + +[[package]] +name = "ntex-rt" +version = "3.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0f8d34b0f586e276ffdd34a4265db243672c6fe7e90b100e573319a7d3eddf" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "crossbeam-channel", + "crossbeam-queue", + "foldhash 0.2.0", + "futures-timer", + "log", + "ntex-error", + "ntex-service", + "oneshot 0.2.1", + "scoped-tls", + "swap-buffer-queue", + "tokio", +] + +[[package]] +name = "ntex-server" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ac36e4b11c0cf0ae53fea574a1ab37e0c54331eb78f0b5a5e02cf6604a1f04" +dependencies = [ + "async-channel 2.5.0", + "atomic-waker", + "core_affinity", + "ctrlc", + "log", + "ntex-io", + "ntex-net", + "ntex-polling", + "ntex-rt", + "ntex-service", + "ntex-util", + "oneshot 0.1.13", + "signal-hook", + "socket2 0.6.1", + "uuid", +] + +[[package]] +name = "ntex-service" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f69442f89962c8c76a76f563c8d5ec0585fe645770d62a42e551f91ccc62278" +dependencies = [ + "foldhash 0.2.0", + "log", + "slab", +] + +[[package]] +name = "ntex-tls" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e987231c62973660d743d599ddc26e82ed4f2f54050ce3e0f6d4bf8d3586106" +dependencies = [ + "log", + "ntex-bytes", + "ntex-error", + "ntex-io", + "ntex-net", + "ntex-service", + "ntex-util", + "rustls", +] + +[[package]] +name = "ntex-util" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d799e658d04ad8be6d750b09a82fa01ad11dde264d9ec40fac873f835e87e85" +dependencies = [ + "bitflags 2.10.0", + "foldhash 0.2.0", + "futures-core", + "futures-timer", + "log", + "ntex-bytes", + "ntex-error", + "ntex-rt", + "ntex-service", + "pin-project-lite", + "slab", + "thiserror 2.0.18", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ @@ -3974,6 +4973,15 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3983,6 +4991,12 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "objc2-io-kit" version = "0.3.2" @@ -4047,6 +5061,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "octseq" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126c3ca37c9c44cec575247f43a3e4374d8927684f129d2beeb0d2cef262fe12" +dependencies = [ + "bytes", + "serde", + "smallvec", +] + +[[package]] +name = "ofb" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc40678e045ff4eb1666ea6c0f994b133c31f673c09aed292261b6d5b6963a0" +dependencies = [ + "cipher", +] + [[package]] name = "olpc-cjson" version = "0.1.4" @@ -4068,6 +5102,46 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -4132,7 +5206,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "thiserror 2.0.18", "tokio", @@ -4160,7 +5234,7 @@ checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -4200,7 +5274,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.4", + "rand 0.9.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4212,6 +5286,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.2" @@ -4248,6 +5331,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -4255,7 +5349,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -4266,17 +5374,29 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9ed2178b0575fff8e1b83b58ba6f75e727aafac2e1b6c795169ad3b17eb518" + [[package]] name = "pem" version = "3.0.6" @@ -4355,6 +5475,16 @@ dependencies = [ "indexmap 2.12.0", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.12.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -4368,6 +5498,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -4377,6 +5516,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4467,6 +5615,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4589,11 +5748,21 @@ dependencies = [ "fnv", "lazy_static", "memchr", - "parking_lot", + "parking_lot 0.12.5", "protobuf", "thiserror 2.0.18", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -4601,7 +5770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", ] [[package]] @@ -4616,8 +5785,8 @@ dependencies = [ "multimap 0.10.1", "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", @@ -4625,6 +5794,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -4638,13 +5820,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -4681,6 +5883,51 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "psl" +version = "2.1.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" @@ -4730,7 +5977,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand 0.9.3", "ring", "rustc-hash 2.1.1", "rustls", @@ -4765,6 +6012,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -4777,6 +6030,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.8.5" @@ -4790,9 +6052,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -4804,7 +6066,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -4874,7 +6136,16 @@ dependencies = [ "cookie-factory", "crc16", "log", - "nom", + "nom 7.1.3", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", ] [[package]] @@ -4937,7 +6208,7 @@ dependencies = [ "ahash", "fluent-uri 0.3.2", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "percent-encoding", "serde_json", ] @@ -4952,7 +6223,22 @@ dependencies = [ "fluent-uri 0.4.1", "getrandom 0.3.4", "hashbrown 0.16.0", - "parking_lot", + "parking_lot 0.12.5", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "referencing" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a8af0c6bb8eaf8b07cb06fc31ff30ca6fe19fb99afa476c276d8b24f365b0b" +dependencies = [ + "ahash", + "fluent-uri 0.4.1", + "getrandom 0.3.4", + "hashbrown 0.16.0", + "parking_lot 0.12.5", "percent-encoding", "serde_json", ] @@ -4980,6 +6266,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-filtered" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac5f7b31fbef748cc46643c1f9ba17f6d5c7c6f0ba5e372fc9c48d31ad1c8612" +dependencies = [ + "aho-corasick", + "itertools 0.14.0", + "regex", + "regex-syntax", +] + [[package]] name = "regex-lite" version = "0.1.8" @@ -5012,6 +6310,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" + [[package]] name = "reqwest" version = "0.12.24" @@ -5072,6 +6376,27 @@ dependencies = [ "tower-service", ] +[[package]] +name = "reqwest-retry" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.16", + "http 1.3.1", + "hyper 1.7.0", + "parking_lot 0.11.2", + "reqwest", + "reqwest-middleware", + "retry-policies 0.4.0", + "thiserror 1.0.69", + "tokio", + "wasm-timer", +] + [[package]] name = "reqwest-retry" version = "0.8.0" @@ -5086,7 +6411,7 @@ dependencies = [ "hyper 1.7.0", "reqwest", "reqwest-middleware", - "retry-policies", + "retry-policies 0.5.1", "thiserror 2.0.18", "tokio", "tracing", @@ -5099,13 +6424,22 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +[[package]] +name = "retry-policies" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +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.4", + "rand 0.9.3", ] [[package]] @@ -5162,6 +6496,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" +dependencies = [ + "bytes", + "hashbrown 0.16.0", + "indexmap 2.12.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rmp" version = "0.8.14" @@ -5185,6 +6548,15 @@ dependencies = [ "text-size", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5233,11 +6605,21 @@ dependencies = [ name = "rust-embed-utils" version = "8.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" +checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ - "globset", - "sha2", - "walkdir", + "arrayvec", + "num-traits", ] [[package]] @@ -5328,9 +6710,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", @@ -5358,6 +6740,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5367,6 +6758,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010e18bd3bfd1d45a7e666b236c78720df0d9a7698ebaa9c1c559961eb60a38b" + [[package]] name = "schannel" version = "0.1.28" @@ -5438,12 +6835,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -5661,6 +7070,30 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5683,6 +7116,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shape" version = "0.7.0" @@ -5719,6 +7162,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -5744,6 +7197,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -5795,6 +7254,55 @@ dependencies = [ "version_check", ] +[[package]] +name = "snafu" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" +dependencies = [ + "doc-comment", + "snafu-derive 0.7.5", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive 0.8.9", +] + +[[package]] +name = "snafu-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "socket2" version = "0.5.10" @@ -5815,6 +7323,45 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sonic-number" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3775c3390edf958191f1ab1e8c5c188907feebd0f3ce1604cb621f72961dbf32" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "sonic-rs" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d971cc77a245ccf1756dbd1a87c3e7f709c0191464096510d43eec056d0f2c4f" +dependencies = [ + "ahash", + "bumpalo", + "bytes", + "cfg-if", + "faststr", + "itoa", + "ref-cast", + "serde", + "simdutf8", + "sonic-number", + "sonic-simd", + "thiserror 2.0.18", + "zmij", +] + +[[package]] +name = "sonic-simd" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99e664ecd2d85a68c87e3c7a3cfe691f647ea9e835de984aba4d54a41f817d4" +dependencies = [ + "cfg-if", +] + [[package]] name = "spin" version = "0.5.2" @@ -5866,11 +7413,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", - "parking_lot", - "phf_shared", + "parking_lot 0.12.5", + "phf_shared 0.11.3", "precomputed-hash", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5904,6 +7460,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swap-buffer-queue" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcec9c96aabcb077bc9c491e2d48d3c50654455a75451c742abe41f5d171bf5" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "syn" version = "1.0.109" @@ -5970,6 +7535,16 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "syslog_loose" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ec4df26907adce53e94eac201a9ba38744baea3bc97f34ffd591d5646231a6" +dependencies = [ + "chrono", + "nom 8.0.0", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -6000,6 +7575,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -6014,9 +7607,9 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "thin-vec" -version = "0.2.16" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" dependencies = [ "serde", ] @@ -6177,7 +7770,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2 0.6.1", @@ -6300,7 +7893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -6313,7 +7906,7 @@ dependencies = [ "prettyplease", "proc-macro2", "prost-build", - "prost-types", + "prost-types 0.14.3", "quote", "syn 2.0.117", "tempfile", @@ -6517,7 +8110,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.4", + "rand 0.9.3", "rustls", "rustls-pki-types", "sha1", @@ -6634,6 +8227,17 @@ dependencies = [ "typify-impl", ] +[[package]] +name = "ua-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f01b7ba339f8874b643216c6c957d792047143abe848568131e5d91d2ae2ada1" +dependencies = [ + "regex", + "regex-filtered", + "serde", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -6700,6 +8304,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -6730,6 +8350,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6771,18 +8397,138 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vrl" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d513609a4e7b19a3715b01978152b7132a6e512bde68736ab5f47d42b22ddc8" +dependencies = [ + "aes", + "aes-siv", + "base16", + "base62", + "base64-simd", + "bytes", + "cbc", + "cfb-mode", + "cfg-if", + "chacha20poly1305", + "charset", + "chrono", + "chrono-tz", + "ciborium", + "cidr", + "codespan-reporting", + "community-id", + "convert_case", + "crc", + "crypto_secretbox", + "csv", + "ctr", + "digest", + "dns-lookup", + "domain", + "dyn-clone", + "encoding_rs", + "fancy-regex 0.17.0", + "flate2", + "grok", + "hex", + "hmac", + "hostname", + "iana-time-zone", + "idna", + "indexmap 2.12.0", + "indoc", + "influxdb-line-protocol", + "ipcrypt-rs", + "itertools 0.14.0", + "jsonschema 0.38.1", + "lalrpop 0.22.2", + "lalrpop-util 0.22.2", + "lz4_flex", + "md-5", + "nom 8.0.0", + "nom-language", + "ofb", + "onig", + "ordered-float", + "parse-size", + "peeking_take_while", + "percent-encoding", + "pest", + "pest_derive", + "prost 0.13.5", + "prost-reflect", + "psl", + "psl-types", + "publicsuffix", + "quoted_printable", + "rand 0.8.5", + "regex", + "reqwest", + "reqwest-middleware", + "reqwest-retry 0.7.0", + "roxmltree", + "rust_decimal", + "seahash", + "serde", + "serde_json", + "serde_yaml 0.9.34+deprecated", + "sha-1", + "sha2", + "sha3", + "simdutf8", + "snafu 0.8.9", + "snap", + "strip-ansi-escapes", + "syslog_loose", + "termcolor", + "thiserror 2.0.18", + "tokio", + "tracing", + "ua-parser", + "unicode-segmentation", + "url", + "utf8-width", + "uuid", + "woothee", + "xxhash-rust", + "zstd", +] + [[package]] name = "vsimd" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6919,6 +8665,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6939,7 +8700,7 @@ checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" dependencies = [ "futures", "js-sys", - "parking_lot", + "parking_lot 0.12.5", "pin-utils", "slab", "wasm-bindgen", @@ -7601,6 +9362,16 @@ dependencies = [ "windows-core 0.59.0", ] +[[package]] +name = "woothee" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896174c6a4779d4d7d4523dd27aef7d46609eda2497e370f6c998325c6bf6971" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7753,6 +9524,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zmij" version = "1.0.21" diff --git a/apollo-router-workspace/bin/router/src/usage.rs b/apollo-router-workspace/bin/router/src/usage.rs index c3c6cd971..ada3583f5 100644 --- a/apollo-router-workspace/bin/router/src/usage.rs +++ b/apollo-router-workspace/bin/router/src/usage.rs @@ -1,11 +1,13 @@ use crate::consts::PLUGIN_VERSION; +use apollo_router::Context; use apollo_router::layers::ServiceBuilderExt; use apollo_router::plugin::Plugin; use apollo_router::plugin::PluginInit; use apollo_router::services::*; -use apollo_router::Context; use core::ops::Drop; +use std::collections::HashSet; use futures::StreamExt; +use hive_console_sdk::agent::usage_agent::RequestDetails; use hive_console_sdk::agent::usage_agent::UsageAgentExt; use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent}; use hive_console_sdk::graphql_tools::parser::parse_schema; @@ -14,7 +16,6 @@ use http::HeaderValue; use rand::RngExt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use std::env; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -53,6 +54,13 @@ pub struct UsagePlugin { cancellation_token: Arc, } +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(untagged)] +enum UsageReportingExclude { + Expression { expression: String }, + OperationNames(Vec), +} + #[derive(Clone, Debug, Deserialize, JsonSchema, Default)] pub struct Config { /// Default: true @@ -74,8 +82,20 @@ pub struct Config { /// 1.0 = 100% chance of being sent. /// Default: 1.0 sample_rate: Option, - /// A list of operations (by name) to be ignored by GraphQL Hive. - exclude: Option>, + /// An expression in VRL to exclude certain operations from being sent to Hive Console. + /// Returning `true` from this expression will exclude the operation, while `false` will include it. + /// This expression is a VRL expression that has access to the request and operation details; + /// + /// ```vrl + /// if (.request.operation.name == "ExcludeMe") { + /// true + /// } else { + /// false + /// } + /// ``` + /// Use `exclude: { expression: "..." }` for expressions. + /// A list of operation names is also supported for backward compatibility. + exclude: Option, client_name_header: Option, client_version_header: Option, /// A maximum number of operations to hold in a buffer before sending to GraphQL Hive @@ -218,6 +238,10 @@ impl Plugin for UsagePlugin { agent = agent.flush_interval(Duration::from_secs(flush_interval)); } + if let Some(UsageReportingExclude::Expression { expression }) = &user_config.exclude { + agent = agent.exclude_expression(expression.clone()); + } + let agent = agent.build().map_err(Box::new)?; let cancellation_token_for_interval = cancellation_token.clone(); @@ -236,11 +260,16 @@ impl Plugin for UsagePlugin { .expect("Failed to parse schema") .into_static(); + let exclude = match user_config.exclude { + Some(UsageReportingExclude::OperationNames(names)) => Some(names), + _ => None, + }; + Ok(UsagePlugin { schema: Arc::new(schema), config: OperationConfig { sample_rate: user_config.sample_rate.unwrap_or(1.0), - exclude: user_config.exclude, + exclude, client_name_header: user_config .client_name_header .unwrap_or("graphql-client-name".to_string()), @@ -263,9 +292,24 @@ impl Plugin for UsagePlugin { .map_future_with_request_data( move |req: &supergraph::Request| { Self::populate_context(config.clone(), req); - req.context.clone() + + let request_details = RequestDetails { + method: req.supergraph_request.method().clone(), + url: req.supergraph_request.uri().clone(), + headers: req + .supergraph_request + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|value| (k.to_string(), value.to_string())) + }) + .collect(), + }; + (request_details, req.context.clone()) }, - move |ctx: Context, fut| { + move |(request_details, ctx): (RequestDetails, Context), fut| { let agent = agent.clone(); let schema = schema.clone(); async move { @@ -313,18 +357,18 @@ impl Plugin for UsagePlugin { Err(e) => { tokio::spawn(async move { let res = agent - .add_report(ExecutionReport { + .add_report_with_request(ExecutionReport { schema, client_name, client_version, timestamp, duration, - ok: false, errors: 1, operation_body, operation_name, persisted_document_hash, - }) + ..Default::default() + }, Some(request_details)) .await; if let Err(e) = res { tracing::error!("Error adding report: {}", e); @@ -352,12 +396,13 @@ impl Plugin for UsagePlugin { errors: response.errors.len(), operation_body: operation_body.clone(), operation_name: operation_name.clone(), - persisted_document_hash: - persisted_document_hash.clone(), + persisted_document_hash: persisted_document_hash.clone(), + ..Default::default() }; + let request_details = request_details.clone(); tokio::spawn(async move { let res = agent - .add_report(execution_report) + .add_report_with_request(execution_report, Some(request_details.clone())) .await; if let Err(e) = res { tracing::error!( @@ -395,7 +440,7 @@ impl Drop for UsagePlugin { #[cfg(test)] mod hive_usage_tests { use apollo_router::{ - plugin::{test::MockSupergraphService, Plugin, PluginInit}, + plugin::{Plugin, PluginInit, test::MockSupergraphService}, services::supergraph, }; use http::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; @@ -404,10 +449,50 @@ mod hive_usage_tests { use serde_json::json; use tower::ServiceExt; - use crate::consts::PLUGIN_VERSION; + use crate::{consts::PLUGIN_VERSION, usage::UsageReportingExclude}; use super::{Config, UsagePlugin}; + #[test] + fn config_exclude_supports_expression_object() { + let config: Config = serde_json::from_value(json!({ + "exclude": { "expression": ".request.operation.name == \"ExcludedOp\"" } + })) + .expect("config with expression object should deserialize"); + + assert!(matches!(config.exclude, Some(UsageReportingExclude::Expression { .. }))); + + if let Some(UsageReportingExclude::Expression { expression }) = config.exclude { + assert_eq!( + expression, + ".request.operation.name == \"ExcludedOp\"", + "expression should match the input" + ); + } else { + panic!("Expected an expression exclude"); + } + } + + #[test] + fn config_exclude_supports_legacy_operation_list() { + let config: Config = serde_json::from_value(json!({ + "exclude": ["ExcludedOp", "IntrospectionQuery"] + })) + .expect("config with legacy operation list should deserialize"); + + assert!(matches!(config.exclude, Some(UsageReportingExclude::OperationNames(_)))); + + if let Some(UsageReportingExclude::OperationNames(names)) = config.exclude { + assert_eq!( + names, + vec!["ExcludedOp".to_string(), "IntrospectionQuery".to_string()], + "operation names should match the input" + ); + } else { + panic!("Expected an operation names exclude"); + } + } + lazy_static::lazy_static! { static ref SCHEMA_VALIDATOR: Validator = jsonschema::validator_for(&serde_json::from_str(&std::fs::read_to_string("../../../lib/hive-console-sdk/usage-report-v2.schema.json").expect("can't load json schema file")).expect("failed to parse json schema")).expect("failed to parse schema"); diff --git a/bin/router/src/pipeline/introspection_policy.rs b/bin/router/src/pipeline/introspection_policy.rs index 6084822aa..695a5b399 100644 --- a/bin/router/src/pipeline/introspection_policy.rs +++ b/bin/router/src/pipeline/introspection_policy.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use hive_router_config::introspection_policy::IntrospectionPermissionConfig; use hive_router_internal::expressions::{ - values::boolean::BooleanOrProgram, CompileExpression, ExpressionCompileError, ProgramHints, + BooleanOrProgram, CompileExpression, ExpressionCompileError, ProgramHints, }; use hive_router_plan_executor::execution::client_request_details::ClientRequestDetailsView; use tracing::debug; diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 026e7c0f0..72ebacb67 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -1,4 +1,5 @@ use futures::StreamExt; +use hive_console_sdk::agent::usage_agent::RequestDetails; use hive_router_internal::{ http::read_body_stream, telemetry::traces::spans::{ @@ -390,6 +391,7 @@ pub async fn graphql_request_handler( client_name, client_version, normalize_payload.operation_for_plan.name.as_deref(), + normalize_payload.operation_for_plan.operation_kind.as_ref(), &parser_payload.minified_document, hive_usage_agent, shared_state @@ -405,6 +407,7 @@ pub async fn graphql_request_handler( "Expected Usage Reporting options to be present when Hive Usage Agent is initialized", ), shared_response.error_count(), + Some(RequestDetails::from(&*req)), ) .await; } diff --git a/bin/router/src/pipeline/usage_reporting.rs b/bin/router/src/pipeline/usage_reporting.rs index 10ce60876..f79acc5c8 100644 --- a/bin/router/src/pipeline/usage_reporting.rs +++ b/bin/router/src/pipeline/usage_reporting.rs @@ -5,14 +5,16 @@ use std::{ use async_trait::async_trait; use graphql_tools::parser::schema::Document; -use hive_console_sdk::agent::usage_agent::{AgentError, UsageAgentExt}; -use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent}; +use hive_console_sdk::agent::usage_agent::{ + AgentError, ExecutionReport, OperationType, RequestDetails, UsageAgent, UsageAgentExt, +}; use hive_router_config::{ telemetry::hive::{is_slug_target_ref, is_uuid_target_ref, HiveTelemetryConfig}, - usage_reporting::UsageReportingConfig, + usage_reporting::{UsageReportingConfig, UsageReportingExclude}, }; use hive_router_internal::background_tasks::{BackgroundTask, BackgroundTasksManager}; use hive_router_internal::telemetry::utils::resolve_value_or_expression; +use hive_router_query_planner::state::supergraph_state::OperationKind; use rand::prelude::*; use tokio_util::sync::CancellationToken; @@ -20,7 +22,9 @@ use crate::consts::ROUTER_VERSION; #[derive(Debug, thiserror::Error)] pub enum UsageReportingError { - #[error("Usage Reporting - Access token is missing. Please provide it via 'HIVE_ACCESS_TOKEN' environment variable or under 'telemetry.hive.token' in the configuration.")] + #[error( + "Usage Reporting - Access token is missing. Please provide it via 'HIVE_ACCESS_TOKEN' environment variable or under 'telemetry.hive.token' in the configuration." + )] MissingAccessToken, #[error("Failed to initialize usage agent: {0}")] AgentCreationError(#[from] AgentError), @@ -71,6 +75,10 @@ pub fn init_hive_usage_agent( agent_builder = agent_builder.target_id(target_id); } + if let Some(UsageReportingExclude::Expression { expression }) = &usage_config.exclude { + agent_builder = agent_builder.exclude_expression(expression.clone()); + } + let agent = agent_builder.build()?; bg_tasks_manager.register_task(UsageAgentTask(agent.clone())); @@ -86,17 +94,23 @@ pub async fn collect_usage_report<'a>( client_name: Option<&str>, client_version: Option<&str>, operation_name: Option<&'a str>, + operation_kind: Option<&'a OperationKind>, operation_body: &'a str, hive_usage_agent: &UsageAgent, usage_config: &UsageReportingConfig, error_count: usize, + request_details: Option, ) { let sample_rate = usage_config.sample_rate.as_f64(); if sample_rate < 1.0 && !rand::rng().random_bool(sample_rate) { return; } - if operation_name.is_some_and(|op_name| usage_config.exclude.iter().any(|s| s == op_name)) { - return; + if let Some(operation_name) = operation_name { + if let Some(UsageReportingExclude::OperationNames(excluded_names)) = &usage_config.exclude { + if excluded_names.iter().any(|name| name == operation_name) { + return; + } + } } let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -111,11 +125,19 @@ pub async fn collect_usage_report<'a>( ok: error_count == 0, errors: error_count, operation_body: operation_body.to_owned(), + operation_type: operation_kind.map(|k| match k { + OperationKind::Query => OperationType::Query, + OperationKind::Mutation => OperationType::Mutation, + OperationKind::Subscription => OperationType::Subscription, + }), operation_name: operation_name.map(|s| s.to_owned()), persisted_document_hash: None, }; - if let Err(err) = hive_usage_agent.add_report(execution_report).await { + if let Err(err) = hive_usage_agent + .add_report_with_request(execution_report, request_details) + .await + { tracing::error!("Failed to send usage report: {}", err); } } diff --git a/bin/router/src/pipeline/websocket_server.rs b/bin/router/src/pipeline/websocket_server.rs index 587ce9aaa..b4bc6dcbd 100644 --- a/bin/router/src/pipeline/websocket_server.rs +++ b/bin/router/src/pipeline/websocket_server.rs @@ -1,3 +1,4 @@ +use hive_console_sdk::agent::usage_agent::RequestDetails; use http::Method; use ntex::channel::oneshot; use ntex::http::{header::HeaderName, header::HeaderValue, HeaderMap}; @@ -461,9 +462,9 @@ async fn handle_text_frame( SingleContentType::default(), StreamContentType::default(), ); - + let method = Method::POST; let exec = |guard| execute_planned_request( - &Method::POST, + &method, ws_uri, headers.as_ref().clone(), payload, @@ -517,12 +518,27 @@ async fn handle_text_frame( }; if let Some(hive_usage_agent) = &shared_state.hive_usage_agent { + let headers = headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|val_str| (name.to_string(), val_str.to_string())) + }) + .collect(); + let request_details = RequestDetails { + method: Method::POST, + url: (*ws_uri).clone(), + headers, + }; usage_reporting::collect_usage_report( supergraph.supergraph_schema.clone(), started_at.elapsed(), client_name, client_version, normalize_payload.operation_for_plan.name.as_deref(), + normalize_payload.operation_for_plan.operation_kind.as_ref(), &parser_payload.minified_document, hive_usage_agent, shared_state @@ -533,6 +549,7 @@ async fn handle_text_frame( .map(|c| &c.usage_reporting) .expect("Expected Usage Reporting options to be present when Hive Usage Agent is initialized"), shared_response.error_count(), + Some(request_details), ) .await; } diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index 096d17196..7e15f3685 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -5,8 +5,7 @@ use hive_router_config::traffic_shaping::{ TrafficShapingRouterDedupeHeadersConfig, TrafficShapingRouterDedupeHeadersKeyword, }; use hive_router_config::HiveRouterConfig; -use hive_router_internal::expressions::values::boolean::BooleanOrProgram; -use hive_router_internal::expressions::ExpressionCompileError; +use hive_router_internal::expressions::{BooleanOrProgram, ExpressionCompileError}; use hive_router_internal::inflight::{InFlightCleanupGuard, InFlightMap}; use hive_router_internal::telemetry::TelemetryContext; use hive_router_plan_executor::coprocessor::{CoprocessorError, CoprocessorRuntime}; diff --git a/docs/README.md b/docs/README.md index b07a07f6b..17ecc9e09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2614,7 +2614,7 @@ version_header: graphql-client-version |**target**||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.
|| |**token**||Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission.
|| |[**tracing**](#telemetryhivetracing)|`object`|Default: `{"batch_processor":{"max_concurrent_exports":1,"max_export_batch_size":500,"max_export_timeout":"5s","max_queue_size":20000,"max_spans_per_trace":1000,"max_traces_in_memory":30000,"scheduled_delay":"5s"},"enabled":false,"endpoint":"https://api.graphql-hive.com/otel/v1/traces"}`
|| -|[**usage\_reporting**](#telemetryhiveusage_reporting)|`object`|Default: `{"accept_invalid_certs":false,"buffer_size":1000,"connect_timeout":"5s","enabled":false,"endpoint":"https://app.graphql-hive.com/usage","exclude":[],"flush_interval":"5s","request_timeout":"15s","sample_rate":"100%"}`
|| +|[**usage\_reporting**](#telemetryhiveusage_reporting)|`object`|Default: `{"accept_invalid_certs":false,"buffer_size":1000,"connect_timeout":"5s","enabled":false,"endpoint":"https://app.graphql-hive.com/usage","exclude":null,"flush_interval":"5s","request_timeout":"15s","sample_rate":"100%"}`
|| **Additional Properties:** not allowed **Example** @@ -2693,7 +2693,7 @@ scheduled_delay: 5s |**connect\_timeout**|`string`|A timeout for only the connect phase of a request to Hive Console
Default: 5 seconds
Default: `"5s"`
|| |**enabled**|`boolean`|Default: `false`
|| |**endpoint**|`string`|For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`).
Default: `"https://app.graphql-hive.com/usage"`
|| -|[**exclude**](#telemetryhiveusage_reportingexclude)|`string[]`|A list of operations (by name) to be ignored by Hive.
Default:
|| +|**exclude**||An expression in VRL to exclude certain operations from being sent to Hive Console.
Returning `true` from this expression will exclude the operation, while `false` will include it.
This expression is a VRL expression that has access to the request and operation details;

```vrl
if (.request.operation.name == "ExcludeMe") {
true
} else {
false
}
```
Backward compatible with both:
- an expression object: `{ expression: "..." }`
- a list of operation names
|| |**flush\_interval**|`string`|Frequency of flushing the buffer to the server
Default: 5 seconds
Default: `"5s"`
|| |**request\_timeout**|`string`|A timeout for the entire request to Hive Console
Default: 15 seconds
Default: `"15s"`
|| |**sample\_rate**|`string`|Sample rate to determine sampling.
0% = never being sent
50% = half of the requests being sent
100% = always being sent
Default: 100%
Default: `"100%"`
|| @@ -2707,23 +2707,13 @@ buffer_size: 1000 connect_timeout: 5s enabled: false endpoint: https://app.graphql-hive.com/usage -exclude: [] +exclude: null flush_interval: 5s request_timeout: 15s sample_rate: 100% ``` -
-##### telemetry\.hive\.usage\_reporting\.exclude\[\]: array - -A list of operations (by name) to be ignored by Hive. -Example: ["IntrospectionQuery", "MeQuery"] - - -**Items** - -**Item Type:** `string` ### telemetry\.metrics: object diff --git a/e2e/src/telemetry/mod.rs b/e2e/src/telemetry/mod.rs index a2b1e8577..af15e110d 100644 --- a/e2e/src/telemetry/mod.rs +++ b/e2e/src/telemetry/mod.rs @@ -1,2 +1,3 @@ mod metrics; mod tracing; +mod usage_reporting; diff --git a/e2e/src/telemetry/usage_reporting.rs b/e2e/src/telemetry/usage_reporting.rs new file mode 100644 index 000000000..821012c08 --- /dev/null +++ b/e2e/src/telemetry/usage_reporting.rs @@ -0,0 +1,578 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; + +use crate::testkit::{TestRouter, TestSubgraphs}; + +/// A simple mock HTTP server that captures usage report requests. +struct MockUsageEndpoint { + address: String, + reports: Arc>>, + _handle: std::thread::JoinHandle<()>, +} + +impl MockUsageEndpoint { + fn start() -> Self { + let server = + tiny_http::Server::http("127.0.0.1:0").expect("Failed to start mock usage endpoint"); + let address = format!("http://{}", server.server_addr()); + let reports: Arc>> = Arc::new(Mutex::new(Vec::new())); + let reports_clone = reports.clone(); + + let handle = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime for mock usage endpoint"); + + for mut request in server.incoming_requests() { + let mut body = Vec::new(); + let mut reader = request.as_reader(); + let _ = std::io::Read::read_to_end(&mut reader, &mut body); + + if let Ok(json) = serde_json::from_slice::(&body) { + rt.block_on(async { + reports_clone.lock().await.push(json); + }); + } + + let response = tiny_http::Response::from_string("{}"); + let _ = request.respond(response); + } + }); + + MockUsageEndpoint { + address, + reports, + _handle: handle, + } + } + + /// Wait until at least `count` reports have been received, with a timeout. + async fn wait_for_reports(&self, count: usize) { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if self.reports.lock().await.len() >= count { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "Timed out waiting for {} usage reports, got {}", + count, + self.reports.try_lock().map(|r| r.len()).unwrap_or(0) + ) + }); + } + + /// Wait for a short duration and assert no reports arrived. + async fn assert_no_reports(&self, wait: Duration) { + tokio::time::sleep(wait).await; + let reports = self.reports.lock().await; + assert!( + reports.is_empty(), + "Expected no usage reports but got {}", + reports.len() + ); + } + + async fn reports(&self) -> Vec { + self.reports.lock().await.clone() + } + + /// Returns the total number of operations across all received reports. + async fn total_operations_count(&self) -> usize { + let reports = self.reports.lock().await; + reports + .iter() + .filter_map(|r| r.get("operations")) + .filter_map(|ops| ops.as_array()) + .map(|ops| ops.len()) + .sum() + } +} + +/// Test that reports are sent when no exclude expression is configured. +#[ntex::test] +async fn usage_reporting_sends_reports_without_exclude() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let res = router + .send_graphql_request("{ users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + // The buffer_size=1 means flush happens immediately after the first report + mock.wait_for_reports(1).await; + + let reports = mock.reports().await; + assert!(!reports.is_empty(), "Expected at least one report"); + let first_report = &reports[0]; + assert!( + first_report.get("operations").is_some(), + "Report should contain operations" + ); + + drop(router); +} + +/// Test that an exclude expression matching the operation name prevents the report from being sent. +#[ntex::test] +async fn usage_reporting_excludes_by_operation_name() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '.request.operation.name == "ExcludedOp"' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Send a request with the excluded operation name + let res = router + .send_graphql_request("query ExcludedOp { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + // Wait a reasonable time to ensure no reports arrive + mock.assert_no_reports(Duration::from_secs(2)).await; + + drop(router); +} + +/// Test that an exclude expression does NOT exclude non-matching operations. +#[ntex::test] +async fn usage_reporting_does_not_exclude_non_matching_operations() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '.request.operation.name == "ExcludedOp"' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Send a request with a different operation name (should NOT be excluded) + let res = router + .send_graphql_request("query AllowedOp { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock.wait_for_reports(1).await; + + let reports = mock.reports().await; + assert!( + !reports.is_empty(), + "Expected report for non-excluded operation" + ); + + drop(router); +} + +/// Test that an exclude expression can filter by request header. +#[ntex::test] +async fn usage_reporting_excludes_by_header() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '.request.headers."x-internal" == "true"' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Send with the x-internal header - should be excluded + let res = router + .send_graphql_request( + "{ users { id } }", + None, + Some({ + let mut headers = http::HeaderMap::new(); + headers.insert("x-internal", "true".parse().unwrap()); + headers + }), + ) + .await; + assert!(res.status().is_success()); + + mock.assert_no_reports(Duration::from_secs(2)).await; + + drop(router); +} + +/// Test that requests without the excluded header still get reported. +#[ntex::test] +async fn usage_reporting_sends_when_header_not_matching() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '.request.headers."x-internal" == "true"' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // Send without the x-internal header - should NOT be excluded + let res = router + .send_graphql_request("{ users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock.wait_for_reports(1).await; + + let reports = mock.reports().await; + assert!( + !reports.is_empty(), + "Expected report when header does not match" + ); + + drop(router); +} + +/// Test a complex exclude expression using if/else to exclude introspection AND mutations. +#[ntex::test] +async fn usage_reporting_complex_exclude_expression() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock_excluded = MockUsageEndpoint::start(); + let usage_endpoint = &mock_excluded.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + // Exclude IntrospectionQuery by name OR any request with x-exclude header + let exclude_expr = r#"if (.request.operation.name == "IntrospectionQuery") { true } else if (.request.headers."x-exclude" == "yes") { true } else { false }"#; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '{exclude_expr}' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + // 1st request: IntrospectionQuery - should be excluded + let res = router + .send_graphql_request("query IntrospectionQuery { __typename }", None, None) + .await; + assert!(res.status().is_success()); + + // 2nd request: with x-exclude header - should be excluded + let res = router + .send_graphql_request( + "query SomeOp { users { id } }", + None, + Some({ + let mut headers = http::HeaderMap::new(); + headers.insert("x-exclude", "yes".parse().unwrap()); + headers + }), + ) + .await; + assert!(res.status().is_success()); + + // Neither should have generated a report + mock_excluded + .assert_no_reports(Duration::from_secs(2)) + .await; + + // 3rd request: normal query - should be reported + let res = router + .send_graphql_request("query NormalQuery { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock_excluded.wait_for_reports(1).await; + + let total_ops = mock_excluded.total_operations_count().await; + assert!( + total_ops >= 1, + "Expected at least one operation in reports for non-excluded query" + ); + + drop(router); +} + +/// Test backward compatibility with legacy operation-name list in usage_reporting.exclude. +#[ntex::test] +async fn usage_reporting_legacy_exclude_list_still_excludes() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + - ExcludedOp + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let res = router + .send_graphql_request("query ExcludedOp { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock.assert_no_reports(Duration::from_secs(2)).await; + + drop(router); +} + +/// Test backward compatibility with legacy list while allowing non-matching operations. +#[ntex::test] +async fn usage_reporting_legacy_exclude_list_allows_non_matching_operation() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + - ExcludedOp + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let res = router + .send_graphql_request("query AllowedOp { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock.wait_for_reports(1).await; + + drop(router); +} + +/// Test explicit expression object format for usage_reporting.exclude. +#[ntex::test] +async fn usage_reporting_exclude_expression_object_excludes() { + let supergraph_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("supergraph.graphql"); + let supergraph_path = supergraph_path.to_str().unwrap(); + + let mock = MockUsageEndpoint::start(); + let usage_endpoint = &mock.address; + + let subgraphs = TestSubgraphs::builder().build().start().await; + + let router = TestRouter::builder() + .inline_config(format!( + r#" + supergraph: + source: file + path: {supergraph_path} + + telemetry: + hive: + token: test-token + usage_reporting: + enabled: true + endpoint: {usage_endpoint} + buffer_size: 1 + flush_interval: 100ms + exclude: + expression: '.request.operation.name == "ExcludedOp"' + "#, + )) + .with_subgraphs(&subgraphs) + .build() + .start() + .await; + + let res = router + .send_graphql_request("query ExcludedOp { users { id } }", None, None) + .await; + assert!(res.status().is_success()); + + mock.assert_no_reports(Duration::from_secs(2)).await; + + drop(router); +} diff --git a/lib/executor/src/coprocessor/stage.rs b/lib/executor/src/coprocessor/stage.rs index 4cd1ec912..1d1fe1651 100644 --- a/lib/executor/src/coprocessor/stage.rs +++ b/lib/executor/src/coprocessor/stage.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use hive_router_config::headers::HOP_BY_HOP_HEADERS; use hive_router_config::primitives::value_or_expression::ValueOrExpression; -use hive_router_internal::expressions::values::boolean::BooleanOrProgram; use hive_router_internal::expressions::vrl::core::Value as VrlValue; +use hive_router_internal::expressions::BooleanOrProgram; use hive_router_internal::expressions::{CompileExpression, ProgramHints, ValueOrProgram}; use ntex::http::header::{HeaderName, HeaderValue}; use ntex::http::HeaderMap; diff --git a/lib/executor/src/coprocessor/stages/graphql.rs b/lib/executor/src/coprocessor/stages/graphql.rs index c66f0c47d..9a37f656f 100644 --- a/lib/executor/src/coprocessor/stages/graphql.rs +++ b/lib/executor/src/coprocessor/stages/graphql.rs @@ -5,8 +5,8 @@ use hive_router_config::coprocessor::{ CoprocessorGraphqlAnalysisIncludeConfig, CoprocessorGraphqlRequestIncludeConfig, CoprocessorGraphqlResponseIncludeConfig, CoprocessorHookConfig, }; -use hive_router_internal::expressions::lib::ToVrlValue; -use hive_router_internal::expressions::values::boolean::BooleanOrProgram; +use hive_router_internal::expressions::BooleanOrProgram; +use hive_router_internal::expressions::ToVrlValue; use ntex::http::body::{Body, ResponseBody}; use ntex::http::HeaderMap; use ntex::web; diff --git a/lib/executor/src/coprocessor/stages/router.rs b/lib/executor/src/coprocessor/stages/router.rs index 3d31b54a9..08a33cc54 100644 --- a/lib/executor/src/coprocessor/stages/router.rs +++ b/lib/executor/src/coprocessor/stages/router.rs @@ -5,7 +5,7 @@ use hive_router_config::coprocessor::{ CoprocessorHookConfig, CoprocessorRouterRequestIncludeConfig, CoprocessorRouterResponseIncludeConfig, }; -use hive_router_internal::expressions::{lib::ToVrlValue, values::boolean::BooleanOrProgram}; +use hive_router_internal::expressions::{BooleanOrProgram, ToVrlValue}; use ntex::http::{ body::{Body, ResponseBody}, error::PayloadError, diff --git a/lib/executor/src/execution/client_request_details.rs b/lib/executor/src/execution/client_request_details.rs index f17ce80e1..6e9b5df94 100644 --- a/lib/executor/src/execution/client_request_details.rs +++ b/lib/executor/src/execution/client_request_details.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, sync::Arc}; use bytes::Bytes; -use hive_router_internal::expressions::{lib::ToVrlValue, vrl::core::Value}; +use hive_router_internal::expressions::{vrl::core::Value, ToVrlValue}; use http::Method; use ntex::http::HeaderMap as NtexHeaderMap; diff --git a/lib/executor/src/executors/map.rs b/lib/executor/src/executors/map.rs index ad0bbfba9..1d8b6585b 100644 --- a/lib/executor/src/executors/map.rs +++ b/lib/executor/src/executors/map.rs @@ -10,7 +10,10 @@ use hive_router_config::{ override_subgraph_urls::UrlOrExpression, subscriptions::SubscriptionProtocol, traffic_shaping::DurationOrExpression, HiveRouterConfig, }; -use hive_router_internal::expressions::vrl::core::Value as VrlValue; +use hive_router_internal::expressions::{ + vrl::{core::Value as VrlValue, prelude::Function}, + ExpressionCompileError, ProgramHints, ValueOrProgram, +}; use hive_router_internal::expressions::{CompileExpression, DurationOrProgram, ExecutableProgram}; use hive_router_internal::{ expressions::vrl::compiler::Program as VrlProgram, inflight::InFlightMap, @@ -121,13 +124,14 @@ impl SubgraphExecutorMap { telemetry_context: Arc, active_callback_subscriptions: CallbackSubscriptionsMap, ) -> Result { - let global_timeout = DurationOrProgram::compile( - &config.traffic_shaping.all.request_timeout, - None, - ) - .map_err(|err| { - SubgraphExecutorError::RequestTimeoutExpressionBuild("all".to_string(), err.diagnostics) - })?; + let global_timeout = + compile_duration_or_expression(&config.traffic_shaping.all.request_timeout, None) + .map_err(|err| { + SubgraphExecutorError::RequestTimeoutExpressionBuild( + "all".to_string(), + err.diagnostics, + ) + })?; let mut subgraph_executor_map = SubgraphExecutorMap::new(config.clone(), global_timeout, telemetry_context)?; subgraph_executor_map.callback_subscriptions = active_callback_subscriptions; @@ -615,7 +619,7 @@ impl SubgraphExecutorMap { .unwrap_or(&self.config.traffic_shaping.all.request_timeout); // Compile the timeout configuration into a DurationOrProgram - let timeout_prog = DurationOrProgram::compile(timeout_config, None).map_err(|err| { + let timeout_prog = compile_duration_or_expression(timeout_config, None).map_err(|err| { SubgraphExecutorError::RequestTimeoutExpressionBuild( subgraph_name.to_string(), err.diagnostics, @@ -653,3 +657,17 @@ fn resolve_timeout( }) .map_err(|err| SubgraphExecutorError::TimeoutExpressionResolution(err.to_string())) } + +pub fn compile_duration_or_expression( + config: &DurationOrExpression, + fns: Option<&[Box]>, +) -> Result, ExpressionCompileError> { + match config { + DurationOrExpression::Duration(dur) => Ok(ValueOrProgram::Value(*dur)), + DurationOrExpression::Expression { expression } => { + let program = expression.as_str().compile_expression(fns)?; + let hints = ProgramHints::from_program(&program); + Ok(ValueOrProgram::Program(Box::new(program), hints)) + } + } +} diff --git a/lib/executor/src/headers/expression.rs b/lib/executor/src/headers/expression.rs index 187aae596..a926f264a 100644 --- a/lib/executor/src/headers/expression.rs +++ b/lib/executor/src/headers/expression.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use bytes::Bytes; -use hive_router_internal::expressions::{lib::ToVrlValue, vrl::core::Value}; +use hive_router_internal::expressions::{vrl::core::Value, ToVrlValue}; use http::HeaderValue; use crate::headers::{request::RequestExpressionContext, response::ResponseExpressionContext}; diff --git a/lib/hive-console-sdk/Cargo.toml b/lib/hive-console-sdk/Cargo.toml index 9f84d2dac..eac7702c7 100644 --- a/lib/hive-console-sdk/Cargo.toml +++ b/lib/hive-console-sdk/Cargo.toml @@ -34,6 +34,13 @@ typify = "0.6.0" regress = "0.11.0" lazy_static = { workspace = true } async-dropper-simple = { version = "0.2.6", features = ["tokio", "no-default-bound"] } +vrl = { workspace = true } +http = { workspace = true } +bytes ={ workspace = true } +sonic-rs = { workspace = true } +humantime = { workspace = true } +http-serde = "2.1.1" +ntex = { workspace = true } [dev-dependencies] mockito = { workspace = true } diff --git a/lib/hive-console-sdk/src/agent/builder.rs b/lib/hive-console-sdk/src/agent/builder.rs index fee4c2e23..1c52ffcef 100644 --- a/lib/hive-console-sdk/src/agent/builder.rs +++ b/lib/hive-console-sdk/src/agent/builder.rs @@ -11,6 +11,7 @@ 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 crate::expressions::CompileExpression; use retry_policies::policies::ExponentialBackoff; pub struct UsageAgentBuilder { @@ -25,6 +26,7 @@ pub struct UsageAgentBuilder { retry_policy: ExponentialBackoff, user_agent: Option, circuit_breaker: Option, + exclude_expression: Option, } pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; @@ -43,6 +45,7 @@ impl Default for UsageAgentBuilder { retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), user_agent: None, circuit_breaker: None, + exclude_expression: None, } } } @@ -183,6 +186,12 @@ impl UsageAgentBuilder { let buffer = Buffer::new(self.buffer_size); + let exclude = if let Some(expr) = self.exclude_expression { + expr.compile_expression(None)?.into() + } else { + None + }; + Ok(UsageAgentInner { endpoint, buffer, @@ -190,8 +199,15 @@ impl UsageAgentBuilder { client, flush_interval: self.flush_interval, circuit_breaker, + exclude_expression: exclude, }) } + pub fn exclude_expression(mut self, expression: String) -> Self { + if let Some(expression) = non_empty_string(Some(expression)) { + self.exclude_expression = Some(expression); + } + self + } pub fn build(self) -> Result { let agent = self.build_agent()?; Ok(Arc::new(AsyncDropper::new(agent))) diff --git a/lib/hive-console-sdk/src/agent/usage_agent.rs b/lib/hive-console-sdk/src/agent/usage_agent.rs index 42497a43c..084f1201c 100644 --- a/lib/hive-console-sdk/src/agent/usage_agent.rs +++ b/lib/hive-console-sdk/src/agent/usage_agent.rs @@ -3,17 +3,33 @@ use graphql_tools::parser::schema::Document; use recloser::AsyncRecloser; use reqwest_middleware::ClientWithMiddleware; use std::{ - collections::{hash_map::Entry, HashMap}, + collections::{hash_map::Entry, BTreeMap, 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}; +use crate::expressions::lib::FromVrlValue; +use crate::{ + agent::{buffer::AddStatus, utils::OperationProcessor}, + expressions::ExecutableProgram, +}; +use crate::{ + agent::{buffer::Buffer, builder::UsageAgentBuilder}, + expressions::values::boolean::BooleanConversionError, +}; +use vrl::{compiler::Program as VrlProgram, core::Value as VrlValue, value::KeyString}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] +pub enum OperationType { + #[default] + Query, + Mutation, + Subscription, +} + +#[derive(Debug, Default, Clone)] pub struct ExecutionReport { pub schema: Arc>, pub client_name: Option, @@ -24,6 +40,7 @@ pub struct ExecutionReport { pub errors: usize, pub operation_body: String, pub operation_name: Option, + pub operation_type: Option, pub persisted_document_hash: Option, } @@ -36,6 +53,7 @@ pub struct UsageAgentInner { pub(crate) client: ClientWithMiddleware, pub(crate) flush_interval: Duration, pub(crate) circuit_breaker: AsyncRecloser, + pub(crate) exclude_expression: Option, } pub fn non_empty_string(value: Option) -> Option { @@ -60,7 +78,9 @@ pub enum AgentError { 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")] + #[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), @@ -70,6 +90,12 @@ pub enum AgentError { CircuitBreakerRejected, #[error("unable to send report: {0}")] Unknown(String), + #[error("failed to compile exclude expression: {0}")] + ExcludeExpressionCompileError(#[from] crate::expressions::ExpressionCompileError), + #[error("failed to execute exclude expression: {0}")] + ExcludeExpressionExecutionError(#[from] crate::expressions::ExpressionExecutionError), + #[error("failed to convert exclude expression result to boolean: {0}")] + ExcludeExpressionResultConversionError(#[from] BooleanConversionError), } pub type UsageAgent = Arc>; @@ -81,7 +107,16 @@ pub trait UsageAgentExt { } async fn flush(&self) -> Result<(), AgentError>; async fn start_flush_interval(&self, token: &CancellationToken); + /// Deprecated: use [`add_report_with_request`] instead. + /// This method will be removed in a future version major. + #[deprecated(note = "use `add_report_with_request` instead")] async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError>; + + async fn add_report_with_request( + &self, + execution_report: ExecutionReport, + request: Option, + ) -> Result<(), AgentError>; } impl UsageAgentInner { @@ -217,6 +252,13 @@ impl UsageAgentInner { } } +#[derive(Debug, Clone)] +pub struct RequestDetails { + pub method: http::Method, + pub url: http::Uri, + pub headers: Vec<(String, String)>, +} + #[async_trait::async_trait] impl UsageAgentExt for UsageAgent { async fn flush(&self) -> Result<(), AgentError> { @@ -236,13 +278,171 @@ impl UsageAgentExt for UsageAgent { } } - 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?; + async fn add_report_with_request( + &self, + execution_report: ExecutionReport, + request: Option, + ) -> Result<(), AgentError> { + let inner = self.inner(); + + if let Some(exclude_program) = &inner.exclude_expression { + let result = exclude_program.execute( + get_vrl_value_from_execution_report_and_request(&execution_report, request), + )?; + let result_bool = bool::from_vrl_value(result)?; + if result_bool { + tracing::debug!( + "Excluding report for operation \"{}\" based on exclude expression evaluation", + execution_report + .operation_name + .clone() + .or_else(|| Some("anonymous".to_string())) + .unwrap() + ); + return Ok(()); + } + } + + if let AddStatus::Full { drained } = inner.buffer.add(execution_report).await { + inner.handle_drained(drained).await?; } Ok(()) } + async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError> { + self.add_report_with_request(execution_report, None).await + } +} + +impl<'req, TBody> From<&'req http::Request> for RequestDetails { + fn from(req: &'req http::Request) -> Self { + let headers = req + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|val_str| (name.to_string(), val_str.to_string())) + }) + .collect(); + + RequestDetails { + method: req.method().clone(), + url: req.uri().clone(), + headers, + } + } +} + +impl<'req> From<&'req ntex::web::HttpRequest> for RequestDetails { + fn from(req: &'req ntex::web::HttpRequest) -> Self { + let headers = req + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|val_str| (name.to_string(), val_str.to_string())) + }) + .collect(); + + RequestDetails { + method: req.method().clone(), + url: req.uri().clone(), + headers, + } + } +} + +impl From for VrlValue { + fn from(details: RequestDetails) -> Self { + let mut merged_headers: BTreeMap = BTreeMap::new(); + for (header_name, header_value) in details.headers { + if let Some(existing_value) = merged_headers.get_mut(&header_name) { + existing_value.push_str(", "); + existing_value.push_str(&header_value); + } else { + merged_headers.insert(header_name, header_value); + } + } + + let headers_value: BTreeMap = merged_headers + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(); + let headers_value = VrlValue::Object(headers_value); + + // .request.url + let url_value = VrlValue::Object(BTreeMap::from([ + ("host".into(), details.url.host().unwrap_or_default().into()), + ("path".into(), details.url.path().into()), + ( + "port".into(), + details + .url + .port_u16() + .map(|p| VrlValue::Integer(p.into())) + .unwrap_or(VrlValue::Null), + ), + ])); + + // .request + VrlValue::Object(BTreeMap::from([ + ("method".into(), details.method.as_str().into()), + ("headers".into(), headers_value), + ("url".into(), url_value), + ])) + } +} + +pub fn get_vrl_value_from_execution_report_and_request( + report: &ExecutionReport, + request: Option, +) -> VrlValue { + let mut map = BTreeMap::from([("default".into(), VrlValue::Boolean(false))]); + let mut request_map = BTreeMap::new(); + + if let Some(request_details) = request { + if let VrlValue::Object(request_object) = VrlValue::from(request_details) { + request_map.extend(request_object); + } + } + + request_map.insert( + "operation".into(), + VrlValue::Object(BTreeMap::from([ + ( + "name".into(), + report.operation_name.clone().unwrap_or_default().into(), + ), + ( + "type".into(), + report + .operation_type + .as_ref() + .map(|operation_type| match operation_type { + OperationType::Query => "query", + OperationType::Mutation => "mutation", + OperationType::Subscription => "subscription", + }) + .unwrap_or_default() + .into(), + ), + ("query".into(), report.operation_body.clone().into()), + ])), + ); + + if let Ok(timestamp_integer) = report.timestamp.try_into() { + let timestamp_value = VrlValue::Integer(timestamp_integer); + map.insert("timestamp".into(), timestamp_value); + request_map.insert("timestamp".into(), VrlValue::Integer(timestamp_integer)); + } + + map.insert("request".into(), VrlValue::Object(request_map)); + + VrlValue::Object(map) } #[async_trait::async_trait] @@ -259,9 +459,34 @@ mod tests { use std::{sync::Arc, time::Duration}; use graphql_tools::parser::{parse_query, parse_schema}; - use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; + use reqwest::{ + header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}, + Method, + }; + use vrl::core::Value as VrlValue; + use vrl::value::KeyString; + + use crate::agent::usage_agent::{ + get_vrl_value_from_execution_report_and_request, ExecutionReport, OperationType, Report, + UsageAgent, UsageAgentExt, + }; - use crate::agent::usage_agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt}; + /// Helper to extract a nested VRL value from an Object using string keys. + fn vrl_get<'a>(value: &'a VrlValue, keys: &[&str]) -> &'a VrlValue { + let mut current = value; + for key in keys { + match current { + VrlValue::Object(map) => { + let ks: KeyString = (*key).into(); + current = map + .get(&ks) + .unwrap_or_else(|| panic!("key '{}' not found", key)); + } + _ => panic!("expected Object at key '{}'", key), + } + } + current + } const CONTENT_TYPE_VALUE: &'static str = "application/json"; const GRAPHQL_CLIENT_NAME: &'static str = "Hive Client"; @@ -430,19 +655,29 @@ mod tests { .user_agent(user_agent.into()) .build()?; + let request = http::Request::builder() + .method(Method::POST) + .uri("http://localhost/graphql") + .body(()) + .unwrap(); + 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, - }) + .add_report_with_request( + ExecutionReport { + schema: Arc::new(schema), + operation_body: op.to_string(), + operation_name: Some("deleteProject".to_string()), + operation_type: Some(OperationType::Mutation), + 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, + }, + Some((&request).into()), + ) .await?; } @@ -450,4 +685,528 @@ mod tests { Ok(()) } + + fn make_test_report( + operation_name: Option<&str>, + operation_type: OperationType, + operation_body: &str, + ) -> ExecutionReport { + let schema: graphql_tools::static_graphql::schema::Document = + parse_schema("type Query { hello: String }").unwrap(); + + ExecutionReport { + schema: Arc::new(schema), + operation_body: operation_body.to_string(), + operation_name: operation_name.map(|s| s.to_string()), + operation_type: Some(operation_type), + client_name: Some("test-client".to_string()), + client_version: Some("1.0.0".to_string()), + timestamp: 1625247600, + duration: Duration::from_millis(10), + ok: true, + errors: 0, + persisted_document_hash: None, + } + } + + fn make_simple_report( + operation_name: Option<&str>, + operation_type: OperationType, + ) -> ExecutionReport { + make_test_report(operation_name, operation_type, "query { hello }") + } + + fn make_simple_request() -> http::Request<()> { + http::Request::builder() + .method(Method::GET) + .uri("http://localhost/graphql") + .body(()) + .unwrap() + } + + #[test] + fn vrl_value_contains_operation_name() { + let report = make_simple_report(Some("MyQuery"), OperationType::Query); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let name = vrl_get(&value, &["request", "operation", "name"]); + assert_eq!(name, &VrlValue::from("MyQuery")); + } + + #[test] + fn vrl_value_contains_operation_type_query() { + let report = make_simple_report(Some("Q"), OperationType::Query); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let op_type = vrl_get(&value, &["request", "operation", "type"]); + assert_eq!(op_type, &VrlValue::from("query")); + } + + #[test] + fn vrl_value_contains_operation_type_mutation() { + let report = make_simple_report(Some("M"), OperationType::Mutation); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let op_type = vrl_get(&value, &["request", "operation", "type"]); + assert_eq!(op_type, &VrlValue::from("mutation")); + } + + #[test] + fn vrl_value_contains_operation_type_subscription() { + let report = make_simple_report(Some("S"), OperationType::Subscription); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let op_type = vrl_get(&value, &["request", "operation", "type"]); + assert_eq!(op_type, &VrlValue::from("subscription")); + } + + #[test] + fn vrl_value_contains_operation_body() { + let report = make_simple_report(Some("Q"), OperationType::Query); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let query = vrl_get(&value, &["request", "operation", "query"]); + assert_eq!(query, &VrlValue::from("query { hello }")); + } + + #[test] + fn vrl_value_contains_request_method() { + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + let request = http::Request::builder() + .method(Method::GET) + .uri("http://localhost/graphql") + .body(()) + .unwrap(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let method = vrl_get(&value, &["request", "method"]); + assert_eq!(method, &VrlValue::from("GET")); + } + + #[test] + fn vrl_value_contains_url_details() { + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + let request = http::Request::builder() + .method(Method::POST) + .uri("http://api.example.com:8080/v1/graphql") + .body(()) + .unwrap(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + assert_eq!( + vrl_get(&value, &["request", "url", "host"]), + &VrlValue::from("api.example.com") + ); + assert_eq!( + vrl_get(&value, &["request", "url", "port"]), + &VrlValue::Integer(8080) + ); + assert_eq!( + vrl_get(&value, &["request", "url", "path"]), + &VrlValue::from("/v1/graphql") + ); + } + + #[test] + fn vrl_value_contains_headers() { + let request = http::Request::builder() + .method(Method::POST) + .uri("http://localhost/graphql") + .header("x-custom-header", "custom-value") + .header("authorization", "Bearer token123") + .body(()) + .unwrap(); + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + assert_eq!( + vrl_get(&value, &["request", "headers", "x-custom-header"]), + &VrlValue::from("custom-value") + ); + assert_eq!( + vrl_get(&value, &["request", "headers", "authorization"]), + &VrlValue::from("Bearer token123") + ); + } + + #[test] + fn vrl_value_joins_duplicate_header_values() { + let mut request = http::Request::builder() + .method(Method::POST) + .uri("http://localhost/graphql") + .body(()) + .unwrap(); + + request + .headers_mut() + .append("x-scope", http::HeaderValue::from_static("one")); + request + .headers_mut() + .append("x-scope", http::HeaderValue::from_static("two")); + + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + assert_eq!( + vrl_get(&value, &["request", "headers", "x-scope"]), + &VrlValue::from("one, two") + ); + } + + #[test] + fn vrl_value_anonymous_operation_has_empty_name() { + let report = make_simple_report(None, OperationType::Query); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let name = vrl_get(&value, &["request", "operation", "name"]); + assert_eq!(name, &VrlValue::from("")); + } + + #[test] + fn vrl_value_has_default_false() { + let report = make_simple_report(Some("Q"), OperationType::Query); + let request = make_simple_request(); + let value = + get_vrl_value_from_execution_report_and_request(&report, Some((&request).into())); + + let default_val = vrl_get(&value, &["default"]); + assert_eq!(default_val, &VrlValue::Boolean(false)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_filters_by_operation_name() -> Result<(), Box> + { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + // The mock expects exactly 0 requests because the operation should be excluded + let mock = server + .mock("POST", "/200") + .expect(0) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(1) // flush on every report + .exclude_expression(r#".request.operation.name == "ExcludeMe""#.to_string()) + .build()?; + + // This report should be excluded + let report = make_simple_report(Some("ExcludeMe"), OperationType::Query); + let request = make_simple_request(); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_allows_non_matching_operations( + ) -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + // This operation should NOT be excluded, so we expect 1 request (via async drop flush) + let mock = server + .mock("POST", "/200") + .expect(1) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .exclude_expression(r#".request.operation.name == "ExcludeMe""#.to_string()) + .build()?; + + let report = make_simple_report(Some("KeepMe"), OperationType::Query); + let request = make_simple_request(); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_filters_by_operation_type() -> Result<(), Box> + { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let mock = server + .mock("POST", "/200") + .expect(0) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(1) + .exclude_expression(r#".request.operation.type == "subscription""#.to_string()) + .build()?; + + let report = make_simple_report(Some("OnMessage"), OperationType::Subscription); + let request = make_simple_request(); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_filters_by_header() -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let mock = server + .mock("POST", "/200") + .expect(0) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(1) + .exclude_expression(r#".request.headers."x-internal" == "true""#.to_string()) + .build()?; + + let request = http::Request::builder() + .method(Method::POST) + .uri("http://localhost/graphql") + .header("x-internal", "true") + .body(()) + .unwrap(); + + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_complex_conditional() -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + // The expression excludes IntrospectionQuery OR any mutation + let exclude_expr = r#" + if (.request.operation.name == "IntrospectionQuery") { + true + } else if (.request.operation.type == "mutation") { + true + } else { + false + } + "#; + + let mock = server + .mock("POST", "/200") + .expect(0) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(1) + .exclude_expression(exclude_expr.to_string()) + .build()?; + + let request = make_simple_request(); + + // Excluded: IntrospectionQuery + let report = make_simple_report(Some("IntrospectionQuery"), OperationType::Query); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + + // Excluded: any mutation + let report = make_simple_report(Some("CreateUser"), OperationType::Mutation); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_allows_through_complex_conditional( + ) -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let exclude_expr = r#" + if (.request.operation.name == "IntrospectionQuery") { + true + } else if (.request.operation.type == "mutation") { + true + } else { + false + } + "#; + + // A normal query should NOT be excluded + let mock = server + .mock("POST", "/200") + .expect(1) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .exclude_expression(exclude_expr.to_string()) + .build()?; + + let request = make_simple_request(); + + let report = make_simple_report(Some("GetUsers"), OperationType::Query); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn exclude_expression_filters_by_url_path() -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let mock = server + .mock("POST", "/200") + .expect(0) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .buffer_size(1) + .exclude_expression(r#".request.url.path == "/internal/graphql""#.to_string()) + .build()?; + + let request = http::Request::builder() + .method(Method::POST) + .uri("http://localhost/internal/graphql") + .body(()) + .unwrap(); + + let report = make_test_report(Some("Q"), OperationType::Query, "query { hello }"); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_exclude_expression_sends_all_reports() -> Result<(), Box> { + let token = "Token"; + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let mock = server + .mock("POST", "/200") + .expect(1) + .with_status(200) + .create_async() + .await; + + { + let usage_agent = UsageAgent::builder() + .token(token.into()) + .endpoint(format!("{}/200", server_url)) + .build()?; + + let report = make_simple_report(Some("AnyOp"), OperationType::Query); + let request = make_simple_request(); + usage_agent + .add_report_with_request(report, Some((&request).into())) + .await?; + } + + mock.assert_async().await; + Ok(()) + } + + #[test] + fn builder_rejects_invalid_exclude_expression() { + let result = UsageAgent::builder() + .token("Token".into()) + .exclude_expression("this is not valid VRL }{".to_string()) + .build(); + + assert!(result.is_err()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn builder_ignores_empty_exclude_expression() { + let result = UsageAgent::builder() + .token("Token".into()) + .exclude_expression("".to_string()) + .build(); + + assert!(result.is_ok()); + } } diff --git a/lib/internal/src/expressions/error.rs b/lib/hive-console-sdk/src/expressions/error.rs similarity index 100% rename from lib/internal/src/expressions/error.rs rename to lib/hive-console-sdk/src/expressions/error.rs diff --git a/lib/internal/src/expressions/functions/env.rs b/lib/hive-console-sdk/src/expressions/functions/env.rs similarity index 100% rename from lib/internal/src/expressions/functions/env.rs rename to lib/hive-console-sdk/src/expressions/functions/env.rs diff --git a/lib/internal/src/expressions/functions/mod.rs b/lib/hive-console-sdk/src/expressions/functions/mod.rs similarity index 100% rename from lib/internal/src/expressions/functions/mod.rs rename to lib/hive-console-sdk/src/expressions/functions/mod.rs diff --git a/lib/internal/src/expressions/lib.rs b/lib/hive-console-sdk/src/expressions/lib.rs similarity index 73% rename from lib/internal/src/expressions/lib.rs rename to lib/hive-console-sdk/src/expressions/lib.rs index 71637e342..aec284991 100644 --- a/lib/internal/src/expressions/lib.rs +++ b/lib/hive-console-sdk/src/expressions/lib.rs @@ -1,8 +1,6 @@ use std::collections::BTreeMap; use std::sync::LazyLock; -use std::time::Duration; -use hive_router_config::traffic_shaping::DurationOrExpression; use vrl::{ compiler::{compile as vrl_compile, Program as VrlProgram, TargetValue as VrlTargetValue}, core::Value as VrlValue, @@ -16,7 +14,6 @@ use vrl::{ use crate::expressions::{ error::{ExpressionCompileError, ExpressionExecutionError}, functions::env::Env, - ProgramResolutionError, }; static VRL_FUNCTIONS: LazyLock>> = LazyLock::new(|| { @@ -106,84 +103,7 @@ impl ExecutableProgram for VrlProgram { } } -pub enum ValueOrProgram { - /// A statically-known value - Value(T), - /// A VRL program that computes the value at runtime - Program(Box, ProgramHints), -} - -impl ValueOrProgram -where - T: FromVrlValue + Clone, -{ - /// Resolve this ValueOrProgram to a concrete value - /// - /// If this is a static value, returns it immediately. - /// If this is a program, executes it against the provided context and converts the result. - /// - /// - `vrl_context_fn` - A function that returns the VRL value context for expression execution - #[inline] - pub fn resolve(&self, vrl_context_fn: F) -> Result> - where - F: FnOnce() -> VrlValue, - { - match self { - ValueOrProgram::Value(v) => Ok(v.clone()), - ValueOrProgram::Program(vrl_program, _) => { - let vrl_context = vrl_context_fn(); - let result_value = vrl_program - .execute(vrl_context) - .map_err(ProgramResolutionError::ExecutionFailed)?; - - T::from_vrl_value(result_value).map_err(ProgramResolutionError::ConversionFailed) - } - } - } - - /// Resolve this ValueOrProgram to a concrete value, providing target query hints - /// to the context function so it can optimize the VRL context structure. - /// - /// - `vrl_context_fn` - A function that returns the VRL value context, given the query hints - #[inline] - pub fn resolve_with_hints( - &self, - vrl_context_fn: F, - ) -> Result> - where - F: FnOnce(&ProgramHints) -> VrlValue, - { - match self { - ValueOrProgram::Value(v) => Ok(v.clone()), - ValueOrProgram::Program(vrl_program, hints) => { - let vrl_context = vrl_context_fn(hints); - let result_value = vrl_program - .execute(vrl_context) - .map_err(ProgramResolutionError::ExecutionFailed)?; - - T::from_vrl_value(result_value).map_err(ProgramResolutionError::ConversionFailed) - } - } - } -} - -impl ValueOrProgram { - pub fn compile( - config: &DurationOrExpression, - fns: Option<&[Box]>, - ) -> Result { - match config { - DurationOrExpression::Duration(dur) => Ok(ValueOrProgram::Value(*dur)), - DurationOrExpression::Expression { expression } => { - let program = expression.as_str().compile_expression(fns)?; - let hints = ProgramHints::from_program(&program); - Ok(ValueOrProgram::Program(Box::new(program), hints)) - } - } - } -} - -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct HintNode { is_terminal: bool, children: Vec<(String, HintNode)>, @@ -219,7 +139,7 @@ impl HintNode { /// This struct analyzes a VRL program to determine which variables are accessed /// during execution. /// The purpose of this struct is to selectively build context for expressions. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct ProgramHints { root: HintNode, } diff --git a/lib/internal/src/expressions/mod.rs b/lib/hive-console-sdk/src/expressions/mod.rs similarity index 65% rename from lib/internal/src/expressions/mod.rs rename to lib/hive-console-sdk/src/expressions/mod.rs index d3e6cbacd..799d6d769 100644 --- a/lib/internal/src/expressions/mod.rs +++ b/lib/hive-console-sdk/src/expressions/mod.rs @@ -6,7 +6,7 @@ pub mod values; pub use vrl; pub use error::{ExpressionCompileError, ExpressionExecutionError, ProgramResolutionError}; -pub use lib::{CompileExpression, ExecutableProgram, FromVrlValue, ProgramHints, ValueOrProgram}; -pub use values::duration::{DurationConversionError, DurationOrProgram}; +pub use lib::{CompileExpression, ExecutableProgram, FromVrlValue, ProgramHints}; +pub use values::duration::DurationConversionError; pub use values::http::HeaderValueConversionError; -pub use values::string::{StringConversionError, StringOrProgram}; +pub use values::string::StringConversionError; diff --git a/lib/internal/src/expressions/values/boolean.rs b/lib/hive-console-sdk/src/expressions/values/boolean.rs similarity index 81% rename from lib/internal/src/expressions/values/boolean.rs rename to lib/hive-console-sdk/src/expressions/values/boolean.rs index 9721f9582..d6cecca10 100644 --- a/lib/internal/src/expressions/values/boolean.rs +++ b/lib/hive-console-sdk/src/expressions/values/boolean.rs @@ -1,9 +1,6 @@ -use crate::expressions::{FromVrlValue, ValueOrProgram}; +use crate::expressions::FromVrlValue; use vrl::core::Value as VrlValue; -/// Type alias for a Boolean that can be either static or computed via expression -pub type BooleanOrProgram = ValueOrProgram; - /// Error type for Boolean conversion failures #[derive(Debug, thiserror::Error, Clone)] pub enum BooleanConversionError { diff --git a/lib/internal/src/expressions/values/duration.rs b/lib/hive-console-sdk/src/expressions/values/duration.rs similarity index 90% rename from lib/internal/src/expressions/values/duration.rs rename to lib/hive-console-sdk/src/expressions/values/duration.rs index 5521da070..fa7fef241 100644 --- a/lib/internal/src/expressions/values/duration.rs +++ b/lib/hive-console-sdk/src/expressions/values/duration.rs @@ -1,11 +1,8 @@ -use crate::expressions::{FromVrlValue, ValueOrProgram}; +use crate::expressions::FromVrlValue; use humantime::{parse_duration, DurationError}; use std::{str::Utf8Error, time::Duration}; use vrl::core::Value as VrlValue; -/// Type alias for a Duration that can be either static or computed via expression -pub type DurationOrProgram = ValueOrProgram; - #[derive(Debug, thiserror::Error, Clone)] pub enum DurationParseErrorSource { #[error("Invalid UTF-8 encoding in duration string: {0}")] diff --git a/lib/internal/src/expressions/values/http.rs b/lib/hive-console-sdk/src/expressions/values/http.rs similarity index 100% rename from lib/internal/src/expressions/values/http.rs rename to lib/hive-console-sdk/src/expressions/values/http.rs diff --git a/lib/internal/src/expressions/values/mod.rs b/lib/hive-console-sdk/src/expressions/values/mod.rs similarity index 100% rename from lib/internal/src/expressions/values/mod.rs rename to lib/hive-console-sdk/src/expressions/values/mod.rs diff --git a/lib/internal/src/expressions/values/sonic.rs b/lib/hive-console-sdk/src/expressions/values/sonic.rs similarity index 76% rename from lib/internal/src/expressions/values/sonic.rs rename to lib/hive-console-sdk/src/expressions/values/sonic.rs index 54ca63d29..131e34794 100644 --- a/lib/internal/src/expressions/values/sonic.rs +++ b/lib/hive-console-sdk/src/expressions/values/sonic.rs @@ -25,11 +25,11 @@ impl ToVrlValue for sonic_rs::Value { } if let Some(u) = self.as_u64() { - // Note: This can overflow if the u64 value is larger than i64::MAX. - // VRL uses i64 for integers, so a choice has to be made. - // For now, we cast, accepting the risk of overflow. A more robust - // implementation might convert to a float, a string, or return an error. - return Value::Integer(u as i64); + if let Ok(value) = i64::try_from(u) { + return Value::Integer(value); + } + + return Value::from_f64_or_zero(u as f64); } if let Some(f) = self.as_f64() { diff --git a/lib/internal/src/expressions/values/string.rs b/lib/hive-console-sdk/src/expressions/values/string.rs similarity index 79% rename from lib/internal/src/expressions/values/string.rs rename to lib/hive-console-sdk/src/expressions/values/string.rs index f52adca14..e1caa9384 100644 --- a/lib/internal/src/expressions/values/string.rs +++ b/lib/hive-console-sdk/src/expressions/values/string.rs @@ -1,13 +1,8 @@ use std::string::FromUtf8Error; -use crate::expressions::{FromVrlValue, ValueOrProgram}; +use crate::expressions::FromVrlValue; use vrl::core::Value as VrlValue; -/// Type alias for a String that can be either static or computed via expression -/// -/// Useful for endpoints, URLs, or any string configuration that can be dynamic -pub type StringOrProgram = ValueOrProgram; - /// Error type for String conversion failures #[derive(Debug, thiserror::Error, Clone)] pub enum StringConversionError { diff --git a/lib/hive-console-sdk/src/lib.rs b/lib/hive-console-sdk/src/lib.rs index 6e4564087..3d8e7e4df 100644 --- a/lib/hive-console-sdk/src/lib.rs +++ b/lib/hive-console-sdk/src/lib.rs @@ -4,3 +4,4 @@ pub mod persisted_documents; pub mod supergraph_fetcher; pub use async_dropper_simple::AsyncDropper; pub use graphql_tools; +pub mod expressions; diff --git a/lib/internal/Cargo.toml b/lib/internal/Cargo.toml index dc9e8a4e2..2c797aa57 100644 --- a/lib/internal/Cargo.toml +++ b/lib/internal/Cargo.toml @@ -16,9 +16,9 @@ noop_otlp_exporter = [] [dependencies] hive-router-config = { path = "../router-config", version = "0.0.32" } +hive-console-sdk = { path = "../hive-console-sdk", version = "0.3.8" } sonic-rs = { workspace = true } -vrl = { workspace = true } humantime = { workspace = true } bytes = { workspace = true } http = { workspace=true } @@ -37,6 +37,7 @@ dashmap = { workspace = true } tokio-stream = "0.1.18" serde = { workspace = true } ipnet = { workspace = true } +vrl = { workspace = true } # telemetry opentelemetry = { workspace = true, features = ["trace"] } diff --git a/lib/internal/src/expressions.rs b/lib/internal/src/expressions.rs new file mode 100644 index 000000000..93f05efc2 --- /dev/null +++ b/lib/internal/src/expressions.rs @@ -0,0 +1,87 @@ +use std::time::Duration; + +// Re-export VRL under our internal namespace +pub use vrl; + +pub use hive_console_sdk::expressions::error::{ + ExpressionCompileError, ExpressionExecutionError, ProgramResolutionError, +}; +pub use hive_console_sdk::expressions::lib::{ + CompileExpression, ExecutableProgram, FromVrlValue, ProgramHints, ToVrlValue, +}; +pub use hive_console_sdk::expressions::values::duration::DurationConversionError; +pub use hive_console_sdk::expressions::values::http::HeaderValueConversionError; +pub use hive_console_sdk::expressions::values::string::StringConversionError; +use vrl::{compiler::Program as VrlProgram, core::Value as VrlValue}; + +/// Generic enum for a value that can be either static or computed via VRL expression +#[derive(Clone)] +pub enum ValueOrProgram { + /// A statically-known value + Value(T), + /// A VRL program that computes the value at runtime, along with hints about which + /// fields are accessed during execution (used to optimize context construction). + Program(Box, ProgramHints), +} + +impl ValueOrProgram +where + T: FromVrlValue + Clone, +{ + /// Resolve this ValueOrProgram to a concrete value + /// + /// If this is a static value, returns it immediately. + /// If this is a program, executes it against the provided context and converts the result. + /// + /// - `vrl_context_fn` - A function that returns the VRL value context for expression execution + #[inline] + pub fn resolve(&self, vrl_context_fn: F) -> Result> + where + F: FnOnce() -> VrlValue, + { + match self { + ValueOrProgram::Value(v) => Ok(v.clone()), + ValueOrProgram::Program(vrl_program, _) => { + let vrl_context = vrl_context_fn(); + let result_value = vrl_program + .execute(vrl_context) + .map_err(ProgramResolutionError::ExecutionFailed)?; + + T::from_vrl_value(result_value).map_err(ProgramResolutionError::ConversionFailed) + } + } + } + + /// Resolve using hints to allow the context function to selectively build the context. + #[inline] + pub fn resolve_with_hints( + &self, + vrl_context_fn: F, + ) -> Result> + where + F: FnOnce(&ProgramHints) -> VrlValue, + { + match self { + ValueOrProgram::Value(v) => Ok(v.clone()), + ValueOrProgram::Program(vrl_program, hints) => { + let vrl_context = vrl_context_fn(hints); + let result_value = vrl_program + .execute(vrl_context) + .map_err(ProgramResolutionError::ExecutionFailed)?; + + T::from_vrl_value(result_value).map_err(ProgramResolutionError::ConversionFailed) + } + } + } +} + +/// Type alias for a Duration that can be either static or computed via expression +pub type DurationOrProgram = ValueOrProgram; + +/// Type alias for a Boolean that can be either static or computed via expression +pub type BooleanOrProgram = ValueOrProgram; + +/// Type alias for a String that can be either static or computed via expression +/// +/// Useful for endpoints, URLs, or any string configuration that can be dynamic +pub type StringOrProgram = ValueOrProgram; diff --git a/lib/internal/src/telemetry/utils.rs b/lib/internal/src/telemetry/utils.rs index a848305b7..27d22b5b5 100644 --- a/lib/internal/src/telemetry/utils.rs +++ b/lib/internal/src/telemetry/utils.rs @@ -1,3 +1,4 @@ +use hive_console_sdk::expressions::{CompileExpression, ExecutableProgram}; use hive_router_config::telemetry::tracing::OtlpGrpcTlsConfig; use std::{collections::HashMap, str::FromStr}; use tonic::{ @@ -6,10 +7,7 @@ use tonic::{ }; use vrl::core::Value; -use crate::{ - expressions::{CompileExpression, ExecutableProgram}, - telemetry::error::TelemetryError, -}; +use crate::telemetry::error::TelemetryError; use hive_router_config::primitives::value_or_expression::ValueOrExpression; pub(super) fn build_metadata( diff --git a/lib/router-config/src/usage_reporting.rs b/lib/router-config/src/usage_reporting.rs index d2ae59163..474b77bbb 100644 --- a/lib/router-config/src/usage_reporting.rs +++ b/lib/router-config/src/usage_reporting.rs @@ -3,6 +3,13 @@ use std::{fmt::Display, str::FromStr, time::Duration}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(untagged)] +pub enum UsageReportingExclude { + Expression { expression: String }, + OperationNames(Vec), +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[serde(deny_unknown_fields)] pub struct UsageReportingConfig { @@ -22,10 +29,22 @@ pub struct UsageReportingConfig { #[schemars(with = "String")] pub sample_rate: Percentage, - /// A list of operations (by name) to be ignored by Hive. - /// Example: ["IntrospectionQuery", "MeQuery"] + /// An expression in VRL to exclude certain operations from being sent to Hive Console. + /// Returning `true` from this expression will exclude the operation, while `false` will include it. + /// This expression is a VRL expression that has access to the request and operation details; + /// + /// ```vrl + /// if (.request.operation.name == "ExcludeMe") { + /// true + /// } else { + /// false + /// } + /// ``` + /// Backward compatible with both: + /// - an expression object: `{ expression: "..." }` + /// - a list of operation names #[serde(default)] - pub exclude: Vec, + pub exclude: Option, /// A maximum number of operations to hold in a buffer before sending to Hive Console /// Default: 1000 @@ -68,13 +87,68 @@ pub struct UsageReportingConfig { pub flush_interval: Duration, } +#[cfg(test)] +mod tests { + use super::UsageReportingConfig; + + #[test] + fn exclude_supports_expression_object() { + let config: UsageReportingConfig = serde_json::from_str( + r#"{ + "enabled": true, + "sample_rate": "100%", + "exclude": { "expression": ".request.operation.name == \"Health\"" } + }"#, + ) + .expect("config with expression object should deserialize"); + + let exclude = config.exclude.expect("exclude should be present"); + + assert!(matches!( + exclude, + super::UsageReportingExclude::Expression { .. } + )); + if let super::UsageReportingExclude::Expression { expression } = exclude { + assert_eq!( + expression, ".request.operation.name == \"Health\"", + "expression should match the input" + ); + } + } + + #[test] + fn exclude_supports_legacy_operation_list() { + let config: UsageReportingConfig = serde_json::from_str( + r#"{ + "enabled": true, + "sample_rate": "100%", + "exclude": ["IntrospectionQuery", "HealthCheck"] + }"#, + ) + .expect("config with legacy operation list should deserialize"); + + let exclude = config.exclude.expect("exclude should be present"); + assert!(matches!( + exclude, + super::UsageReportingExclude::OperationNames(_) + )); + if let super::UsageReportingExclude::OperationNames(names) = exclude { + assert_eq!( + names, + vec!["IntrospectionQuery".to_string(), "HealthCheck".to_string()], + "operation names should match the input" + ); + } + } +} + impl Default for UsageReportingConfig { fn default() -> Self { Self { enabled: default_enabled(), endpoint: default_endpoint(), sample_rate: default_sample_rate(), - exclude: Vec::new(), + exclude: None, buffer_size: default_buffer_size(), accept_invalid_certs: default_accept_invalid_certs(), connect_timeout: default_connect_timeout(), From d69f1102d3d73ace19d4baca7eef1845210e420b Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 5 May 2026 16:29:08 +0200 Subject: [PATCH 76/76] Update dynamic-exclusions.md Co-authored-by: kamilkisiela <8167190+kamilkisiela@users.noreply.github.com> --- .changeset/dynamic-exclusions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/dynamic-exclusions.md b/.changeset/dynamic-exclusions.md index 04f94cadb..d8cc79afc 100644 --- a/.changeset/dynamic-exclusions.md +++ b/.changeset/dynamic-exclusions.md @@ -2,6 +2,8 @@ hive-router: minor hive-console-sdk: minor hive-apollo-router-plugin: minor +hive-router-internal: patch +hive-router-plan-executor: patch --- # Dynamic Exclusions