diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 2c346e3af2..5ef97d83e4 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -35,8 +35,89 @@ This reduces the number of copies of this string we keep in memory, as schemas c By [@SimonSapin](https://github.com/SimonSapin) +### Changes to `PluginTestHarness` ([PR #1468](https://github.com/apollographql/router/pull/1468)) + +The `plugin` method of the builder for `apollo_router::plugin::test::PluginTestHarness` was removed. +Users of `PluginTestHarness` don’t create plugin instances themselves anymore. + +Instead, the builder has a new mandatory `configuration` method, +which takes the full Router configuration as would be found in a `router.yaml` file. +Through that configuration, plugins can be enabled (and configured) by name. +Instead of YAML syntax though, the method takes a `serde_json::Value`. +A convenient way to create such a value in Rust code is with the `json!` macro. + +The `IntoSchema` enum has been removed. +The `schema` method of the builder is now optional and takes a `&str`. +If not provided, the canned testing schema is used by default. + +Additionally, `PluginTestHarness` internally creates a Tower `Service` +that is closer to a “full” Router than before: +Apollo plugins that are enabled by default (such as CSRF protection) +will be enabled in the test harness, +and all enabled plugins will have their `Plugin::activate` method called +during harness creation. + +Changes to tests for an example plugin: + +```diff +-use apollo_router::plugin::test::IntoSchema::Canned; + use apollo_router::plugin::test::PluginTestHarness; +-use apollo_router::plugin::Plugin; +-use apollo_router::plugin::PluginInit; ++use serde_json::json; + +-let conf = MyPluginConfig { +- something: "something".to_string(), +-}; +-let plugin = MyPlugin::new(PluginInit::new(conf, Default::default())) +- .await +- .unwrap(); ++let conf = json!({ ++ "plugins": { ++ "example.my_plugin": { ++ "something": "something" ++ } ++ } ++}); + let test_harness = PluginTestHarness::builder() +- .plugin(plugin) +- .schema(Canned) ++ .configuration(conf) + .build() + .await + .unwarp(); +``` + +By [@SimonSapin](https://github.com/SimonSapin) + +### Changes to `RouterRequest::fake_builder` defaults to `Content-Type: application/json` ([PR #1468](https://github.com/apollographql/router/pull/1468)) + +Because of the change above, tests that use `PluginTestHarness` will now go through +CSRF-protection, which might reject some requests. +`apollo_router::services::RouterRequest` has a builder for creating a “fake” request during tests. +When no `Content-Type` header is specified, this builder will now default to `application/json` +which makes the request be accepted by CSRF protection. +If a test requires a request specifically *without* a `Content-Type` header, +this default can be removed from a `RouterRequest` after building it: + +```rust +let mut router_request = RouterRequesT::fake_builder().build(); +router_request.originating_request.headers_mut().remove("content-type"); +``` + +By [@SimonSapin](https://github.com/SimonSapin) + ## 🚀 Features +### `mock_execution_service` for `PluginTestHarness` ([PR #1468](https://github.com/apollographql/router/pull/1468)) + +The builder for `apollo_router::plugin::test::PluginTestHarness` +has a new `mock_execution_service` method. +This allows adding a mock at the execution stage of the pipeline, +similar to the other `mock_*` methods taht were already present. + +By [@SimonSapin](https://github.com/SimonSapin) + ## 🐛 Fixes ### Configuration handling enhancements ([PR #1454](https://github.com/apollographql/router/pull/1454)) diff --git a/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs b/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs index acb349c98e..2dba3e7c9b 100644 --- a/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs +++ b/apollo-router-scaffold/templates/plugin/src/plugins/{{snake_name}}.rs @@ -157,12 +157,7 @@ register_plugin!("{{project_name}}", "{{snake_name}}", {{pascal_name}}); #[cfg(test)] mod tests { - use super::{Conf, {{pascal_name}}}; - - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; use tower::BoxError; #[tokio::test] @@ -177,18 +172,18 @@ mod tests { #[tokio::test] async fn basic_test() -> Result<(), BoxError> { - // Define a configuration to use with our plugin - let conf = Conf { - message: "Starting my plugin".to_string(), - }; - - // Build an instance of our plugin to use in the test harness - let plugin = {{pascal_name}}::new(PluginInit::new(conf, Default::default())).await.expect("created plugin"); + // Define a configuration that enables our plugin + let conf = serde_json::json!({ + "plugins": { + "{{project_name}}.{{snake_name}}": { + "message": "Starting my plugin" + } + } + }); // Create the test harness. You can add mocks for individual services, or use prebuilt canned services. let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await?; 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 1d61d631c3..b17c652c83 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 @@ -1902,6 +1902,9 @@ expression: "&schema" } } }, + "test.empty_plugin": { + "type": "null" + }, "traffic_shaping": { "type": "object", "properties": { diff --git a/apollo-router/src/plugin/mod.rs b/apollo-router/src/plugin/mod.rs index c84cf5f3d4..5dd594ed66 100644 --- a/apollo-router/src/plugin/mod.rs +++ b/apollo-router/src/plugin/mod.rs @@ -17,6 +17,7 @@ pub mod serde; pub mod test; +use std::any::TypeId; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; @@ -265,6 +266,13 @@ pub trait DynPlugin: Send + Sync + 'static { /// Return the name of the plugin. fn name(&self) -> &'static str; + + fn type_id(&self) -> TypeId + where + Self: 'static, + { + TypeId::of::() + } } #[async_trait] @@ -316,6 +324,17 @@ where } } +impl dyn DynPlugin { + // Same pattern as https://github.com/rust-lang/rust/blob/1.62.1/library/std/src/error.rs#L675-L685 + pub(crate) fn downcast_ref(&self) -> Option<&T> { + if TypeId::of::() == self.type_id() { + Some(unsafe { &*(self as *const dyn DynPlugin as *const T) }) + } else { + None + } + } +} + /// Register a plugin with a group and a name /// Grouping prevent name clashes for plugins, so choose something unique, like your domain name. /// Plugins will appear in the configuration as a layer property called: {group}.{name} diff --git a/apollo-router/src/plugin/test/mod.rs b/apollo-router/src/plugin/test/mod.rs index c2af57027c..db8a40a7a7 100644 --- a/apollo-router/src/plugin/test/mod.rs +++ b/apollo-router/src/plugin/test/mod.rs @@ -6,71 +6,37 @@ mod service; use std::collections::HashMap; use std::sync::Arc; -use indexmap::IndexMap; pub use mock::subgraph::MockSubgraph; pub use service::MockExecutionService; pub use service::MockQueryPlanningService; pub use service::MockRouterService; pub use service::MockSubgraphService; use tower::buffer::Buffer; +use tower::util::BoxCloneService; use tower::util::BoxService; use tower::BoxError; use tower::Service; -use tower::ServiceBuilder; use tower::ServiceExt; -use super::DynPlugin; -use crate::cache::DeduplicatingCache; -use crate::introspection::Introspection; use crate::layers::DEFAULT_BUFFER_SIZE; -use crate::plugin::Plugin; -use crate::query_planner::BridgeQueryPlanner; -use crate::query_planner::CachingQueryPlanner; -use crate::services::layers::apq::APQLayer; -use crate::services::layers::ensure_query_presence::EnsureQueryPresence; +use crate::router_factory::RouterServiceConfigurator; +use crate::router_factory::YamlRouterServiceFactory; use crate::services::subgraph_service::SubgraphServiceFactory; -use crate::services::ExecutionCreator; use crate::services::Plugins; use crate::services::RouterRequest; use crate::services::RouterResponse; use crate::services::SubgraphRequest; -use crate::Configuration; -use crate::RouterService; use crate::Schema; pub struct PluginTestHarness { - router_service: BoxService, -} -pub enum IntoSchema { - String(String), - Schema(Box), - Canned, -} - -impl From for IntoSchema { - fn from(schema: Schema) -> Self { - IntoSchema::Schema(Box::new(schema)) - } -} -impl From for IntoSchema { - fn from(schema: String) -> Self { - IntoSchema::String(schema) - } + router_service: BoxCloneService, + plugins: Arc, } -impl IntoSchema { - fn into_schema(self, config: &Configuration) -> Schema { - match self { - IntoSchema::String(s) => Schema::parse(&s, config).expect("test schema must be valid"), - IntoSchema::Schema(s) => *s, - IntoSchema::Canned => Schema::parse( - include_str!("../../../../examples/graphql/local.graphql"), - config, - ) - .expect("test schema must be valid"), - } - } -} +pub(crate) type BufferedSubgraphService = Buffer< + BoxService, + crate::SubgraphRequest, +>; #[buildstructor::buildstructor] impl PluginTestHarness { @@ -80,7 +46,7 @@ impl PluginTestHarness { /// # Arguments /// /// * `plugin`: The plugin to test - /// * `schema`: The schema, either Canned, or a custom schema. + /// * `schema`: (Optional) the supergraph schema to use. If not provided, a canned testing schema is used. /// * `mock_router_service`: (Optional) router service. If none is supplied it will be defaulted. /// * `mock_query_planner_service`: (Optional) query planner service. If none is supplied it will be defaulted. /// * `mock_execution_service`: (Optional) execution service. If none is supplied it will be defaulted. @@ -89,19 +55,26 @@ impl PluginTestHarness { /// returns: Result> /// #[builder] - pub async fn new( - plugin: P, - schema: IntoSchema, + #[allow(clippy::needless_lifetimes)] // Not needless in the builder + pub async fn new<'schema>( + configuration: serde_json::Value, + schema: Option<&'schema str>, mock_router_service: Option, mock_query_planner_service: Option, + mock_execution_service: Option, mock_subgraph_services: HashMap, ) -> Result { + let configuration: Arc<_> = serde_json::from_value(configuration)?; + let canned_schema = schema.is_none(); + let schema = schema.unwrap_or(include_str!("../../../../examples/graphql/local.graphql")); + let schema = Arc::new(Schema::parse(schema, &configuration)?); + let mut subgraph_services = mock_subgraph_services .into_iter() .map(|(k, v)| (k, Buffer::new(v.build().boxed(), DEFAULT_BUFFER_SIZE))) - .collect::>(); + .collect::>(); // If we're using the canned schema then add some canned results - if let IntoSchema::Canned = schema { + if canned_schema { subgraph_services .entry("products".to_string()) .or_insert_with(|| { @@ -128,65 +101,22 @@ impl PluginTestHarness { }); } - let schema = Arc::new(schema.into_schema(&Default::default())); - - let query_planner = CachingQueryPlanner::new( - BridgeQueryPlanner::new( - schema.clone(), - Some(Arc::new(Introspection::from_schema(&schema))), - Default::default(), - ) - .await?, - DEFAULT_BUFFER_SIZE, - ) - .await - .boxed(); - let query_planner_service = plugin.query_planning_service( - mock_query_planner_service - .map(|s| s.build().boxed()) - .unwrap_or(query_planner), - ); - - let mut plugins = IndexMap::new(); - plugins.insert( - "tested_plugin".to_string(), - Box::new(plugin) as Box, - ); - let plugins = Arc::new(plugins); - - let apq = APQLayer::with_cache(DeduplicatingCache::new().await); - let router_service = mock_router_service - .map(|s| s.build().boxed()) - .unwrap_or_else(|| { - BoxService::new( - RouterService::builder() - .query_planner_service(Buffer::new( - query_planner_service, - DEFAULT_BUFFER_SIZE, - )) - .execution_service_factory(ExecutionCreator { - schema: schema.clone(), - plugins: plugins.clone(), - subgraph_creator: Arc::new(MockSubgraphFactory { - plugins: plugins.clone(), - subgraphs: subgraph_services, - }), - }) - .schema(schema.clone()) - .build(), - ) - }); - let router_service = ServiceBuilder::new() - .layer(apq) - .layer(EnsureQueryPresence::default()) - .service( - plugins - .get("tested_plugin") - .unwrap() - .router_service(router_service), + let router_creator = YamlRouterServiceFactory + .create_with_mocks( + configuration, + schema, + mock_router_service, + mock_query_planner_service, + mock_execution_service, + Some(subgraph_services), + None, ) - .boxed(); - Ok(Self { router_service }) + .await?; + let router_service = router_creator.test_service(); + Ok(Self { + router_service, + plugins: router_creator.plugins, + }) } /// Call the test harness with a request. Not that you will need to have set up appropriate responses via mocks. @@ -207,6 +137,14 @@ impl PluginTestHarness { ) .await } + + /// Return the plugin instance that has the given `name` + /// + /// Returns `None` if the name or `Plugin` type does not match those given to [`register_plugin!`], + /// or if that plugin was not enabled in configuration. + pub fn plugin(&self, name: &str) -> Option<&Plugin> { + self.plugins.get(name)?.downcast_ref::() + } } #[derive(Clone)] @@ -244,9 +182,12 @@ impl SubgraphServiceFactory for MockSubgraphFactory { #[cfg(test)] mod testing { use insta::assert_json_snapshot; + use serde_json::json; use super::*; + use crate::plugin::Plugin; use crate::plugin::PluginInit; + use crate::register_plugin; struct EmptyPlugin {} #[async_trait::async_trait] @@ -257,15 +198,16 @@ mod testing { Ok(Self {}) } } + register_plugin!("apollo.test", "empty_plugin", EmptyPlugin); #[tokio::test] async fn test_test_harness() -> Result<(), BoxError> { let mut harness = PluginTestHarness::builder() - .plugin(EmptyPlugin {}) - .schema(IntoSchema::Canned) + .configuration(json!({ "test.empty_plugin": null })) .build() .await?; let graphql = harness.call_canned().await?.next_response().await.unwrap(); + assert_eq!(graphql.errors, []); insta::with_settings!({sort_maps => true}, { assert_json_snapshot!(graphql.data); }); diff --git a/apollo-router/src/plugins/csrf.rs b/apollo-router/src/plugins/csrf.rs index 0b2130424e..573a89e453 100644 --- a/apollo-router/src/plugins/csrf.rs +++ b/apollo-router/src/plugins/csrf.rs @@ -263,7 +263,10 @@ mod csrf_tests { #[tokio::test] async fn it_rejects_non_preflighted_headers_request() { let config = CSRFConfig::default(); - let non_preflighted_request = RouterRequest::fake_builder().build().unwrap(); + let non_preflighted_request = RouterRequest::fake_builder() + .header("content-type", "text/plain") + .build() + .unwrap(); assert_rejected(config, non_preflighted_request).await } @@ -323,6 +326,7 @@ mod csrf_tests { .await .unwrap(); + assert_eq!(res.errors, []); assert_eq!(res.data.unwrap(), json!({ "test": 1234_u32 })); } diff --git a/apollo-router/src/plugins/telemetry/apollo.rs b/apollo-router/src/plugins/telemetry/apollo.rs index fbacd15368..5ae24c5c72 100644 --- a/apollo-router/src/plugins/telemetry/apollo.rs +++ b/apollo-router/src/plugins/telemetry/apollo.rs @@ -14,11 +14,13 @@ pub struct Config { pub endpoint: Option, #[schemars(skip)] - #[serde(skip, default = "apollo_key")] + #[serde(default = "apollo_key")] + #[cfg_attr(not(test), serde(skip))] pub apollo_key: Option, #[schemars(skip)] - #[serde(skip, default = "apollo_graph_reference")] + #[serde(default = "apollo_graph_reference")] + #[cfg_attr(not(test), serde(skip))] pub apollo_graph_ref: Option, #[schemars(with = "Option", default = "client_name_header_default_str")] diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo.rs b/apollo-router/src/plugins/telemetry/metrics/apollo.rs index 2c07a5f5f6..f6484ec97b 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo.rs @@ -4,17 +4,24 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +#[cfg(not(test))] use apollo_spaceport::ReportHeader; use apollo_spaceport::Reporter; use apollo_spaceport::ReporterError; use async_trait::async_trait; use deadpool::managed; +#[cfg(not(test))] use deadpool::managed::Pool; +#[cfg(not(test))] use deadpool::Runtime; use futures::channel::mpsc; +#[cfg(test)] +use futures::lock::Mutex; use futures::stream::StreamExt; +#[cfg(not(test))] use studio::Report; use studio::SingleReport; +#[cfg(not(test))] use sys_info::hostname; use tower::BoxError; use url::Url; @@ -90,6 +97,7 @@ impl MetricsConfigurator for Config { } } +#[cfg(not(test))] #[cfg(not(target_os = "windows"))] fn get_uname() -> Result { let u = uname::uname()?; @@ -99,6 +107,7 @@ fn get_uname() -> Result { )) } +#[cfg(not(test))] #[cfg(target_os = "windows")] fn get_uname() -> Result { // Best we can do on windows right now @@ -115,9 +124,27 @@ fn get_uname() -> Result { struct ApolloMetricsExporter { tx: mpsc::Sender, + #[cfg(test)] + testing_rx: Mutex>>, } impl ApolloMetricsExporter { + #[cfg(test)] + fn new( + _endpoint: &Url, + _apollo_key: &str, + _apollo_graph_ref: &str, + _schema_id: &str, + ) -> Result { + let (tx, rx) = mpsc::channel::(DEFAULT_QUEUE_SIZE); + Ok(ApolloMetricsExporter { + tx, + #[cfg(test)] + testing_rx: Mutex::new(Some(rx)), + }) + } + + #[cfg(not(test))] fn new( endpoint: &Url, apollo_key: &str, @@ -187,6 +214,7 @@ impl ApolloMetricsExporter { Sender::Spaceport(self.tx.clone()) } + #[cfg(not(test))] async fn send_report( pool: &Pool, apollo_key: &str, @@ -244,17 +272,10 @@ impl managed::Manager for ReporterManager { #[cfg(test)] mod test { - use std::future::Future; - - use http::header::HeaderName; + use serde_json::json; - use super::super::super::config; use super::*; - use crate::plugin::test::IntoSchema::Canned; use crate::plugin::test::PluginTestHarness; - use crate::plugin::Plugin; - use crate::plugin::PluginInit; - use crate::plugins::telemetry::apollo; use crate::plugins::telemetry::Telemetry; use crate::plugins::telemetry::STUDIO_EXCLUDE; use crate::Context; @@ -262,22 +283,29 @@ mod test { #[tokio::test] async fn apollo_metrics_disabled() -> Result<(), BoxError> { - let plugin = create_plugin_with_apollo_config(super::super::apollo::Config { - endpoint: None, - apollo_key: None, - apollo_graph_ref: None, - client_name_header: HeaderName::from_static("name_header"), - client_version_header: HeaderName::from_static("version_header"), - schema_id: "schema_sha".to_string(), - }) - .await?; + let config = with_apollo_config(json!({ + "client_name_header": "name_header", + "client_version_header": "version_header", + "schema_id": "schema_sha", + })); + let harness = PluginTestHarness::builder() + .configuration(config) + .build() + .await + .unwrap(); + let plugin = harness.plugin::("apollo.telemetry").unwrap(); assert!(matches!(plugin.apollo_metrics_sender, Sender::Noop)); Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn apollo_metrics_enabled() -> Result<(), BoxError> { - let plugin = create_plugin().await?; + let harness = PluginTestHarness::builder() + .configuration(config()) + .build() + .await + .unwrap(); + let plugin = harness.plugin::("apollo.telemetry").unwrap(); assert!(matches!(plugin.apollo_metrics_sender, Sender::Spaceport(_))); Ok(()) } @@ -352,15 +380,23 @@ mod test { context: Option, ) -> Result, BoxError> { let _ = tracing_subscriber::fmt::try_init(); - let mut plugin = create_plugin().await?; - // Replace the apollo metrics sender so we can test metrics collection. - let (tx, rx) = futures::channel::mpsc::channel(100); - plugin.apollo_metrics_sender = Sender::Spaceport(tx); let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(config()) .build() .await?; + let plugin = test_harness + .plugin::("apollo.telemetry") + .unwrap(); + // The apollo metrics sender differs in cfg(test) so we can test metrics collection. + assert_eq!(plugin._metrics_exporters.len(), 1); + let rx = plugin._metrics_exporters[0] + .downcast_ref::() + .unwrap() + .testing_rx + .lock() + .await + .take() + .unwrap(); let _ = test_harness .call( RouterRequest::fake_builder() @@ -392,28 +428,21 @@ mod test { Ok(results) } - fn create_plugin() -> impl Future> { - create_plugin_with_apollo_config(apollo::Config { - endpoint: None, - apollo_key: Some("key".to_string()), - apollo_graph_ref: Some("ref".to_string()), - client_name_header: HeaderName::from_static("name_header"), - client_version_header: HeaderName::from_static("version_header"), - schema_id: "schema_sha".to_string(), - }) + fn config() -> serde_json::Value { + with_apollo_config(json!({ + "apollo_key": "key", + "apollo_graph_ref": "ref", + "client_name_header": "name_header", + "client_version_header": "version_header", + "schema_id": "schema_sha", + })) } - async fn create_plugin_with_apollo_config( - apollo_config: apollo::Config, - ) -> Result { - Telemetry::new(PluginInit::new( - config::Conf { - metrics: None, - tracing: None, - apollo: Some(apollo_config), + fn with_apollo_config(apollo_config: serde_json::Value) -> serde_json::Value { + json!({ + "telemetry": { + "apollo": apollo_config, }, - Default::default(), - )) - .await + }) } } diff --git a/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs b/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs index 3fab826a70..2c8e24d5b0 100644 --- a/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs +++ b/apollo-router/src/plugins/telemetry/metrics/apollo/studio.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::ops::AddAssign; use std::time::Duration; +#[cfg(not(test))] use std::time::SystemTime; use apollo_spaceport::ReferencedFieldsForType; +#[cfg(not(test))] use apollo_spaceport::ReportHeader; use apollo_spaceport::StatsContext; use itertools::Itertools; @@ -21,6 +23,7 @@ impl Report { aggregated_report } + #[cfg(not(test))] pub(crate) fn into_report(self, header: ReportHeader) -> apollo_spaceport::Report { let mut report = apollo_spaceport::Report { header: Some(header), diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index fb32d174a0..631be358ed 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -174,8 +174,10 @@ impl Plugin for Telemetry { let telemetry = tracing_opentelemetry::layer().with_tracer(tracer); Self::replace_tracer_provider(tracer_provider); - replace_layer(Box::new(telemetry)) - .expect("set_global_subscriber() was not called at startup, fatal"); + let result = replace_layer(Box::new(telemetry)); + if cfg!(not(test)) { + result.expect("set_global_subscriber() was not called at startup, fatal"); + } opentelemetry::global::set_error_handler(handle_error) .expect("otel error handler lock poisoned, fatal"); global::set_text_map_propagator(Self::create_propagator(&self.config)); diff --git a/apollo-router/src/router_factory.rs b/apollo-router/src/router_factory.rs index bb35dceaf1..c913878aaa 100644 --- a/apollo-router/src/router_factory.rs +++ b/apollo-router/src/router_factory.rs @@ -13,6 +13,10 @@ use crate::configuration::ConfigurationError; use crate::graphql; use crate::http_ext::Request; use crate::http_ext::Response; +use crate::plugin::test::BufferedSubgraphService; +use crate::plugin::test::MockExecutionService; +use crate::plugin::test::MockQueryPlanningService; +use crate::plugin::test::MockRouterService; use crate::plugin::DynPlugin; use crate::plugin::Handler; use crate::services::new_service::NewService; @@ -52,7 +56,33 @@ pub(crate) trait RouterServiceConfigurator: Send + Sync + 'static { configuration: Arc, schema: Arc, previous_router: Option<&'a Self::RouterServiceFactory>, - ) -> Result; + ) -> Result { + self.create_with_mocks( + configuration, + schema, + None, + None, + None, + None, + previous_router, + ) + .await + } + + // TODO refactor PluggableRouterServiceBuilder etc to pass a struct here + #[allow(clippy::too_many_arguments)] + async fn create_with_mocks<'a>( + &'a mut self, + _configuration: Arc, + _schema: Arc, + _mock_router_service: Option, + _mock_query_planner_service: Option, + _mock_execution_service: Option, + _mock_subgraph_services: Option>, + _previous_router: Option<&'a Self::RouterServiceFactory>, + ) -> Result { + unimplemented!() + } } /// Main implementation of the RouterService factory, supporting the extensions system @@ -63,20 +93,33 @@ pub(crate) struct YamlRouterServiceFactory; impl RouterServiceConfigurator for YamlRouterServiceFactory { type RouterServiceFactory = RouterCreator; - async fn create<'a>( + async fn create_with_mocks<'a>( &'a mut self, configuration: Arc, schema: Arc, + mock_router_service: Option, + mock_query_planner_service: Option, + mock_execution_service: Option, + mock_subgraph_services: Option>, _previous_router: Option<&'a Self::RouterServiceFactory>, ) -> Result { // Process the plugins. let plugins = create_plugins(&configuration, &schema).await?; let mut builder = PluggableRouterServiceBuilder::new(schema.clone()); + builder.mock_router_service = mock_router_service; + builder.mock_query_planner_service = mock_query_planner_service; + builder.mock_execution_service = mock_execution_service; builder = builder.with_configuration(configuration); - for (name, _) in schema.subgraphs() { - builder = builder.with_subgraph_service(name, SubgraphService::new(name)); + if let Some(mocks) = mock_subgraph_services { + for (name, service) in mocks { + builder = builder.with_subgraph_service(&name, service); + } + } else { + for (name, _) in schema.subgraphs() { + builder = builder.with_subgraph_service(name, SubgraphService::new(name)); + } } for (plugin_name, plugin) in plugins { diff --git a/apollo-router/src/services/execution_service.rs b/apollo-router/src/services/execution_service.rs index 0ee52c115e..4118e5e90b 100644 --- a/apollo-router/src/services/execution_service.rs +++ b/apollo-router/src/services/execution_service.rs @@ -141,3 +141,26 @@ impl ExecutionServiceFactory for ExecutionCreator>::Future; } + +impl ExecutionServiceFactory for S +where + S: Clone + + Send + + Service + + 'static, + >::Future: Send, +{ + type ExecutionService = S; + + type Future = >::Future; +} +impl NewService for S +where + S: Clone + Send + Service, +{ + type Service = S; + + fn new_service(&self) -> Self::Service { + self.clone() + } +} diff --git a/apollo-router/src/services/http_ext.rs b/apollo-router/src/services/http_ext.rs index c9717736f5..ad077a41a9 100644 --- a/apollo-router/src/services/http_ext.rs +++ b/apollo-router/src/services/http_ext.rs @@ -23,7 +23,7 @@ use crate::graphql; /// Temporary holder of header name while for use while building requests and responses. Required /// because header name creation is fallible. -#[derive(Eq, Hash, PartialEq)] +#[derive(Eq)] pub enum IntoHeaderName { String(String), HeaderName(HeaderName), @@ -31,12 +31,54 @@ pub enum IntoHeaderName { /// Temporary holder of header value while for use while building requests and responses. Required /// because header value creation is fallible. -#[derive(Eq, Hash, PartialEq)] +#[derive(Eq)] pub enum IntoHeaderValue { String(String), HeaderValue(HeaderValue), } +impl PartialEq for IntoHeaderName { + fn eq(&self, other: &Self) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl PartialEq for IntoHeaderValue { + fn eq(&self, other: &Self) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl Hash for IntoHeaderName { + fn hash(&self, state: &mut H) { + self.as_bytes().hash(state); + } +} + +impl Hash for IntoHeaderValue { + fn hash(&self, state: &mut H) { + self.as_bytes().hash(state); + } +} + +impl IntoHeaderName { + fn as_bytes(&self) -> &[u8] { + match self { + IntoHeaderName::String(s) => s.as_bytes(), + IntoHeaderName::HeaderName(h) => h.as_str().as_bytes(), + } + } +} + +impl IntoHeaderValue { + fn as_bytes(&self) -> &[u8] { + match self { + IntoHeaderValue::String(s) => s.as_bytes(), + IntoHeaderValue::HeaderValue(v) => v.as_bytes(), + } + } +} + impl From for IntoHeaderName where T: std::fmt::Display, diff --git a/apollo-router/src/services/mod.rs b/apollo-router/src/services/mod.rs index 3432542325..b0bd35d9ed 100644 --- a/apollo-router/src/services/mod.rs +++ b/apollo-router/src/services/mod.rs @@ -122,8 +122,14 @@ impl RouterRequest { variables: HashMap, extensions: HashMap, context: Option, - headers: MultiMap, + mut headers: MultiMap, ) -> Result { + // Avoid testing requests getting blocked by the CSRF-prevention plugin + headers + .entry(IntoHeaderName::HeaderName(http::header::CONTENT_TYPE)) + .or_insert(IntoHeaderValue::HeaderValue(HeaderValue::from_static( + "application/json", + ))); RouterRequest::new( query, operation_name, diff --git a/apollo-router/src/services/router_service.rs b/apollo-router/src/services/router_service.rs index ced2b966b6..aa49e08455 100644 --- a/apollo-router/src/services/router_service.rs +++ b/apollo-router/src/services/router_service.rs @@ -34,6 +34,9 @@ use crate::graphql::Response; use crate::http_ext::Request; use crate::introspection::Introspection; use crate::layers::ServiceBuilderExt; +use crate::plugin::test::MockExecutionService; +use crate::plugin::test::MockQueryPlanningService; +use crate::plugin::test::MockRouterService; use crate::plugin::DynPlugin; use crate::plugin::Plugin; use crate::query_planner::BridgeQueryPlanner; @@ -247,6 +250,9 @@ pub struct PluggableRouterServiceBuilder { plugins: Plugins, subgraph_services: Vec<(String, Arc)>, configuration: Option>, + pub(crate) mock_router_service: Option, + pub(crate) mock_query_planner_service: Option, + pub(crate) mock_execution_service: Option, } impl PluggableRouterServiceBuilder { @@ -256,6 +262,9 @@ impl PluggableRouterServiceBuilder { plugins: Default::default(), subgraph_services: Default::default(), configuration: None, + mock_router_service: None, + mock_query_planner_service: None, + mock_execution_service: None, } } @@ -312,37 +321,43 @@ impl PluggableRouterServiceBuilder { let configuration = self.configuration.unwrap_or_default(); - let plan_cache_limit = std::env::var("ROUTER_PLAN_CACHE_LIMIT") - .ok() - .and_then(|x| x.parse().ok()) - .unwrap_or(100); - - let introspection = if configuration.server.introspection { - // Introspection instantiation can potentially block for some time - // We don't need to use the api schema here because on the deno side we always convert to API schema - - let schema = self.schema.clone(); - Some(Arc::new( - tokio::task::spawn_blocking(move || Introspection::from_schema(&schema)) - .await - .expect("Introspection instantiation panicked"), - )) + let query_planner_service = if let Some(mock) = self.mock_query_planner_service { + mock.build().boxed() } else { - None + let plan_cache_limit = std::env::var("ROUTER_PLAN_CACHE_LIMIT") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(100); + let introspection = if configuration.server.introspection { + // Introspection instantiation can potentially block for some time + // We don't need to use the api schema here because on the deno side we always convert to API schema + + let schema = self.schema.clone(); + Some(Arc::new( + tokio::task::spawn_blocking(move || Introspection::from_schema(&schema)) + .await + .expect("Introspection instantiation panicked"), + )) + } else { + None + }; + // QueryPlannerService takes an UnplannedRequest and outputs PlannedRequest + let bridge_query_planner = + BridgeQueryPlanner::new(self.schema.clone(), introspection, configuration) + .await + .map_err(ServiceBuildError::QueryPlannerError)?; + CachingQueryPlanner::new(bridge_query_planner, plan_cache_limit) + .await + .boxed() }; - // QueryPlannerService takes an UnplannedRequest and outputs PlannedRequest - let bridge_query_planner = - BridgeQueryPlanner::new(self.schema.clone(), introspection, configuration) - .await - .map_err(ServiceBuildError::QueryPlannerError)?; let query_planner_service = ServiceBuilder::new().buffered().service( - self.plugins.iter_mut().rev().fold( - CachingQueryPlanner::new(bridge_query_planner, plan_cache_limit) - .await - .boxed(), - |acc, (_, e)| e.query_planning_service(acc), - ), + self.plugins + .iter_mut() + .rev() + .fold(query_planner_service, |acc, (_, e)| { + e.query_planning_service(acc) + }), ); let plugins = Arc::new(self.plugins); @@ -360,6 +375,12 @@ impl PluggableRouterServiceBuilder { schema: self.schema, plugins, apq, + mock_router_service: self + .mock_router_service + .map(|m| ServiceBuilder::new().buffered().service(m.build())), + mock_execution_service: self + .mock_execution_service + .map(|m| ServiceBuilder::new().buffered().service(m.build())), }) } } @@ -373,8 +394,13 @@ pub struct RouterCreator { >, subgraph_creator: Arc, schema: Arc, - plugins: Arc, + pub(crate) plugins: Arc, apq: APQLayer, + mock_router_service: + Option, RouterRequest>>, + mock_execution_service: Option< + Buffer, ExecutionRequest>, + >, } impl NewService> for RouterCreator { @@ -425,24 +451,37 @@ impl RouterCreator { Error = BoxError, Future = BoxFuture<'static, Result>, > + Send { + let router_service = if let Some(mock) = &self.mock_router_service { + mock.clone().boxed() + } else { + macro_rules! router_service { + ($execution_service: expr) => { + RouterService::builder() + .query_planner_service(self.query_planner_service.clone()) + .execution_service_factory($execution_service) + .schema(self.schema.clone()) + .build() + .boxed() + }; + } + if let Some(mock) = &self.mock_execution_service { + router_service!(mock.clone()) + } else { + router_service!(ExecutionCreator { + schema: self.schema.clone(), + plugins: self.plugins.clone(), + subgraph_creator: self.subgraph_creator.clone(), + }) + } + }; ServiceBuilder::new() .layer(self.apq.clone()) .layer(EnsureQueryPresence::default()) .service( - self.plugins.iter().rev().fold( - BoxService::new( - RouterService::builder() - .query_planner_service(self.query_planner_service.clone()) - .execution_service_factory(ExecutionCreator { - schema: self.schema.clone(), - plugins: self.plugins.clone(), - subgraph_creator: self.subgraph_creator.clone(), - }) - .schema(self.schema.clone()) - .build(), - ), - |acc, (_, e)| e.router_service(acc), - ), + self.plugins + .iter() + .rev() + .fold(router_service, |acc, (_, e)| e.router_service(acc)), ) } diff --git a/examples/hello-world/src/hello_world.rs b/examples/hello-world/src/hello_world.rs index 0b71f1f919..9c2171c6b4 100644 --- a/examples/hello-world/src/hello_world.rs +++ b/examples/hello-world/src/hello_world.rs @@ -107,13 +107,8 @@ register_plugin!("example", "hello_world", HelloWorld); #[cfg(test)] mod tests { - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; - - use super::Conf; - use super::HelloWorld; + use serde_json::json; #[tokio::test] async fn plugin_registered() { @@ -129,22 +124,20 @@ mod tests { // we will see the message "Hello Bob" printed to standard out #[tokio::test] async fn display_message() { - // Define a configuration to use with our plugin - let conf = Conf { - name: "Bob".to_string(), - }; - - // Build an instance of our plugin to use in the test harness - let plugin = HelloWorld::new(PluginInit::new(conf, Default::default())) - .await - .expect("created plugin"); + // Define a configuration that enables our plugin + let conf = json!({ + "plugins": { + "example.hello_world": { + "name": "Bob" + } + } + }); // Build a test harness. Usually we'd use this and send requests to // it, but in this case it's enough to build the harness to see our // output when our service registers. let _test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await .expect("building harness"); diff --git a/examples/rhai-data-response-mutate/src/main.rs b/examples/rhai-data-response-mutate/src/main.rs index affafbf1af..379d20b8be 100644 --- a/examples/rhai-data-response-mutate/src/main.rs +++ b/examples/rhai-data-response-mutate/src/main.rs @@ -12,34 +12,23 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; - use apollo_router::plugins::rhai::Conf; - use apollo_router::plugins::rhai::Rhai; use apollo_router::services::RouterRequest; use apollo_router::Context; use http::StatusCode; #[tokio::test] async fn test_subgraph_mutates_data() { - // Define a configuration to use with our plugin - let conf: Conf = serde_json::from_value(serde_json::json!({ - "scripts": "src", - "main": "rhai_data_response_mutate.rhai", - })) - .expect("valid conf supplied"); - - // Build an instance of our plugin to use in the test harness - let plugin = Rhai::new(PluginInit::new(conf, Default::default())) - .await - .expect("created plugin"); - + // Define a configuration to use our plugin + let conf = serde_json::json!({ + "rhai": { + "scripts": "src", + "main": "rhai_data_response_mutate.rhai", + } + }); // Build a test harness. let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await .expect("building harness"); diff --git a/examples/rhai-error-response-mutate/src/main.rs b/examples/rhai-error-response-mutate/src/main.rs index 1ca244d041..0b03486c4f 100644 --- a/examples/rhai-error-response-mutate/src/main.rs +++ b/examples/rhai-error-response-mutate/src/main.rs @@ -12,34 +12,24 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; - use apollo_router::plugins::rhai::Conf; - use apollo_router::plugins::rhai::Rhai; use apollo_router::services::RouterRequest; use apollo_router::Context; use http::StatusCode; #[tokio::test] async fn test_subgraph_mutates_error() { - // Define a configuration to use with our plugin - let conf: Conf = serde_json::from_value(serde_json::json!({ - "scripts": "src", - "main": "rhai_error_response_mutate.rhai", - })) - .expect("valid conf supplied"); - - // Build an instance of our plugin to use in the test harness - let plugin = Rhai::new(PluginInit::new(conf, Default::default())) - .await - .expect("created plugin"); + // Define a configuration to use our plugin + let conf = serde_json::json!({ + "rhai": { + "scripts": "src", + "main": "rhai_error_response_mutate.rhai", + } + }); // Build a test harness. let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await .expect("building harness"); diff --git a/examples/rhai-subgraph-request-log/src/main.rs b/examples/rhai-subgraph-request-log/src/main.rs index 6c28ad1060..22a3bc35bc 100644 --- a/examples/rhai-subgraph-request-log/src/main.rs +++ b/examples/rhai-subgraph-request-log/src/main.rs @@ -12,12 +12,7 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; - use apollo_router::plugins::rhai::Conf; - use apollo_router::plugins::rhai::Rhai; use apollo_router::services::RouterRequest; use apollo_router::Context; use http::StatusCode; @@ -25,21 +20,16 @@ mod tests { #[tokio::test] async fn test_subgraph_logs_data() { // Define a configuration to use with our plugin - let conf: Conf = serde_json::from_value(serde_json::json!({ - "scripts": "src", - "main": "rhai_subgraph_request_log.rhai", - })) - .expect("valid conf supplied"); - - // Build an instance of our plugin to use in the test harness - let plugin = Rhai::new(PluginInit::new(conf, Default::default())) - .await - .expect("created plugin"); + let conf = serde_json::json!({ + "rhai": { + "scripts": "src", + "main": "rhai_subgraph_request_log.rhai", + } + }); // Build a test harness. let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await .expect("building harness"); diff --git a/examples/rhai-surrogate-cache-key/src/main.rs b/examples/rhai-surrogate-cache-key/src/main.rs index da4fffe229..9a297bfb85 100644 --- a/examples/rhai-surrogate-cache-key/src/main.rs +++ b/examples/rhai-surrogate-cache-key/src/main.rs @@ -12,12 +12,7 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use apollo_router::plugin::test::IntoSchema::Canned; use apollo_router::plugin::test::PluginTestHarness; - use apollo_router::plugin::Plugin; - use apollo_router::plugin::PluginInit; - use apollo_router::plugins::rhai::Conf; - use apollo_router::plugins::rhai::Rhai; use apollo_router::services::RouterRequest; use apollo_router::Context; use http::StatusCode; @@ -25,21 +20,16 @@ mod tests { #[tokio::test] async fn test_surrogate_cache_key_created() { // Define a configuration to use with our plugin - let conf: Conf = serde_json::from_value(serde_json::json!({ - "scripts": "src", - "main": "rhai_surrogate_cache_key.rhai", - })) - .expect("valid conf supplied"); - - // Build an instance of our plugin to use in the test harness - let plugin = Rhai::new(PluginInit::new(conf, Default::default())) - .await - .expect("created plugin"); + let conf = serde_json::json!({ + "rhai": { + "scripts": "src", + "main": "rhai_surrogate_cache_key.rhai", + } + }); // Build a test harness. let mut test_harness = PluginTestHarness::builder() - .plugin(plugin) - .schema(Canned) + .configuration(conf) .build() .await .expect("building harness");