diff --git a/src/core/document.rs b/src/core/document.rs index baaf7b9ca8..a65b307f7c 100644 --- a/src/core/document.rs +++ b/src/core/document.rs @@ -1,10 +1,12 @@ +use std::borrow::Cow; +use std::fmt::Display; + use async_graphql::parser::types::*; -use async_graphql::{Pos, Positioned}; -use async_graphql_value::{ConstValue, Name}; +use async_graphql::Positioned; +use async_graphql_value::ConstValue; -fn pos(a: A) -> Positioned { - Positioned::new(a, Pos::default()) -} +use super::jit::Directive as JitDirective; +use super::json::JsonLikeOwned; struct LineBreaker<'a> { string: &'a str, @@ -61,9 +63,12 @@ fn get_formatted_docs(docs: Option, indent: usize) -> String { formatted_docs } -pub fn print_directives<'a>(directives: impl Iterator) -> String { +pub fn print_directives<'a, T>(directives: impl Iterator) -> String +where + &'a T: Into> + 'a, +{ directives - .map(|d| print_directive(&const_directive_to_sdl(d))) + .map(|d| print_directive(d)) .collect::>() .join(" ") } @@ -102,37 +107,6 @@ fn print_schema(schema: &SchemaDefinition) -> String { ) } -fn const_directive_to_sdl(directive: &ConstDirective) -> DirectiveDefinition { - DirectiveDefinition { - description: None, - name: pos(Name::new(directive.name.node.as_str())), - arguments: directive - .arguments - .iter() - .filter_map(|(k, v)| { - if v.node != ConstValue::Null { - Some(pos(InputValueDefinition { - description: None, - name: pos(Name::new(k.node.clone())), - ty: pos(Type { - nullable: true, - base: async_graphql::parser::types::BaseType::Named(Name::new( - v.to_string(), - )), - }), - default_value: Some(pos(ConstValue::String(v.to_string()))), - directives: Vec::new(), - })) - } else { - None - } - }) - .collect(), - is_repeatable: true, - locations: vec![], - } -} - fn print_type_def(type_def: &TypeDefinition) -> String { match &type_def.kind { TypeKind::Scalar => { @@ -320,18 +294,23 @@ fn print_input_value(field: &async_graphql::parser::types::InputValueDefinition) print_default_value(field.default_value.as_ref()) ) } -fn print_directive(directive: &DirectiveDefinition) -> String { + +pub fn print_directive<'a, T>(directive: &'a T) -> String +where + &'a T: Into>, +{ + let directive: Directive<'a> = directive.into(); let args = directive - .arguments + .args .iter() - .map(|arg| format!("{}: {}", arg.node.name.node, arg.node.ty.node)) + .map(|arg| format!("{}: {}", arg.name, arg.value)) .collect::>() .join(", "); if args.is_empty() { - format!("@{}", directive.name.node) + format!("@{}", directive.name) } else { - format!("@{}({})", directive.name.node, args) + format!("@{}({})", directive.name, args) } } @@ -420,3 +399,60 @@ pub fn print(sd: ServiceDocument) -> String { sdl_string.trim_end_matches('\n').to_string() } + +pub struct Directive<'a> { + pub name: Cow<'a, str>, + pub args: Vec>, +} + +pub struct Arg<'a> { + pub name: Cow<'a, str>, + pub value: Cow<'a, str>, +} + +impl<'a> From<&'a ConstDirective> for Directive<'a> { + fn from(value: &'a ConstDirective) -> Self { + Self { + name: Cow::Borrowed(value.name.node.as_str()), + args: value + .arguments + .iter() + .filter_map(|(k, v)| { + if v.node != async_graphql_value::ConstValue::Null { + Some(Arg { + name: Cow::Borrowed(k.node.as_str()), + value: Cow::Owned(v.to_string()), + }) + } else { + None + } + }) + .collect(), + } + } +} + +impl<'a, Input: JsonLikeOwned + Display> From<&'a JitDirective> for Directive<'a> { + fn from(value: &'a JitDirective) -> Self { + let to_mustache = |s: &str| -> String { + s.strip_prefix('$') + .map(|v| format!("{{{{{}}}}}", v)) + .unwrap_or_else(|| s.to_string()) + }; + Self { + name: Cow::Borrowed(value.name.as_str()), + args: value + .arguments + .iter() + .filter_map(|(k, v)| { + if !v.is_null() { + let v_str = to_mustache(&v.to_string()); + Some(Arg { name: Cow::Borrowed(k), value: Cow::Owned(v_str) }) + } else { + None + } + }) + .collect(), + } + } +} diff --git a/src/core/graphql/request_template.rs b/src/core/graphql/request_template.rs index 1eb87f1cc8..9e849d3033 100644 --- a/src/core/graphql/request_template.rs +++ b/src/core/graphql/request_template.rs @@ -6,6 +6,7 @@ use std::hash::{Hash, Hasher}; use derive_setters::Setters; use http::header::{HeaderMap, HeaderValue}; use tailcall_hasher::TailcallHasher; +use tracing::info; use crate::core::config::{GraphQLOperationType, KeyValue}; use crate::core::has_headers::HasHeaders; @@ -14,7 +15,35 @@ use crate::core::http::Method::POST; use crate::core::ir::model::{CacheKey, IoId}; use crate::core::ir::{GraphQLOperationContext, RelatedFields}; use crate::core::mustache::Mustache; -use crate::core::path::PathGraphql; +use crate::core::path::{PathGraphql, PathString}; + +/// Represents a GraphQL selection that can either be resolved or unresolved. +#[derive(Debug, Clone)] +pub enum Selection { + /// A selection with a resolved string value. + Resolved(String), + /// A selection that contains a Mustache template to be resolved later. + UnResolved(Mustache), +} + +impl Selection { + /// Resolves the `Unresolved` variant using the provided `PathString`. + pub fn resolve(self, p: &impl PathString) -> Selection { + match self { + Selection::UnResolved(template) => Selection::Resolved(template.render(p)), + resolved => resolved, + } + } +} + +impl From for Selection { + fn from(value: Mustache) -> Self { + match value.is_const() { + true => Selection::Resolved(value.to_string()), + false => Selection::UnResolved(value), + } + } +} /// RequestTemplate for GraphQL requests (See RequestTemplate documentation) #[derive(Setters, Debug, Clone)] @@ -26,6 +55,7 @@ pub struct RequestTemplate { pub operation_arguments: Option>, pub headers: MustacheHeaders, pub related_fields: RelatedFields, + pub selection: Option, } impl RequestTemplate { @@ -85,7 +115,12 @@ impl RequestTemplate { ctx: &C, ) -> String { let operation_type = &self.operation_type; - let selection_set = ctx.selection_set(&self.related_fields).unwrap_or_default(); + + let selection_set = match &self.selection { + Some(Selection::Resolved(s)) => Cow::Borrowed(s), + Some(Selection::UnResolved(u)) => Cow::Owned(u.to_string()), + None => Cow::Owned(ctx.selection_set(&self.related_fields).unwrap_or_default()), + }; let mut operation = Cow::Borrowed(&self.operation_name); @@ -121,7 +156,10 @@ impl RequestTemplate { } } - format!(r#"{{ "query": "{operation_type} {{ {operation} {selection_set} }}" }}"#) + let query = + format!(r#"{{ "query": "{operation_type} {{ {operation} {selection_set} }}" }}"#); + info!("Query {} ", query); + query } pub fn new( @@ -149,6 +187,7 @@ impl RequestTemplate { operation_arguments, headers, related_fields, + selection: None, }) } } diff --git a/src/core/ir/model.rs b/src/core/ir/model.rs index 9a07742503..f4fbfdb563 100644 --- a/src/core/ir/model.rs +++ b/src/core/ir/model.rs @@ -131,6 +131,28 @@ impl Cache { } impl IR { + // allows to modify the IO node in the IR tree + pub fn modify_io(&mut self, io_modifier: &mut dyn FnMut(&mut IO)) { + match self { + IR::IO(io) => io_modifier(io), + IR::Cache(cache) => io_modifier(&mut cache.io), + IR::Discriminate(_, ir) | IR::Protect(_, ir) | IR::Path(ir, _) => { + ir.modify_io(io_modifier) + } + IR::Pipe(ir1, ir2) => { + ir1.modify_io(io_modifier); + ir2.modify_io(io_modifier); + } + IR::Entity(hash_map) => { + for ir in hash_map.values_mut() { + ir.modify_io(io_modifier); + } + } + IR::Map(map) => map.input.modify_io(io_modifier), + _ => {} + } + } + pub fn pipe(self, next: Self) -> Self { IR::Pipe(Box::new(self), Box::new(next)) } diff --git a/src/core/jit/model.rs b/src/core/jit/model.rs index 1b833b0ed0..9b2950a22a 100644 --- a/src/core/jit/model.rs +++ b/src/core/jit/model.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::collections::HashMap; -use std::fmt::{Debug, Formatter}; +use std::fmt::{Debug, Display, Formatter}; use std::num::NonZeroU64; use std::sync::Arc; @@ -13,12 +13,20 @@ use super::Error; use crate::core::blueprint::Index; use crate::core::ir::model::IR; use crate::core::ir::TypedValue; -use crate::core::json::JsonLike; +use crate::core::json::{JsonLike, JsonLikeOwned}; +use crate::core::path::PathString; use crate::core::scalar::Scalar; #[derive(Debug, Deserialize, Clone)] pub struct Variables(HashMap); +impl PathString for Variables { + fn path_string<'a, T: AsRef>(&'a self, path: &'a [T]) -> Option> { + self.get(path[0].as_ref()) + .map(|v| Cow::Owned(v.to_string())) + } +} + impl Default for Variables { fn default() -> Self { Self::new() @@ -96,6 +104,22 @@ pub struct Arg { pub default_value: Option, } +impl Display for Arg { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let v = self + .value + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| { + self.default_value + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_default() + }); + write!(f, "{}: {}", self.name, v) + } +} + impl Arg { pub fn try_map( self, diff --git a/src/core/jit/request.rs b/src/core/jit/request.rs index dc08ee53d7..cbd3bc0faf 100644 --- a/src/core/jit/request.rs +++ b/src/core/jit/request.rs @@ -50,6 +50,7 @@ impl Request { .pipe(transform::AuthPlanner::new()) .pipe(transform::CheckDedupe::new()) .pipe(transform::CheckCache::new()) + .pipe(transform::GraphQL::new()) .transform(plan) .to_result() // both transformers are infallible right now diff --git a/src/core/jit/transform/graphql.rs b/src/core/jit/transform/graphql.rs new file mode 100644 index 0000000000..79139c9891 --- /dev/null +++ b/src/core/jit/transform/graphql.rs @@ -0,0 +1,109 @@ +use std::borrow::Cow; +use std::convert::Infallible; +use std::fmt::{Debug, Display}; +use std::marker::PhantomData; + +use tailcall_valid::Valid; + +use crate::core::document::print_directives; +use crate::core::ir::model::{IO, IR}; +use crate::core::jit::{Field, OperationPlan}; +use crate::core::json::JsonLikeOwned; +use crate::core::{Mustache, Transform}; + +#[derive(Default)] +pub struct GraphQL(PhantomData); + +impl GraphQL { + pub fn new() -> Self { + Self(PhantomData) + } +} + +fn compute_selection_set(base_field: &mut [Field]) { + for field in base_field.iter_mut() { + if let Some(ir) = field.ir.as_mut() { + ir.modify_io(&mut |io| { + if let IO::GraphQL { req_template, .. } = io { + if let Some(v) = format_selection_set(field.selection.iter()) { + req_template.selection = Some(Mustache::parse(&v).into()); + } + } + }); + } + compute_selection_set(field.selection.as_mut()); + } +} + +impl Transform for GraphQL { + type Value = OperationPlan; + type Error = Infallible; + + fn transform(&self, mut plan: Self::Value) -> Valid { + compute_selection_set(&mut plan.selection); + + Valid::succeed(plan) + } +} + +fn format_selection_set<'a, A: 'a + Display + JsonLikeOwned>( + selection_set: impl Iterator>, +) -> Option { + let set = selection_set + .filter(|field| !matches!(&field.ir, Some(IR::IO(_)) | Some(IR::Dynamic(_)))) + .map(|field| { + // handle @modify directive scenario. + let field_name = if let Some(IR::ContextPath(data)) = &field.ir { + data.first().cloned().unwrap_or(field.name.to_string()) + } else { + field.name.to_string() + }; + format_selection_field(field, &field_name) + }) + .collect::>(); + + if set.is_empty() { + return None; + } + + Some(format!("{{ {} }}", set.join(" "))) +} + +fn format_selection_field(field: &Field, name: &str) -> String { + let arguments = format_selection_field_arguments(field); + let selection_set = format_selection_set(field.selection.iter()); + + let mut output = format!("{}{}", name, arguments); + + if !field.directives.is_empty() { + let directives = print_directives(field.directives.iter()); + + if !directives.is_empty() { + output.push(' '); + output.push_str(&directives.escape_default().to_string()); + } + } + + if let Some(selection_set) = selection_set { + output.push(' '); + output.push_str(&selection_set); + } + + output +} + +fn format_selection_field_arguments(field: &Field) -> Cow<'static, str> { + let arguments = field + .args + .iter() + .filter(|a| a.value.is_some()) + .map(|arg| arg.to_string()) + .collect::>() + .join(","); + + if arguments.is_empty() { + Cow::Borrowed("") + } else { + Cow::Owned(format!("({})", arguments.escape_default())) + } +} diff --git a/src/core/jit/transform/input_resolver.rs b/src/core/jit/transform/input_resolver.rs index 920bcfdae1..86b5fe59ba 100644 --- a/src/core/jit/transform/input_resolver.rs +++ b/src/core/jit/transform/input_resolver.rs @@ -1,7 +1,10 @@ +use std::fmt::Display; + use async_graphql_value::{ConstValue, Value}; use super::super::{Arg, Field, OperationPlan, ResolveInputError, Variables}; use crate::core::blueprint::Index; +use crate::core::ir::model::IO; use crate::core::json::{JsonLikeOwned, JsonObjectLike}; use crate::core::Type; @@ -46,7 +49,7 @@ impl InputResolver { impl InputResolver where Input: Clone + std::fmt::Debug, - Output: Clone + JsonLikeOwned + TryFrom + std::fmt::Debug, + Output: Clone + JsonLikeOwned + TryFrom + std::fmt::Debug + Display, Input: InputResolvable, >::Error: std::fmt::Debug, { @@ -55,7 +58,7 @@ where variables: &Variables, ) -> Result, ResolveInputError> { let index = self.plan.index; - let selection = self + let mut selection = self .plan .selection .into_iter() @@ -68,6 +71,10 @@ where .map(|field| Self::resolve_field(&index, field?)) .collect::, _>>()?; + // adjust the pre-computed values in selection set like graphql query for + // @graphql directive. + Self::resolve_graphql_selection_set(&mut selection, variables); + Ok(OperationPlan { root_name: self.plan.root_name.to_string(), operation_type: self.plan.operation_type, @@ -82,6 +89,25 @@ where }) } + // resolves the variables in selection set mustache template for graphql query. + fn resolve_graphql_selection_set( + base_field: &mut [Field], + variables: &Variables, + ) { + for field in base_field.iter_mut() { + if let Some(ir) = field.ir.as_mut() { + ir.modify_io(&mut |io| { + if let IO::GraphQL { req_template, .. } = io { + if let Some(selection) = req_template.selection.take() { + req_template.selection = Some(selection.resolve(variables)); + } + } + }); + } + Self::resolve_graphql_selection_set(field.selection.as_mut(), variables); + } + } + fn resolve_field( index: &Index, field: Field, diff --git a/src/core/jit/transform/mod.rs b/src/core/jit/transform/mod.rs index 71928ddbfd..c3cd01c409 100644 --- a/src/core/jit/transform/mod.rs +++ b/src/core/jit/transform/mod.rs @@ -3,6 +3,7 @@ mod check_cache; mod check_const; mod check_dedupe; mod check_protected; +mod graphql; mod input_resolver; mod skip; @@ -11,5 +12,6 @@ pub use check_cache::*; pub use check_const::*; pub use check_dedupe::*; pub use check_protected::*; +pub use graphql::*; pub use input_resolver::*; pub use skip::*; diff --git a/src/core/json/graphql.rs b/src/core/json/graphql.rs index 5491625461..f9d4260f55 100644 --- a/src/core/json/graphql.rs +++ b/src/core/json/graphql.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::collections::HashMap; use async_graphql::Name; -use async_graphql_value::ConstValue; +use async_graphql_value::{ConstValue, Value}; use indexmap::IndexMap; use super::*; @@ -196,3 +196,153 @@ impl<'json> JsonLike<'json> for ConstValue { ConstValue::String(s.to_string()) } } + +impl<'json> JsonLike<'json> for Value { + type JsonObject = IndexMap; + + fn from_primitive(x: JsonPrimitive<'json>) -> Self { + match x { + JsonPrimitive::Null => Value::Null, + JsonPrimitive::Bool(x) => Value::Boolean(x), + JsonPrimitive::Str(s) => Value::String(s.to_string()), + JsonPrimitive::Number(number) => Value::Number(number), + } + } + + fn as_primitive(&self) -> Option { + let val = match self { + Value::Null => JsonPrimitive::Null, + Value::Boolean(x) => JsonPrimitive::Bool(*x), + Value::Number(number) => JsonPrimitive::Number(number.clone()), + Value::String(s) => JsonPrimitive::Str(s.as_ref()), + Value::Enum(e) => JsonPrimitive::Str(e.as_str()), + _ => return None, + }; + + Some(val) + } + + fn as_array(&self) -> Option<&Vec> { + match self { + Value::List(seq) => Some(seq), + _ => None, + } + } + + fn as_array_mut(&mut self) -> Option<&mut Vec> { + match self { + Value::List(seq) => Some(seq), + _ => None, + } + } + + fn into_array(self) -> Option> { + match self { + Value::List(seq) => Some(seq), + _ => None, + } + } + + fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s), + _ => None, + } + } + + fn as_i64(&self) -> Option { + match self { + Value::Number(n) => n.as_i64(), + _ => None, + } + } + + fn as_u64(&self) -> Option { + match self { + Value::Number(n) => n.as_u64(), + _ => None, + } + } + + fn as_f64(&self) -> Option { + match self { + Value::Number(n) => n.as_f64(), + _ => None, + } + } + + fn as_bool(&self) -> Option { + match self { + Value::Boolean(b) => Some(*b), + _ => None, + } + } + + fn is_null(&self) -> bool { + matches!(self, Value::Null) + } + + fn get_path>(&self, path: &[T]) -> Option<&Self> { + let mut val = self; + for token in path { + val = match val { + Value::List(seq) => { + let index = token.as_ref().parse::().ok()?; + seq.get(index)? + } + Value::Object(map) => map.get(token.as_ref())?, + _ => return None, + }; + } + Some(val) + } + + fn get_key(&self, path: &str) -> Option<&Self> { + match self { + Value::Object(map) => map.get(&async_graphql::Name::new(path)), + _ => None, + } + } + + fn group_by(&self, path: &[String]) -> HashMap> { + let src = gather_path_matches(self, path, vec![]); + group_by_key(src) + } + + fn null() -> Self { + Default::default() + } + + fn as_object(&self) -> Option<&Self::JsonObject> { + match self { + Value::Object(map) => Some(map), + _ => None, + } + } + + fn as_object_mut(&mut self) -> Option<&mut Self::JsonObject> { + match self { + Value::Object(map) => Some(map), + _ => None, + } + } + + fn into_object(self) -> Option { + match self { + Value::Object(map) => Some(map), + _ => None, + } + } + + fn object(obj: Self::JsonObject) -> Self { + Value::Object(obj) + } + + fn array(arr: Vec) -> Self { + Value::List(arr) + } + + fn string(s: Cow<'json, str>) -> Self { + Value::String(s.to_string()) + } +} diff --git a/tests/core/snapshots/graphql-nested.md_0.snap b/tests/core/snapshots/graphql-nested.md_0.snap new file mode 100644 index 0000000000..0b3e1b3e22 --- /dev/null +++ b/tests/core/snapshots/graphql-nested.md_0.snap @@ -0,0 +1,23 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "queryNodeA": { + "name": "nodeA", + "nodeB": { + "name": "nodeB" + }, + "nodeC": { + "name": "nodeC" + } + } + } + } +} diff --git a/tests/core/snapshots/graphql-nested.md_client.snap b/tests/core/snapshots/graphql-nested.md_client.snap new file mode 100644 index 0000000000..b804ac9539 --- /dev/null +++ b/tests/core/snapshots/graphql-nested.md_client.snap @@ -0,0 +1,30 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +type NodeA { + child: NodeA + name: String + nodeB: NodeB + nodeC: NodeC +} + +type NodeB { + name: String + nodeA: NodeA + nodeC: NodeC +} + +type NodeC { + name: String + nodeA: NodeA + nodeB: NodeB +} + +type Query { + queryNodeA: NodeA +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/graphql-nested.md_merged.snap b/tests/core/snapshots/graphql-nested.md_merged.snap new file mode 100644 index 0000000000..8accbbde47 --- /dev/null +++ b/tests/core/snapshots/graphql-nested.md_merged.snap @@ -0,0 +1,30 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(hostname: "0.0.0.0", port: 8000) @upstream { + query: Query +} + +type NodeA { + name: String + nodeA: NodeA @modify(name: "child") + nodeB: NodeB + nodeC: NodeC +} + +type NodeB { + name: String + nodeA: NodeA + nodeC: NodeC +} + +type NodeC { + name: String + nodeA: NodeA + nodeB: NodeB +} + +type Query { + queryNodeA: NodeA @graphQL(url: "http://upstream/graphql", name: "nodeA") +} diff --git a/tests/execution/graphql-datasource-query-directives.md b/tests/execution/graphql-datasource-query-directives.md index f56b4b6a16..e1580c70c3 100644 --- a/tests/execution/graphql-datasource-query-directives.md +++ b/tests/execution/graphql-datasource-query-directives.md @@ -21,7 +21,7 @@ type Query { - request: method: POST url: http://upstream/graphql - textBody: '{ "query": "query { user @cascade(fields: [\\\"id\\\"]) { id @options(paging: false) } }" }' + textBody: '{ "query": "query { user @cascade(fields: [\\\"id\\\"]) { id @options(paging: false) name } }" }' response: status: 200 body: diff --git a/tests/execution/graphql-nested.md b/tests/execution/graphql-nested.md new file mode 100644 index 0000000000..bb9a277611 --- /dev/null +++ b/tests/execution/graphql-nested.md @@ -0,0 +1,71 @@ +# Complicated queries + +```graphql @config +schema @server(port: 8000, hostname: "0.0.0.0") { + query: Query +} + +type Query { + queryNodeA: NodeA @graphQL(url: "http://upstream/graphql", name: "nodeA") +} + +type NodeA { + name: String + nodeB: NodeB + nodeC: NodeC + nodeA: NodeA @modify(name: "child") +} + +type NodeB { + name: String + nodeA: NodeA + nodeC: NodeC +} + +type NodeC { + name: String + nodeA: NodeA + nodeB: NodeB +} +``` + +```yml @mock +- request: + method: POST + url: http://upstream/graphql + textBody: {"query": "query { nodeA { name nodeB { name } nodeC { name } } }"} + expectedHits: 1 + response: + status: 200 + body: + data: + nodeA: + name: nodeA + nodeB: + name: nodeB + nodeC: + name: nodeC + nodeA: + name: nodeA +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: | + query queryNodeA { + queryNodeA { + name + nodeA { + name + } + nodeB { + name + } + nodeC { + name + } + } + } +```