diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index a2ba1acf44..721baa068f 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -282,6 +282,18 @@ directive @server( """ port: Int """ + `queryComplexity` sets the maximum allowed complexity for a GraphQL query. It helps + prevent resource-intensive queries by limiting their complexity. If set, queries + exceeding this complexity will be rejected. + """ + queryComplexity: Int + """ + `queryDepth` sets the maximum allowed depth for a GraphQL query. It helps prevent + deeply nested queries that could potentially overload the server. If set, queries + exceeding this depth will be rejected. + """ + queryDepth: Int + """ `queryValidation` checks incoming GraphQL queries against the schema, preventing errors from invalid queries. Can be disabled for performance. @default `false`. """ diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index 76e5cab3c2..a2d94946d0 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -1042,6 +1042,24 @@ "format": "uint16", "minimum": 0.0 }, + "queryComplexity": { + "description": "`queryComplexity` sets the maximum allowed complexity for a GraphQL query. It helps prevent resource-intensive queries by limiting their complexity. If set, queries exceeding this complexity will be rejected.", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0.0 + }, + "queryDepth": { + "description": "`queryDepth` sets the maximum allowed depth for a GraphQL query. It helps prevent deeply nested queries that could potentially overload the server. If set, queries exceeding this depth will be rejected.", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0.0 + }, "queryValidation": { "description": "`queryValidation` checks incoming GraphQL queries against the schema, preventing errors from invalid queries. Can be disabled for performance. @default `false`.", "type": [ diff --git a/src/core/blueprint/index.rs b/src/core/blueprint/index.rs index d8ebe1a317..2c1d2c4b82 100644 --- a/src/core/blueprint/index.rs +++ b/src/core/blueprint/index.rs @@ -1,7 +1,7 @@ use indexmap::IndexMap; use crate::core::blueprint::{ - Blueprint, Definition, FieldDefinition, InputFieldDefinition, SchemaDefinition, + Blueprint, Definition, FieldDefinition, InputFieldDefinition, SchemaDefinition, Server, }; use crate::core::scalar; @@ -13,6 +13,7 @@ use crate::core::scalar; pub struct Index { map: IndexMap)>, schema: SchemaDefinition, + server: Server, } #[derive(Debug)] @@ -78,6 +79,14 @@ impl Index { false } } + + pub fn query_complexity(&self) -> Option { + self.server.query_complexity + } + + pub fn query_depth(&self) -> Option { + self.server.query_depth + } } impl From<&Blueprint> for Index { @@ -169,7 +178,11 @@ impl From<&Blueprint> for Index { } } - Self { map, schema: blueprint.schema.to_owned() } + Self { + map, + schema: blueprint.schema.to_owned(), + server: blueprint.server.to_owned(), + } } } @@ -183,7 +196,11 @@ mod test { fn setup() -> Index { let config = include_config!("./fixture/all-constructs.graphql").unwrap(); let cfg_module = ConfigModule::from(config); - let blueprint = Blueprint::try_from(&cfg_module).unwrap(); + let mut blueprint = Blueprint::try_from(&cfg_module).unwrap(); + // Set a fixed number of workers to ensure consistent snapshots across different + // environments This is necessary because the number of workers might + // vary on different CI systems + blueprint.server = blueprint.server.worker(4); Index::from(&blueprint) } diff --git a/src/core/blueprint/server.rs b/src/core/blueprint/server.rs index e8077a1514..fc66c50ceb 100644 --- a/src/core/blueprint/server.rs +++ b/src/core/blueprint/server.rs @@ -38,6 +38,8 @@ pub struct Server { pub experimental_headers: HashSet, pub auth: Option, pub dedupe: bool, + pub query_complexity: Option, + pub query_depth: Option, } /// Mimic of mini_v8::Script that's wasm compatible @@ -154,6 +156,8 @@ impl TryFrom for Server { cors, auth, dedupe: config_server.get_dedupe(), + query_complexity: config_server.get_query_complexity(), + query_depth: config_server.get_query_depth(), } }, ) 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 fc441a38e2..396ac7bb1b 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 @@ -1200,4 +1200,30 @@ Index { }, ], }, + server: Server { + enable_jit: true, + enable_apollo_tracing: false, + enable_cache_control_header: false, + enable_set_cookie_header: false, + enable_introspection: true, + enable_query_validation: false, + enable_response_validation: false, + enable_batch_requests: false, + enable_showcase: false, + global_response_timeout: 0, + worker: 4, + port: 8000, + hostname: 127.0.0.1, + vars: {}, + response_headers: {}, + http: HTTP1, + pipeline_flush: true, + script: None, + cors: None, + experimental_headers: {}, + auth: None, + dedupe: false, + query_complexity: None, + query_depth: None, + }, } diff --git a/src/core/config/server.rs b/src/core/config/server.rs index 960e8ce40e..52c1e812fd 100644 --- a/src/core/config/server.rs +++ b/src/core/config/server.rs @@ -120,6 +120,19 @@ pub struct Server { /// `workers` sets the number of worker threads. @default the number of /// system cores. pub workers: Option, + + #[serde(default, skip_serializing_if = "is_default")] + /// `queryComplexity` sets the maximum allowed complexity for a GraphQL + /// query. It helps prevent resource-intensive queries by limiting their + /// complexity. If set, queries exceeding this complexity will be + /// rejected. + pub query_complexity: Option, + + #[serde(default, skip_serializing_if = "is_default")] + /// `queryDepth` sets the maximum allowed depth for a GraphQL query. + /// It helps prevent deeply nested queries that could potentially overload + /// the server. If set, queries exceeding this depth will be rejected. + pub query_depth: Option, } fn merge_right_vars(mut left: Vec, right: Vec) -> Vec { @@ -231,6 +244,12 @@ impl Server { pub fn enable_jit(&self) -> bool { self.enable_jit.unwrap_or(true) } + pub fn get_query_complexity(&self) -> Option { + self.query_complexity + } + pub fn get_query_depth(&self) -> Option { + self.query_depth + } } #[cfg(test)] diff --git a/src/core/jit/builder.rs b/src/core/jit/builder.rs index 970eef92fb..56f8c5f538 100644 --- a/src/core/jit/builder.rs +++ b/src/core/jit/builder.rs @@ -11,11 +11,13 @@ use async_graphql_value::{ConstValue, Value}; use super::input_resolver::InputResolver; use super::model::{Directive as JitDirective, *}; +use super::rules::{ExecutionRule, QueryComplexity, QueryDepth, RuleOps, Rules}; use super::BuildError; use crate::core::blueprint::{Blueprint, Index, QueryField}; use crate::core::counter::{Count, Counter}; use crate::core::jit::model::OperationPlan; use crate::core::merge_right::MergeRight; +use crate::core::valid::Validator; use crate::core::Type; #[derive(PartialEq, strum_macros::Display)] @@ -360,12 +362,26 @@ impl Builder { is_introspection_query, ); + // perform the rule check. + Rules + .pipe( + QueryComplexity::new(self.index.query_complexity().unwrap_or_default()) + .when(self.index.query_complexity().is_some()), + ) + .pipe( + QueryDepth::new(self.index.query_depth().unwrap_or_default()) + .when(self.index.query_depth().is_some()), + ) + .validate(&plan) + .to_result()?; + // TODO: operation from [ExecutableDocument] could contain definitions for // default values of arguments. That info should be passed to // [InputResolver] to resolve defaults properly let input_resolver = InputResolver::new(plan); + let plan = input_resolver.resolve_input(variables)?; - Ok(input_resolver.resolve_input(variables)?) + Ok(plan) } } diff --git a/src/core/jit/error.rs b/src/core/jit/error.rs index b47380097c..8757c4d171 100644 --- a/src/core/jit/error.rs +++ b/src/core/jit/error.rs @@ -2,7 +2,9 @@ use async_graphql::parser::types::OperationType; use async_graphql::{ErrorExtensions, ServerError}; use thiserror::Error; -#[derive(Error, Debug, Clone, PartialEq, Eq)] +use crate::core::valid::ValidationError as CoreValidationError; + +#[derive(Error, Debug, Clone, PartialEq)] #[error("Error while building the plan")] pub enum BuildError { #[error("Root Operation type not defined for {operation}")] @@ -13,6 +15,8 @@ pub enum BuildError { OperationNotFound(String), #[error("Operation name required in request")] OperationNameRequired, + #[error("{0}")] + ValidationError(#[from] CoreValidationError), } #[derive(Error, Debug, Clone, PartialEq, Eq)] diff --git a/src/core/jit/mod.rs b/src/core/jit/mod.rs index e2b281bc6c..723025c4d5 100644 --- a/src/core/jit/mod.rs +++ b/src/core/jit/mod.rs @@ -11,6 +11,7 @@ mod exec_const; mod input_resolver; mod request; mod response; +mod rules; // NOTE: Only used in tests and benchmarks mod builder; diff --git a/src/core/jit/rules/mod.rs b/src/core/jit/rules/mod.rs new file mode 100644 index 0000000000..8ef53c7513 --- /dev/null +++ b/src/core/jit/rules/mod.rs @@ -0,0 +1,59 @@ +use super::OperationPlan; +use crate::core::valid::{Valid, Validator}; + +mod query_complexity; +mod query_depth; + +pub use query_complexity::QueryComplexity; +pub use query_depth::QueryDepth; + +pub trait ExecutionRule { + fn validate(&self, plan: &OperationPlan) -> Valid<(), String>; +} + +pub trait RuleOps: Sized + ExecutionRule { + fn pipe(self, other: Other) -> Pipe { + Pipe(self, other) + } + fn when(self, cond: bool) -> When { + When(self, cond) + } +} + +impl RuleOps for T {} + +pub struct Pipe(A, B); + +impl ExecutionRule for Pipe +where + A: ExecutionRule, + B: ExecutionRule, +{ + fn validate(&self, plan: &OperationPlan) -> Valid<(), String> { + self.0.validate(plan).and_then(|_| self.1.validate(plan)) + } +} + +pub struct When(A, bool); + +impl ExecutionRule for When +where + A: ExecutionRule, +{ + fn validate(&self, plan: &OperationPlan) -> Valid<(), String> { + if self.1 { + self.0.validate(plan) + } else { + Valid::succeed(()) + } + } +} + +#[derive(Default)] +pub struct Rules; + +impl ExecutionRule for Rules { + fn validate(&self, _: &OperationPlan) -> Valid<(), String> { + Valid::succeed(()) + } +} diff --git a/src/core/jit/rules/query_complexity.rs b/src/core/jit/rules/query_complexity.rs new file mode 100644 index 0000000000..2c8a0ec076 --- /dev/null +++ b/src/core/jit/rules/query_complexity.rs @@ -0,0 +1,106 @@ +use super::ExecutionRule; +use crate::core::jit::{Field, Nested, OperationPlan}; +use crate::core::valid::Valid; + +pub struct QueryComplexity(usize); + +impl QueryComplexity { + pub fn new(depth: usize) -> Self { + Self(depth) + } +} + +impl ExecutionRule for QueryComplexity { + fn validate(&self, plan: &OperationPlan) -> Valid<(), String> { + let complexity: usize = plan.as_nested().iter().map(Self::complexity_helper).sum(); + if complexity > self.0 { + Valid::fail("Query is too complex.".into()) + } else { + Valid::succeed(()) + } + } +} + +impl QueryComplexity { + fn complexity_helper(field: &Field, T>) -> usize { + let mut complexity = 1; + + for child in field.iter_only(|_| true) { + complexity += Self::complexity_helper(child); + } + + complexity + } +} + +#[cfg(test)] +mod test { + use async_graphql_value::ConstValue; + + use crate::core::blueprint::Blueprint; + use crate::core::config::Config; + use crate::core::jit::rules::{ExecutionRule, QueryComplexity}; + use crate::core::jit::{Builder, OperationPlan, Variables}; + use crate::core::valid::Validator; + + const CONFIG: &str = include_str!("./../fixtures/jsonplaceholder-mutation.graphql"); + + fn plan(query: impl AsRef) -> OperationPlan { + let config = Config::from_sdl(CONFIG).to_result().unwrap(); + let blueprint = Blueprint::try_from(&config.into()).unwrap(); + let document = async_graphql::parser::parse_query(query).unwrap(); + let variables: Variables = Variables::default(); + + Builder::new(&blueprint, document) + .build(&variables, None) + .unwrap() + } + + #[test] + fn test_query_complexity() { + let query = r#" + { + posts { + id + userId + title + } + } + "#; + + let plan = plan(query); + let query_complexity = QueryComplexity::new(4); + let val_result = query_complexity.validate(&plan); + assert!(val_result.is_succeed()); + + let query_complexity = QueryComplexity::new(2); + let val_result = query_complexity.validate(&plan); + assert!(!val_result.is_succeed()); + } + + #[test] + fn test_nested_query_complexity() { + let query = r#" + { + posts { + id + title + user { + id + name + } + } + } + "#; + + let plan = plan(query); + + let query_complexity = QueryComplexity::new(6); + let val_result = query_complexity.validate(&plan); + assert!(val_result.is_succeed()); + + let query_complexity = QueryComplexity::new(5); + let val_result = query_complexity.validate(&plan); + assert!(!val_result.is_succeed()); + } +} diff --git a/src/core/jit/rules/query_depth.rs b/src/core/jit/rules/query_depth.rs new file mode 100644 index 0000000000..237cb797ee --- /dev/null +++ b/src/core/jit/rules/query_depth.rs @@ -0,0 +1,118 @@ +use super::ExecutionRule; +use crate::core::jit::{Field, Nested, OperationPlan}; +use crate::core::valid::Valid; + +pub struct QueryDepth(usize); + +impl QueryDepth { + pub fn new(depth: usize) -> Self { + Self(depth) + } +} + +impl ExecutionRule for QueryDepth { + fn validate(&self, plan: &OperationPlan) -> Valid<(), String> { + let depth = plan + .as_nested() + .iter() + .map(|field| Self::depth_helper(field, 1)) + .max() + .unwrap_or(0); + + if depth > self.0 { + Valid::fail("Query is nested too deep.".into()) + } else { + Valid::succeed(()) + } + } +} + +impl QueryDepth { + /// Helper function to recursively calculate depth. + fn depth_helper( + field: &Field, T>, + current_depth: usize, + ) -> usize { + let mut max_depth = current_depth; + + for child in field.iter_only(|_| true) { + let depth = Self::depth_helper(child, current_depth + 1); + if depth > max_depth { + max_depth = depth; + } + } + max_depth + } +} + +#[cfg(test)] +mod test { + use async_graphql_value::ConstValue; + + use super::QueryDepth; + use crate::core::blueprint::Blueprint; + use crate::core::config::Config; + use crate::core::jit::rules::ExecutionRule; + use crate::core::jit::{Builder, OperationPlan, Variables}; + use crate::core::valid::Validator; + + const CONFIG: &str = include_str!("./../fixtures/jsonplaceholder-mutation.graphql"); + + fn plan(query: impl AsRef) -> OperationPlan { + let config = Config::from_sdl(CONFIG).to_result().unwrap(); + let blueprint = Blueprint::try_from(&config.into()).unwrap(); + let document = async_graphql::parser::parse_query(query).unwrap(); + let variables: Variables = Variables::default(); + + Builder::new(&blueprint, document) + .build(&variables, None) + .unwrap() + } + #[test] + fn test_query_complexity() { + let query = r#" + { + posts { + id + userId + title + } + } + "#; + + let plan = plan(query); + let query_complexity = QueryDepth::new(4); + let val_result = query_complexity.validate(&plan); + assert!(val_result.is_succeed()); + + let query_complexity = QueryDepth::new(1); + let val_result = query_complexity.validate(&plan); + assert!(!val_result.is_succeed()); + } + + #[test] + fn test_nested_query_complexity() { + let query = r#" + { + posts { + id + title + user { + id + name + } + } + } + "#; + + let plan = plan(query); + + let query_complexity = QueryDepth::new(4); + let val_result = query_complexity.validate(&plan); + assert!(val_result.is_succeed()); + + let query_complexity = QueryDepth::new(2); + let val_result = query_complexity.validate(&plan); + assert!(!val_result.is_succeed()); + } +} diff --git a/tests/core/snapshots/test-query-complexity.md_0.snap b/tests/core/snapshots/test-query-complexity.md_0.snap new file mode 100644 index 0000000000..156ccd1e69 --- /dev/null +++ b/tests/core/snapshots/test-query-complexity.md_0.snap @@ -0,0 +1,18 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": null, + "errors": [ + { + "message": "Build error: Validation Error\n• Query is too complex.\n" + } + ] + } +} diff --git a/tests/core/snapshots/test-query-complexity.md_1.snap b/tests/core/snapshots/test-query-complexity.md_1.snap new file mode 100644 index 0000000000..b9c2b8ce75 --- /dev/null +++ b/tests/core/snapshots/test-query-complexity.md_1.snap @@ -0,0 +1,18 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "user": { + "name": "foo", + "username": "foo" + } + } + } +} diff --git a/tests/core/snapshots/test-query-complexity.md_client.snap b/tests/core/snapshots/test-query-complexity.md_client.snap new file mode 100644 index 0000000000..b16158795f --- /dev/null +++ b/tests/core/snapshots/test-query-complexity.md_client.snap @@ -0,0 +1,56 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +scalar Bytes + +scalar Date + +scalar DateTime + +scalar Email + +scalar Empty + +scalar Int128 + +scalar Int16 + +scalar Int32 + +scalar Int64 + +scalar Int8 + +scalar JSON + +scalar PhoneNumber + +type Query { + user: User +} + +scalar UInt128 + +scalar UInt16 + +scalar UInt32 + +scalar UInt64 + +scalar UInt8 + +scalar Url + +type User { + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/test-query-complexity.md_merged.snap b/tests/core/snapshots/test-query-complexity.md_merged.snap new file mode 100644 index 0000000000..a869d53883 --- /dev/null +++ b/tests/core/snapshots/test-query-complexity.md_merged.snap @@ -0,0 +1,20 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(queryComplexity: 3) @upstream { + query: Query +} + +type Query { + user: User @http(baseURL: "http://jsonplaceholder.typicode.com", path: "/users/1") +} + +type User { + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} diff --git a/tests/core/snapshots/test-query-depth.md_0.snap b/tests/core/snapshots/test-query-depth.md_0.snap new file mode 100644 index 0000000000..bddc011699 --- /dev/null +++ b/tests/core/snapshots/test-query-depth.md_0.snap @@ -0,0 +1,18 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": null, + "errors": [ + { + "message": "Build error: Validation Error\n• Query is nested too deep.\n" + } + ] + } +} diff --git a/tests/core/snapshots/test-query-depth.md_1.snap b/tests/core/snapshots/test-query-depth.md_1.snap new file mode 100644 index 0000000000..fff715673e --- /dev/null +++ b/tests/core/snapshots/test-query-depth.md_1.snap @@ -0,0 +1,22 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "post": { + "title": "foo title", + "id": 1, + "user": { + "name": "foo", + "username": "foo" + } + } + } + } +} diff --git a/tests/core/snapshots/test-query-depth.md_client.snap b/tests/core/snapshots/test-query-depth.md_client.snap new file mode 100644 index 0000000000..95b066653e --- /dev/null +++ b/tests/core/snapshots/test-query-depth.md_client.snap @@ -0,0 +1,72 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +type Blog { + id: Int! + name: String! +} + +scalar Bytes + +scalar Date + +scalar DateTime + +scalar Email + +scalar Empty + +scalar Int128 + +scalar Int16 + +scalar Int32 + +scalar Int64 + +scalar Int8 + +scalar JSON + +scalar PhoneNumber + +type Post { + body: String! + id: Int! + title: String! + user: User + userId: Int! +} + +type Query { + blog(id: Int!): Blog + post: Post + user(id: Int!): User +} + +scalar UInt128 + +scalar UInt16 + +scalar UInt32 + +scalar UInt64 + +scalar UInt8 + +scalar Url + +type User { + blog: Blog + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/test-query-depth.md_merged.snap b/tests/core/snapshots/test-query-depth.md_merged.snap new file mode 100644 index 0000000000..90622a66fd --- /dev/null +++ b/tests/core/snapshots/test-query-depth.md_merged.snap @@ -0,0 +1,36 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(queryDepth: 3) @upstream { + query: Query +} + +type Blog { + id: Int! + name: String! +} + +type Post { + body: String! + id: Int! + title: String! + user: User @call(steps: [{query: "user", args: {id: "{{.value.userId}}"}}]) + userId: Int! +} + +type Query { + blog(id: Int!): Blog @http(baseURL: "http://jsonplaceholder.typicode.com", path: "/blogs/{{.args.id}}") + post: Post @http(baseURL: "http://jsonplaceholder.typicode.com", path: "/posts/1") + user(id: Int!): User @http(baseURL: "http://jsonplaceholder.typicode.com", path: "/users/{{.args.id}}") +} + +type User { + blog: Blog @call(steps: [{query: "blog", args: {id: "{{.value.id}}"}}]) + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} diff --git a/tests/execution/test-query-complexity.md b/tests/execution/test-query-complexity.md new file mode 100644 index 0000000000..c3ab8d8ef7 --- /dev/null +++ b/tests/execution/test-query-complexity.md @@ -0,0 +1,45 @@ +# Query Complexity + +```graphql @config +schema @server(queryComplexity: 3) { + query: Query +} + +type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String +} + +type Query { + user: User @http(path: "/users/1", baseURL: "http://jsonplaceholder.typicode.com") +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users/1 + response: + status: 200 + body: + id: 1 + name: foo + username: foo + email: foo@typicode.com +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: query { user { name, username, phone, email } } + +- method: POST + url: http://localhost:8080/graphql + body: + query: query { user { name, username } } +``` diff --git a/tests/execution/test-query-depth.md b/tests/execution/test-query-depth.md new file mode 100644 index 0000000000..0743928f12 --- /dev/null +++ b/tests/execution/test-query-depth.md @@ -0,0 +1,81 @@ +# Query Complexity + +```graphql @config +schema @server(queryDepth: 3) { + query: Query +} + +type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String + blog: Blog @call(steps: [{query: "blog", args: {id: "{{.value.id}}"}}]) +} + +type Blog { + id: Int! + name: String! +} + +type Post { + id: Int! + userId: Int! + title: String! + body: String! + user: User @call(steps: [{query: "user", args: {id: "{{.value.userId}}"}}]) +} + +type Query { + post: Post @http(path: "/posts/1", baseURL: "http://jsonplaceholder.typicode.com") + user(id: Int!): User @http(path: "/users/{{.args.id}}", baseURL: "http://jsonplaceholder.typicode.com") + blog(id: Int!): Blog @http(path: "/blogs/{{.args.id}}", baseURL: "http://jsonplaceholder.typicode.com") +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users/1 + response: + status: 200 + body: + id: 1 + name: foo + username: foo + email: foo@typicode.com + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/posts/1 + response: + status: 200 + body: + id: 1 + userId: 1 + title: foo title + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/blogs/1 + expectedHits: 0 + response: + status: 200 + body: + id: 1 + title: foo blog +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: query { post { title, id, user { name username blog { id } } } } + +- method: POST + url: http://localhost:8080/graphql + body: + query: query { post { title, id, user { name username } } } +```