diff --git a/examples/jsonplaceholder.graphql b/examples/jsonplaceholder.graphql index 85ad270ce1..e3fd6f59f1 100644 --- a/examples/jsonplaceholder.graphql +++ b/examples/jsonplaceholder.graphql @@ -24,5 +24,5 @@ type Post { userId: Int! title: String! body: String! - user: User @http(path: "/users/{{value.userId}}") + user: User @call(query: "user", args: {id: "{{value.userId}}"}) } diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index 7d2c460c10..9a14383baa 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -322,6 +322,24 @@ directive @cache( maxAge: Int! ) on FIELD_DEFINITION +""" +The @call operator is used to reference a resolver operator (available resolvers are `@http`, `@grpc` and `@graphQL`). +""" +directive @call( + """ + The name of the field that has the resolver to be called. + """ + query: String! + # """ + # The name of the field that has the resolver to be called. + # """ + # mutation: String! + """ + The arguments to be replace the values on the actual resolver. + """ + args: JSON +) on FIELD_DEFINITION + """ Allows composing operators as simple expressions """ diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index c00a2e849d..3c1fd68d44 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -96,6 +96,36 @@ ], "type": "object" }, + "Call": { + "description": "For instance, if you have a `user(id: Int!): User @http(path: \"/users/{{args.id}}\")` field on the `Query` type, you can reference it from another field on the `Query` type using the `@call` operator. So, on `Post.user` you can declare `user: User @call(query: \"user\", args: {id: \"{{value.userId}}\"})`, and this will replace the `{{args.id}}` used in the `@http` operator with the value of `userId` from the `Post` type.\n\nIn case you have a `user(input: UserInput!): User @http(path: \"/users\")` field on the `Mutation` type, you can reference it from another field on the `Mutation` type. So, on `Post.user` you can declare `user: User @call(mutation: \"user\", args: {input: \"{{value.userInput}}\"})`, and this will replace the `{{args.input}}` used in the `@http` operator with the value of `userInput` from the `Post` type.", + "properties": { + "args": { + "additionalProperties": { + "type": "string" + }, + "description": "The arguments of the field on the `Query` type that you want to call. For instance `{id: \"{{value.userId}}\"}`.", + "type": "object" + }, + "mutation": { + "description": "The name of the field on the `Mutation` type that you want to call. For instance `createUser`.", + "type": [ + "string", + "null" + ] + }, + "query": { + "description": "The name of the field on the `Query` type that you want to call. For instance `user`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "args" + ], + "type": "object" + }, "Const": { "description": "The `@const` operators allows us to embed a constant response for the schema.", "properties": { @@ -171,6 +201,19 @@ ], "type": "object" }, + { + "additionalProperties": false, + "description": "Reuses a resolver pre-defined on type `Query`", + "properties": { + "call": { + "$ref": "#/definitions/Call" + } + }, + "required": [ + "call" + ], + "type": "object" + }, { "additionalProperties": false, "description": "Evaluate to constant data", @@ -868,6 +911,17 @@ ], "description": "Sets the cache configuration for a field" }, + "call": { + "anyOf": [ + { + "$ref": "#/definitions/Call" + }, + { + "type": "null" + } + ], + "description": "Inserts a call resolver for the field." + }, "const": { "anyOf": [ { diff --git a/src/blueprint/definitions.rs b/src/blueprint/definitions.rs index 6d7056f05b..93c255b0c1 100644 --- a/src/blueprint/definitions.rs +++ b/src/blueprint/definitions.rs @@ -388,6 +388,7 @@ fn to_fields( .and(update_graphql(&operation_type).trace(config::GraphQL::trace_name().as_str())) .and(update_expr(&operation_type).trace(config::Expr::trace_name().as_str())) .and(update_modify().trace(config::Modify::trace_name().as_str())) + .and(update_call(&operation_type).trace(config::Call::trace_name().as_str())) .and(update_nested_resolvers()) .try_fold(&(config, field, type_of, name), FieldDefinition::default()) }; diff --git a/src/blueprint/mustache.rs b/src/blueprint/mustache.rs index 2f5aaccea2..1c58dfafb6 100644 --- a/src/blueprint/mustache.rs +++ b/src/blueprint/mustache.rs @@ -101,14 +101,14 @@ impl<'a> MustachePartsValidator<'a> { } impl FieldDefinition { - pub fn validate_field(&self, type_of: &config::Type, config: &Config) -> Valid<(), String> { + pub fn validate_field(self, type_of: &config::Type, config: &Config) -> Valid { // XXX we could use `Mustache`'s `render` method with a mock // struct implementing the `PathString` trait encapsulating `validation_map` // but `render` simply falls back to the default value for a given // type if it doesn't exist, so we wouldn't be able to get enough // context from that method alone // So we must duplicate some of that logic here :( - let parts_validator = MustachePartsValidator::new(type_of, config, self); + let parts_validator = MustachePartsValidator::new(type_of, config, &self); match &self.resolver { Some(Expression::IO(IO::Http { req_template, .. })) => { @@ -168,5 +168,6 @@ impl FieldDefinition { } _ => Valid::succeed(()), } + .map_to(self) } } diff --git a/src/blueprint/operators/call.rs b/src/blueprint/operators/call.rs new file mode 100644 index 0000000000..82dd9c3db7 --- /dev/null +++ b/src/blueprint/operators/call.rs @@ -0,0 +1,345 @@ +use std::collections::hash_map::Iter; + +use crate::blueprint::*; +use crate::config::group_by::GroupBy; +use crate::config::{Config, Field, GraphQLOperationType}; +use crate::lambda::{DataLoaderId, Expression, IO}; +use crate::mustache::{Mustache, Segment}; +use crate::try_fold::TryFold; +use crate::valid::Valid; +use crate::{config, graphql, grpc, http}; + +fn find_value<'a>(args: &'a Iter<'a, String, String>, key: &'a String) -> Option<&'a String> { + args.clone() + .find_map(|(k, value)| if k == key { Some(value) } else { None }) +} + +pub fn update_call( + operation_type: &GraphQLOperationType, +) -> TryFold<'_, (&Config, &Field, &config::Type, &str), FieldDefinition, String> { + TryFold::<(&Config, &Field, &config::Type, &str), FieldDefinition, String>::new( + move |(config, field, _, _), b_field| { + let Some(call) = &field.call else { + return Valid::succeed(b_field); + }; + + compile_call(field, config, call, operation_type) + .and_then(|resolver| Valid::succeed(b_field.resolver(Some(resolver)))) + }, + ) +} + +struct Http { + pub req_template: http::RequestTemplate, + pub group_by: Option, + pub dl_id: Option, +} + +struct GraphQLEndpoint { + pub req_template: graphql::RequestTemplate, + pub field_name: String, + pub batch: bool, + pub dl_id: Option, +} + +struct Grpc { + pub req_template: grpc::RequestTemplate, + pub group_by: Option, + pub dl_id: Option, +} + +impl TryFrom for Http { + type Error = String; + + fn try_from(expr: Expression) -> Result { + match expr { + Expression::IO(IO::Http { req_template, group_by, dl_id }) => { + Ok(Http { req_template, group_by, dl_id }) + } + _ => Err("not an http expression".to_string()), + } + } +} + +impl TryFrom for GraphQLEndpoint { + type Error = String; + + fn try_from(expr: Expression) -> Result { + match expr { + Expression::IO(IO::GraphQLEndpoint { req_template, field_name, batch, dl_id }) => { + Ok(GraphQLEndpoint { req_template, field_name, batch, dl_id }) + } + _ => Err("not a graphql expression".to_string()), + } + } +} + +impl TryFrom for Grpc { + type Error = String; + + fn try_from(expr: Expression) -> Result { + match expr { + Expression::IO(IO::Grpc { req_template, group_by, dl_id }) => { + Ok(Grpc { req_template, group_by, dl_id }) + } + _ => Err("not a grpc expression".to_string()), + } + } +} + +fn get_type_and_field(call: &config::Call) -> Option<(String, String)> { + if let Some(query) = &call.query { + Some(("Query".to_string(), query.clone())) + } else { call.mutation.as_ref().map(|mutation| ("Mutation".to_string(), mutation.clone())) } +} + +pub fn compile_call( + field: &Field, + config: &Config, + call: &config::Call, + operation_type: &GraphQLOperationType, +) -> Valid { + Valid::from_option( + get_type_and_field(call), + "call must have query or mutation".to_string(), + ) + .and_then(|(type_name, field_name)| { + Valid::from_option( + config.find_type(&type_name), + format!("{} type not found on config", type_name), + ) + .zip(Valid::succeed(field_name)) + }) + .and_then(|(query_type, field_name)| { + Valid::from_option( + query_type.fields.get(&field_name), + format!("{} field not found", field_name), + ) + .zip(Valid::succeed(field_name)) + .and_then(|(field, field_name)| { + if field.has_resolver() { + Valid::succeed((field, field_name, call.args.iter())) + } else { + Valid::fail(format!("{} field has no resolver", field_name)) + } + }) + }) + .and_then(|(_field, field_name, args)| { + let empties: Vec<(&String, &config::Arg)> = _field + .args + .iter() + .filter(|(k, _)| !args.clone().any(|(k1, _)| k1.eq(*k))) + .collect(); + + if empties.len().gt(&0) { + return Valid::fail(format!( + "no argument {} found", + empties + .iter() + .map(|(k, _)| format!("'{}'", k)) + .collect::>() + .join(", ") + )) + .trace(field_name.as_str()); + } + + if let Some(http) = _field.http.clone() { + compile_http(config, field, &http).and_then(|expr| { + let http = Http::try_from(expr).unwrap(); + + Valid::succeed( + http.req_template + .clone() + .root_url(replace_url(&http.req_template.root_url, &args)), + ) + .map(|req_template| { + req_template.clone().query( + req_template + .clone() + .query + .iter() + .map(replace_mustache(&args)) + .collect(), + ) + }) + .map(|req_template| { + req_template.clone().headers( + req_template + .headers + .iter() + .map(replace_mustache(&args)) + .collect(), + ) + }) + .map(|req_template| { + Expression::IO(IO::Http { + req_template, + dl_id: http.dl_id, + group_by: http.group_by, + }) + }) + }) + } else if let Some(graphql) = _field.graphql.clone() { + compile_graphql(config, operation_type, &graphql).and_then(|expr| { + let graphql = GraphQLEndpoint::try_from(expr).unwrap(); + + Valid::succeed( + graphql.req_template.clone().headers( + graphql + .req_template + .headers + .iter() + .map(replace_mustache(&args)) + .collect(), + ), + ) + .map(|req_template| { + if req_template.operation_arguments.is_some() { + let operation_arguments = req_template + .clone() + .operation_arguments + .unwrap() + .iter() + .map(replace_mustache(&args)) + .collect(); + + req_template.operation_arguments(Some(operation_arguments)) + } else { + req_template + } + }) + .and_then(|req_template| { + Valid::succeed(Expression::IO(IO::GraphQLEndpoint { + req_template, + field_name: graphql.field_name, + batch: graphql.batch, + dl_id: graphql.dl_id, + })) + }) + }) + } else if let Some(grpc) = _field.grpc.clone() { + // todo!("needs to be implemented"); + let inputs: CompileGrpc<'_> = CompileGrpc { + config, + operation_type, + field, + grpc: &grpc, + validate_with_schema: false, + }; + compile_grpc(inputs).and_then(|expr| { + let grpc = Grpc::try_from(expr).unwrap(); + + Valid::succeed( + grpc.req_template + .clone() + .url(replace_url(&grpc.req_template.url, &args)), + ) + .map(|req_template| { + req_template.clone().headers( + req_template + .headers + .iter() + .map(replace_mustache(&args)) + .collect(), + ) + }) + .map(|req_template| { + if let Some(body) = req_template.clone().body { + req_template.clone().body(Some(replace_url(&body, &args))) + } else { + req_template + } + }) + .map(|req_template| { + Expression::IO(IO::Grpc { + req_template, + group_by: grpc.group_by, + dl_id: grpc.dl_id, + }) + }) + }) + } else { + return Valid::fail(format!("{} field has no resolver", field_name)); + } + }) +} + +fn replace_url(url: &Mustache, args: &Iter<'_, String, String>) -> Mustache { + url.get_segments() + .iter() + .map(|segment| match segment { + Segment::Literal(literal) => Segment::Literal(literal.clone()), + Segment::Expression(expression) => { + if expression[0] == "args" { + let value = find_value(args, &expression[1]).unwrap(); + let item = Mustache::parse(value).unwrap(); + + let expression = item.get_segments().first().unwrap().to_owned().to_owned(); + + expression + } else { + Segment::Expression(expression.clone()) + } + } + }) + .collect::>() + .into() +} + +fn replace_mustache<'a, T: Clone>( + args: &'a Iter<'a, String, String>, +) -> impl Fn(&(T, Mustache)) -> (T, Mustache) + 'a { + |(key, value)| { + let value: Mustache = value + .expression_segments() + .iter() + .map(|expression| { + if expression[0] == "args" { + let value = find_value(args, &expression[1]).unwrap(); + let item = Mustache::parse(value).unwrap(); + + let expression = item.get_segments().first().unwrap().to_owned().to_owned(); + + expression + } else { + Segment::Expression(expression.to_owned().to_owned()) + } + }) + .collect::>() + .into(); + + (key.clone().to_owned(), value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_from_http_fail() { + let expr = Expression::Literal("test".into()); + + let http = Http::try_from(expr); + + assert!(http.is_err()); + } + + #[test] + fn try_from_graphql_fail() { + let expr = Expression::Literal("test".into()); + + let graphql = GraphQLEndpoint::try_from(expr); + + assert!(graphql.is_err()); + } + + #[test] + fn try_from_grpc_fail() { + let expr = Expression::Literal("test".into()); + + let grpc = Grpc::try_from(expr); + + assert!(grpc.is_err()); + } +} diff --git a/src/blueprint/operators/expr.rs b/src/blueprint/operators/expr.rs index 0a009c8b5d..85e868928a 100644 --- a/src/blueprint/operators/expr.rs +++ b/src/blueprint/operators/expr.rs @@ -68,6 +68,7 @@ fn compile(ctx: &CompilationContext, expr: ExprBody) -> Valid compile_graphql(config, operation_type, &gql), + ExprBody::Call(call) => compile_call(field, config, &call, operation_type), // Safe Expr ExprBody::Const(value) => { @@ -559,10 +560,10 @@ mod tests { async fn test_relation_difference() { let expected = json!([1]); let actual = Expr::eval( - json!({"body": {"difference": [[{"const": 1}, {"const": 2}, {"const": 3}], [{"const": 2}, {"const": 3}]]}}), - ) - .await - .unwrap(); + json!({"body": {"difference": [[{"const": 1}, {"const": 2}, {"const": 3}], [{"const": 2}, {"const": 3}]]}}), + ) + .await + .unwrap(); assert_eq!(actual, expected); } @@ -811,10 +812,10 @@ mod tests { async fn test_relation_max() { let expected = json!(923.83); let actual = Expr::eval( - json!({"body": {"max": [{"const": 1}, {"const": 23}, {"const": -423}, {"const": 0}, {"const": 923.83}]}}), - ) - .await - .unwrap(); + json!({"body": {"max": [{"const": 1}, {"const": 23}, {"const": -423}, {"const": 0}, {"const": 923.83}]}}), + ) + .await + .unwrap(); assert_eq!(actual, expected); let expected = json!("z"); @@ -838,10 +839,10 @@ mod tests { async fn test_relation_min() { let expected = json!(-423); let actual = Expr::eval( - json!({"body": {"min": [{"const": 1}, {"const": 23}, {"const": -423}, {"const": 0}, {"const": 923.83}]}}), - ) - .await - .unwrap(); + json!({"body": {"min": [{"const": 1}, {"const": 23}, {"const": -423}, {"const": 0}, {"const": 923.83}]}}), + ) + .await + .unwrap(); assert_eq!(actual, expected); let expected = json!("abc"); diff --git a/src/blueprint/operators/graphql.rs b/src/blueprint/operators/graphql.rs index 4be5959e7d..0094c159fb 100644 --- a/src/blueprint/operators/graphql.rs +++ b/src/blueprint/operators/graphql.rs @@ -49,7 +49,7 @@ pub fn update_graphql<'a>( compile_graphql(config, operation_type, graphql) .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| b_field.validate_field(type_of, config).map_to(b_field)) + .and_then(|b_field| b_field.validate_field(type_of, config)) }, ) } diff --git a/src/blueprint/operators/grpc.rs b/src/blueprint/operators/grpc.rs index f974d34d39..01b9a89541 100644 --- a/src/blueprint/operators/grpc.rs +++ b/src/blueprint/operators/grpc.rs @@ -178,7 +178,7 @@ pub fn update_grpc<'a>( validate_with_schema: true, }) .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| b_field.validate_field(type_of, config).map_to(b_field)) + .and_then(|b_field| b_field.validate_field(type_of, config)) }, ) } diff --git a/src/blueprint/operators/http.rs b/src/blueprint/operators/http.rs index ce0990548b..02ae5ac211 100644 --- a/src/blueprint/operators/http.rs +++ b/src/blueprint/operators/http.rs @@ -78,7 +78,7 @@ pub fn update_http<'a>( compile_http(config, field, http) .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| b_field.validate_field(type_of, config).map_to(b_field)) + .and_then(|b_field| b_field.validate_field(type_of, config)) }, ) } diff --git a/src/blueprint/operators/mod.rs b/src/blueprint/operators/mod.rs index d5aca0731f..9a75b95654 100644 --- a/src/blueprint/operators/mod.rs +++ b/src/blueprint/operators/mod.rs @@ -1,3 +1,4 @@ +mod call; mod const_field; mod expr; mod graphql; @@ -5,6 +6,7 @@ mod grpc; mod http; mod modify; +pub use call::*; pub use const_field::*; pub use expr::*; pub use graphql::*; diff --git a/src/config/config.rs b/src/config/config.rs index de6f256d6c..f6cbc722ce 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::{self, Display}; use std::num::NonZeroU64; @@ -365,6 +365,12 @@ pub struct Field { #[serde(default, skip_serializing_if = "is_default")] pub http: Option, + /// + /// Inserts a call resolver for the field. + /// + #[serde(default, skip_serializing_if = "is_default")] + pub call: Option, + /// /// Inserts a GRPC resolver for the field. /// @@ -426,6 +432,9 @@ impl Field { if self.grpc.is_some() { directives.push(Grpc::trace_name()); } + if self.call.is_some() { + directives.push(Call::trace_name()); + } directives } pub fn has_batched_resolver(&self) -> bool { @@ -555,6 +564,27 @@ pub struct Http { pub encoding: Encoding, } +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema)] +// The @call operator is used to reference a resolver operator (`@http`, `@grpc`, `@graphQL`) from a field on `Query` type. +/// +/// For instance, if you have a `user(id: Int!): User @http(path: "/users/{{args.id}}")` field on the `Query` type, you can reference it from another field on the `Query` type using the `@call` operator. +/// So, on `Post.user` you can declare `user: User @call(query: "user", args: {id: "{{value.userId}}"})`, and this will replace the `{{args.id}}` used in the `@http` operator with the value of `userId` from the `Post` type. +/// +/// In case you have a `user(input: UserInput!): User @http(path: "/users")` field on the `Mutation` type, you can reference it from another field on the `Mutation` type. +/// So, on `Post.user` you can declare `user: User @call(mutation: "user", args: {input: "{{value.userInput}}"})`, and this will replace the `{{args.input}}` used in the `@http` operator with the value of `userInput` from the `Post` type. +pub struct Call { + #[serde(default, skip_serializing_if = "is_default")] + /// The name of the field on the `Query` type that you want to call. For instance `user`. + pub query: Option, + + #[serde(default, skip_serializing_if = "is_default")] + /// The name of the field on the `Mutation` type that you want to call. For instance `createUser`. + pub mutation: Option, + + /// The arguments of the field on the `Query` type that you want to call. For instance `{id: "{{value.userId}}"}`. + pub args: HashMap, +} + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] /// The @grpc operator indicates that a field or node is backed by a gRPC API. diff --git a/src/config/expr.rs b/src/config/expr.rs index 5f3531b9b2..88f7d23097 100644 --- a/src/config/expr.rs +++ b/src/config/expr.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{GraphQL, Grpc, Http}; +use super::{Call, GraphQL, Grpc, Http}; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, schemars::JsonSchema)] /// Allows composing operators as simple expressions @@ -24,6 +24,10 @@ pub enum ExprBody { #[serde(rename = "graphQL")] GraphQL(GraphQL), + /// Reuses a resolver pre-defined on type `Query` + #[serde(rename = "call")] + Call(Call), + /// Evaluate to constant data #[serde(rename = "const")] Const(Value), @@ -121,6 +125,7 @@ impl ExprBody { ExprBody::Http(_) => true, ExprBody::Grpc(_) => true, ExprBody::GraphQL(_) => true, + ExprBody::Call(_) => true, ExprBody::Const(_) => false, ExprBody::If { cond, on_true, on_false } => { cond.has_io() || on_true.has_io() || on_false.has_io() diff --git a/src/config/from_document.rs b/src/config/from_document.rs index 191772a2bc..65c77b5331 100644 --- a/src/config/from_document.rs +++ b/src/config/from_document.rs @@ -10,7 +10,8 @@ use async_graphql::Name; use super::JS; use crate::config::{ - self, Cache, Config, Expr, GraphQL, Grpc, Modify, Omit, RootSchema, Server, Union, Upstream, + self, Cache, Call, Config, Expr, GraphQL, Grpc, Modify, Omit, RootSchema, Server, Union, + Upstream, }; use crate::directive::DirectiveCodec; use crate::valid::Valid; @@ -272,8 +273,9 @@ where .zip(Omit::from_directives(directives.iter())) .zip(Modify::from_directives(directives.iter())) .zip(JS::from_directives(directives.iter())) + .zip(Call::from_directives(directives.iter())) .map( - |(((((((http, graphql), cache), grpc), expr), omit), modify), script)| { + |((((((((http, graphql), cache), grpc), expr), omit), modify), script), call)| { let const_field = to_const_field(directives); config::Field { type_of, @@ -291,6 +293,7 @@ where graphql, expr, cache: cache.or(parent_cache), + call, } }, ) diff --git a/src/config/into_document.rs b/src/config/into_document.rs index 4cd4509c1a..bbc6cb8694 100644 --- a/src/config/into_document.rs +++ b/src/config/into_document.rs @@ -219,6 +219,7 @@ fn get_directives(field: &crate::config::Field) -> Vec Vec<&Segment> { + match self { + Mustache(segments) => segments.iter().collect(), + } + } + pub fn expression_segments(&self) -> Vec<&Vec> { match self { Mustache(segments) => segments diff --git a/tests/graphql/errors/test-call-operator.graphql b/tests/graphql/errors/test-call-operator.graphql new file mode 100644 index 0000000000..4f99bbfaf4 --- /dev/null +++ b/tests/graphql/errors/test-call-operator.graphql @@ -0,0 +1,41 @@ +#> server-sdl +schema @server @upstream(baseURL: "http://localhost:3000") { + query: Query +} + +type Query { + posts: [Post] @http(path: "/posts") + userWithoutResolver(id: Int!): User + user(id: Int!): User @http(path: "/users/{{args.id}}") + userWithGraphQLResolver(id: Int!): User @graphQL(name: "user", args: [{key: "id", value: "{{args.id}}"}]) + userWithGraphQLHeaders(id: Int!): User @graphQL(name: "user", headers: [{key: "id", value: "{{args.id}}"}]) +} + +type User { + id: Int! +} + +type Post { + userId: Int! + withoutResolver: User @call(query: "userWithoutResolver", args: {id: "{{value.userId}}"}) + withoutOperator: User @call(args: {id: "{{value.userId}}"}) + urlMismatchHttp: User @call(query: "user", args: {}) + argumentMismatchGraphQL: User @call(query: "userWithGraphQLResolver", args: {}) + headersMismatchGraphQL: User @call(query: "userWithGraphQLResolver", args: {}) +} + +#> client-sdl +type Failure @error(message: "No resolver has been found in the schema", trace: ["Query", "userWithoutResolver"]) +type Failure + @error( + message: "no argument 'id' found" + trace: ["Post", "argumentMismatchGraphQL", "@call", "userWithGraphQLResolver"] + ) +type Failure + @error( + message: "no argument 'id' found" + trace: ["Post", "headersMismatchGraphQL", "@call", "userWithGraphQLResolver"] + ) +type Failure @error(message: "no argument 'id' found", trace: ["Post", "urlMismatchHttp", "@call", "user"]) +type Failure @error(message: "call must have query or mutation", trace: ["Post", "withoutOperator", "@call"]) +type Failure @error(message: "userWithoutResolver field has no resolver", trace: ["Post", "withoutResolver", "@call"]) diff --git a/tests/graphql/errors/test-expr-errors.graphql b/tests/graphql/errors/test-expr-errors.graphql index 6359851059..958c143ea7 100644 --- a/tests/graphql/errors/test-expr-errors.graphql +++ b/tests/graphql/errors/test-expr-errors.graphql @@ -12,6 +12,6 @@ type Query { type Failure @error(message: "Parsing failed because of missing field `body`", trace: ["@expr"]) @error( - message: "Parsing failed because of unknown variant `unsupported`, expected one of `http`, `grpc`, `graphQL`, `const`, `if`, `and`, `or`, `cond`, `defaultTo`, `isEmpty`, `not`, `concat`, `intersection`, `difference`, `eq`, `gt`, `gte`, `lt`, `lte`, `max`, `min`, `pathEq`, `propEq`, `sortPath`, `symmetricDifference`, `union`, `mod`, `add`, `dec`, `divide`, `inc`, `multiply`, `negate`, `product`, `subtract`, `sum`" + message: "Parsing failed because of unknown variant `unsupported`, expected one of `http`, `grpc`, `graphQL`, `call`, `const`, `if`, `and`, `or`, `cond`, `defaultTo`, `isEmpty`, `not`, `concat`, `intersection`, `difference`, `eq`, `gt`, `gte`, `lt`, `lte`, `max`, `min`, `pathEq`, `propEq`, `sortPath`, `symmetricDifference`, `union`, `mod`, `add`, `dec`, `divide`, `inc`, `multiply`, `negate`, `product`, `subtract`, `sum`" trace: ["@expr", "body"] ) diff --git a/tests/graphql/errors/test-multiple-resolvable-directives-on-field.graphql b/tests/graphql/errors/test-multiple-resolvable-directives-on-field.graphql index c48b7dc2d5..22df130f3e 100644 --- a/tests/graphql/errors/test-multiple-resolvable-directives-on-field.graphql +++ b/tests/graphql/errors/test-multiple-resolvable-directives-on-field.graphql @@ -10,7 +10,9 @@ type User { type Query { user1: User @const(data: {name: "John"}) @http(path: "/users/1") + user2: User @http(path: "/users/2") @call(query: "something", args: {}) } #> client-sdl type Failure @error(message: "Multiple resolvers detected [@http, @const]", trace: ["Query", "user1"]) +type Failure @error(message: "Multiple resolvers detected [@http, @call]", trace: ["Query", "user2"]) diff --git a/tests/http/call-graphql-datasource.yml b/tests/http/call-graphql-datasource.yml new file mode 100644 index 0000000000..763dc5eed0 --- /dev/null +++ b/tests/http/call-graphql-datasource.yml @@ -0,0 +1,61 @@ +--- +name: Call operator with graphQL datasource +config: !file tests/http/config/call-graphql-datasource.graphql +mock: + - request: + url: http://jsonplaceholder.typicode.com/posts + response: + body: + - id: 1 + title: "a" + userId: 1 + - id: 2 + title: "b" + userId: 1 + - id: 3 + title: "c" + userId: 2 + - id: 4 + title: "d" + userId: 2 + - request: + url: http://upstream/graphql + method: POST + body: '{ "query": "query { user(id: 1) { name } }" }' + response: + body: + data: + user: + name: "Leanne Graham" + - request: + url: http://upstream/graphql + method: POST + body: '{ "query": "query { user(id: 2) { name } }" }' + response: + body: + data: + user: + name: "Ervin Howell" + +assert: + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { title user { name } } }" + response: + body: + data: + posts: + - title: "a" + user: + name: "Leanne Graham" + - title: "b" + user: + name: "Leanne Graham" + - title: "c" + user: + name: "Ervin Howell" + - title: "d" + user: + name: "Ervin Howell" diff --git a/tests/http/call-operator.yml b/tests/http/call-operator.yml new file mode 100644 index 0000000000..f55623d693 --- /dev/null +++ b/tests/http/call-operator.yml @@ -0,0 +1,207 @@ +--- +name: With call operator +config: !file tests/http/config/call.graphql +runner: only + +mock: + - request: + url: http://jsonplaceholder.typicode.com/users/1 + response: + body: + id: 1 + name: foo + - request: + url: http://jsonplaceholder.typicode.com/users + headers: + id: 1 + response: + body: + id: 1 + name: "Leanne Graham http headers" + - request: + url: http://jsonplaceholder.typicode.com/posts + response: + body: + - id: 1 + userId: 1 + - request: + url: http://jsonplaceholder.typicode.com/users?id=1 + response: + body: + id: 1 + name: "Leanne Graham http query" + - request: + url: http://upstream/graphql + method: POST + body: '{ "query": "query { user(id: 1) { name } }" }' + response: + body: + data: + user: + name: "Leanne Graham" + - request: + url: http://upstream/graphql + method: POST + body: '{ "query": "query { user { name } }" }' + headers: + id: 1 + response: + body: + data: + user: + name: "Leanne Graham" + - request: + url: http://jsonplaceholder.typicode.com/users + response: + body: + - id: 1 + name: foo + - request: + url: http://jsonplaceholder.typicode.com/posts?userId=1 + response: + body: + - id: 1 + userId: 1 + title: bar + body: baz + - id: 2 + userId: 1 + title: qux + body: quux + - request: + url: http://localhost:50051/NewsService/GetAllNews + method: POST + response: + body: \0\0\0\0t\n#\x08\x01\x12\x06Note 1\x1a\tContent 1\"\x0cPost image 1\n#\x08\x02\x12\x06Note 2\x1a\tContent 2\"\x0cPost image 2 + +assert: + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { user { name } } }" + response: + body: + data: + posts: + - user: + name: foo + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { userGraphQLHeaders { name } } }" + headers: + id: 1 + response: + body: + data: + posts: + - userGraphQLHeaders: + name: "Leanne Graham" + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { userGraphQLHeaders { name } } }" + response: + body: + data: + posts: + - userGraphQLHeaders: + name: "Leanne Graham" + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { userHttpHeaders { name } } }" + response: + body: + data: + posts: + - userHttpHeaders: + name: "Leanne Graham http headers" + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { userHttpQuery { name } } }" + response: + body: + data: + posts: + - userHttpQuery: + name: "Leanne Graham http query" + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { userPosts(id: 1) { title } }" + response: + body: + data: + userPosts: + - title: bar + - title: qux + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { userWithPosts { posts { title } } }" + response: + body: + data: + userWithPosts: + posts: + - title: bar + - title: qux + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { news { news{ id }} }" + response: + body: + data: + news: + news: + - id: 1 + - id: 2 + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { news { news { id } } } }" + response: + body: + data: + posts: + - news: + news: + - id: 1 + - id: 2 + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { posts { newsWithPortArg { news { id } } } }" + response: + body: + data: + posts: + - newsWithPortArg: + news: + - id: 1 + - id: 2 + - request: + method: POST + url: http://localhost:8080/graphql + body: + query: "query { newsWithPortArg(port: 50051) { news { id } } }" + response: + body: + data: + newsWithPortArg: + news: + - id: 1 + - id: 2 diff --git a/tests/http/config/call-graphql-datasource.graphql b/tests/http/config/call-graphql-datasource.graphql new file mode 100644 index 0000000000..39713588b4 --- /dev/null +++ b/tests/http/config/call-graphql-datasource.graphql @@ -0,0 +1,28 @@ +schema + @server(port: 8000, graphiql: true, hostname: "0.0.0.0") + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: true) { + query: Query +} + +type Query { + posts: [Post] @http(path: "/posts") + user(id: Int!): User + @graphQL(baseURL: "http://upstream/graphql", name: "user", args: [{key: "id", value: "{{args.id}}"}]) +} + +type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String +} + +type Post { + id: Int! + userId: Int! + title: String! + body: String! + user: User @call(query: "user", args: {id: "{{value.userId}}"}) +} diff --git a/tests/http/config/call.graphql b/tests/http/config/call.graphql new file mode 100644 index 0000000000..db07e36ce1 --- /dev/null +++ b/tests/http/config/call.graphql @@ -0,0 +1,72 @@ +schema + @server(port: 8000, graphiql: true, hostname: "0.0.0.0") + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: true) { + query: Query +} + +type Query { + posts: [Post] @http(path: "/posts") + user(id: Int!): User @http(path: "/users/{{args.id}}") + userPosts(id: ID!): [Post] @http(path: "/posts", query: [{key: "userId", value: "{{args.id}}"}]) + userHttpHeaders(id: ID!): User @http(path: "/users", headers: [{key: "id", value: "{{args.id}}"}]) + userHttpQuery(id: ID!): User @http(path: "/users", query: [{key: "id", value: "{{args.id}}"}]) + userGraphQL(id: Int): User + @graphQL(baseURL: "http://upstream/graphql", name: "user", args: [{key: "id", value: "{{args.id}}"}]) + userGraphQLHeaders(id: Int!): User + @graphQL(baseURL: "http://upstream/graphql", name: "user", headers: [{key: "id", value: "{{args.id}}"}]) + userWithPosts: UserWithPosts @http(path: "/users/1") + news: NewsData! + @grpc( + service: "NewsService" + method: "GetAllNews" + baseURL: "http://localhost:50051" + protoPath: "src/grpc/tests/news.proto" + ) + newsWithPortArg(port: Int!): NewsData! + @grpc( + service: "NewsService" + method: "GetAllNews" + baseURL: "http://localhost:{{args.port}}" + protoPath: "src/grpc/tests/news.proto" + ) +} + +type NewsData { + news: [News]! +} + +type News { + id: Int + title: String + body: String + postImage: String +} + +type UserWithPosts { + id: Int! + name: String! + posts: [Post] @call(query: "userPosts", args: {id: "{{value.id}}"}) +} + +type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String +} + +type Post { + id: Int + userId: Int! + title: String + body: String + user: User @call(query: "user", args: {id: "{{value.userId}}"}) + userHttpHeaders: User @call(query: "userHttpHeaders", args: {id: "{{value.userId}}"}) + userHttpQuery: User @call(query: "userHttpQuery", args: {id: "{{value.userId}}"}) + userGraphQL: User @call(query: "userGraphQL", args: {id: "{{value.userId}}"}) + userGraphQLHeaders: User @call(query: "userGraphQLHeaders", args: {id: "{{value.userId}}"}) + news: NewsData! @call(query: "news", args: {}) + newsWithPortArg: NewsData! @call(query: "news", args: {port: "50051"}) +} diff --git a/tests/http_spec.rs b/tests/http_spec.rs index 539819962b..c3bc35ea8e 100644 --- a/tests/http_spec.rs +++ b/tests/http_spec.rs @@ -287,7 +287,24 @@ impl HttpIO for MockHttpClient { None => Value::Null, }; let body_match = req_body == mock_req.0.body; - method_match && url_match && (body_match || is_grpc) + let headers_match = req + .headers() + .iter() + .filter(|(key, _)| *key != "content-type") + .all(|(key, value)| { + let header_name = key.to_string(); + + let header_value = value.to_str().unwrap(); + let mock_header_value = "".to_string(); + let mock_header_value = mock_req + .0 + .headers + .get(&header_name) + .unwrap_or(&mock_header_value); + header_value == mock_header_value + }); + + method_match && url_match && headers_match && (body_match || is_grpc) }) .ok_or(anyhow!( "No mock found for request: {:?} {} in {}",