diff --git a/.changesets/feat_geal_h2c.md b/.changesets/feat_geal_h2c.md new file mode 100644 index 0000000000..0852c40671 --- /dev/null +++ b/.changesets/feat_geal_h2c.md @@ -0,0 +1,5 @@ +### HTTP/2 Cleartext protocol (h2c) support for subgraph connections ([Issue #3535](https://github.com/apollographql/router/issues/3535)) + +The router can now connect to subgraphs over HTTP/2 Cleartext, which uses the HTTP/2 binary protocol directly over TCP without TLS. To activate it, set the `experimental_http2` option to `http2_only`. + +By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3852 \ No newline at end of file diff --git a/apollo-router/src/configuration/metrics.rs b/apollo-router/src/configuration/metrics.rs index 20dd595598..9506b1e1f2 100644 --- a/apollo-router/src/configuration/metrics.rs +++ b/apollo-router/src/configuration/metrics.rs @@ -282,7 +282,7 @@ impl Metrics { opt.subgraph.rate_limit, "$[?(@.all.global_rate_limit || @.subgraphs..global_rate_limit)]", opt.subgraph.http2, - "$[?(@.all.experimental_enable_http2 == true || @.subgraphs..experimental_enable_http2 == true)]", + "$[?(@.all.experimental_http2 == 'enable' || @.all.experimental_http2 == 'http2only' || @.subgraphs..experimental_http2 == 'enable' || @.subgraphs..experimental_http2 == 'http2only')]", opt.subgraph.compression, "$[?(@.all.compression || @.subgraphs..compression)]", opt.subgraph.deduplicate_query, diff --git a/apollo-router/src/configuration/migrations/0011-experimental-http2.yaml b/apollo-router/src/configuration/migrations/0011-experimental-http2.yaml new file mode 100644 index 0000000000..e73b228a03 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0011-experimental-http2.yaml @@ -0,0 +1,25 @@ +description: Move experimental_enable_http2 to experimental_http2 +actions: + - type: change + path: traffic_shaping.all.experimental_enable_http2 + from: true + to: enable + - type: change + path: traffic_shaping.all.experimental_enable_http2 + from: false + to: disable + - type: move + from: traffic_shaping.all.experimental_enable_http2 + to: traffic_shaping.all.experimental_http2 + + - type: change + path: traffic_shaping.subgraphs..experimental_enable_http2 + from: true + to: enable + - type: change + path: traffic_shaping.subgraphs..experimental_enable_http2 + from: false + to: disable + - type: move + from: traffic_shaping.subgraphs..experimental_enable_http2 + to: traffic_shaping.subgraphs..experimental_http2 diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index 428ef466d4..20fba9012f 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -5558,11 +5558,6 @@ expression: "&schema" "type": "boolean", "nullable": true }, - "experimental_enable_http2": { - "description": "Enable HTTP2 for subgraphs", - "type": "boolean", - "nullable": true - }, "experimental_entity_caching": { "description": "Enable entity caching", "type": "object", @@ -5578,6 +5573,33 @@ expression: "&schema" "additionalProperties": false, "nullable": true }, + "experimental_http2": { + "description": "Enable HTTP2 for subgraphs", + "oneOf": [ + { + "description": "Enable HTTP2 for subgraphs", + "type": "string", + "enum": [ + "enable" + ] + }, + { + "description": "Disable HTTP2 for subgraphs", + "type": "string", + "enum": [ + "disable" + ] + }, + { + "description": "Only HTTP2 is active", + "type": "string", + "enum": [ + "http2only" + ] + } + ], + "nullable": true + }, "experimental_retry": { "description": "Retry configuration", "type": "object", @@ -5746,11 +5768,6 @@ expression: "&schema" "type": "boolean", "nullable": true }, - "experimental_enable_http2": { - "description": "Enable HTTP2 for subgraphs", - "type": "boolean", - "nullable": true - }, "experimental_entity_caching": { "description": "Enable entity caching", "type": "object", @@ -5766,6 +5783,33 @@ expression: "&schema" "additionalProperties": false, "nullable": true }, + "experimental_http2": { + "description": "Enable HTTP2 for subgraphs", + "oneOf": [ + { + "description": "Enable HTTP2 for subgraphs", + "type": "string", + "enum": [ + "enable" + ] + }, + { + "description": "Disable HTTP2 for subgraphs", + "type": "string", + "enum": [ + "disable" + ] + }, + { + "description": "Only HTTP2 is active", + "type": "string", + "enum": [ + "http2only" + ] + } + ], + "nullable": true + }, "experimental_retry": { "description": "Retry configuration", "type": "object", diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__change_field.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__change_field.snap new file mode 100644 index 0000000000..d1a8ad8ae0 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__change_field.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Change {\n path: \"obj.field1\".to_string(),\n from: Value::Number(1u64.into()),\n to: Value::String(\"a\".into()),\n }).description(\"change field1\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field1": "a", + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ] +} diff --git a/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml b/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml index b5304812f4..ad221ae92d 100644 --- a/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml +++ b/apollo-router/src/configuration/testdata/metrics/traffic_shaping.router.yaml @@ -11,7 +11,7 @@ traffic_shaping: global_rate_limit: capacity: 100 interval: 1s - experimental_enable_http2: true + experimental_http2: enable experimental_retry: ttl: 1s min_per_sec: 2 diff --git a/apollo-router/src/configuration/upgrade.rs b/apollo-router/src/configuration/upgrade.rs index c88e2960dc..11d6fee51b 100644 --- a/apollo-router/src/configuration/upgrade.rs +++ b/apollo-router/src/configuration/upgrade.rs @@ -38,6 +38,11 @@ enum Action { from: String, to: String, }, + Change { + path: String, + from: Value, + to: Value, + }, } const REMOVAL_VALUE: &str = "__PLEASE_DELETE_ME"; @@ -129,6 +134,17 @@ fn apply_migration(config: &Value, migration: &Migration) -> Result { + if !jsonpath_lib::select(config, &format!("$.{path} == {from}")) + .unwrap_or_default() + .is_empty() + { + transformer_builder = transformer_builder.add_action( + Parser::parse(&format!(r#"const({to})"#), path) + .expect("migration must be valid"), + ); + } + } } } let transformer = transformer_builder @@ -423,4 +439,20 @@ mod test { ) .expect("expected successful migration")); } + + #[test] + fn change_field() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Change { + path: "obj.field1".to_string(), + from: Value::Number(1u64.into()), + to: Value::String("a".into()), + }) + .description("change field1") + .build(), + ) + .expect("expected successful migration")); + } } diff --git a/apollo-router/src/plugins/traffic_shaping/mod.rs b/apollo-router/src/plugins/traffic_shaping/mod.rs index 6d21338d73..566534b82d 100644 --- a/apollo-router/src/plugins/traffic_shaping/mod.rs +++ b/apollo-router/src/plugins/traffic_shaping/mod.rs @@ -73,7 +73,19 @@ struct Shaping { // *experimental feature*: Enables request retry experimental_retry: Option, /// Enable HTTP2 for subgraphs - experimental_enable_http2: Option, + experimental_http2: Option, +} + +#[derive(PartialEq, Default, Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Http2Config { + #[default] + /// Enable HTTP2 for subgraphs + Enable, + /// Disable HTTP2 for subgraphs + Disable, + /// Only HTTP2 is active + Http2Only, } impl Merge for Shaping { @@ -94,10 +106,10 @@ impl Merge for Shaping { .as_ref() .or(fallback.experimental_retry.as_ref()) .cloned(), - experimental_enable_http2: self - .experimental_enable_http2 + experimental_http2: self + .experimental_http2 .as_ref() - .or(fallback.experimental_enable_http2.as_ref()) + .or(fallback.experimental_http2.as_ref()) .cloned(), }, } @@ -458,13 +470,13 @@ impl TrafficShaping { } } - pub(crate) fn enable_subgraph_http2(&self, service_name: &str) -> bool { + pub(crate) fn enable_subgraph_http2(&self, service_name: &str) -> Http2Config { Self::merge_config( self.config.all.as_ref(), self.config.subgraphs.get(service_name), ) - .and_then(|config| config.shaping.experimental_enable_http2) - .unwrap_or(true) + .and_then(|config| config.shaping.experimental_http2) + .unwrap_or(Http2Config::Enable) } } @@ -714,39 +726,42 @@ mod test { let config = serde_yaml::from_str::( r#" all: - experimental_enable_http2: false + experimental_http2: disable subgraphs: products: - experimental_enable_http2: true + experimental_http2: enable reviews: - experimental_enable_http2: false + experimental_http2: disable router: timeout: 65s "#, ) .unwrap(); - assert!(TrafficShaping::merge_config( - config.all.as_ref(), - config.subgraphs.get("products") - ) - .unwrap() - .shaping - .experimental_enable_http2 - .unwrap()); - assert!(!TrafficShaping::merge_config( - config.all.as_ref(), - config.subgraphs.get("reviews") - ) - .unwrap() - .shaping - .experimental_enable_http2 - .unwrap()); - assert!(!TrafficShaping::merge_config(config.all.as_ref(), None) - .unwrap() - .shaping - .experimental_enable_http2 - .unwrap()); + assert!( + TrafficShaping::merge_config(config.all.as_ref(), config.subgraphs.get("products")) + .unwrap() + .shaping + .experimental_http2 + .unwrap() + == Http2Config::Enable + ); + assert!( + TrafficShaping::merge_config(config.all.as_ref(), config.subgraphs.get("reviews")) + .unwrap() + .shaping + .experimental_http2 + .unwrap() + == Http2Config::Disable + ); + assert!( + TrafficShaping::merge_config(config.all.as_ref(), None) + .unwrap() + .shaping + .experimental_http2 + .unwrap() + == Http2Config::Disable + ); } #[tokio::test] @@ -754,12 +769,12 @@ mod test { let config = serde_yaml::from_str::( r#" all: - experimental_enable_http2: false + experimental_http2: disable subgraphs: products: - experimental_enable_http2: true + experimental_http2: enable reviews: - experimental_enable_http2: false + experimental_http2: disable router: timeout: 65s "#, @@ -770,9 +785,9 @@ mod test { .await .unwrap(); - assert!(shaping_config.enable_subgraph_http2("products")); - assert!(!shaping_config.enable_subgraph_http2("reviews")); - assert!(!shaping_config.enable_subgraph_http2("this_doesnt_exist")); + assert!(shaping_config.enable_subgraph_http2("products") == Http2Config::Enable); + assert!(shaping_config.enable_subgraph_http2("reviews") == Http2Config::Disable); + assert!(shaping_config.enable_subgraph_http2("this_doesnt_exist") == Http2Config::Disable); } #[tokio::test(flavor = "multi_thread")] diff --git a/apollo-router/src/services/subgraph_service.rs b/apollo-router/src/services/subgraph_service.rs index 78e870bf17..b8b2e39591 100644 --- a/apollo-router/src/services/subgraph_service.rs +++ b/apollo-router/src/services/subgraph_service.rs @@ -70,6 +70,7 @@ use crate::plugins::subscription::WebSocketConfiguration; use crate::plugins::subscription::SUBSCRIPTION_WS_CUSTOM_CONNECTION_PARAMS; use crate::plugins::telemetry::LOGGING_DISPLAY_BODY; use crate::plugins::telemetry::LOGGING_DISPLAY_HEADERS; +use crate::plugins::traffic_shaping::Http2Config; use crate::protocols::websocket::convert_websocket_stream; use crate::protocols::websocket::GraphqlWebSocket; use crate::query_planner::OperationKind; @@ -160,7 +161,7 @@ impl SubgraphService { service: impl Into, configuration: &Configuration, tls_root_store: &Option, - enable_http2: bool, + http2: Http2Config, subscription_config: Option, ) -> Result { let name: String = service.into(); @@ -217,7 +218,7 @@ impl SubgraphService { Ok(SubgraphService::new( name, enable_apq, - enable_http2, + http2, subscription_config, tls_client_config, configuration.notify.clone(), @@ -227,7 +228,7 @@ impl SubgraphService { pub(crate) fn new( service: impl Into, enable_apq: bool, - enable_http2: bool, + http2: Http2Config, subscription_config: Option, tls_config: ClientConfig, notify: Notify, @@ -242,7 +243,7 @@ impl SubgraphService { .https_or_http() .enable_http1(); - let connector = if enable_http2 { + let connector = if http2 != Http2Config::Disable { builder.enable_http2().wrap_connector(http_connector) } else { builder.wrap_connector(http_connector) @@ -250,6 +251,7 @@ impl SubgraphService { let http_client = hyper::Client::builder() .pool_idle_timeout(POOL_IDLE_TIMEOUT_DURATION) + .http2_only(http2 == Http2Config::Http2Only) .build(connector); Self { client: ServiceBuilder::new() @@ -1850,7 +1852,7 @@ mod tests { let subgraph_service = SubgraphService::new( "testbis", true, - false, + Http2Config::Disable, subscription_config().into(), ClientConfig::builder() .with_safe_defaults() @@ -1905,7 +1907,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -1948,7 +1950,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -1991,7 +1993,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2039,7 +2041,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2091,7 +2093,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2141,7 +2143,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - false, + Http2Config::Disable, subscription_config().into(), ClientConfig::builder() .with_safe_defaults() @@ -2204,7 +2206,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - false, + Http2Config::Disable, subscription_config().into(), ClientConfig::builder() .with_safe_defaults() @@ -2259,7 +2261,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2310,7 +2312,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2356,7 +2358,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", false, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2406,7 +2408,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2452,7 +2454,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2507,7 +2509,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2560,7 +2562,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2610,7 +2612,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2660,7 +2662,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", true, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2710,7 +2712,7 @@ mod tests { let subgraph_service = SubgraphService::new( "test", false, - true, + Http2Config::Enable, None, ClientConfig::builder() .with_safe_defaults() @@ -2803,7 +2805,8 @@ mod tests { }, ); let subgraph_service = - SubgraphService::from_config("test", &config, &None, false, None).unwrap(); + SubgraphService::from_config("test", &config, &None, Http2Config::Enable, None) + .unwrap(); let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); let response = subgraph_service @@ -2828,6 +2831,7 @@ mod tests { }) .await .unwrap(); + assert_eq!(response.response.body().data, Some(Value::Null)); } @@ -2857,7 +2861,8 @@ mod tests { }, ); let subgraph_service = - SubgraphService::from_config("test", &config, &None, false, None).unwrap(); + SubgraphService::from_config("test", &config, &None, Http2Config::Enable, None) + .unwrap(); let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); let response = subgraph_service @@ -2965,7 +2970,8 @@ mod tests { }, ); let subgraph_service = - SubgraphService::from_config("test", &config, &None, false, None).unwrap(); + SubgraphService::from_config("test", &config, &None, Http2Config::Enable, None) + .unwrap(); let url = Uri::from_str(&format!("https://localhost:{}", socket_addr.port())).unwrap(); let response = subgraph_service @@ -2992,4 +2998,73 @@ mod tests { .unwrap(); assert_eq!(response.response.body().data, Some(Value::Null)); } + + // starts a local server emulating a subgraph returning status code 401 + async fn emulate_h2c_server(listener: TcpListener) { + async fn handle(_request: http::Request) -> Result, Infallible> { + println!("h2C server got req: {_request:?}"); + Ok(http::Response::builder() + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .status(StatusCode::OK) + .body( + serde_json::to_string(&Response { + data: Some(Value::default()), + ..Response::default() + }) + .expect("always valid") + .into(), + ) + .unwrap()) + } + + let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) }); + let server = Server::from_tcp(listener) + .unwrap() + .http2_only(true) + .serve(make_svc); + server.await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_subgraph_h2c() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(emulate_h2c_server(listener)); + let subgraph_service = SubgraphService::new( + "test", + true, + Http2Config::Http2Only, + None, + rustls::ClientConfig::builder() + .with_safe_defaults() + .with_native_roots() + .with_no_client_auth(), + Notify::default(), + ); + + let url = Uri::from_str(&format!("http://{socket_addr}")).unwrap(); + let response = subgraph_service + .oneshot(SubgraphRequest { + supergraph_request: Arc::new( + http::Request::builder() + .header(HOST, "host") + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + ), + subgraph_request: http::Request::builder() + .header(HOST, "rhost") + .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) + .uri(url) + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + operation_kind: OperationKind::Query, + context: Context::new(), + subscription_stream: None, + connection_closed_signal: None, + }) + .await + .unwrap(); + assert!(response.response.body().errors.is_empty()); + } } diff --git a/docs/source/configuration/traffic-shaping.mdx b/docs/source/configuration/traffic-shaping.mdx index b16723690e..52b428781b 100644 --- a/docs/source/configuration/traffic-shaping.mdx +++ b/docs/source/configuration/traffic-shaping.mdx @@ -32,6 +32,7 @@ traffic_shaping: ttl: 10s # for each successful request, we register a token, that expires according to this option (default: 10s) retry_percent: 0.2 # defines the proportion of available retries to the current number of tokens retry_mutations: false # allows retries on mutations. This should only be enabled if mutations are idempotent + experimental_http2: enable # Configures HTTP/2 usage. Can be 'enable' (default), 'disable' or 'http2only' ``` ## Client side traffic shaping @@ -151,6 +152,14 @@ traffic_shaping: deduplicate_query: true # Enable query deduplication for all subgraphs. ``` +### HTTP/2 + +The router supports subgraph connections over +- HTTP/2 with TLS. This is the default configuration. +- HTTP/2 Cleartext protocol (h2c). This uses HTTP/2 over plaintext connections. + +To use h2c, the subgraph URL must have the `http` scheme, and the `experimental_http2` option must be set to `http2only`. + ### Ordering Traffic shaping always executes these steps in the same order, to ensure a consistent behaviour. Declaration order in the configuration will not affect the runtime order: