Skip to content

Commit

Permalink
feat: add a select field on HTTP and gRPC (#2962)
Browse files Browse the repository at this point in the history
Co-authored-by: Tushar Mathur <tusharmath@gmail.com>
  • Loading branch information
karatakis and tusharmath authored Oct 5, 2024
1 parent 5f667fb commit 44777af
Show file tree
Hide file tree
Showing 15 changed files with 353 additions and 5 deletions.
40 changes: 40 additions & 0 deletions generated/.tailcallrc.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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

"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}

"""
Expand Down Expand Up @@ -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
}

"""
Expand Down
6 changes: 6 additions & 0 deletions generated/.tailcallrc.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 76 additions & 1 deletion src/core/blueprint/dynamic_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,49 @@ use serde_json::Value;

use crate::core::mustache::Mustache;

#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum DynamicValue<A> {
Value(A),
Mustache(Mustache),
Object(IndexMap<Name, DynamicValue<A>>),
Array(Vec<DynamicValue<A>>),
}

impl<A> DynamicValue<A> {
/// 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<ConstValue>> for ConstValue {
type Error = anyhow::Error;

Expand Down Expand Up @@ -79,3 +114,43 @@ impl TryFrom<&Value> for DynamicValue<ConstValue> {
}
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_dynamic_value_inject() {
let value: DynamicValue<ConstValue> =
DynamicValue::Mustache(Mustache::parse("{{.foo}}")).prepend("args");
let expected: DynamicValue<ConstValue> =
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<ConstValue> = 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<ConstValue> = DynamicValue::Object(expected_map);
assert_eq!(value, expected);

let value: DynamicValue<ConstValue> =
DynamicValue::Array(vec![DynamicValue::Mustache(Mustache::parse("{{.foo}}"))])
.prepend("args");
let expected: DynamicValue<ConstValue> = DynamicValue::Array(vec![DynamicValue::Mustache(
Mustache::parse("{{.args.foo}}"),
)]);
assert_eq!(value, expected);

let value: DynamicValue<ConstValue> = DynamicValue::Value(ConstValue::Null).prepend("args");
let expected: DynamicValue<ConstValue> = DynamicValue::Value(ConstValue::Null);
assert_eq!(value, expected);
}
}
8 changes: 6 additions & 2 deletions src/core/blueprint/operators/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -197,7 +198,7 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid<IR, String> {
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)),
Expand All @@ -206,8 +207,11 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid<IR, String> {
})
} 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>(
Expand Down
6 changes: 4 additions & 2 deletions src/core/blueprint/operators/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -94,8 +94,10 @@ pub fn compile_http(
is_list,
dedupe,
})
}
};
(io, &http.select)
})
.and_then(apply_select)
}

pub fn update_http<'a>(
Expand Down
2 changes: 2 additions & 0 deletions src/core/blueprint/operators/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod http;
mod js;
mod modify;
mod protected;
mod select;

pub use apollo_federation::*;
pub use call::*;
Expand All @@ -19,3 +20,4 @@ pub use http::*;
pub use js::*;
pub use modify::*;
pub use protected::*;
pub use select::*;
26 changes: 26 additions & 0 deletions src/core/blueprint/operators/select.rs
Original file line number Diff line number Diff line change
@@ -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<Value>)) -> Valid<IR, String> {
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)
}
}
11 changes: 11 additions & 0 deletions src/core/config/directives/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,15 @@ pub struct Grpc {
/// with APIs that expect unique results for identical inputs, such as
/// nonce-based APIs.
pub dedupe: Option<bool>,

/// 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<Value>,
}
12 changes: 12 additions & 0 deletions src/core/config/directives/http.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailcall_macros::{DirectiveDefinition, InputDefinition};

use crate::core::config::{Encoding, KeyValue, URLQuery};
Expand Down Expand Up @@ -98,4 +99,15 @@ pub struct Http {
/// with APIs that expect unique results for identical inputs, such as
/// nonce-based APIs.
pub dedupe: Option<bool>,

/// 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<Value>,
}
1 change: 1 addition & 0 deletions src/core/generator/from_proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ impl Context {
headers: vec![],
method: field_name.id(),
dedupe: None,
select: None,
}));

let method_path =
Expand Down
4 changes: 4 additions & 0 deletions src/core/mustache/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ impl Mustache {
&self.0
}

pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
&mut self.0
}

pub fn expression_segments(&self) -> Vec<&Vec<String>> {
self.segments()
.iter()
Expand Down
21 changes: 21 additions & 0 deletions tests/core/snapshots/http-select.md_0.snap
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Loading

1 comment on commit 44777af

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running 30s test @ http://localhost:8000/graphql

4 threads and 100 connections

Thread Stats Avg Stdev Max +/- Stdev
Latency 7.07ms 3.02ms 36.96ms 71.43%
Req/Sec 3.58k 372.49 4.07k 95.67%

428050 requests in 30.02s, 795.96MB read

Requests/sec: 14256.49

Transfer/sec: 26.51MB

Please sign in to comment.