From f067629b7f63e843048a38a542a8734d90d8aaa8 Mon Sep 17 00:00:00 2001 From: laststylebender <43403528+laststylebender14@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:49:05 +0530 Subject: [PATCH] feat: enable POST requests batching with dl. (#3140) Co-authored-by: Tushar Mathur --- Cargo.toml | 1 - generated/.tailcallrc.graphql | 12 +- src/core/blueprint/error.rs | 10 +- src/core/blueprint/operators/http.rs | 107 ++++++-- ...lueprint__index__test__from_blueprint.snap | 8 +- src/core/config/directives/http.rs | 5 +- src/core/config/transformer/subgraph.rs | 10 +- src/core/endpoint.rs | 2 +- .../generator/json/operation_generator.rs | 5 +- src/core/generator/proto/connect_rpc.rs | 9 +- src/core/http/data_loader.rs | 147 ++++++----- src/core/http/data_loader_request.rs | 42 ++- src/core/http/mod.rs | 1 + src/core/http/request_template.rs | 146 ++++++++--- .../http/transformations/body_batching.rs | 248 ++++++++++++++++++ src/core/http/transformations/mod.rs | 5 + .../http/transformations/query_batching.rs | 200 ++++++++++++++ src/core/ir/eval_http.rs | 32 ++- src/core/ir/eval_io.rs | 8 +- src/core/ir/mod.rs | 2 + src/core/ir/request.rs | 40 +++ src/core/json/borrow.rs | 8 + src/core/json/graphql.rs | 8 + src/core/json/json_like.rs | 4 +- src/core/json/serde.rs | 8 + src/core/worker/worker.rs | 10 + ...ures__generator__proto-connect-rpc.md.snap | 12 +- .../batching-validation.md_error.snap | 45 ++++ .../snapshots/body-batching-cases.md_0.snap | 34 +++ .../snapshots/body-batching-cases.md_1.snap | 32 +++ .../snapshots/body-batching-cases.md_2.snap | 32 +++ .../body-batching-cases.md_client.snap | 40 +++ .../body-batching-cases.md_merged.snap | 53 ++++ tests/core/snapshots/body-batching.md_0.snap | 43 +++ tests/core/snapshots/body-batching.md_1.snap | 40 +++ .../snapshots/body-batching.md_client.snap | 29 ++ .../snapshots/body-batching.md_merged.snap | 43 +++ .../test-batch-operator-post.md_error.snap | 2 +- tests/execution/batching-validation.md | 47 ++++ tests/execution/body-batching-cases.md | 154 +++++++++++ tests/execution/body-batching.md | 114 ++++++++ tests/execution/call-mutation.md | 2 +- 42 files changed, 1622 insertions(+), 178 deletions(-) create mode 100644 src/core/http/transformations/body_batching.rs create mode 100644 src/core/http/transformations/mod.rs create mode 100644 src/core/http/transformations/query_batching.rs create mode 100644 src/core/ir/request.rs create mode 100644 tests/core/snapshots/batching-validation.md_error.snap create mode 100644 tests/core/snapshots/body-batching-cases.md_0.snap create mode 100644 tests/core/snapshots/body-batching-cases.md_1.snap create mode 100644 tests/core/snapshots/body-batching-cases.md_2.snap create mode 100644 tests/core/snapshots/body-batching-cases.md_client.snap create mode 100644 tests/core/snapshots/body-batching-cases.md_merged.snap create mode 100644 tests/core/snapshots/body-batching.md_0.snap create mode 100644 tests/core/snapshots/body-batching.md_1.snap create mode 100644 tests/core/snapshots/body-batching.md_client.snap create mode 100644 tests/core/snapshots/body-batching.md_merged.snap create mode 100644 tests/execution/batching-validation.md create mode 100644 tests/execution/body-batching-cases.md create mode 100644 tests/execution/body-batching.md diff --git a/Cargo.toml b/Cargo.toml index 199d15005d..b47ae50314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,7 +248,6 @@ default = ["cli", "js"] # Feature flag to force JIT engine inside integration tests force_jit = [] - [workspace] members = [ ".", diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index 9f6f170ea6..587bcd53c7 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -173,10 +173,10 @@ directive @http( batchKey: [String!] """ The body of the API call. It's used for methods like POST or PUT that send data to - the server. You can pass it as a static object or use a Mustache template to substitute - variables from the GraphQL variables. + the server. You can pass it as a static object or use a Mustache template with object + to substitute variables from the GraphQL variables. """ - body: String + body: JSON """ Enables deduplication of IO operations to enhance performance.This flag prevents duplicate IO requests from being executed concurrently, reducing resource load. Caution: @@ -918,10 +918,10 @@ input Http { batchKey: [String!] """ The body of the API call. It's used for methods like POST or PUT that send data to - the server. You can pass it as a static object or use a Mustache template to substitute - variables from the GraphQL variables. + the server. You can pass it as a static object or use a Mustache template with object + to substitute variables from the GraphQL variables. """ - body: String + body: JSON """ Enables deduplication of IO operations to enhance performance.This flag prevents duplicate IO requests from being executed concurrently, reducing resource load. Caution: diff --git a/src/core/blueprint/error.rs b/src/core/blueprint/error.rs index a8980ac8e7..4d4dd6337b 100644 --- a/src/core/blueprint/error.rs +++ b/src/core/blueprint/error.rs @@ -46,12 +46,18 @@ pub enum BlueprintError { #[error("Protobuf files were not specified in the config")] ProtobufFilesNotSpecifiedInConfig, - #[error("GroupBy is only supported for GET requests")] - GroupByOnlyForGet, + #[error("GroupBy is only supported for GET and POST requests")] + GroupByOnlyForGetAndPost, + + #[error("Request body batching requires exactly one dynamic value in the body.")] + BatchRequiresDynamicParameter, #[error("Batching capability was used without enabling it in upstream")] IncorrectBatchingUsage, + #[error("batchKey requires either body or query parameters")] + BatchKeyRequiresEitherBodyOrQuery, + #[error("script is required")] ScriptIsRequired, diff --git a/src/core/blueprint/operators/http.rs b/src/core/blueprint/operators/http.rs index cfc8ca879f..ab25a25ced 100644 --- a/src/core/blueprint/operators/http.rs +++ b/src/core/blueprint/operators/http.rs @@ -22,15 +22,11 @@ pub fn compile_http( Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), }; - Valid::<(), BlueprintError>::fail(BlueprintError::GroupByOnlyForGet) - .when(|| !http.batch_key.is_empty() && http.method != Method::GET) - .and( - Valid::<(), BlueprintError>::fail(BlueprintError::IncorrectBatchingUsage).when(|| { - (config_module.upstream.get_delay() < 1 - || config_module.upstream.get_max_size() < 1) - && !http.batch_key.is_empty() - }), - ) + Valid::<(), BlueprintError>::fail(BlueprintError::IncorrectBatchingUsage) + .when(|| { + (config_module.upstream.get_delay() < 1 || config_module.upstream.get_max_size() < 1) + && !http.batch_key.is_empty() + }) .and( Valid::from_iter(http.query.iter(), |query| { validate_argument(config_module, Mustache::parse(query.value.as_str()), field) @@ -38,6 +34,12 @@ pub fn compile_http( .unit() .trace("query"), ) + .and( + Valid::<(), BlueprintError>::fail(BlueprintError::BatchKeyRequiresEitherBodyOrQuery) + .when(|| { + !http.batch_key.is_empty() && (http.body.is_none() && http.query.is_empty()) + }), + ) .and(Valid::succeed(http.url.as_str())) .zip(mustache_headers) .and_then(|(base_url, headers)| { @@ -67,6 +69,22 @@ pub fn compile_http( Err(e) => Valid::fail(BlueprintError::Error(e)), } }) + .and_then(|request_template| { + if !http.batch_key.is_empty() && (http.body.is_some() || http.method != Method::GET) { + if let Some(body) = http.body.as_ref() { + let dynamic_paths = count_dynamic_paths(body); + if dynamic_paths != 1 { + Valid::fail(BlueprintError::BatchRequiresDynamicParameter).trace("body") + } else { + Valid::succeed(request_template) + } + } else { + Valid::fail(BlueprintError::BatchRequiresDynamicParameter).trace("body") + } + } else { + Valid::succeed(request_template) + } + }) .map(|req_template| { // marge http and upstream on_request let on_request = http @@ -76,13 +94,18 @@ pub fn compile_http( let on_response_body = http.on_response_body.clone(); let hook = WorkerHooks::try_new(on_request, on_response_body).ok(); - let io = if !http.batch_key.is_empty() && http.method == Method::GET { + let io = if !http.batch_key.is_empty() { // Find a query parameter that contains a reference to the {{.value}} key - let key = http.query.iter().find_map(|q| { - Mustache::parse(&q.value) - .expression_contains("value") - .then(|| q.key.clone()) - }); + let key = if http.method == Method::GET { + http.query.iter().find_map(|q| { + Mustache::parse(&q.value) + .expression_contains("value") + .then(|| q.key.clone()) + }) + } else { + None + }; + IR::IO(IO::Http { req_template, group_by: Some(GroupBy::new(http.batch_key.clone(), key)), @@ -105,3 +128,57 @@ pub fn compile_http( }) .and_then(apply_select) } + +/// Count the number of dynamic expressions in the JSON value. +fn count_dynamic_paths(json: &serde_json::Value) -> usize { + let mut count = 0; + match json { + serde_json::Value::Array(arr) => { + for v in arr { + count += count_dynamic_paths(v) + } + } + serde_json::Value::Object(obj) => { + for (_, v) in obj { + count += count_dynamic_paths(v) + } + } + serde_json::Value::String(s) => { + if !Mustache::parse(s).is_const() { + count += 1; + } + } + _ => {} + } + count +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use super::*; + + #[test] + fn test_extract_expression_keys_from_nested_objects() { + let json = r#"{"body":"d","userId":"{{.value.uid}}","nested":{"other":"{{test}}"}}"#; + let json = serde_json::from_str(json).unwrap(); + let keys = count_dynamic_paths(&json); + assert_eq!(keys, 2); + } + + #[test] + fn test_extract_expression_keys_from_mixed_json() { + let json = r#"{"body":"d","userId":"{{.value.uid}}","nested":{"other":"{{test}}"},"meta":[{"key": "id", "value": "{{.value.userId}}"}]}"#; + let json = serde_json::from_str(json).unwrap(); + let keys = count_dynamic_paths(&json); + assert_eq!(keys, 3); + } + + #[test] + fn test_with_non_json_value() { + let json = json!(r#"{{.value}}"#); + let keys = count_dynamic_paths(&json); + assert_eq!(keys, 1); + } +} diff --git a/src/core/blueprint/snapshots/tailcall__core__blueprint__index__test__from_blueprint.snap b/src/core/blueprint/snapshots/tailcall__core__blueprint__index__test__from_blueprint.snap index 33dd769568..7336ccd0cf 100644 --- a/src/core/blueprint/snapshots/tailcall__core__blueprint__index__test__from_blueprint.snap +++ b/src/core/blueprint/snapshots/tailcall__core__blueprint__index__test__from_blueprint.snap @@ -58,7 +58,7 @@ Index { ), headers: {}, body: Some( - "{{.args.input}}", + String("{{.args.input}}"), ), description: None, encoding: ApplicationJson, @@ -127,7 +127,7 @@ Index { ), headers: {}, body: Some( - "{{.args.input}}", + String("{{.args.input}}"), ), description: None, encoding: ApplicationJson, @@ -205,7 +205,7 @@ Index { ), headers: {}, body: Some( - "{{.args.input}}", + String("{{.args.input}}"), ), description: None, encoding: ApplicationJson, @@ -286,7 +286,7 @@ Index { ), headers: {}, body: Some( - "{{.args.input}}", + String("{{.args.input}}"), ), description: None, encoding: ApplicationJson, diff --git a/src/core/config/directives/http.rs b/src/core/config/directives/http.rs index 9aec5e73be..150f871d8d 100644 --- a/src/core/config/directives/http.rs +++ b/src/core/config/directives/http.rs @@ -40,8 +40,9 @@ pub struct Http { #[serde(default, skip_serializing_if = "is_default")] /// The body of the API call. It's used for methods like POST or PUT that /// send data to the server. You can pass it as a static object or use a - /// Mustache template to substitute variables from the GraphQL variables. - pub body: Option, + /// Mustache template with object to substitute variables from the GraphQL + /// variables. + pub body: Option, #[serde(default, skip_serializing_if = "is_default")] /// The `encoding` parameter specifies the encoding of the request body. It diff --git a/src/core/config/transformer/subgraph.rs b/src/core/config/transformer/subgraph.rs index 24eed1f86c..0d336d544f 100644 --- a/src/core/config/transformer/subgraph.rs +++ b/src/core/config/transformer/subgraph.rs @@ -275,7 +275,7 @@ impl KeysExtractor { Valid::from_iter( [ Self::parse_str(http.url.as_str()).trace("url"), - Self::parse_str_option(http.body.as_deref()).trace("body"), + Self::parse_json_option(http.body.as_ref()).trace("body"), Self::parse_key_value_iter(http.headers.iter()).trace("headers"), Self::parse_key_value_iter(http.query.iter().map(|q| KeyValue { key: q.key.to_string(), @@ -355,9 +355,9 @@ impl KeysExtractor { .map_to(keys) } - fn parse_str_option(s: Option<&str>) -> Valid { + fn parse_json_option(s: Option<&serde_json::Value>) -> Valid { if let Some(s) = s { - Self::parse_str(s) + Self::parse_str(&s.to_string()) } else { Valid::succeed(Keys::new()) } @@ -483,7 +483,9 @@ mod tests { fn test_extract_http() { let http = Http { url: "http://tailcall.run/users/{{.value.id}}".to_string(), - body: Some(r#"{ "obj": "{{.value.obj}}"} "#.to_string()), + body: Some(serde_json::Value::String( + r#"{ "obj": "{{.value.obj}}"} "#.to_string(), + )), headers: vec![KeyValue { key: "{{.value.header.key}}".to_string(), value: "{{.value.header.value}}".to_string(), diff --git a/src/core/endpoint.rs b/src/core/endpoint.rs index d809096779..6099acec54 100644 --- a/src/core/endpoint.rs +++ b/src/core/endpoint.rs @@ -13,7 +13,7 @@ pub struct Endpoint { pub input: JsonSchema, pub output: JsonSchema, pub headers: HeaderMap, - pub body: Option, + pub body: Option, pub description: Option, pub encoding: Encoding, } diff --git a/src/core/generator/json/operation_generator.rs b/src/core/generator/json/operation_generator.rs index fe0aeb36aa..79bde56e71 100644 --- a/src/core/generator/json/operation_generator.rs +++ b/src/core/generator/json/operation_generator.rs @@ -41,7 +41,10 @@ impl OperationTypeGenerator { let arg_name_gen = NameGenerator::new(prefix.as_str()); let arg_name = arg_name_gen.next(); - http_resolver.body = Some(format!("{{{{.args.{}}}}}", arg_name)); + http_resolver.body = Some(serde_json::Value::String(format!( + "{{{{.args.{}}}}}", + arg_name + ))); http_resolver.method = request_sample.method.to_owned(); field.args.insert( diff --git a/src/core/generator/proto/connect_rpc.rs b/src/core/generator/proto/connect_rpc.rs index 4363631e2c..14fb51243b 100644 --- a/src/core/generator/proto/connect_rpc.rs +++ b/src/core/generator/proto/connect_rpc.rs @@ -55,7 +55,7 @@ impl From for Http { Self { url: new_url, - body: body.map(|b| b.to_string()), + body, method: crate::core::http::Method::POST, headers, batch_key, @@ -91,7 +91,7 @@ mod tests { assert_eq!(http.url, "http://localhost:8080/package.service/method"); assert_eq!(http.method, crate::core::http::Method::POST); - assert_eq!(http.body, Some(r#"{"key":"value"}"#.to_string())); + assert_eq!(http.body, Some(json!({"key": "value"}))); } #[test] @@ -109,7 +109,7 @@ mod tests { let http = Http::from(grpc); - assert_eq!(http.body, Some("{}".to_string())); + assert_eq!(http.body, Some(json!({}))); } #[test] @@ -136,6 +136,7 @@ mod tests { .value, "bar".to_string() ); + assert_eq!(http.body, Some(json!({}))); } #[test] @@ -155,7 +156,7 @@ mod tests { assert_eq!(http.url, "http://localhost:8080/package.service/method"); assert_eq!(http.method, crate::core::http::Method::POST); - assert_eq!(http.body, Some(r#"{"key":"value"}"#.to_string())); + assert_eq!(http.body, Some(json!({"key": "value"}))); assert_eq!( http.headers .iter() diff --git a/src/core/http/data_loader.rs b/src/core/http/data_loader.rs index c5443d93fd..72d72c4a50 100644 --- a/src/core/http/data_loader.rs +++ b/src/core/http/data_loader.rs @@ -5,13 +5,17 @@ use std::time::Duration; use async_graphql::async_trait; use async_graphql::futures_util::future::join_all; use async_graphql_value::ConstValue; +use tailcall_valid::Validator; +use super::transformations::{BodyBatching, QueryBatching}; use crate::core::config::group_by::GroupBy; use crate::core::config::Batch; use crate::core::data_loader::{DataLoader, Loader}; use crate::core::http::{DataLoaderRequest, Response}; use crate::core::json::JsonLike; use crate::core::runtime::TargetRuntime; +use crate::core::transform::TransformerOps; +use crate::core::Transform; fn get_body_value_single(body_value: &HashMap>, id: &str) -> ConstValue { body_value @@ -35,19 +39,11 @@ fn get_body_value_list(body_value: &HashMap>, id: &str) pub struct HttpDataLoader { pub runtime: TargetRuntime, pub group_by: Option, - pub body: fn(&HashMap>, &str) -> ConstValue, + is_list: bool, } impl HttpDataLoader { pub fn new(runtime: TargetRuntime, group_by: Option, is_list: bool) -> Self { - HttpDataLoader { - runtime, - group_by, - body: if is_list { - get_body_value_list - } else { - get_body_value_single - }, - } + HttpDataLoader { runtime, group_by, is_list } } pub fn to_data_loader(self, batch: Batch) -> DataLoader { @@ -69,61 +65,86 @@ impl Loader for HttpDataLoader { if let Some(group_by) = &self.group_by { let query_name = group_by.key(); let mut dl_requests = keys.to_vec(); - - // Sort keys to build consistent URLs - // TODO: enable in tests only - dl_requests.sort_by(|a, b| a.to_request().url().cmp(b.to_request().url())); - - // Create base request - let mut request = dl_requests[0].to_request(); - let first_url = request.url_mut(); - - // Merge query params in the request - for key in &dl_requests[1..] { - let request = key.to_request(); - let url = request.url(); - let pairs: Vec<_> = url - .query_pairs() - .filter(|(key, _)| group_by.key().eq(&key.to_string())) - .collect(); - first_url.query_pairs_mut().extend_pairs(pairs); + if cfg!(debug_assertions) { + // Sort keys to build consistent URLs only in Testing environment. + dl_requests.sort_by(|a, b| a.to_request().url().cmp(b.to_request().url())); } - // Dispatch request - let res = self - .runtime - .http - .execute(request) - .await? - .to_json::()?; - - // Create a response HashMap - #[allow(clippy::mutable_key_type)] - let mut hashmap = HashMap::with_capacity(dl_requests.len()); - - // Parse the response body and group it by batchKey - let path = &group_by.path(); - - // ResponseMap contains the response body grouped by the batchKey - let response_map = res.body.group_by(path); - - // For each request and insert its corresponding value - for dl_req in dl_requests.iter() { - let url = dl_req.url(); - let query_set: HashMap<_, _> = url.query_pairs().collect(); - let id = query_set.get(query_name).ok_or(anyhow::anyhow!( - "Unable to find key {} in query params", - query_name - ))?; - - // Clone the response and set the body - let body = (self.body)(&response_map, id); - let res = res.clone().body(body); - - hashmap.insert(dl_req.clone(), res); + if let Some(base_dl_request) = dl_requests.first().as_mut() { + let base_request = if base_dl_request.method() == http::Method::GET { + QueryBatching::new( + &dl_requests.iter().skip(1).collect::>(), + Some(group_by.key()), + ) + .transform(base_dl_request.to_request()) + .to_result() + .map_err(|e| anyhow::anyhow!(e))? + } else { + QueryBatching::new(&dl_requests.iter().skip(1).collect::>(), None) + .pipe(BodyBatching::new(&dl_requests.iter().collect::>())) + .transform(base_dl_request.to_request()) + .to_result() + .map_err(|e| anyhow::anyhow!(e))? + }; + + // Dispatch request + let res = self + .runtime + .http + .execute(base_request) + .await? + .to_json::()?; + + // Create a response HashMap + #[allow(clippy::mutable_key_type)] + let mut hashmap = HashMap::with_capacity(dl_requests.len()); + + // Parse the response body and group it by batchKey + let path = &group_by.path(); + + // ResponseMap contains the response body grouped by the batchKey + let response_map = res.body.group_by(path); + + // depending on graphql type, it will extract the data out of the response. + let data_extractor = if self.is_list { + get_body_value_list + } else { + get_body_value_single + }; + + // For each request and insert its corresponding value + if base_dl_request.method() == reqwest::Method::GET { + for dl_req in dl_requests.iter() { + let url = dl_req.url(); + let query_set: HashMap<_, _> = url.query_pairs().collect(); + let id = query_set.get(query_name).ok_or(anyhow::anyhow!( + "Unable to find key {} in query params", + query_name + ))?; + + // Clone the response and set the body + let body = data_extractor(&response_map, id); + let res = res.clone().body(body); + + hashmap.insert(dl_req.clone(), res); + } + } else { + for dl_req in dl_requests.into_iter() { + let body_key = dl_req.batching_value().ok_or(anyhow::anyhow!( + "Unable to find batching value in the body for data loader request {}", + dl_req.url().as_str() + ))?; + let extracted_value = data_extractor(&response_map, body_key); + let res = res.clone().body(extracted_value); + hashmap.insert(dl_req.clone(), res); + } + } + + Ok(hashmap) + } else { + let error_message = "This is definitely a bug in http data loaders, please report it to the maintainers."; + Err(anyhow::anyhow!(error_message).into()) } - - Ok(hashmap) } else { let results = keys.iter().map(|key| async { let result = self.runtime.http.execute(key.to_request()).await; @@ -133,7 +154,7 @@ impl Loader for HttpDataLoader { let results = join_all(results).await; #[allow(clippy::mutable_key_type)] - let mut hashmap = HashMap::new(); + let mut hashmap = HashMap::with_capacity(results.len()); for (key, value) in results { hashmap.insert(key, value?.to_json()?); } diff --git a/src/core/http/data_loader_request.rs b/src/core/http/data_loader_request.rs index b386f8daea..4631fdd8a0 100644 --- a/src/core/http/data_loader_request.rs +++ b/src/core/http/data_loader_request.rs @@ -5,34 +5,48 @@ use std::ops::Deref; use tailcall_hasher::TailcallHasher; #[derive(Debug)] -pub struct DataLoaderRequest(reqwest::Request, BTreeSet); +pub struct DataLoaderRequest { + request: reqwest::Request, + headers: BTreeSet, + /// used for request body batching. + batching_value: Option, +} impl DataLoaderRequest { pub fn new(req: reqwest::Request, headers: BTreeSet) -> Self { // TODO: req should already have headers builtin, no? - DataLoaderRequest(req, headers) + Self { request: req, headers, batching_value: None } + } + + pub fn with_batching_value(self, body: Option) -> Self { + Self { batching_value: body, ..self } } + + pub fn batching_value(&self) -> Option<&String> { + self.batching_value.as_ref() + } + pub fn to_request(&self) -> reqwest::Request { // TODO: excessive clone for the whole structure instead of cloning only part of // it check if we really need to clone anything at all or just pass // references? - self.clone().0 + self.clone().request } pub fn headers(&self) -> &BTreeSet { - &self.1 + &self.headers } } impl Hash for DataLoaderRequest { fn hash(&self, state: &mut H) { - self.0.url().hash(state); + self.request.url().hash(state); // use body in hash for graphql queries with query operation as they used to // fetch data while http post and graphql mutation should not be loaded // through dataloader at all! - if let Some(body) = self.0.body() { + if let Some(body) = self.request.body() { body.as_bytes().hash(state); } - for name in &self.1 { - if let Some(value) = self.0.headers().get(name) { + for name in &self.headers { + if let Some(value) = self.request.headers().get(name) { name.hash(state); value.hash(state); } @@ -58,13 +72,15 @@ impl Eq for DataLoaderRequest {} impl Clone for DataLoaderRequest { fn clone(&self) -> Self { - let req = self.0.try_clone().unwrap_or_else(|| { - let mut req = reqwest::Request::new(self.0.method().clone(), self.0.url().clone()); - req.headers_mut().extend(self.0.headers().clone()); + let req = self.request.try_clone().unwrap_or_else(|| { + let mut req = + reqwest::Request::new(self.request.method().clone(), self.request.url().clone()); + req.headers_mut().extend(self.request.headers().clone()); req }); - DataLoaderRequest(req, self.1.clone()) + DataLoaderRequest::new(req, self.headers.clone()) + .with_batching_value(self.batching_value.clone()) } } @@ -72,7 +88,7 @@ impl Deref for DataLoaderRequest { type Target = reqwest::Request; fn deref(&self) -> &Self::Target { - &self.0 + &self.request } } diff --git a/src/core/http/mod.rs b/src/core/http/mod.rs index 499e47de6f..816f0fcb70 100644 --- a/src/core/http/mod.rs +++ b/src/core/http/mod.rs @@ -20,6 +20,7 @@ mod request_template; mod response; pub mod showcase; mod telemetry; +mod transformations; pub static TAILCALL_HTTPS_ORIGIN: HeaderValue = HeaderValue::from_static("https://tailcall.run"); pub static TAILCALL_HTTP_ORIGIN: HeaderValue = HeaderValue::from_static("http://tailcall.run"); diff --git a/src/core/http/request_template.rs b/src/core/http/request_template.rs index 748cd395a1..1c57f140db 100644 --- a/src/core/http/request_template.rs +++ b/src/core/http/request_template.rs @@ -12,6 +12,7 @@ use crate::core::endpoint::Endpoint; use crate::core::has_headers::HasHeaders; use crate::core::helpers::headers::MustacheHeaders; use crate::core::ir::model::{CacheKey, IoId}; +use crate::core::ir::DynamicRequest; use crate::core::mustache::{Eval, Mustache, Segment}; use crate::core::path::{PathString, PathValue, ValueString}; @@ -91,7 +92,7 @@ impl RequestTemplate { /// Returns true if there are not templates pub fn is_const(&self) -> bool { self.root_url.is_const() - && self.body_path.as_ref().map_or(true, Mustache::is_const) + && self.body_path.as_ref().map_or(true, |b| b.is_const()) && self.query.iter().all(|query| query.value.is_const()) && self.headers.iter().all(|(_, v)| v.is_const()) } @@ -113,15 +114,12 @@ impl RequestTemplate { pub fn to_request( &self, ctx: &C, - ) -> anyhow::Result { - // Create url + ) -> anyhow::Result> { let url = self.create_url(ctx)?; let method = self.method.clone(); - let mut req = reqwest::Request::new(method, url); - req = self.set_headers(req, ctx); - req = self.set_body(req, ctx)?; - - Ok(req) + let req = reqwest::Request::new(method, url); + let req = self.set_headers(req, ctx); + self.set_body(req, ctx) } /// Sets the body for the request @@ -129,26 +127,32 @@ impl RequestTemplate { &self, mut req: reqwest::Request, ctx: &C, - ) -> anyhow::Result { - if let Some(body_path) = &self.body_path { + ) -> anyhow::Result> { + let batching_value = if let Some(body_path) = &self.body_path { match &self.encoding { Encoding::ApplicationJson => { - req.body_mut().replace(body_path.render(ctx).into()); + let (body, batching_value) = + ExpressionValueEval::default().eval(body_path, ctx); + req.body_mut().replace(body.into()); + batching_value } Encoding::ApplicationXWwwFormUrlencoded => { // TODO: this is a performance bottleneck // We first encode everything to string and then back to form-urlencoded - let body: String = body_path.render(ctx); + let body = body_path.render(ctx); let form_data = match serde_json::from_str::(&body) { Ok(deserialized_data) => serde_urlencoded::to_string(deserialized_data)?, Err(_) => body, }; req.body_mut().replace(form_data.into()); + None } } - } - Ok(req) + } else { + None + }; + Ok(DynamicRequest::new(req).with_batching_value(batching_value)) } /// Sets the headers for the request @@ -231,7 +235,7 @@ impl TryFrom for RequestTemplate { let body = endpoint .body .as_ref() - .map(|body| Mustache::parse(body.as_str())); + .map(|b| Mustache::parse(&b.to_string())); let encoding = endpoint.encoding.clone(); Ok(Self { @@ -302,6 +306,50 @@ impl<'a, A: PathValue> Eval<'a> for ValueStringEval { } } +struct ExpressionValueEval(std::marker::PhantomData); +impl Default for ExpressionValueEval { + fn default() -> Self { + Self(std::marker::PhantomData) + } +} + +impl<'a, A: PathString> Eval<'a> for ExpressionValueEval { + type In = A; + type Out = (String, Option); + + fn eval(&self, mustache: &Mustache, in_value: &'a Self::In) -> Self::Out { + let mut result = String::new(); + // This evaluator returns a tuple of (evaluated_string, body_key) where: + // 1. evaluated_string: The fully rendered template string + // 2. body_key: The value of the first expression found in the template + // + // This implementation is a critical optimization for request batching: + // - During batching, we need to extract individual request values from the + // batch response and map them back to their original requests + // - Instead of parsing the body JSON multiple times, we extract the key during + // initial template evaluation + // - Since we enforce that batch requests can only contain one expression in + // their body, this key uniquely identifies each request + // - This approach eliminates the need for repeated JSON parsing/serialization + // during the batching process, significantly improving performance + let mut first_expression_value = None; + for segment in mustache.segments().iter() { + match segment { + Segment::Literal(text) => result.push_str(text), + Segment::Expression(parts) => { + if let Some(value) = in_value.path_string(parts) { + result.push_str(value.as_ref()); + if first_expression_value.is_none() { + first_expression_value = Some(value.into_owned()); + } + } + } + } + } + (result, first_expression_value) + } +} + #[cfg(test)] mod tests { use std::borrow::Cow; @@ -361,6 +409,7 @@ mod tests { ) -> anyhow::Result { let body = self .to_request(ctx)? + .into_request() .body() .and_then(|a| a.as_bytes()) .map(|a| a.to_vec()) @@ -398,8 +447,8 @@ mod tests { } })); - let req = tmpl.to_request(&ctx).unwrap(); - + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.url().to_string(), "http://localhost:3000/?baz=1&baz=2&baz=3&foo=12" @@ -410,7 +459,8 @@ mod tests { fn test_url() { let tmpl = RequestTemplate::new("http://localhost:3000/").unwrap(); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.url().to_string(), "http://localhost:3000/"); } @@ -418,7 +468,8 @@ mod tests { fn test_url_path() { let tmpl = RequestTemplate::new("http://localhost:3000/foo/bar").unwrap(); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.url().to_string(), "http://localhost:3000/foo/bar"); } @@ -431,7 +482,8 @@ mod tests { } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.url().to_string(), "http://localhost:3000/foo/bar"); } @@ -446,7 +498,9 @@ mod tests { "booz": 1 } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); + assert_eq!( req.url().to_string(), "http://localhost:3000/foo/bar/boozes/1" @@ -472,11 +526,15 @@ mod tests { skip_empty: false, }, ]; + let tmpl = RequestTemplate::new("http://localhost:3000") .unwrap() .query(query); + let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); + assert_eq!( req.url().to_string(), "http://localhost:3000/?foo=0&bar=1&baz=2" @@ -513,7 +571,8 @@ mod tests { "id": 2 } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.url().to_string(), "http://localhost:3000/?foo=0&bar=1&baz=2" @@ -531,7 +590,8 @@ mod tests { .unwrap() .headers(headers); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.headers().get("foo").unwrap(), "foo"); assert_eq!(req.headers().get("bar").unwrap(), "bar"); assert_eq!(req.headers().get("baz").unwrap(), "baz"); @@ -561,7 +621,8 @@ mod tests { "id": 2 } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.headers().get("foo").unwrap(), "0"); assert_eq!(req.headers().get("bar").unwrap(), "1"); assert_eq!(req.headers().get("baz").unwrap(), "2"); @@ -574,7 +635,8 @@ mod tests { .method(reqwest::Method::POST) .encoding(crate::core::config::Encoding::ApplicationJson); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.headers().get("Content-Type").unwrap(), "application/json" @@ -588,7 +650,8 @@ mod tests { .method(reqwest::Method::POST) .encoding(crate::core::config::Encoding::ApplicationXWwwFormUrlencoded); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.headers().get("Content-Type").unwrap(), "application/x-www-form-urlencoded" @@ -601,7 +664,8 @@ mod tests { .unwrap() .method(reqwest::Method::POST); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.method(), reqwest::Method::POST); } @@ -662,11 +726,12 @@ mod tests { .body(Some("foo".into())); let tmpl = RequestTemplate::try_from(endpoint).unwrap(); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let req_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = req_wrapper.request(); assert_eq!(req.method(), reqwest::Method::POST); assert_eq!(req.headers().get("foo").unwrap(), "bar"); let body = req.body().unwrap().as_bytes().unwrap().to_owned(); - assert_eq!(body, "foo".as_bytes()); + assert_eq!(body, "\"foo\"".as_bytes()); assert_eq!(req.url().to_string(), "http://localhost:3000/"); } @@ -688,7 +753,8 @@ mod tests { "header": "abc" } })); - let req = tmpl.to_request(&ctx).unwrap(); + let req_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = req_wrapper.request(); assert_eq!(req.method(), reqwest::Method::POST); assert_eq!(req.headers().get("foo").unwrap(), "abc"); let body = req.body().unwrap().as_bytes().unwrap().to_owned(); @@ -703,7 +769,8 @@ mod tests { ); let tmpl = RequestTemplate::try_from(endpoint).unwrap(); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.url().to_string(), "http://localhost:3000/"); } @@ -718,7 +785,8 @@ mod tests { ]); let tmpl = RequestTemplate::try_from(endpoint).unwrap(); let ctx = Context::default(); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.url().to_string(), "http://localhost:3000/?q=1&b=1&c"); } @@ -734,7 +802,8 @@ mod tests { "d": "bar" } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.url().to_string(), "http://localhost:3000/foo?b=foo&d=bar" @@ -758,7 +827,8 @@ mod tests { "d": "bar", } })); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!( req.url().to_string(), "http://localhost:3000/foo?b=foo&d=bar&f=baz&e" @@ -773,7 +843,8 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("baz", "qux".parse().unwrap()); let ctx = Context::default().headers(headers); - let req = tmpl.to_request(&ctx).unwrap(); + let request_wrapper = tmpl.to_request(&ctx).unwrap(); + let req = request_wrapper.request(); assert_eq!(req.headers().get("baz").unwrap(), "qux"); } } @@ -790,7 +861,8 @@ mod tests { let tmpl = RequestTemplate::form_encoded_url("http://localhost:3000") .unwrap() .body_path(Some(Mustache::parse("{{foo.bar}}"))); - let ctx = Context::default().value(json!({"foo": {"bar": "baz"}})); + let ctx = Context::default().value(json!({"foo": {"bar": + "baz"}})); let request_body = tmpl.to_body(&ctx); let body = request_body.unwrap(); assert_eq!(body, "baz"); diff --git a/src/core/http/transformations/body_batching.rs b/src/core/http/transformations/body_batching.rs new file mode 100644 index 0000000000..173b776546 --- /dev/null +++ b/src/core/http/transformations/body_batching.rs @@ -0,0 +1,248 @@ +use std::convert::Infallible; + +use reqwest::Request; +use tailcall_valid::Valid; + +use crate::core::http::DataLoaderRequest; +use crate::core::Transform; + +pub struct BodyBatching<'a> { + dl_requests: &'a [&'a DataLoaderRequest], +} + +impl<'a> BodyBatching<'a> { + pub fn new(dl_requests: &'a [&'a DataLoaderRequest]) -> Self { + BodyBatching { dl_requests } + } +} + +impl Transform for BodyBatching<'_> { + type Value = Request; + type Error = Infallible; + + // This function is used to batch the body of the requests. + // working of this function is as follows: + // 1. It takes the list of requests and extracts the body from each request. + // 2. It then clubs all the extracted bodies into list format. like [body1, + // body2, body3] + // 3. It does this all manually to avoid extra serialization cost. + fn transform(&self, mut base_request: Self::Value) -> Valid { + let mut request_bodies = Vec::with_capacity(self.dl_requests.len()); + + for req in self.dl_requests { + if let Some(body) = req.body().and_then(|b| b.as_bytes()) { + request_bodies.push(body); + } + } + + if !request_bodies.is_empty() { + if cfg!(debug_assertions) { + // sort the body to make it consistent for testing env. + request_bodies.sort(); + } + + // construct serialization manually. + let merged_body = request_bodies.iter().fold( + Vec::with_capacity( + request_bodies.iter().map(|i| i.len()).sum::() + request_bodies.len(), + ), + |mut acc, item| { + if !acc.is_empty() { + // add ',' to separate the body from each other. + acc.extend_from_slice(b","); + } + acc.extend_from_slice(item); + acc + }, + ); + + // add list brackets to the serialized body. + let mut serialized_body = Vec::with_capacity(merged_body.len() + 2); + serialized_body.extend_from_slice(b"["); + serialized_body.extend_from_slice(&merged_body); + serialized_body.extend_from_slice(b"]"); + base_request.body_mut().replace(serialized_body.into()); + } + + Valid::succeed(base_request) + } +} + +#[cfg(test)] +mod tests { + use http::Method; + use reqwest::Request; + use serde_json::json; + use tailcall_valid::Validator; + + use super::*; + use crate::core::http::DataLoaderRequest; + + fn create_request(body: Option) -> DataLoaderRequest { + let mut request = create_base_request(); + if let Some(body) = body { + let bytes_body = serde_json::to_vec(&body).unwrap(); + request.body_mut().replace(reqwest::Body::from(bytes_body)); + } + + DataLoaderRequest::new(request, Default::default()) + } + + fn create_base_request() -> Request { + Request::new(Method::POST, "http://example.com".parse().unwrap()) + } + + #[test] + fn test_empty_requests() { + let requests: Vec<&DataLoaderRequest> = vec![]; + let base_request = create_base_request(); + + let result = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + assert!(result.body().is_none()); + } + + #[test] + fn test_single_request() { + let req = create_request(Some(json!({"id": 1}))); + let requests = vec![&req]; + let base_request = create_base_request(); + + let request = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + let bytes = request + .body() + .and_then(|b| b.as_bytes()) + .unwrap_or_default(); + let body_str = String::from_utf8(bytes.to_vec()).unwrap(); + assert_eq!(body_str, r#"[{"id":1}]"#); + } + + #[test] + fn test_multiple_requests() { + let req1 = create_request(Some(json!({"id": 1}))); + let req2 = create_request(Some(json!({"id": 2}))); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + let body = result.body().and_then(|b| b.as_bytes()).unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, r#"[{"id":1},{"id":2}]"#); + } + + #[test] + fn test_requests_with_empty_bodies() { + let req1 = create_request(Some(json!({"id": 1}))); + let req2 = create_request(None); + let req3 = create_request(Some(json!({"id": 3}))); + let requests = vec![&req1, &req2, &req3]; + let base_request = create_base_request(); + + let result = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + let body_bytes = result + .body() + .and_then(|b| b.as_bytes()) + .expect("Body should be present"); + let parsed: Vec = serde_json::from_slice(body_bytes).unwrap(); + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["id"], 1); + assert_eq!(parsed[1]["id"], 3); + } + + #[test] + #[cfg(test)] + fn test_body_sorting_in_test_env() { + let req1 = create_request(Some(json!({ + "id": 2, + "value": "second" + }))); + let req2 = create_request(Some(json!({ + "id": 1, + "value": "first" + }))); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + let body_bytes = result + .body() + .and_then(|b| b.as_bytes()) + .expect("Body should be present"); + let parsed: Vec = serde_json::from_slice(body_bytes).unwrap(); + + // Verify sorting by comparing the serialized form + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["id"], 1); + assert_eq!(parsed[0]["value"], "first"); + assert_eq!(parsed[1]["id"], 2); + assert_eq!(parsed[1]["value"], "second"); + } + + #[test] + fn test_complex_json_bodies() { + let req1 = create_request(Some(json!({ + "id": 1, + "nested": { + "array": [1, 2, 3], + "object": {"key": "value"} + }, + "tags": ["a", "b", "c"] + }))); + let req2 = create_request(Some(json!({ + "id": 2, + "nested": { + "array": [4, 5, 6], + "object": {"key": "another"} + }, + "tags": ["x", "y", "z"] + }))); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = BodyBatching::new(&requests) + .transform(base_request) + .to_result() + .unwrap(); + + let body_bytes = result + .body() + .and_then(|b| b.as_bytes()) + .expect("Body should be present"); + let parsed: Vec = serde_json::from_slice(body_bytes).unwrap(); + + // Verify structure and content of both objects + assert_eq!(parsed.len(), 2); + + // First object + assert_eq!(parsed[0]["id"], 1); + assert_eq!(parsed[0]["nested"]["array"], json!([1, 2, 3])); + assert_eq!(parsed[0]["nested"]["object"]["key"], "value"); + assert_eq!(parsed[0]["tags"], json!(["a", "b", "c"])); + + // Second object + assert_eq!(parsed[1]["id"], 2); + assert_eq!(parsed[1]["nested"]["array"], json!([4, 5, 6])); + assert_eq!(parsed[1]["nested"]["object"]["key"], "another"); + assert_eq!(parsed[1]["tags"], json!(["x", "y", "z"])); + } +} diff --git a/src/core/http/transformations/mod.rs b/src/core/http/transformations/mod.rs new file mode 100644 index 0000000000..b6ab71810c --- /dev/null +++ b/src/core/http/transformations/mod.rs @@ -0,0 +1,5 @@ +mod body_batching; +mod query_batching; + +pub use body_batching::BodyBatching; +pub use query_batching::QueryBatching; diff --git a/src/core/http/transformations/query_batching.rs b/src/core/http/transformations/query_batching.rs new file mode 100644 index 0000000000..1612608388 --- /dev/null +++ b/src/core/http/transformations/query_batching.rs @@ -0,0 +1,200 @@ +use std::convert::Infallible; + +use reqwest::Request; +use tailcall_valid::Valid; + +use crate::core::http::DataLoaderRequest; +use crate::core::Transform; + +pub struct QueryBatching<'a> { + dl_requests: &'a [&'a DataLoaderRequest], + group_by: Option<&'a str>, +} + +impl<'a> QueryBatching<'a> { + pub fn new(dl_requests: &'a [&'a DataLoaderRequest], group_by: Option<&'a str>) -> Self { + QueryBatching { dl_requests, group_by } + } +} + +impl Transform for QueryBatching<'_> { + type Value = Request; + type Error = Infallible; + fn transform(&self, mut base_request: Self::Value) -> Valid { + // Merge query params in the request + for key in self.dl_requests.iter() { + let request = key.to_request(); + let url = request.url(); + let pairs: Vec<_> = if let Some(group_by_key) = self.group_by { + url.query_pairs() + .filter(|(key, _)| group_by_key.eq(&key.to_string())) + .collect() + } else { + url.query_pairs().collect() + }; + + if !pairs.is_empty() { + // if pair's are empty then don't extend the query params else it ends + // up appending '?' to the url. + base_request.url_mut().query_pairs_mut().extend_pairs(pairs); + } + } + Valid::succeed(base_request) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use http::Method; + use reqwest::Url; + use tailcall_valid::Validator; + + use super::*; + + fn create_base_request() -> Request { + Request::new(Method::GET, "http://example.com".parse().unwrap()) + } + + fn create_request_with_params(params: &[(&str, &str)]) -> DataLoaderRequest { + let mut url = Url::parse("http://example.com").unwrap(); + { + let mut query_pairs = url.query_pairs_mut(); + for (key, value) in params { + query_pairs.append_pair(key, value); + } + } + let request = Request::new(Method::GET, url); + DataLoaderRequest::new(request, Default::default()) + } + + fn get_query_params(request: &Request) -> HashMap { + request + .url() + .query_pairs() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn test_empty_requests() { + let requests: Vec<&DataLoaderRequest> = vec![]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, None) + .transform(base_request) + .to_result() + .unwrap(); + + assert!(result.url().query().is_none()); + } + + #[test] + fn test_single_request_no_grouping() { + let req = create_request_with_params(&[("id", "1"), ("name", "test")]); + let requests = vec![&req]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, None) + .transform(base_request) + .to_result() + .unwrap(); + + let params = get_query_params(&result); + assert_eq!(params.len(), 2); + assert_eq!(params.get("id").unwrap(), "1"); + assert_eq!(params.get("name").unwrap(), "test"); + } + + #[test] + fn test_multiple_requests_with_grouping() { + let req1 = create_request_with_params(&[("user_id", "1"), ("extra", "data1")]); + let req2 = create_request_with_params(&[("user_id", "2"), ("extra", "data2")]); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, Some("user_id")) + .transform(base_request) + .to_result() + .unwrap(); + + let params = get_query_params(&result); + assert!(params.contains_key("user_id")); + assert!(!params.contains_key("extra")); + + // URL should contain both user_ids + let url = result.url().to_string(); + assert!(url.contains("user_id=1")); + assert!(url.contains("user_id=2")); + } + + #[test] + fn test_multiple_requests_no_grouping() { + let req1 = create_request_with_params(&[("param1", "value1"), ("shared", "a")]); + let req2 = create_request_with_params(&[("param2", "value2"), ("shared", "b")]); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, None) + .transform(base_request) + .to_result() + .unwrap(); + + let params = get_query_params(&result); + assert_eq!(params.get("param1").unwrap(), "value1"); + assert_eq!(params.get("param2").unwrap(), "value2"); + assert_eq!(params.get("shared").unwrap(), "b"); + } + + #[test] + fn test_requests_with_empty_params() { + let req1 = create_request_with_params(&[("id", "1")]); + let req2 = create_request_with_params(&[]); + let req3 = create_request_with_params(&[("id", "3")]); + let requests = vec![&req1, &req2, &req3]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, Some("id")) + .transform(base_request) + .to_result() + .unwrap(); + + let url = result.url().to_string(); + assert!(url.contains("id=1")); + assert!(url.contains("id=3")); + } + + #[test] + fn test_special_characters() { + let req1 = create_request_with_params(&[("query", "hello world"), ("tag", "a+b")]); + let req2 = create_request_with_params(&[("query", "foo&bar"), ("tag", "c%20d")]); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, None) + .transform(base_request) + .to_result() + .unwrap(); + + let params = get_query_params(&result); + // Verify URL encoding is preserved + assert!(params.values().any(|v| v.contains(" ") || v.contains("&"))); + } + + #[test] + fn test_group_by_with_missing_key() { + let req1 = create_request_with_params(&[("id", "1"), ("data", "test")]); + let req2 = create_request_with_params(&[("other", "2"), ("data", "test2")]); + let requests = vec![&req1, &req2]; + let base_request = create_base_request(); + + let result = QueryBatching::new(&requests, Some("missing_key")) + .transform(base_request) + .to_result() + .unwrap(); + + // Should have no query parameters since grouped key doesn't exist + assert!(result.url().query().is_none()); + } +} diff --git a/src/core/ir/eval_http.rs b/src/core/ir/eval_http.rs index f196b7d017..d863472fbd 100644 --- a/src/core/ir/eval_http.rs +++ b/src/core/ir/eval_http.rs @@ -5,6 +5,7 @@ use reqwest::Request; use tailcall_valid::Validator; use super::model::DataLoaderId; +use super::request::DynamicRequest; use super::{EvalContext, ResolverContextLike}; use crate::core::data_loader::{DataLoader, Loader}; use crate::core::grpc::protobuf::ProtobufOperation; @@ -68,15 +69,18 @@ impl<'a, 'ctx, Context: ResolverContextLike + Sync> EvalHttp<'a, 'ctx, Context> Self { evaluation_ctx, data_loader, request_template } } - pub fn init_request(&self) -> Result { - Ok(self.request_template.to_request(self.evaluation_ctx)?) + pub fn init_request(&self) -> Result, Error> { + let inner = self.request_template.to_request(self.evaluation_ctx)?; + Ok(inner) } - pub async fn execute(&self, req: Request) -> Result, Error> { + pub async fn execute( + &self, + req: DynamicRequest, + ) -> Result, Error> { let ctx = &self.evaluation_ctx; - let is_get = req.method() == reqwest::Method::GET; let dl = &self.data_loader; - let response = if is_get && dl.is_some() { + let response = if dl.is_some() { execute_request_with_dl(ctx, req, self.data_loader).await? } else { execute_raw_request(ctx, req).await? @@ -99,7 +103,7 @@ impl<'a, 'ctx, Context: ResolverContextLike + Sync> EvalHttp<'a, 'ctx, Context> #[async_recursion::async_recursion] pub async fn execute_with_worker<'worker: 'async_recursion>( &self, - mut request: reqwest::Request, + mut request: DynamicRequest, worker_ctx: WorkerContext<'worker>, ) -> Result, Error> { // extract variables from the worker context. @@ -107,10 +111,10 @@ impl<'a, 'ctx, Context: ResolverContextLike + Sync> EvalHttp<'a, 'ctx, Context> let worker = worker_ctx.worker; let js_worker = worker_ctx.js_worker; - let response = match js_hooks.on_request(worker, &request).await? { + let response = match js_hooks.on_request(worker, request.request()).await? { Some(command) => match command { worker::Command::Request(w_request) => { - let response = self.execute(w_request.into()).await?; + let response = self.execute(w_request.try_into()?).await?; Ok(response) } worker::Command::Response(w_response) => { @@ -119,6 +123,7 @@ impl<'a, 'ctx, Context: ResolverContextLike + Sync> EvalHttp<'a, 'ctx, Context> && w_response.headers().contains_key("location") { request + .request_mut() .url_mut() .set_path(w_response.headers()["location"].as_str()); self.execute_with_worker(request, worker_ctx).await @@ -145,7 +150,7 @@ pub async fn execute_request_with_dl< Dl: Loader, Error = Arc>, >( ctx: &EvalContext<'ctx, Ctx>, - req: Request, + req: DynamicRequest, data_loader: Option<&DataLoader>, ) -> Result, Error> { let headers = ctx @@ -155,7 +160,10 @@ pub async fn execute_request_with_dl< .clone() .map(|s| s.headers) .unwrap_or_default(); - let endpoint_key = crate::core::http::DataLoaderRequest::new(req, headers); + + let (req, batching_value) = req.into_parts(); + let endpoint_key = + crate::core::http::DataLoaderRequest::new(req, headers).with_batching_value(batching_value); Ok(data_loader .unwrap() @@ -203,13 +211,13 @@ fn set_cookie_headers( pub async fn execute_raw_request( ctx: &EvalContext<'_, Ctx>, - req: Request, + req: DynamicRequest, ) -> Result, Error> { let response = ctx .request_ctx .runtime .http - .execute(req) + .execute(req.into_request()) .await .map_err(Error::from)? .to_json()?; diff --git a/src/core/ir/eval_io.rs b/src/core/ir/eval_io.rs index cc8f27e7c3..f9ef59b3da 100644 --- a/src/core/ir/eval_io.rs +++ b/src/core/ir/eval_io.rs @@ -5,7 +5,7 @@ use super::eval_http::{ execute_request_with_dl, parse_graphql_response, set_headers, EvalHttp, WorkerContext, }; use super::model::{CacheKey, IO}; -use super::{EvalContext, ResolverContextLike}; +use super::{DynamicRequest, EvalContext, ResolverContextLike}; use crate::core::config::GraphQLOperationType; use crate::core::data_loader::DataLoader; use crate::core::graphql::GraphqlDataLoader; @@ -62,15 +62,15 @@ where } IO::GraphQL { req_template, field_name, dl_id, .. } => { let req = req_template.to_request(ctx)?; - + let request = DynamicRequest::new(req); let res = if ctx.request_ctx.upstream.batch.is_some() && matches!(req_template.operation_type, GraphQLOperationType::Query) { let data_loader: Option<&DataLoader> = dl_id.and_then(|dl| ctx.request_ctx.gql_data_loaders.get(dl.as_usize())); - execute_request_with_dl(ctx, req, data_loader).await? + execute_request_with_dl(ctx, request, data_loader).await? } else { - execute_raw_request(ctx, req).await? + execute_raw_request(ctx, request).await? }; set_headers(ctx, &res); diff --git a/src/core/ir/mod.rs b/src/core/ir/mod.rs index 4540424848..99008c585b 100644 --- a/src/core/ir/mod.rs +++ b/src/core/ir/mod.rs @@ -4,6 +4,7 @@ mod eval; mod eval_context; mod eval_http; mod eval_io; +mod request; mod resolver_context_like; pub mod model; @@ -13,6 +14,7 @@ use std::ops::Deref; pub use discriminator::*; pub use error::*; pub use eval_context::EvalContext; +pub(crate) use request::DynamicRequest; pub use resolver_context_like::{ EmptyResolverContext, ResolverContext, ResolverContextLike, SelectionField, }; diff --git a/src/core/ir/request.rs b/src/core/ir/request.rs new file mode 100644 index 0000000000..7d5334f360 --- /dev/null +++ b/src/core/ir/request.rs @@ -0,0 +1,40 @@ +/// Holds necessary information for request execution. +pub struct DynamicRequest { + request: reqwest::Request, + /// used for request body batching. + batching_value: Option, +} + +impl DynamicRequest { + pub fn new(request: reqwest::Request) -> Self { + Self { request, batching_value: None } + } + + pub fn with_batching_value(self, body_key: Option) -> Self { + Self { batching_value: body_key, ..self } + } + + pub fn request(&self) -> &reqwest::Request { + &self.request + } + + pub fn request_mut(&mut self) -> &mut reqwest::Request { + &mut self.request + } + + pub fn body_key(&self) -> Option<&Value> { + self.batching_value.as_ref() + } + + pub fn into_request(self) -> reqwest::Request { + self.request + } + + pub fn into_body_key(self) -> Option { + self.batching_value + } + + pub fn into_parts(self) -> (reqwest::Request, Option) { + (self.request, self.batching_value) + } +} diff --git a/src/core/json/borrow.rs b/src/core/json/borrow.rs index ca93926b4f..60edafcc77 100644 --- a/src/core/json/borrow.rs +++ b/src/core/json/borrow.rs @@ -35,6 +35,14 @@ impl<'ctx> JsonObjectLike<'ctx> for ObjectAsVec<'ctx> { { self.iter() } + + fn len(&self) -> usize { + self.len() + } + + fn is_empty(&self) -> bool { + self.is_empty() + } } impl<'ctx> JsonLike<'ctx> for Value<'ctx> { diff --git a/src/core/json/graphql.rs b/src/core/json/graphql.rs index cb44568d8b..5491625461 100644 --- a/src/core/json/graphql.rs +++ b/src/core/json/graphql.rs @@ -37,6 +37,14 @@ impl<'obj, Value: JsonLike<'obj>> JsonObjectLike<'obj> for IndexMap { self.iter().map(|(k, v)| (k.as_str(), v)) } + + fn len(&self) -> usize { + self.len() + } + + fn is_empty(&self) -> bool { + self.is_empty() + } } impl<'json> JsonLike<'json> for ConstValue { diff --git a/src/core/json/json_like.rs b/src/core/json/json_like.rs index 3a346b576f..364bee32d6 100644 --- a/src/core/json/json_like.rs +++ b/src/core/json/json_like.rs @@ -27,7 +27,7 @@ pub trait JsonLike<'json>: Sized { T::JsonObject: JsonObjectLike<'json, Value = T>, { if let Some(obj) = other.as_object() { - let mut fields = Vec::new(); + let mut fields = Vec::with_capacity(obj.len()); for (k, v) in obj.iter() { fields.push((k, Self::clone_from(v))); } @@ -67,6 +67,8 @@ pub trait JsonLike<'json>: Sized { pub trait JsonObjectLike<'obj>: Sized { type Value; fn new() -> Self; + fn is_empty(&self) -> bool; + fn len(&self) -> usize; fn with_capacity(n: usize) -> Self; fn from_vec(v: Vec<(&'obj str, Self::Value)>) -> Self; fn get_key(&self, key: &str) -> Option<&Self::Value>; diff --git a/src/core/json/serde.rs b/src/core/json/serde.rs index 62beed0f45..b726b061a6 100644 --- a/src/core/json/serde.rs +++ b/src/core/json/serde.rs @@ -35,6 +35,14 @@ impl<'obj> JsonObjectLike<'obj> for serde_json::Map { { self.iter().map(|(k, v)| (k.as_str(), v)) } + + fn len(&self) -> usize { + self.len() + } + + fn is_empty(&self) -> bool { + self.is_empty() + } } impl<'json> JsonLike<'json> for Value { diff --git a/src/core/worker/worker.rs b/src/core/worker/worker.rs index 4e6ef46dde..9b9ddd124e 100644 --- a/src/core/worker/worker.rs +++ b/src/core/worker/worker.rs @@ -3,9 +3,11 @@ use std::fmt::Display; use hyper::body::Bytes; use reqwest::Request; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use super::error::{Error, Result}; +use crate::core::ir::DynamicRequest; use crate::core::{is_default, Response}; #[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq)] @@ -186,6 +188,14 @@ impl From for reqwest::Request { } } +impl TryFrom for DynamicRequest { + type Error = anyhow::Error; + + fn try_from(value: WorkerRequest) -> std::result::Result { + Ok(DynamicRequest::new(value.0)) + } +} + impl From<&reqwest::Url> for Uri { fn from(value: &reqwest::Url) -> Self { Self { diff --git a/tests/cli/snapshots/cli_spec__test__generator_spec__tests__cli__fixtures__generator__proto-connect-rpc.md.snap b/tests/cli/snapshots/cli_spec__test__generator_spec__tests__cli__fixtures__generator__proto-connect-rpc.md.snap index 23a4aba11f..b4581cc432 100644 --- a/tests/cli/snapshots/cli_spec__test__generator_spec__tests__cli__fixtures__generator__proto-connect-rpc.md.snap +++ b/tests/cli/snapshots/cli_spec__test__generator_spec__tests__cli__fixtures__generator__proto-connect-rpc.md.snap @@ -60,12 +60,12 @@ type News { } type Query { - GEN__news__NewsService__AddNews(news: GEN__news__NewsInput!): News @http(url: "http://localhost:50051/news.NewsService/AddNews", body: "\"{{.args.news}}\"", method: "POST") - GEN__news__NewsService__DeleteNews(newsId: Id!): Empty @http(url: "http://localhost:50051/news.NewsService/DeleteNews", body: "\"{{.args.newsId}}\"", method: "POST") - GEN__news__NewsService__EditNews(news: GEN__news__NewsInput!): News @http(url: "http://localhost:50051/news.NewsService/EditNews", body: "\"{{.args.news}}\"", method: "POST") - GEN__news__NewsService__GetAllNews: GEN__news__NewsList @http(url: "http://localhost:50051/news.NewsService/GetAllNews", body: "{}", method: "POST") - GEN__news__NewsService__GetMultipleNews(multipleNewsId: GEN__news__MultipleNewsId!): GEN__news__NewsList @http(url: "http://localhost:50051/news.NewsService/GetMultipleNews", body: "\"{{.args.multipleNewsId}}\"", method: "POST") - GEN__news__NewsService__GetNews(newsId: Id!): News @http(url: "http://localhost:50051/news.NewsService/GetNews", body: "\"{{.args.newsId}}\"", method: "POST") + GEN__news__NewsService__AddNews(news: GEN__news__NewsInput!): News @http(url: "http://localhost:50051/news.NewsService/AddNews", body: "{{.args.news}}", method: "POST") + GEN__news__NewsService__DeleteNews(newsId: Id!): Empty @http(url: "http://localhost:50051/news.NewsService/DeleteNews", body: "{{.args.newsId}}", method: "POST") + GEN__news__NewsService__EditNews(news: GEN__news__NewsInput!): News @http(url: "http://localhost:50051/news.NewsService/EditNews", body: "{{.args.news}}", method: "POST") + GEN__news__NewsService__GetAllNews: GEN__news__NewsList @http(url: "http://localhost:50051/news.NewsService/GetAllNews", body: {}, method: "POST") + GEN__news__NewsService__GetMultipleNews(multipleNewsId: GEN__news__MultipleNewsId!): GEN__news__NewsList @http(url: "http://localhost:50051/news.NewsService/GetMultipleNews", body: "{{.args.multipleNewsId}}", method: "POST") + GEN__news__NewsService__GetNews(newsId: Id!): News @http(url: "http://localhost:50051/news.NewsService/GetNews", body: "{{.args.newsId}}", method: "POST") users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") } diff --git a/tests/core/snapshots/batching-validation.md_error.snap b/tests/core/snapshots/batching-validation.md_error.snap new file mode 100644 index 0000000000..7c03000416 --- /dev/null +++ b/tests/core/snapshots/batching-validation.md_error.snap @@ -0,0 +1,45 @@ +--- +source: tests/core/spec.rs +expression: errors +--- +[ + { + "message": "batchKey requires either body or query parameters", + "trace": [ + "Query", + "posts", + "@http" + ], + "description": null + }, + { + "message": "Request body batching requires exactly one dynamic value in the body.", + "trace": [ + "Query", + "user", + "@http", + "body" + ], + "description": null + }, + { + "message": "Request body batching requires exactly one dynamic value in the body.", + "trace": [ + "Query", + "userWithId", + "@http", + "body" + ], + "description": null + }, + { + "message": "Request body batching requires exactly one dynamic value in the body.", + "trace": [ + "Query", + "userWithIdTest", + "@http", + "body" + ], + "description": null + } +] diff --git a/tests/core/snapshots/body-batching-cases.md_0.snap b/tests/core/snapshots/body-batching-cases.md_0.snap new file mode 100644 index 0000000000..cd99803cc2 --- /dev/null +++ b/tests/core/snapshots/body-batching-cases.md_0.snap @@ -0,0 +1,34 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "users": [ + { + "id": 1, + "name": "user-1", + "post": { + "id": 1, + "title": "user-1", + "userId": 1 + } + }, + { + "id": 2, + "name": "user-2", + "post": { + "id": 2, + "title": "user-2", + "userId": 2 + } + } + ] + } + } +} diff --git a/tests/core/snapshots/body-batching-cases.md_1.snap b/tests/core/snapshots/body-batching-cases.md_1.snap new file mode 100644 index 0000000000..c555390e10 --- /dev/null +++ b/tests/core/snapshots/body-batching-cases.md_1.snap @@ -0,0 +1,32 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "posts": [ + { + "id": 1, + "title": "user-1", + "user": { + "id": 1, + "name": "user-1" + } + }, + { + "id": 2, + "title": "user-2", + "user": { + "id": 2, + "name": "user-2" + } + } + ] + } + } +} diff --git a/tests/core/snapshots/body-batching-cases.md_2.snap b/tests/core/snapshots/body-batching-cases.md_2.snap new file mode 100644 index 0000000000..3957e2c1a4 --- /dev/null +++ b/tests/core/snapshots/body-batching-cases.md_2.snap @@ -0,0 +1,32 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "foo": [ + { + "a": 11, + "b": 12, + "bar": { + "a": 11, + "b": 12 + } + }, + { + "a": 21, + "b": 22, + "bar": { + "a": 21, + "b": 22 + } + } + ] + } + } +} diff --git a/tests/core/snapshots/body-batching-cases.md_client.snap b/tests/core/snapshots/body-batching-cases.md_client.snap new file mode 100644 index 0000000000..2c8ce93782 --- /dev/null +++ b/tests/core/snapshots/body-batching-cases.md_client.snap @@ -0,0 +1,40 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +type Bar { + a: Int + b: Int +} + +type Foo { + a: Int + b: Int + bar: Bar +} + +type Post { + body: String! + id: Int! + title: String! + user: User + userId: Int! +} + +type Query { + foo: [Foo] + posts: [Post] + user: User + users: [User] +} + +type User { + email: String! + id: Int! + name: String! + post: Post +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/body-batching-cases.md_merged.snap b/tests/core/snapshots/body-batching-cases.md_merged.snap new file mode 100644 index 0000000000..a546176af5 --- /dev/null +++ b/tests/core/snapshots/body-batching-cases.md_merged.snap @@ -0,0 +1,53 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(port: 8000) @upstream(batch: {delay: 1, headers: []}, httpCache: 42) { + query: Query +} + +type Bar { + a: Int + b: Int +} + +type Foo { + a: Int + b: Int + bar: Bar + @http(url: "http://jsonplaceholder.typicode.com/bar", body: {id: "{{.value.a}}"}, batchKey: ["a"], method: "POST") +} + +type Post { + body: String! + id: Int! + title: String! + user: User + @http( + url: "http://jsonplaceholder.typicode.com/users" + body: {key: "id", value: "{{.value.userId}}"} + batchKey: ["id"] + method: "POST" + ) + userId: Int! +} + +type Query { + foo: [Foo] @http(url: "http://jsonplaceholder.typicode.com/foo") + posts: [Post] @http(url: "http://jsonplaceholder.typicode.com/posts") + user: User @http(url: "http://jsonplaceholder.typicode.com/users/1") + users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") +} + +type User { + email: String! + id: Int! + name: String! + post: Post + @http( + url: "http://jsonplaceholder.typicode.com/posts" + body: {userId: "{{.value.id}}", title: "title", body: "body"} + batchKey: ["userId"] + method: "POST" + ) +} diff --git a/tests/core/snapshots/body-batching.md_0.snap b/tests/core/snapshots/body-batching.md_0.snap new file mode 100644 index 0000000000..ab8ac6b554 --- /dev/null +++ b/tests/core/snapshots/body-batching.md_0.snap @@ -0,0 +1,43 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "users": [ + { + "id": 1, + "posts": [ + { + "userId": 1, + "title": "foo" + } + ] + }, + { + "id": 2, + "posts": [ + { + "userId": 2, + "title": "foo" + } + ] + }, + { + "id": 3, + "posts": [ + { + "userId": 3, + "title": "foo" + } + ] + } + ] + } + } +} diff --git a/tests/core/snapshots/body-batching.md_1.snap b/tests/core/snapshots/body-batching.md_1.snap new file mode 100644 index 0000000000..ec1863ee43 --- /dev/null +++ b/tests/core/snapshots/body-batching.md_1.snap @@ -0,0 +1,40 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "users": [ + { + "id": 1, + "comments": [ + { + "id": 1 + } + ] + }, + { + "id": 2, + "comments": [ + { + "id": 2 + } + ] + }, + { + "id": 3, + "comments": [ + { + "id": 3 + } + ] + } + ] + } + } +} diff --git a/tests/core/snapshots/body-batching.md_client.snap b/tests/core/snapshots/body-batching.md_client.snap new file mode 100644 index 0000000000..92f94eba7f --- /dev/null +++ b/tests/core/snapshots/body-batching.md_client.snap @@ -0,0 +1,29 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +type Comment { + id: Int +} + +type Post { + body: String + id: Int + title: String + userId: Int! +} + +type Query { + users: [User] +} + +type User { + comments: [Comment] + id: Int! + name: String! + posts: [Post] +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/body-batching.md_merged.snap b/tests/core/snapshots/body-batching.md_merged.snap new file mode 100644 index 0000000000..3c4b7a45b6 --- /dev/null +++ b/tests/core/snapshots/body-batching.md_merged.snap @@ -0,0 +1,43 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema + @server(port: 8000, queryValidation: false) + @upstream(batch: {delay: 1, headers: [], maxSize: 1000}, httpCache: 42) { + query: Query +} + +type Comment { + id: Int +} + +type Post { + body: String + id: Int + title: String + userId: Int! +} + +type Query { + users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") +} + +type User { + comments: [Comment] + @http( + url: "https://jsonplaceholder.typicode.com/comments" + body: {title: "foo", body: "bar", meta: {information: {userId: "{{.value.id}}"}}} + batchKey: ["userId"] + method: "POST" + ) + id: Int! + name: String! + posts: [Post] + @http( + url: "https://jsonplaceholder.typicode.com/posts" + body: {userId: "{{.value.id}}", title: "foo", body: "bar"} + batchKey: ["userId"] + method: "POST" + ) +} diff --git a/tests/core/snapshots/test-batch-operator-post.md_error.snap b/tests/core/snapshots/test-batch-operator-post.md_error.snap index 3ac6e3e721..d7898cfbcf 100644 --- a/tests/core/snapshots/test-batch-operator-post.md_error.snap +++ b/tests/core/snapshots/test-batch-operator-post.md_error.snap @@ -4,7 +4,7 @@ expression: errors --- [ { - "message": "GroupBy is only supported for GET requests", + "message": "batchKey requires either body or query parameters", "trace": [ "Query", "user", diff --git a/tests/execution/batching-validation.md b/tests/execution/batching-validation.md new file mode 100644 index 0000000000..208107efc9 --- /dev/null +++ b/tests/execution/batching-validation.md @@ -0,0 +1,47 @@ +--- +error: true +--- + +# batching validation + +```graphql @config +schema @upstream(httpCache: 42, batch: {delay: 1}) { + query: Query +} + +type User { + id: Int + name: String +} + +type Post { + id: Int + title: String + body: String +} + +type Query { + user(id: Int!): User + @http( + url: "http://jsonplaceholder.typicode.com/users" + method: POST + body: {uId: "{{.args.id}}", userId: "{{.args.id}}"} + batchKey: ["id"] + ) + posts: [Post] @http(url: "http://jsonplaceholder.typicode.com/posts", batchKey: ["id"]) + userWithId(id: Int!): User + @http( + url: "http://jsonplaceholder.typicode.com/users" + method: POST + body: {uId: "uId", userId: "userId"} + batchKey: ["id"] + ) + userWithIdTest(id: Int!): User + @http( + url: "http://jsonplaceholder.typicode.com/users" + method: PUT + body: {uId: "uId", userId: "userId"} + batchKey: ["id"] + ) +} +``` diff --git a/tests/execution/body-batching-cases.md b/tests/execution/body-batching-cases.md new file mode 100644 index 0000000000..a8e124a49a --- /dev/null +++ b/tests/execution/body-batching-cases.md @@ -0,0 +1,154 @@ +# Batching default + +```graphql @config +schema @server(port: 8000) @upstream(httpCache: 42, batch: {delay: 1}) { + query: Query +} + +type Query { + user: User @http(url: "http://jsonplaceholder.typicode.com/users/1") + users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") + posts: [Post] @http(url: "http://jsonplaceholder.typicode.com/posts") + foo: [Foo] @http(url: "http://jsonplaceholder.typicode.com/foo") +} + +type Foo { + a: Int + b: Int + bar: Bar + @http(url: "http://jsonplaceholder.typicode.com/bar", method: POST, body: {id: "{{.value.a}}"}, batchKey: ["a"]) +} + +type Bar { + a: Int + b: Int +} + +type User { + id: Int! + name: String! + email: String! + post: Post + @http( + url: "http://jsonplaceholder.typicode.com/posts" + method: POST + body: {userId: "{{.value.id}}", title: "title", body: "body"} + batchKey: ["userId"] + ) +} + +type Post { + id: Int! + userId: Int! + title: String! + body: String! + user: User + @http( + url: "http://jsonplaceholder.typicode.com/users" + method: POST + body: {key: "id", value: "{{.value.userId}}"} + batchKey: ["id"] + ) +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users + response: + status: 200 + body: + - id: 1 + name: user-1 + email: user-1@gmail.com + - id: 2 + name: user-2 + email: user-2@gmail.com +- request: + method: POST + url: http://jsonplaceholder.typicode.com/posts + body: [{"userId": "1", "title": "title", "body": "body"}, {"userId": "2", "title": "title", "body": "body"}] + response: + status: 200 + body: + - id: 1 + userId: 1 + title: user-1 + body: user-1@gmail.com + - id: 2 + userId: 2 + title: user-2 + body: user-2@gmail.com + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/posts + response: + status: 200 + body: + - id: 1 + userId: 1 + title: user-1 + body: user-1@gmail.com + - id: 2 + userId: 2 + title: user-2 + body: user-2@gmail.com + +- request: + method: POST + url: http://jsonplaceholder.typicode.com/users + body: [{"key": "id", "value": "1"}, {"key": "id", "value": "2"}] + response: + status: 200 + body: + - id: 1 + name: user-1 + email: user-1@gmail.com + - id: 2 + userId: 2 + name: user-2 + email: user-2@gmail.com + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/foo + expectedHits: 1 + response: + status: 200 + body: + - a: 11 + b: 12 + - a: 21 + b: 22 + +- request: + method: POST + url: http://jsonplaceholder.typicode.com/bar + body: [{"id": "11"}, {"id": "21"}] + response: + status: 200 + body: + - a: 11 + b: 12 + - a: 21 + b: 22 +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: query { users { id name post { id title userId } } } + +- method: POST + url: http://localhost:8080/graphql + body: + query: query { posts { id title user { id name } } } + +- method: POST + url: http://localhost:8080/graphql + body: + query: query { foo { a b bar { a b } } } +``` diff --git a/tests/execution/body-batching.md b/tests/execution/body-batching.md new file mode 100644 index 0000000000..a5fce54fce --- /dev/null +++ b/tests/execution/body-batching.md @@ -0,0 +1,114 @@ +# Batching post + +```graphql @config +schema + @server(port: 8000, queryValidation: false) + @upstream(httpCache: 42, batch: {maxSize: 1000, delay: 1, headers: []}) { + query: Query +} + +type Query { + users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") +} + +type Post { + id: Int + title: String + body: String + userId: Int! +} + +type User { + id: Int! + name: String! + posts: [Post] + @http( + url: "https://jsonplaceholder.typicode.com/posts" + method: POST + body: {userId: "{{.value.id}}", title: "foo", body: "bar"} + batchKey: ["userId"] + ) + comments: [Comment] + @http( + url: "https://jsonplaceholder.typicode.com/comments" + method: POST + body: {title: "foo", body: "bar", meta: {information: {userId: "{{.value.id}}"}}} + batchKey: ["userId"] + ) +} + +type Comment { + id: Int +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users + expectedHits: 2 + response: + status: 200 + body: + - id: 1 + name: user-1 + - id: 2 + name: user-2 + - id: 3 + name: user-3 +- request: + method: POST + url: https://jsonplaceholder.typicode.com/posts + body: + [ + {"userId": "1", "title": "foo", "body": "bar"}, + {"userId": "2", "title": "foo", "body": "bar"}, + {"userId": "3", "title": "foo", "body": "bar"}, + ] + response: + status: 200 + body: + - id: 1 + title: foo + body: bar + userId: 1 + - id: 2 + title: foo + body: bar + userId: 2 + - id: 3 + title: foo + body: bar + userId: 3 + +- request: + method: POST + url: https://jsonplaceholder.typicode.com/comments + body: + [ + {"title": "foo", "body": "bar", "meta": {"information": {"userId": "1"}}}, + {"title": "foo", "body": "bar", "meta": {"information": {"userId": "2"}}}, + {"title": "foo", "body": "bar", "meta": {"information": {"userId": "3"}}}, + ] + response: + status: 200 + body: + - id: 1 + userId: 1 + - id: 2 + userId: 2 + - id: 3 + userId: 3 +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: query { users { id posts { userId title } } } + +- method: POST + url: http://localhost:8080/graphql + body: + query: query { users { id comments { id } } } +``` diff --git a/tests/execution/call-mutation.md b/tests/execution/call-mutation.md index 109a86eee2..bcda2cd7b0 100644 --- a/tests/execution/call-mutation.md +++ b/tests/execution/call-mutation.md @@ -83,7 +83,7 @@ type User { - request: method: PATCH url: http://jsonplaceholder.typicode.com/users/1 - body: {"postId": 1} + body: '{"postId":1}' response: status: 200 body: