From 44777afd9347012b6b441f897b3fd396cb74fcbf Mon Sep 17 00:00:00 2001 From: Panagiotis Karatakis Date: Sat, 5 Oct 2024 07:27:23 +0300 Subject: [PATCH] feat: add a select field on HTTP and gRPC (#2962) Co-authored-by: Tushar Mathur --- generated/.tailcallrc.graphql | 40 ++++++++++ generated/.tailcallrc.schema.json | 6 ++ src/core/blueprint/dynamic_value.rs | 77 ++++++++++++++++++- src/core/blueprint/operators/grpc.rs | 8 +- src/core/blueprint/operators/http.rs | 6 +- src/core/blueprint/operators/mod.rs | 2 + src/core/blueprint/operators/select.rs | 26 +++++++ src/core/config/directives/grpc.rs | 11 +++ src/core/config/directives/http.rs | 12 +++ src/core/generator/from_proto.rs | 1 + src/core/mustache/model.rs | 4 + tests/core/snapshots/http-select.md_0.snap | 21 +++++ .../core/snapshots/http-select.md_client.snap | 59 ++++++++++++++ .../core/snapshots/http-select.md_merged.snap | 26 +++++++ tests/execution/http-select.md | 59 ++++++++++++++ 15 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 src/core/blueprint/operators/select.rs create mode 100644 tests/core/snapshots/http-select.md_0.snap create mode 100644 tests/core/snapshots/http-select.md_client.snap create mode 100644 tests/core/snapshots/http-select.md_merged.snap create mode 100644 tests/execution/http-select.md diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index eb3df631b4..2bd44e0626 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -141,6 +141,16 @@ directive @grpc( This refers to the gRPC method you're going to call. For instance `GetAllNews`. """ method: String! + """ + You can use `select` with mustache syntax to re-construct the directives response + to the desired format. This is useful when data are deeply nested or want to keep + specific fields only from the response.* EXAMPLE 1: if we have a call that returns + `{ "user": { "items": [...], ... } ... }` we can use `"{{.user.items}}"`, to extract + the `items`. * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": + { "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: "{{.fizz.buzz}}" + }` + """ + select: JSON ) on FIELD_DEFINITION | OBJECT """ @@ -218,6 +228,16 @@ directive @http( is automatically selected as the batching parameter. """ query: [URLQuery] + """ + You can use `select` with mustache syntax to re-construct the directives response + to the desired format. This is useful when data are deeply nested or want to keep + specific fields only from the response.* EXAMPLE 1: if we have a call that returns + `{ "user": { "items": [...], ... } ... }` we can use `"{{.user.items}}"`, to extract + the `items`. * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": + { "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: "{{.fizz.buzz}}" + }` + """ + select: JSON ) on FIELD_DEFINITION | OBJECT directive @js( @@ -836,6 +856,16 @@ input Grpc { This refers to the gRPC method you're going to call. For instance `GetAllNews`. """ method: String! + """ + You can use `select` with mustache syntax to re-construct the directives response + to the desired format. This is useful when data are deeply nested or want to keep + specific fields only from the response.* EXAMPLE 1: if we have a call that returns + `{ "user": { "items": [...], ... } ... }` we can use `"{{.user.items}}"`, to extract + the `items`. * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": + { "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: "{{.fizz.buzz}}" + }` + """ + select: JSON } """ @@ -913,6 +943,16 @@ input Http { is automatically selected as the batching parameter. """ query: [URLQuery] + """ + You can use `select` with mustache syntax to re-construct the directives response + to the desired format. This is useful when data are deeply nested or want to keep + specific fields only from the response.* EXAMPLE 1: if we have a call that returns + `{ "user": { "items": [...], ... } ... }` we can use `"{{.user.items}}"`, to extract + the `items`. * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": + { "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: "{{.fizz.buzz}}" + }` + """ + select: JSON } """ diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index 2a8a9993ef..e42dc18f92 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -610,6 +610,9 @@ "method": { "description": "This refers to the gRPC method you're going to call. For instance `GetAllNews`.", "type": "string" + }, + "select": { + "description": "You can use `select` with mustache syntax to re-construct the directives response to the desired format. This is useful when data are deeply nested or want to keep specific fields only from the response.\n\n* EXAMPLE 1: if we have a call that returns `{ \"user\": { \"items\": [...], ... } ... }` we can use `\"{{.user.items}}\"`, to extract the `items`. * EXAMPLE 2: if we have a call that returns `{ \"foo\": \"bar\", \"fizz\": { \"buzz\": \"eggs\", ... }, ... }` we can use { foo: \"{{.foo}}\", buzz: \"{{.fizz.buzz}}\" }`" } }, "additionalProperties": false @@ -759,6 +762,9 @@ "items": { "$ref": "#/definitions/URLQuery" } + }, + "select": { + "description": "You can use `select` with mustache syntax to re-construct the directives response to the desired format. This is useful when data are deeply nested or want to keep specific fields only from the response.\n\n* EXAMPLE 1: if we have a call that returns `{ \"user\": { \"items\": [...], ... } ... }` we can use `\"{{.user.items}}\"`, to extract the `items`. * EXAMPLE 2: if we have a call that returns `{ \"foo\": \"bar\", \"fizz\": { \"buzz\": \"eggs\", ... }, ... }` we can use { foo: \"{{.foo}}\", buzz: \"{{.fizz.buzz}}\" }`" } }, "additionalProperties": false diff --git a/src/core/blueprint/dynamic_value.rs b/src/core/blueprint/dynamic_value.rs index b38f78a3c2..0d3d36b6ad 100644 --- a/src/core/blueprint/dynamic_value.rs +++ b/src/core/blueprint/dynamic_value.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::core::mustache::Mustache; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum DynamicValue { Value(A), Mustache(Mustache), @@ -12,6 +12,41 @@ pub enum DynamicValue { Array(Vec>), } +impl DynamicValue { + /// This function is used to prepend a string to every Mustache Expression. + /// This is useful when we want to hide a Mustache data argument from the + /// user and make the use of Tailcall easier + pub fn prepend(self, name: &str) -> Self { + match self { + DynamicValue::Value(value) => DynamicValue::Value(value), + DynamicValue::Mustache(mut mustache) => { + if mustache.is_const() { + DynamicValue::Mustache(mustache) + } else { + let segments = mustache.segments_mut(); + if let Some(crate::core::mustache::Segment::Expression(vec)) = + segments.get_mut(0) + { + vec.insert(0, name.to_string()); + } + DynamicValue::Mustache(mustache) + } + } + DynamicValue::Object(index_map) => { + let index_map = index_map + .into_iter() + .map(|(key, val)| (key, val.prepend(name))) + .collect(); + DynamicValue::Object(index_map) + } + DynamicValue::Array(vec) => { + let vec = vec.into_iter().map(|val| val.prepend(name)).collect(); + DynamicValue::Array(vec) + } + } + } +} + impl TryFrom<&DynamicValue> for ConstValue { type Error = anyhow::Error; @@ -79,3 +114,43 @@ impl TryFrom<&Value> for DynamicValue { } } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_dynamic_value_inject() { + let value: DynamicValue = + DynamicValue::Mustache(Mustache::parse("{{.foo}}")).prepend("args"); + let expected: DynamicValue = + DynamicValue::Mustache(Mustache::parse("{{.args.foo}}")); + assert_eq!(value, expected); + + let mut value_map = IndexMap::new(); + value_map.insert( + Name::new("foo"), + DynamicValue::Mustache(Mustache::parse("{{.foo}}")), + ); + let value: DynamicValue = DynamicValue::Object(value_map).prepend("args"); + let mut expected_map = IndexMap::new(); + expected_map.insert( + Name::new("foo"), + DynamicValue::Mustache(Mustache::parse("{{.args.foo}}")), + ); + let expected: DynamicValue = DynamicValue::Object(expected_map); + assert_eq!(value, expected); + + let value: DynamicValue = + DynamicValue::Array(vec![DynamicValue::Mustache(Mustache::parse("{{.foo}}"))]) + .prepend("args"); + let expected: DynamicValue = DynamicValue::Array(vec![DynamicValue::Mustache( + Mustache::parse("{{.args.foo}}"), + )]); + assert_eq!(value, expected); + + let value: DynamicValue = DynamicValue::Value(ConstValue::Null).prepend("args"); + let expected: DynamicValue = DynamicValue::Value(ConstValue::Null); + assert_eq!(value, expected); + } +} diff --git a/src/core/blueprint/operators/grpc.rs b/src/core/blueprint/operators/grpc.rs index 7b4f56c6af..adbfd419ea 100644 --- a/src/core/blueprint/operators/grpc.rs +++ b/src/core/blueprint/operators/grpc.rs @@ -3,6 +3,7 @@ use std::fmt::Display; use prost_reflect::prost_types::FileDescriptorSet; use prost_reflect::FieldDescriptor; +use super::apply_select; use crate::core::blueprint::FieldDefinition; use crate::core::config::group_by::GroupBy; use crate::core::config::{Config, ConfigModule, Field, GraphQLOperationType, Grpc, Resolver}; @@ -197,7 +198,7 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid { body, operation_type: operation_type.clone(), }; - if !grpc.batch_key.is_empty() { + let io = if !grpc.batch_key.is_empty() { IR::IO(IO::Grpc { req_template, group_by: Some(GroupBy::new(grpc.batch_key.clone(), None)), @@ -206,8 +207,11 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid { }) } else { IR::IO(IO::Grpc { req_template, group_by: None, dl_id: None, dedupe }) - } + }; + + (io, &grpc.select) }) + .and_then(apply_select) } pub fn update_grpc<'a>( diff --git a/src/core/blueprint/operators/http.rs b/src/core/blueprint/operators/http.rs index a03133e553..b394a87d64 100644 --- a/src/core/blueprint/operators/http.rs +++ b/src/core/blueprint/operators/http.rs @@ -70,7 +70,7 @@ pub fn compile_http( .or(config_module.upstream.on_request.clone()) .map(|on_request| HttpFilter { on_request }); - if !http.batch_key.is_empty() && http.method == Method::GET { + let io = if !http.batch_key.is_empty() && http.method == Method::GET { // Find a query parameter that contains a reference to the {{.value}} key let key = http.query.iter().find_map(|q| { Mustache::parse(&q.value) @@ -94,8 +94,10 @@ pub fn compile_http( is_list, dedupe, }) - } + }; + (io, &http.select) }) + .and_then(apply_select) } pub fn update_http<'a>( diff --git a/src/core/blueprint/operators/mod.rs b/src/core/blueprint/operators/mod.rs index 2b4842899e..77947e9571 100644 --- a/src/core/blueprint/operators/mod.rs +++ b/src/core/blueprint/operators/mod.rs @@ -8,6 +8,7 @@ mod http; mod js; mod modify; mod protected; +mod select; pub use apollo_federation::*; pub use call::*; @@ -19,3 +20,4 @@ pub use http::*; pub use js::*; pub use modify::*; pub use protected::*; +pub use select::*; diff --git a/src/core/blueprint/operators/select.rs b/src/core/blueprint/operators/select.rs new file mode 100644 index 0000000000..8af1c90401 --- /dev/null +++ b/src/core/blueprint/operators/select.rs @@ -0,0 +1,26 @@ +use serde_json::Value; + +use crate::core::blueprint::DynamicValue; +use crate::core::ir::model::IR; +use crate::core::valid::Valid; + +pub fn apply_select(input: (IR, &Option)) -> Valid { + let (mut ir, select) = input; + + if let Some(select_value) = select { + let dynamic_value = match DynamicValue::try_from(select_value) { + Ok(dynamic_value) => dynamic_value.prepend("args"), + Err(e) => { + return Valid::fail_with( + format!("syntax error when parsing `{:?}`", select), + e.to_string(), + ) + } + }; + + ir = ir.pipe(IR::Dynamic(dynamic_value)); + Valid::succeed(ir) + } else { + Valid::succeed(ir) + } +} diff --git a/src/core/config/directives/grpc.rs b/src/core/config/directives/grpc.rs index 95155ceb3d..cdb744d05d 100644 --- a/src/core/config/directives/grpc.rs +++ b/src/core/config/directives/grpc.rs @@ -59,4 +59,15 @@ pub struct Grpc { /// with APIs that expect unique results for identical inputs, such as /// nonce-based APIs. pub dedupe: Option, + + /// You can use `select` with mustache syntax to re-construct the directives + /// response to the desired format. This is useful when data are deeply + /// nested or want to keep specific fields only from the response. + /// + /// * EXAMPLE 1: if we have a call that returns `{ "user": { "items": [...], + /// ... } ... }` we can use `"{{.user.items}}"`, to extract the `items`. + /// * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": { + /// "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: + /// "{{.fizz.buzz}}" }` + pub select: Option, } diff --git a/src/core/config/directives/http.rs b/src/core/config/directives/http.rs index 74fa72b3e5..00defb62c2 100644 --- a/src/core/config/directives/http.rs +++ b/src/core/config/directives/http.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use tailcall_macros::{DirectiveDefinition, InputDefinition}; use crate::core::config::{Encoding, KeyValue, URLQuery}; @@ -98,4 +99,15 @@ pub struct Http { /// with APIs that expect unique results for identical inputs, such as /// nonce-based APIs. pub dedupe: Option, + + /// You can use `select` with mustache syntax to re-construct the directives + /// response to the desired format. This is useful when data are deeply + /// nested or want to keep specific fields only from the response. + /// + /// * EXAMPLE 1: if we have a call that returns `{ "user": { "items": [...], + /// ... } ... }` we can use `"{{.user.items}}"`, to extract the `items`. + /// * EXAMPLE 2: if we have a call that returns `{ "foo": "bar", "fizz": { + /// "buzz": "eggs", ... }, ... }` we can use { foo: "{{.foo}}", buzz: + /// "{{.fizz.buzz}}" }` + pub select: Option, } diff --git a/src/core/generator/from_proto.rs b/src/core/generator/from_proto.rs index b334fcbcca..633d7b4fb8 100644 --- a/src/core/generator/from_proto.rs +++ b/src/core/generator/from_proto.rs @@ -373,6 +373,7 @@ impl Context { headers: vec![], method: field_name.id(), dedupe: None, + select: None, })); let method_path = diff --git a/src/core/mustache/model.rs b/src/core/mustache/model.rs index af18d19e68..c007e7a875 100644 --- a/src/core/mustache/model.rs +++ b/src/core/mustache/model.rs @@ -33,6 +33,10 @@ impl Mustache { &self.0 } + pub fn segments_mut(&mut self) -> &mut Vec { + &mut self.0 + } + pub fn expression_segments(&self) -> Vec<&Vec> { self.segments() .iter() diff --git a/tests/core/snapshots/http-select.md_0.snap b/tests/core/snapshots/http-select.md_0.snap new file mode 100644 index 0000000000..81a80739bd --- /dev/null +++ b/tests/core/snapshots/http-select.md_0.snap @@ -0,0 +1,21 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "userCompany": { + "name": "FOO", + "catchPhrase": "BAR" + }, + "userDetails": { + "city": "FIZZ" + } + } + } +} diff --git a/tests/core/snapshots/http-select.md_client.snap b/tests/core/snapshots/http-select.md_client.snap new file mode 100644 index 0000000000..18eb763eb3 --- /dev/null +++ b/tests/core/snapshots/http-select.md_client.snap @@ -0,0 +1,59 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +scalar Bytes + +type Company { + catchPhrase: String! + name: String! +} + +scalar Date + +scalar DateTime + +scalar Email + +scalar Empty + +scalar Int128 + +scalar Int16 + +scalar Int32 + +scalar Int64 + +scalar Int8 + +scalar JSON + +scalar PhoneNumber + +type Query { + userCompany(id: Int!): Company + userDetails(id: Int!): UserDetails +} + +scalar UInt128 + +scalar UInt16 + +scalar UInt32 + +scalar UInt64 + +scalar UInt8 + +scalar Url + +type UserDetails { + city: String! + id: Int! + phone: String! +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/http-select.md_merged.snap b/tests/core/snapshots/http-select.md_merged.snap new file mode 100644 index 0000000000..f3d4e3e2dc --- /dev/null +++ b/tests/core/snapshots/http-select.md_merged.snap @@ -0,0 +1,26 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema + @server(hostname: "0.0.0.0", port: 8001, queryValidation: false) + @upstream(baseURL: "http://upstream", httpCache: 42) { + query: Query +} + +type Company { + catchPhrase: String! + name: String! +} + +type Query { + userCompany(id: Int!): Company @http(path: "/users/{{.args.id}}", select: "{{.company}}") + userDetails(id: Int!): UserDetails + @http(path: "/users/{{.args.id}}", select: {id: "{{.id}}", city: "{{.address.city}}", phone: "{{.phone}}"}) +} + +type UserDetails { + city: String! + id: Int! + phone: String! +} diff --git a/tests/execution/http-select.md b/tests/execution/http-select.md new file mode 100644 index 0000000000..74ad46b02b --- /dev/null +++ b/tests/execution/http-select.md @@ -0,0 +1,59 @@ +# Basic queries with field ordering check + +```graphql @config +schema + @server(port: 8001, queryValidation: false, hostname: "0.0.0.0") + @upstream(baseURL: "http://upstream", httpCache: 42) { + query: Query +} + +type Query { + userCompany(id: Int!): Company @http(path: "/users/{{.args.id}}", select: "{{.company}}") + userDetails(id: Int!): UserDetails + @http(path: "/users/{{.args.id}}", select: {id: "{{.id}}", city: "{{.address.city}}", phone: "{{.phone}}"}) +} + +type UserDetails { + id: Int! + city: String! + phone: String! +} + +type Company { + name: String! + catchPhrase: String! +} +``` + +```yml @mock +- request: + method: GET + url: http://upstream/users/1 + expectedHits: 2 + response: + status: 200 + body: + id: 1 + company: + name: FOO + catchPhrase: BAR + address: + city: FIZZ + phone: BUZZ +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: | + { + userCompany(id: 1) { + name + catchPhrase + } + userDetails(id: 1) { + city + } + } +```