diff --git a/Cargo.lock b/Cargo.lock index b1a3482b1f..21d0ed4fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -643,7 +643,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -1017,12 +1017,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.6.0" @@ -1359,19 +1353,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_more" -version = "0.99.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.86", -] - [[package]] name = "derive_more" version = "1.0.0" @@ -1646,9 +1627,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.34" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1822,7 +1803,7 @@ version = "0.1.7-wip" source = "git+https://github.com/laststylebender14/rust-genai.git?rev=63a542ce20132503c520f4e07108e0d768f243c3#63a542ce20132503c520f4e07108e0d768f243c3" dependencies = [ "bytes", - "derive_more 1.0.0", + "derive_more", "eventsource-stream", "futures", "reqwest 0.12.7", @@ -2454,7 +2435,7 @@ dependencies = [ "http 1.1.0", "hyper 1.4.1", "hyper-util", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pki-types", "tokio", "tokio-rustls 0.26.0", @@ -2868,7 +2849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -4314,7 +4295,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls 0.23.16", + "rustls 0.23.18", "socket2", "thiserror", "tokio", @@ -4331,7 +4312,7 @@ dependencies = [ "rand", "ring", "rustc-hash 2.0.0", - "rustls 0.23.16", + "rustls 0.23.18", "slab", "thiserror", "tinyvec", @@ -4578,7 +4559,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pemfile 2.1.3", "rustls-pki-types", "serde", @@ -4682,7 +4663,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c602d3f35d1a5725235ef874b9a9e24232534e34fe610d53657e24c6c37572d" dependencies = [ - "convert_case 0.6.0", + "convert_case", "fnv", "ident_case", "indexmap 2.6.0", @@ -4771,9 +4752,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.16" +version = "0.23.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" dependencies = [ "once_cell", "ring", @@ -5461,13 +5442,13 @@ dependencies = [ "chrono", "clap", "colored", - "convert_case 0.6.0", + "convert_case", "criterion", "ctrlc", "dashmap", "datatest-stable", "derive-getters", - "derive_more 0.99.18", + "derive_more", "derive_setters", "dotenvy", "exitcode", @@ -5527,7 +5508,7 @@ dependencies = [ "reqwest-middleware", "resource", "rquickjs", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pemfile 1.0.4", "rustls-pki-types", "schemars", @@ -5589,9 +5570,9 @@ dependencies = [ [[package]] name = "tailcall-chunk" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d684c265789778d9b76a268b079bca63dd8801d695e3c1d7f41df78e7a7adb4f" +checksum = "5d244d8876e9677c9699d5254b72366c9249760d73a8b7295d1fb3eb6333f682" [[package]] name = "tailcall-cloudflare" @@ -5620,8 +5601,8 @@ dependencies = [ name = "tailcall-fixtures" version = "0.1.0" dependencies = [ - "convert_case 0.6.0", - "derive_more 0.99.18", + "convert_case", + "derive_more", "indenter", ] @@ -5673,8 +5654,8 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", - "convert_case 0.6.0", - "derive_more 0.99.18", + "convert_case", + "derive_more", "http 0.2.12", "lazy_static", "machineid-rs", @@ -5721,7 +5702,7 @@ dependencies = [ name = "tailcall-upstream-grpc" version = "0.1.0" dependencies = [ - "derive_more 0.99.18", + "derive_more", "headers", "http 0.2.12", "http-body-util", @@ -6028,7 +6009,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pki-types", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 7303fb7ca8..f03aef860f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ tracing = "0.1.40" lazy_static = "1.4.0" serde_json = { version = "1.0.116", features = ["preserve_order"] } serde = { version = "1.0.200", features = ["derive"] } -derive_more = "0.99.18" +derive_more = { version = "1", features = ["from", "debug"] } thiserror = "1.0.59" url = { version = "2.5.0", features = ["serde"] } convert_case = "0.6.0" @@ -175,7 +175,7 @@ strum = "0.26.2" tailcall-valid = { workspace = true } dashmap = "6.1.0" urlencoding = "2.1.3" -tailcall-chunk = "0.2.5" +tailcall-chunk = "0.3.0" # to build rquickjs bindings on systems without builtin bindings [target.'cfg(all(target_os = "windows", target_arch = "x86"))'.dependencies] diff --git a/README.md b/README.md index 145ffe1c0d..572d66b7b8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Tailcall Logo](https://raw.githubusercontent.com/tailcallhq/tailcall/main/assets/logo_main.svg)](https://tailcall.run) +[![Tailcall Logo](https://raw.githubusercontent.com/tailcallhq/tailcall/refs/heads/main/assets/logo_light.svg)](https://tailcall.run) Tailcall is an open-source solution for building [high-performance] GraphQL backends. diff --git a/assets/logo_light.svg b/assets/logo_light.svg new file mode 100644 index 0000000000..9f42b8ae70 --- /dev/null +++ b/assets/logo_light.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index b80a628218..a484e6864e 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -47,7 +47,7 @@ directive @call( of the previous step is passed as input to the next step. """ steps: [Step] -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT """ The `@expr` operators allows you to specify an expression that can evaluate to a @@ -55,7 +55,7 @@ value. The expression can be a static value or built form a Mustache template. s """ directive @expr( body: JSON -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT """ The @graphQL operator allows to specify GraphQL API server request to fetch data @@ -95,7 +95,7 @@ directive @graphQL( This refers URL of the API. """ url: String! -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT """ The @grpc operator indicates that a field or node is backed by a gRPC API.For instance, @@ -149,7 +149,7 @@ directive @grpc( This refers to URL of the API. """ url: String! -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT """ The @http operator indicates that a field or node is backed by a REST API.For instance, @@ -229,11 +229,11 @@ directive @http( This refers to URL of the API. """ url: String! -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT directive @js( name: String! -) on FIELD_DEFINITION | OBJECT +) repeatable on FIELD_DEFINITION | OBJECT """ The @link directive allows you to import external resources, such as configuration @@ -1026,14 +1026,41 @@ enum Method { } enum LinkType { + """ + Points to another Tailcall Configuration file. The imported configuration will be merged into the importing configuration. + """ Config + """ + Points to a Protobuf file. The imported Protobuf file will be used by the `@grpc` directive. If your API exposes a reflection endpoint, you should set the type to `Grpc` instead. + """ Protobuf + """ + Points to a JS file. The imported JS file will be used by the `@js` directive. + """ Script + """ + Points to a Cert file. The imported Cert file will be used by the server to serve over HTTPS. + """ Cert + """ + Points to a Key file. The imported Key file will be used by the server to serve over HTTPS. + """ Key + """ + A trusted document that contains GraphQL operations (queries, mutations) that can be exposed a REST API using the `@rest` directive. + """ Operation + """ + Points to a Htpasswd file. The imported Htpasswd file will be used by the server to authenticate users. + """ Htpasswd + """ + Points to a Jwks file. The imported Jwks file will be used by the server to authenticate users. + """ Jwks + """ + Points to a reflection endpoint. The imported reflection endpoint will be used by the `@grpc` directive to resolve data from gRPC services. + """ Grpc } diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index 484b1356fb..f02274dcb4 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -400,81 +400,13 @@ }, "Field": { "description": "A field definition containing all the metadata information about resolving a field.", - "type": "object", - "oneOf": [ - { - "type": "object", - "required": [ - "http" - ], - "properties": { - "http": { - "$ref": "#/definitions/Http" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "grpc" - ], - "properties": { - "grpc": { - "$ref": "#/definitions/Grpc" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "graphql" - ], - "properties": { - "graphql": { - "$ref": "#/definitions/GraphQL" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "call" - ], - "properties": { - "call": { - "$ref": "#/definitions/Call" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "js" - ], - "properties": { - "js": { - "$ref": "#/definitions/JS" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "expr" - ], - "properties": { - "expr": { - "$ref": "#/definitions/Expr" - } - }, - "additionalProperties": false - } + "type": [ + "object", + "array" ], + "items": { + "$ref": "#/definitions/Resolver" + }, "properties": { "args": { "description": "Map of argument name and its definition.", @@ -901,17 +833,70 @@ "additionalProperties": false }, "LinkType": { - "type": "string", - "enum": [ - "Config", - "Protobuf", - "Script", - "Cert", - "Key", - "Operation", - "Htpasswd", - "Jwks", - "Grpc" + "oneOf": [ + { + "description": "Points to another Tailcall Configuration file. The imported configuration will be merged into the importing configuration.", + "type": "string", + "enum": [ + "Config" + ] + }, + { + "description": "Points to a Protobuf file. The imported Protobuf file will be used by the `@grpc` directive. If your API exposes a reflection endpoint, you should set the type to `Grpc` instead.", + "type": "string", + "enum": [ + "Protobuf" + ] + }, + { + "description": "Points to a JS file. The imported JS file will be used by the `@js` directive.", + "type": "string", + "enum": [ + "Script" + ] + }, + { + "description": "Points to a Cert file. The imported Cert file will be used by the server to serve over HTTPS.", + "type": "string", + "enum": [ + "Cert" + ] + }, + { + "description": "Points to a Key file. The imported Key file will be used by the server to serve over HTTPS.", + "type": "string", + "enum": [ + "Key" + ] + }, + { + "description": "A trusted document that contains GraphQL operations (queries, mutations) that can be exposed a REST API using the `@rest` directive.", + "type": "string", + "enum": [ + "Operation" + ] + }, + { + "description": "Points to a Htpasswd file. The imported Htpasswd file will be used by the server to authenticate users.", + "type": "string", + "enum": [ + "Htpasswd" + ] + }, + { + "description": "Points to a Jwks file. The imported Jwks file will be used by the server to authenticate users.", + "type": "string", + "enum": [ + "Jwks" + ] + }, + { + "description": "Points to a reflection endpoint. The imported reflection endpoint will be used by the `@grpc` directive to resolve data from gRPC services.", + "type": "string", + "enum": [ + "Grpc" + ] + } ] }, "Method": { @@ -1021,6 +1006,82 @@ } } }, + "Resolver": { + "oneOf": [ + { + "type": "object", + "required": [ + "http" + ], + "properties": { + "http": { + "$ref": "#/definitions/Http" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "grpc" + ], + "properties": { + "grpc": { + "$ref": "#/definitions/Grpc" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "graphql" + ], + "properties": { + "graphql": { + "$ref": "#/definitions/GraphQL" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "call" + ], + "properties": { + "call": { + "$ref": "#/definitions/Call" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "js" + ], + "properties": { + "js": { + "$ref": "#/definitions/JS" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "expr" + ], + "properties": { + "expr": { + "$ref": "#/definitions/Expr" + } + }, + "additionalProperties": false + } + ] + }, "RootSchema": { "type": "object", "properties": { @@ -1336,81 +1397,13 @@ }, "Type": { "description": "Represents a GraphQL type. A type can be an object, interface, enum or scalar.", - "type": "object", - "oneOf": [ - { - "type": "object", - "required": [ - "http" - ], - "properties": { - "http": { - "$ref": "#/definitions/Http" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "grpc" - ], - "properties": { - "grpc": { - "$ref": "#/definitions/Grpc" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "graphql" - ], - "properties": { - "graphql": { - "$ref": "#/definitions/GraphQL" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "call" - ], - "properties": { - "call": { - "$ref": "#/definitions/Call" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "js" - ], - "properties": { - "js": { - "$ref": "#/definitions/JS" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "expr" - ], - "properties": { - "expr": { - "$ref": "#/definitions/Expr" - } - }, - "additionalProperties": false - } + "type": [ + "object", + "array" ], + "items": { + "$ref": "#/definitions/Resolver" + }, "required": [ "fields" ], diff --git a/src/cli/tc/init.rs b/src/cli/tc/init.rs index f3064e6194..9797ebf8c2 100644 --- a/src/cli/tc/init.rs +++ b/src/cli/tc/init.rs @@ -90,7 +90,7 @@ async fn confirm_and_write_yml( fn main_config() -> Config { let field = Field { type_of: Type::from("String".to_owned()).into_required(), - resolver: Some(Resolver::Expr(Expr { body: "Hello, World!".into() })), + resolvers: Resolver::Expr(Expr { body: "Hello, World!".into() }).into(), ..Default::default() }; diff --git a/src/cli/tc/run.rs b/src/cli/tc/run.rs index 938a43ec5d..61f93f4394 100644 --- a/src/cli/tc/run.rs +++ b/src/cli/tc/run.rs @@ -16,7 +16,7 @@ pub async fn run() -> Result<()> { tracing::info!("Env file: {:?} loaded", path); } let cli = Cli::parse(); - update_checker::check_for_update().await; + tokio::task::spawn(update_checker::check_for_update()); // Initialize ping event every 60 seconds let _ = TRACKER .init_ping(tokio::time::Duration::from_secs(60)) diff --git a/src/cli/update_checker.rs b/src/cli/update_checker.rs index 89852ccf4a..f471ba4412 100644 --- a/src/cli/update_checker.rs +++ b/src/cli/update_checker.rs @@ -75,22 +75,20 @@ fn show_update_message(name: &str, latest_version: Version) { } pub async fn check_for_update() { - tokio::task::spawn_blocking(|| { - if VERSION.is_dev() { - // skip validation if it's not a release - return; - } + if VERSION.is_dev() { + // skip validation if it's not a release + return; + } - let name: &str = "tailcallhq/tailcall"; + let name: &str = "tailcallhq/tailcall"; - let informer = update_informer::new(registry::GitHub, name, VERSION.as_str()); + let informer = update_informer::new(registry::GitHub, name, VERSION.as_str()); - if let Some(latest_version) = informer.check_version().ok().flatten() { - // schedules the update message to be shown when the user presses Ctrl+C on cli. - let _ = set_handler(move || { - show_update_message(name, latest_version.clone()); - std::process::exit(exitcode::OK); - }); - } - }); + if let Some(latest_version) = informer.check_version().ok().flatten() { + // schedules the update message to be shown when the user presses Ctrl+C on cli. + let _ = set_handler(move || { + show_update_message(name, latest_version.clone()); + std::process::exit(exitcode::OK); + }); + } } diff --git a/src/core/blueprint/cors.rs b/src/core/blueprint/cors.rs index b871d1905e..13edf8b0a1 100644 --- a/src/core/blueprint/cors.rs +++ b/src/core/blueprint/cors.rs @@ -1,11 +1,9 @@ -use std::fmt::Display; - use derive_setters::Setters; -use http::header; -use http::header::{HeaderName, HeaderValue}; +use http::header::{self, HeaderName, HeaderValue, InvalidHeaderValue}; use http::request::Parts; use tailcall_valid::ValidationError; +use super::BlueprintError; use crate::core::config; #[derive(Clone, Debug, Setters, Default)] @@ -118,7 +116,9 @@ impl Cors { } } -fn ensure_usable_cors_rules(layer: &Cors) -> Result<(), ValidationError> { +fn ensure_usable_cors_rules( + layer: &Cors, +) -> Result<(), ValidationError> { if layer.allow_credentials { let allowing_all_headers = layer .allow_headers @@ -127,8 +127,11 @@ fn ensure_usable_cors_rules(layer: &Cors) -> Result<(), ValidationError> .is_some(); if allowing_all_headers { - Err(ValidationError::new("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Allow-Headers: *`".into()))? + return Err(ValidationError::new( + BlueprintError::InvalidCORSConfiguration( + "Access-Control-Allow-Headers".to_string(), + ), + )); } let allowing_all_methods = layer @@ -138,33 +141,38 @@ fn ensure_usable_cors_rules(layer: &Cors) -> Result<(), ValidationError> .is_some(); if allowing_all_methods { - Err(ValidationError::new("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Allow-Methods: *`".into()))? + return Err(ValidationError::new( + BlueprintError::InvalidCORSConfiguration( + "Access-Control-Allow-Methods".to_string(), + ), + )); } let allowing_all_origins = layer.allow_origins.iter().any(is_wildcard); if allowing_all_origins { - Err(ValidationError::new("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Allow-Origin: *`".into()))? + return Err(ValidationError::new( + BlueprintError::InvalidCORSConfiguration("Access-Control-Allow-Origin".to_string()), + )); } if layer.expose_headers_is_wildcard() { - Err(ValidationError::new("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \ - with `Access-Control-Expose-Headers: *`".into()))? + return Err(ValidationError::new( + BlueprintError::InvalidCORSConfiguration( + "Access-Control-Expose-Headers".to_string(), + ), + )); } } Ok(()) } -fn to_validation_err(err: T) -> ValidationError { - ValidationError::new(err.to_string()) -} - impl TryFrom for Cors { - type Error = ValidationError; + type Error = ValidationError; - fn try_from(value: config::cors::Cors) -> Result> { + fn try_from( + value: config::cors::Cors, + ) -> Result> { let cors = Cors { allow_credentials: value.allow_credentials.unwrap_or_default(), allow_headers: (!value.allow_headers.is_empty()).then_some( @@ -172,11 +180,12 @@ impl TryFrom for Cors { .allow_headers .join(", ") .parse() - .map_err(to_validation_err)?, + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into()))?, ), allow_methods: { Some(if value.allow_methods.is_empty() { - "*".parse().map_err(to_validation_err)? + "*".parse() + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into()))? } else { value .allow_methods @@ -185,28 +194,34 @@ impl TryFrom for Cors { .collect::>() .join(", ") .parse() - .map_err(to_validation_err)? + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into()))? }) }, allow_origins: value .allow_origins .into_iter() - .map(|val| val.parse().map_err(to_validation_err)) - .collect::>>()?, + .map(|val| { + val.parse() + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into())) + }) + .collect::>>()?, allow_private_network: value.allow_private_network.unwrap_or_default(), expose_headers: Some( value .expose_headers .join(", ") .parse() - .map_err(to_validation_err)?, + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into()))?, ), max_age: value.max_age.map(|val| val.into()), vary: value .vary .iter() - .map(|val| val.parse().map_err(to_validation_err)) - .collect::>>()?, + .map(|val| { + val.parse() + .map_err(|e: InvalidHeaderValue| ValidationError::new(e.into())) + }) + .collect::>>()?, }; ensure_usable_cors_rules(&cors)?; Ok(cors) diff --git a/src/core/blueprint/definitions.rs b/src/core/blueprint/definitions.rs index 15dc40bb41..a38b498d7e 100644 --- a/src/core/blueprint/definitions.rs +++ b/src/core/blueprint/definitions.rs @@ -14,9 +14,9 @@ use crate::core::ir::model::{Cache, IR}; use crate::core::try_fold::TryFold; use crate::core::{config, scalar, Type}; -pub fn to_scalar_type_definition(name: &str) -> Valid { +pub fn to_scalar_type_definition(name: &str) -> Valid { if scalar::Scalar::is_predefined(name) { - Valid::fail(format!("Scalar type {} is predefined", name)) + Valid::fail(BlueprintError::ScalarTypeIsPredefined(name.to_string())) } else { Valid::succeed(Definition::Scalar(ScalarTypeDefinition { name: name.to_string(), @@ -40,7 +40,7 @@ pub fn to_union_type_definition((name, u): (&String, &Union)) -> Definition { pub fn to_input_object_type_definition( definition: ObjectTypeDefinition, -) -> Valid { +) -> Valid { Valid::succeed(Definition::InputObject(InputObjectTypeDefinition { name: definition.name, fields: definition @@ -58,7 +58,9 @@ pub fn to_input_object_type_definition( })) } -pub fn to_interface_type_definition(definition: ObjectTypeDefinition) -> Valid { +pub fn to_interface_type_definition( + definition: ObjectTypeDefinition, +) -> Valid { Valid::succeed(Definition::Interface(InterfaceTypeDefinition { name: definition.name, fields: definition.fields, @@ -68,8 +70,8 @@ pub fn to_interface_type_definition(definition: ObjectTypeDefinition) -> Valid Valid; -type PathResolverErrorHandler = dyn Fn(&str, &str, &str, &[String]) -> Valid; +type InvalidPathHandler = dyn Fn(&str, &[String], &[String]) -> Valid; +type PathResolverErrorHandler = dyn Fn(&str, &str, &str, &[String]) -> Valid; struct ProcessFieldWithinTypeContext<'a> { field: &'a config::Field, @@ -96,7 +98,9 @@ struct ProcessPathContext<'a> { original_path: &'a [String], } -fn process_field_within_type(context: ProcessFieldWithinTypeContext) -> Valid { +fn process_field_within_type( + context: ProcessFieldWithinTypeContext, +) -> Valid { let field = context.field; let field_name = context.field_name; let remaining_path = context.remaining_path; @@ -107,14 +111,19 @@ fn process_field_within_type(context: ProcessFieldWithinTypeContext) -> Valid Valid Valid { +fn process_path(context: ProcessPathContext) -> Valid { let path = context.path; let field = context.field; let type_info = context.type_info; @@ -254,7 +263,7 @@ fn to_object_type_definition( name: &str, type_of: &config::Type, config_module: &ConfigModule, -) -> Valid { +) -> Valid { to_fields(name, type_of, config_module).map(|fields| { Definition::Object(ObjectTypeDefinition { name: name.to_string(), @@ -266,10 +275,13 @@ fn to_object_type_definition( }) } -fn update_args<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( +fn update_args<'a>() -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, BlueprintError>::new( move |(_, field, _typ, name), _| { // TODO: assert type name Valid::from_iter(field.args.iter(), |(name, arg)| { @@ -303,7 +315,7 @@ fn item_is_numeric(list: &[String]) -> bool { fn update_resolver_from_path( context: &ProcessPathContext, base_field: blueprint::FieldDefinition, -) -> Valid { +) -> Valid { let has_index = item_is_numeric(context.path); process_path(context.clone()).and_then(|of_type| { @@ -328,10 +340,13 @@ fn update_resolver_from_path( /// resolvers that cannot be resolved from the root of the schema. This function /// finds such dangling resolvers and creates a resolvable path from the root /// schema. -pub fn fix_dangling_resolvers<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( +pub fn fix_dangling_resolvers<'a>() -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, BlueprintError>::new( move |(config, field, _, name), mut b_field| { let mut set = HashSet::new(); if !field.has_resolver() @@ -349,10 +364,13 @@ pub fn fix_dangling_resolvers<'a>( /// Wraps the IO Expression with Expression::Cached /// if `Field::cache` is present for that field -pub fn update_cache_resolvers<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( +pub fn update_cache_resolvers<'a>() -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, BlueprintError>::new( move |(_config, field, typ, _name), mut b_field| { if let Some(config::Cache { max_age }) = field.cache.as_ref().or(typ.cache.as_ref()) { b_field.map_expr(|expression| Cache::wrap(*max_age, expression)) @@ -363,10 +381,10 @@ pub fn update_cache_resolvers<'a>( ) } -fn validate_field_type_exist(config: &Config, field: &Field) -> Valid<(), String> { +fn validate_field_type_exist(config: &Config, field: &Field) -> Valid<(), BlueprintError> { let field_type = field.type_of.name(); if !scalar::Scalar::is_predefined(field_type) && !config.contains(field_type) { - Valid::fail(format!("Undeclared type '{field_type}' was found")) + Valid::fail(BlueprintError::UndeclaredTypeFound(field_type.clone())) } else { Valid::succeed(()) } @@ -376,7 +394,7 @@ fn to_fields( object_name: &str, type_of: &config::Type, config_module: &ConfigModule, -) -> Valid, String> { +) -> Valid, BlueprintError> { let operation_type = if config_module .schema .mutation @@ -388,28 +406,55 @@ fn to_fields( GraphQLOperationType::Query }; // Process fields that are not marked as `omit` + + // collect the parent auth ids + let parent_auth_ids = type_of.protected.as_ref().and_then(|p| p.id.as_ref()); + // collect the field names that have different auth ids than the parent type + let fields_with_different_auth_ids = type_of + .fields + .iter() + .filter_map(|(k, v)| { + if let Some(p) = &v.protected { + if p.id.as_ref() != parent_auth_ids { + Some(k) + } else { + None + } + } else { + None + } + }) + .collect::>(); + let fields = Valid::from_iter( type_of .fields .iter() .filter(|(_, field)| !field.is_omitted()), |(name, field)| { - validate_field_type_exist(config_module, field) - .and(to_field_definition( + let mut result = + validate_field_type_exist(config_module, field).and(to_field_definition( field, &operation_type, object_name, config_module, type_of, name, - )) - .trace(name) + )); + + if fields_with_different_auth_ids.contains(name) || parent_auth_ids.is_none() { + // if the field has a different auth id than the parent type or parent has no + // auth id, we need to add correct trace. + result = result.trace(name); + } + + result }, ); let to_added_field = |add_field: &config::AddField, type_of: &config::Type| - -> Valid { + -> Valid { let source_field = type_of .fields .iter() @@ -424,20 +469,21 @@ fn to_fields( &add_field.name, ) .and_then(|field_definition| { - let added_field_path = match source_field.resolver { - Some(_) => add_field.path[1..] + let added_field_path = if source_field.resolvers.is_empty() { + add_field.path.clone() + } else { + add_field.path[1..] .iter() .map(|s| s.to_owned()) - .collect::>(), - None => add_field.path.clone(), + .collect::>() }; let invalid_path_handler = |field_name: &str, _added_field_path: &[String], original_path: &[String]| - -> Valid { + -> Valid { Valid::fail_with( - "Cannot add field".to_string(), - format!("Path [{}] does not exist", original_path.join(", ")), + BlueprintError::CannotAddField, + BlueprintError::PathDoesNotExist(original_path.join(", ")), ) .trace(field_name) }; @@ -445,15 +491,14 @@ fn to_fields( field_type: &str, field_name: &str, original_path: &[String]| - -> Valid { - Valid::::fail_with( - "Cannot add field".to_string(), - format!( - "Path: [{}] contains resolver {} at [{}.{}]", + -> Valid { + Valid::::fail_with( + BlueprintError::CannotAddField, + BlueprintError::PathContainsResolver( original_path.join(", "), - resolver_name, - field_type, - field_name + resolver_name.to_string(), + field_type.to_string(), + field_name.to_string(), ), ) }; @@ -472,10 +517,9 @@ fn to_fields( ) }) .trace(config::AddField::trace_name().as_str()), - None => Valid::fail(format!( - "Could not find field {} in path {}", - add_field.path[0], - add_field.path.join(",") + None => Valid::fail(BlueprintError::FieldNotFoundInPath( + add_field.path[0].clone(), + add_field.path.join(","), )), } }; @@ -497,15 +541,10 @@ pub fn to_field_definition( config_module: &ConfigModule, type_of: &config::Type, name: &str, -) -> Valid { +) -> Valid { update_args() - .and(update_http().trace(config::Http::trace_name().as_str())) - .and(update_grpc(operation_type).trace(config::Grpc::trace_name().as_str())) - .and(update_const_field().trace(config::Expr::trace_name().as_str())) - .and(update_js_field().trace(config::JS::trace_name().as_str())) - .and(update_graphql(operation_type).trace(config::GraphQL::trace_name().as_str())) + .and(update_resolver(operation_type, object_name)) .and(update_modify().trace(config::Modify::trace_name().as_str())) - .and(update_call(operation_type, object_name).trace(config::Call::trace_name().as_str())) .and(fix_dangling_resolvers()) .and(update_cache_resolvers()) .and(update_protected(object_name).trace(Protected::trace_name().as_str())) @@ -518,8 +557,8 @@ pub fn to_field_definition( ) } -pub fn to_definitions<'a>() -> TryFold<'a, ConfigModule, Vec, String> { - TryFold::, String>::new(|config_module, _| { +pub fn to_definitions<'a>() -> TryFold<'a, ConfigModule, Vec, BlueprintError> { + TryFold::, BlueprintError>::new(|config_module, _| { Valid::from_iter(config_module.types.iter(), |(name, type_)| { if type_.scalar() { to_scalar_type_definition(name).trace(name) @@ -548,7 +587,7 @@ pub fn to_definitions<'a>() -> TryFold<'a, ConfigModule, Vec, String config_module.enums.iter(), |(name, type_)| { if type_.variants.is_empty() { - Valid::fail("No variants found for enum".to_string()) + Valid::fail(BlueprintError::NoVariantsFoundForEnum) } else { Valid::succeed(to_enum_type_definition((name, type_))) } diff --git a/src/core/blueprint/directive.rs b/src/core/blueprint/directive.rs index 3ef9045a3b..99333908ab 100644 --- a/src/core/blueprint/directive.rs +++ b/src/core/blueprint/directive.rs @@ -5,6 +5,7 @@ use async_graphql::Name; use serde_json::Value; use tailcall_valid::{Valid, ValidationError, Validator}; +use super::BlueprintError; use crate::core::{config, pos}; #[derive(Clone, Debug)] @@ -13,8 +14,8 @@ pub struct Directive { pub arguments: HashMap, } -pub fn to_directive(const_directive: ConstDirective) -> Valid { - const_directive +pub fn to_directive(const_directive: ConstDirective) -> Valid { + match const_directive .arguments .into_iter() .map(|(k, v)| { @@ -25,7 +26,10 @@ pub fn to_directive(const_directive: ConstDirective) -> Valid .collect::>() .map_err(|e| ValidationError::new(e.to_string())) .map(|arguments| Directive { name: const_directive.name.node.to_string(), arguments }) - .into() + { + Ok(data) => Valid::succeed(data), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + } } pub fn to_const_directive(directive: &Directive) -> Valid { diff --git a/src/core/blueprint/error.rs b/src/core/blueprint/error.rs new file mode 100644 index 0000000000..a8980ac8e7 --- /dev/null +++ b/src/core/blueprint/error.rs @@ -0,0 +1,271 @@ +use std::net::AddrParseError; + +use async_graphql::dynamic::SchemaError; +use tailcall_valid::{Cause, ValidationError}; + +use crate::core::Errata; + +#[derive(Debug, thiserror::Error)] +pub enum BlueprintError { + #[error("Apollo federation resolvers can't be a part of entity resolver")] + ApolloFederationResolversNoPartOfEntityResolver, + + #[error("Query type is not an object inside the blueprint")] + QueryTypeNotObject, + + #[error("Cannot find type {0} in the config")] + TypeNotFoundInConfig(String), + + #[error("Cannot find field {0} in the type")] + FieldNotFoundInType(String), + + #[error("no argument '{0}' found")] + ArgumentNotFound(String), + + #[error("field {0} has no resolver")] + FieldHasNoResolver(String), + + #[error("Steps can't be empty")] + StepsCanNotBeEmpty, + + #[error("Result resolver can't be empty")] + ResultResolverCanNotBeEmpty, + + #[error("call must have query or mutation")] + CallMustHaveQueryOrMutation, + + #[error("invalid JSON: {0}")] + InvalidJson(anyhow::Error), + + #[error("field {0} not found")] + FieldNotFound(String), + + #[error("Invalid method format: {0}. Expected format is ..")] + InvalidGrpcMethodFormat(String), + + #[error("Protobuf files were not specified in the config")] + ProtobufFilesNotSpecifiedInConfig, + + #[error("GroupBy is only supported for GET requests")] + GroupByOnlyForGet, + + #[error("Batching capability was used without enabling it in upstream")] + IncorrectBatchingUsage, + + #[error("script is required")] + ScriptIsRequired, + + #[error("Field is already implemented from interface")] + FieldExistsInInterface, + + #[error("Input types can not be protected")] + InputTypesCannotBeProtected, + + #[error("@protected operator is used but there is no @link definitions for auth providers")] + ProtectedOperatorNoAuthProviders, + + #[error("Auth provider {0} not found")] + AuthProviderNotFound(String), + + #[error("syntax error when parsing `{0}`")] + SyntaxErrorWhenParsing(String), + + #[error("Scalar type {0} is predefined")] + ScalarTypeIsPredefined(String), + + #[error("Undeclared type '{0}' was found")] + UndeclaredTypeFound(String), + + #[error("Cannot add field")] + CannotAddField, + + #[error("Path [{0}] does not exist")] + PathDoesNotExist(String), + + #[error("Path: [{0}] contains resolver {1} at [{2}.{3}]")] + PathContainsResolver(String, String, String, String), + + #[error("Could not find field {0} in path {1}")] + FieldNotFoundInPath(String, String), + + #[error("No variants found for enum")] + NoVariantsFoundForEnum, + + #[error("Link src cannot be empty")] + LinkSrcCannotBeEmpty, + + #[error("Duplicated id: {0}")] + Duplicated(String), + + #[error("Only one script link is allowed")] + OnlyOneScriptLinkAllowed, + + #[error("Only one key link is allowed")] + OnlyOneKeyLinkAllowed, + + #[error("no value '{0}' found")] + NoValueFound(String), + + #[error("value '{0}' is a nullable type")] + ValueIsNullableType(String), + + #[error("value '{0}' is not of a scalar type")] + ValueIsNotOfScalarType(String), + + #[error("no type '{0}' found")] + NoTypeFound(String), + + #[error("too few parts in template")] + TooFewPartsInTemplate, + + #[error("can't use list type '{0}' here")] + CantUseListTypeHere(String), + + #[error("argument '{0}' is a nullable type")] + ArgumentIsNullableType(String), + + #[error("var '{0}' is not set in the server config")] + VarNotSetInServerConfig(String), + + #[error("unknown template directive '{0}'")] + UnknownTemplateDirective(String), + + #[error("Query root is missing")] + QueryRootIsMissing, + + #[error("Query type is not defined")] + QueryTypeNotDefined, + + #[error("No resolver has been found in the schema")] + NoResolverFoundInSchema, + + #[error("Mutation type is not defined")] + MutationTypeNotDefined, + + #[error("Certificate is required for HTTP2")] + CertificateIsRequiredForHTTP2, + + #[error("Key is required for HTTP2")] + KeyIsRequiredForHTTP2, + + #[error("Experimental headers must start with 'x-' or 'X-'. Got: '{0}'")] + ExperimentalHeaderInvalidFormat(String), + + #[error("`graph_ref` should be in the format @ where `graph_id` and `variant` can only contain letters, numbers, '-' and '_'. Found {0}")] + InvalidGraphRef(String), + + #[error("Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with `{0}: *`")] + InvalidCORSConfiguration(String), + + #[error("{0}")] + Cause(String), + + #[error("{0}")] + Description(String), + + #[error("Parsing failed because of {0}")] + ParsingFailed(#[from] AddrParseError), + + #[error(transparent)] + Schema(#[from] SchemaError), + + #[error(transparent)] + UrlParse(#[from] url::ParseError), + + #[error("Parsing failed because of {0}")] + InvalidHeaderName(#[from] http::header::InvalidHeaderName), + + #[error("Parsing failed because of {0}")] + InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), + + #[error(transparent)] + Error(#[from] anyhow::Error), +} + +impl PartialEq for BlueprintError { + fn eq(&self, other: &Self) -> bool { + self.to_string() == other.to_string() + } +} + +impl From> for Errata { + fn from(error: ValidationError) -> Self { + Errata::new("Blueprint Error").caused_by( + error + .as_vec() + .iter() + .map(|cause| { + let mut err = + Errata::new(&cause.message.to_string()).trace(cause.trace.clone().into()); + if let Some(description) = &cause.description { + err = err.description(description.to_string()); + } + err + }) + .collect(), + ) + } +} + +impl BlueprintError { + pub fn to_validation_string( + errors: ValidationError, + ) -> ValidationError { + let causes: Vec> = errors + .as_vec() + .iter() + .map(|cause| { + let new_cause = + Cause::new(cause.message.to_string()).trace(cause.trace.clone().into()); + + if let Some(description) = &cause.description { + new_cause.description(description.to_string()) + } else { + new_cause + } + }) + .collect(); + + ValidationError::from(causes) + } + + pub fn from_validation_str(errors: ValidationError<&str>) -> ValidationError { + let causes: Vec> = errors + .as_vec() + .iter() + .map(|cause| { + let new_cause = Cause::new(BlueprintError::Cause(cause.message.to_string())) + .trace(cause.trace.clone().into()); + + if let Some(description) = cause.description { + new_cause.description(BlueprintError::Description(description.to_string())) + } else { + new_cause + } + }) + .collect(); + + ValidationError::from(causes) + } + + pub fn from_validation_string( + errors: ValidationError, + ) -> ValidationError { + let causes: Vec> = errors + .as_vec() + .iter() + .map(|cause| { + let new_cause = Cause::new(BlueprintError::Cause(cause.message.to_string())) + .trace(cause.trace.clone().into()); + + if let Some(description) = &cause.description { + new_cause.description(BlueprintError::Description(description.to_string())) + } else { + new_cause + } + }) + .collect(); + + ValidationError::from(causes) + } +} diff --git a/src/core/blueprint/fixture/recursive-arg.graphql b/src/core/blueprint/fixture/recursive-arg.graphql new file mode 100644 index 0000000000..a124e657b5 --- /dev/null +++ b/src/core/blueprint/fixture/recursive-arg.graphql @@ -0,0 +1,10 @@ +schema @server(port: 8000) { + query: Query +} +type Query { + posts(id: PostData): Int @http(url: "upstream.com", query: [{key: "id", value: "{{.args.id.data}}"}]) +} +type PostData { + author: String + data: PostData +} diff --git a/src/core/blueprint/from_config.rs b/src/core/blueprint/from_config.rs index e9a8d094b3..fb95066b8e 100644 --- a/src/core/blueprint/from_config.rs +++ b/src/core/blueprint/from_config.rs @@ -15,7 +15,7 @@ use crate::core::json::JsonSchema; use crate::core::try_fold::TryFold; use crate::core::Type; -pub fn config_blueprint<'a>() -> TryFold<'a, ConfigModule, Blueprint, String> { +pub fn config_blueprint<'a>() -> TryFold<'a, ConfigModule, Blueprint, BlueprintError> { let server = TryFoldConfig::::new(|config_module, blueprint| { Valid::from(Server::try_from(config_module.clone())).map(|server| blueprint.server(server)) }); @@ -88,7 +88,7 @@ pub fn to_json_schema(type_of: &Type, config: &Config) -> JsonSchema { if let Some(type_) = type_ { let mut schema_fields = BTreeMap::new(); for (name, field) in type_.fields.iter() { - if field.resolver.is_none() { + if field.resolvers.is_empty() { schema_fields.insert(name.clone(), to_json_schema(&field.type_of, config)); } } @@ -116,20 +116,24 @@ pub fn to_json_schema(type_of: &Type, config: &Config) -> JsonSchema { } impl TryFrom<&ConfigModule> for Blueprint { - type Error = ValidationError; + type Error = ValidationError; fn try_from(config_module: &ConfigModule) -> Result { config_blueprint() .try_fold( // Apply required transformers to the configuration - &config_module.to_owned().transform(Required).to_result()?, + &config_module + .to_owned() + .transform(Required) + .to_result() + .map_err(BlueprintError::from_validation_string)?, Blueprint::default(), ) .and_then(|blueprint| { let schema_builder = SchemaBuilder::from(&blueprint); match schema_builder.finish() { Ok(_) => Valid::succeed(blueprint), - Err(e) => Valid::fail(e.to_string()), + Err(e) => Valid::fail(e.into()), } }) .to_result() diff --git a/src/core/blueprint/interface_resolver.rs b/src/core/blueprint/interface_resolver.rs index 8fca58cbc5..282ab7fa5c 100644 --- a/src/core/blueprint/interface_resolver.rs +++ b/src/core/blueprint/interface_resolver.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; use tailcall_valid::{Valid, Validator}; +use super::BlueprintError; use crate::core::blueprint::FieldDefinition; use crate::core::config::{ConfigModule, Discriminate, Field, Type}; use crate::core::ir::model::IR; @@ -12,19 +13,25 @@ fn compile_interface_resolver( interface_name: &str, interface_types: &BTreeSet, discriminate: &Option, -) -> Valid { +) -> Valid { let typename_field = discriminate.as_ref().map(|d| d.get_field()); - Discriminator::new( + match Discriminator::new( interface_name.to_string(), interface_types.clone(), typename_field, ) + .to_result() + { + Ok(data) => Valid::succeed(data), + Err(err) => Valid::from_validation_err(BlueprintError::from_validation_string(err)), + } } pub fn update_interface_resolver<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a Type, &'a str), FieldDefinition, String> { - TryFold::<(&ConfigModule, &Field, &Type, &str), FieldDefinition, String>::new( +) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a Type, &'a str), FieldDefinition, BlueprintError> +{ + TryFold::<(&ConfigModule, &Field, &Type, &str), FieldDefinition, BlueprintError>::new( |(config, field, _, _), mut b_field| { let Some(interface_types) = config.interfaces_types_map().get(field.type_of.name()) else { diff --git a/src/core/blueprint/links.rs b/src/core/blueprint/links.rs index bd9ad5a766..defe7a7f1e 100644 --- a/src/core/blueprint/links.rs +++ b/src/core/blueprint/links.rs @@ -1,19 +1,20 @@ use tailcall_valid::{Valid, ValidationError, Validator}; +use super::BlueprintError; use crate::core::config::{Link, LinkType}; use crate::core::directive::DirectiveCodec; pub struct Links; impl TryFrom> for Links { - type Error = ValidationError; + type Error = ValidationError; fn try_from(links: Vec) -> Result { Valid::from_iter(links.iter().enumerate(), |(pos, link)| { Valid::succeed(link.to_owned()) .and_then(|link| { if link.src.is_empty() { - Valid::fail("Link src cannot be empty".to_string()) + Valid::fail(BlueprintError::LinkSrcCannotBeEmpty) } else { Valid::succeed(link) } @@ -21,7 +22,7 @@ impl TryFrom> for Links { .and_then(|link| { if let Some(id) = &link.id { if links.iter().filter(|l| l.id.as_ref() == Some(id)).count() > 1 { - return Valid::fail(format!("Duplicated id: {}", id)); + return Valid::fail(BlueprintError::Duplicated(id.clone())); } } Valid::succeed(link) @@ -35,7 +36,7 @@ impl TryFrom> for Links { .collect::>(); if script_links.len() > 1 { - Valid::fail("Only one script link is allowed".to_string()) + Valid::fail(BlueprintError::OnlyOneScriptLinkAllowed) } else { Valid::succeed(links) } @@ -47,7 +48,7 @@ impl TryFrom> for Links { .collect::>(); if key_links.len() > 1 { - Valid::fail("Only one key link is allowed".to_string()) + Valid::fail(BlueprintError::OnlyOneKeyLinkAllowed) } else { Valid::succeed(links) } diff --git a/src/core/blueprint/mod.rs b/src/core/blueprint/mod.rs index 93c97302d0..91bdc0f773 100644 --- a/src/core/blueprint/mod.rs +++ b/src/core/blueprint/mod.rs @@ -5,6 +5,7 @@ mod cors; mod definitions; mod directive; mod dynamic_value; +mod error; mod from_config; mod index; mod interface_resolver; @@ -16,6 +17,7 @@ mod operators; mod schema; mod server; pub mod telemetry; +mod template_validation; mod timeout; mod union_resolver; mod upstream; @@ -25,6 +27,7 @@ pub use blueprint::*; pub use cors::*; pub use definitions::*; pub use dynamic_value::*; +pub use error::*; pub use from_config::*; pub use index::*; pub use links::*; @@ -37,4 +40,4 @@ pub use upstream::*; use crate::core::config::ConfigModule; use crate::core::try_fold::TryFold; -pub type TryFoldConfig<'a, A> = TryFold<'a, ConfigModule, A, String>; +pub type TryFoldConfig<'a, A> = TryFold<'a, ConfigModule, A, BlueprintError>; diff --git a/src/core/blueprint/mustache.rs b/src/core/blueprint/mustache.rs index 8845aaa5ac..1718de2feb 100644 --- a/src/core/blueprint/mustache.rs +++ b/src/core/blueprint/mustache.rs @@ -1,7 +1,8 @@ use tailcall_valid::{Valid, Validator}; -use super::FieldDefinition; +use super::{BlueprintError, FieldDefinition}; use crate::core::config::{self, Config}; +use crate::core::directive::DirectiveCodec; use crate::core::ir::model::{IO, IR}; use crate::core::scalar; @@ -16,22 +17,19 @@ impl<'a> MustachePartsValidator<'a> { Self { type_of, config, field } } - fn validate_type(&self, parts: &[String], is_query: bool) -> Result<(), String> { + fn validate_type(&self, parts: &[String], is_query: bool) -> Result<(), BlueprintError> { let mut len = parts.len(); let mut type_of = self.type_of; for item in parts { let field = type_of.fields.get(item).ok_or_else(|| { - format!( - "no value '{}' found", - parts[0..parts.len() - len + 1].join(".").as_str() - ) + BlueprintError::NoValueFound(parts[0..parts.len() - len + 1].join(".")) })?; let val_type = &field.type_of; if !is_query && val_type.is_nullable() { - return Err(format!("value '{}' is a nullable type", item.as_str())); + return Err(BlueprintError::ValueIsNullableType(item.clone())); } else if len == 1 && !scalar::Scalar::is_predefined(val_type.name()) { - return Err(format!("value '{}' is not of a scalar type", item.as_str())); + return Err(BlueprintError::ValueIsNotOfScalarType(item.clone())); } else if len == 1 { break; } @@ -39,7 +37,7 @@ impl<'a> MustachePartsValidator<'a> { type_of = self .config .find_type(val_type.name()) - .ok_or_else(|| format!("no type '{}' found", parts.join(".").as_str()))?; + .ok_or_else(|| BlueprintError::NoTypeFound(parts.join(".")))?; len -= 1; } @@ -47,12 +45,12 @@ impl<'a> MustachePartsValidator<'a> { Ok(()) } - fn validate(&self, parts: &[String], is_query: bool) -> Valid<(), String> { + fn validate(&self, parts: &[String], is_query: bool) -> Valid<(), BlueprintError> { let config = self.config; let args = &self.field.args; if parts.len() < 2 { - return Valid::fail("too few parts in template".to_string()); + return Valid::fail(BlueprintError::TooFewPartsInTemplate); } let head = parts[0].as_str(); @@ -73,20 +71,22 @@ impl<'a> MustachePartsValidator<'a> { // most cases if let Some(arg) = args.iter().find(|arg| arg.name == tail) { if !is_query && arg.of_type.is_list() { - return Valid::fail(format!("can't use list type '{tail}' here")); + return Valid::fail(BlueprintError::CantUseListTypeHere(tail.to_string())); } // we can use non-scalar types in args if !is_query && arg.default_value.is_none() && arg.of_type.is_nullable() { - return Valid::fail(format!("argument '{tail}' is a nullable type")); + return Valid::fail(BlueprintError::ArgumentIsNullableType( + tail.to_string(), + )); } } else { - return Valid::fail(format!("no argument '{tail}' found")); + return Valid::fail(BlueprintError::ArgumentNotFound(tail.to_string())); } } "vars" => { if !config.server.vars.iter().any(|vars| vars.key == tail) { - return Valid::fail(format!("var '{tail}' is not set in the server config")); + return Valid::fail(BlueprintError::VarNotSetInServerConfig(tail.to_string())); } } "headers" | "env" => { @@ -94,49 +94,43 @@ impl<'a> MustachePartsValidator<'a> { // we can't validate here } _ => { - return Valid::fail(format!("unknown template directive '{head}'")); + return Valid::fail(BlueprintError::UnknownTemplateDirective(head.to_string())); } } Valid::succeed(()) } -} - -impl FieldDefinition { - pub fn validate_field(&self, type_of: &config::Type, config: &Config) -> Valid<(), String> { - // 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); - match &self.resolver { - Some(IR::IO(IO::Http { req_template, .. })) => { + fn validate_resolver(&self, resolver: &IR) -> Valid<(), BlueprintError> { + match resolver { + IR::Merge(resolvers) => { + Valid::from_iter(resolvers, |resolver| self.validate_resolver(resolver)).unit() + } + IR::IO(IO::Http { req_template, .. }) => { Valid::from_iter(req_template.root_url.expression_segments(), |parts| { - parts_validator.validate(parts, false).trace("path") + self.validate(parts, false).trace("path") }) .and(Valid::from_iter(req_template.query.clone(), |query| { let mustache = &query.value; Valid::from_iter(mustache.expression_segments(), |parts| { - parts_validator.validate(parts, true).trace("query") + self.validate(parts, true).trace("query") }) })) .unit() + .trace(config::Http::trace_name().as_str()) } - Some(IR::IO(IO::GraphQL { req_template, .. })) => { + IR::IO(IO::GraphQL { req_template, .. }) => { Valid::from_iter(req_template.headers.clone(), |(_, mustache)| { Valid::from_iter(mustache.expression_segments(), |parts| { - parts_validator.validate(parts, true).trace("headers") + self.validate(parts, true).trace("headers") }) }) .and_then(|_| { if let Some(args) = &req_template.operation_arguments { Valid::from_iter(args, |(_, mustache)| { Valid::from_iter(mustache.expression_segments(), |parts| { - parts_validator.validate(parts, true).trace("args") + self.validate(parts, true).trace("args") }) }) } else { @@ -144,15 +138,16 @@ impl FieldDefinition { } }) .unit() + .trace(config::GraphQL::trace_name().as_str()) } - Some(IR::IO(IO::Grpc { req_template, .. })) => { + IR::IO(IO::Grpc { req_template, .. }) => { Valid::from_iter(req_template.url.expression_segments(), |parts| { - parts_validator.validate(parts, false).trace("path") + self.validate(parts, false).trace("path") }) .and( Valid::from_iter(req_template.headers.clone(), |(_, mustache)| { Valid::from_iter(mustache.expression_segments(), |parts| { - parts_validator.validate(parts, true).trace("headers") + self.validate(parts, true).trace("headers") }) }) .unit(), @@ -161,7 +156,7 @@ impl FieldDefinition { if let Some(body) = &req_template.body { if let Some(mustache) = &body.mustache { Valid::from_iter(mustache.expression_segments(), |parts| { - parts_validator.validate(parts, true).trace("body") + self.validate(parts, true).trace("body") }) } else { // TODO: needs review @@ -172,12 +167,35 @@ impl FieldDefinition { } }) .unit() + .trace(config::Grpc::trace_name().as_str()) } + // TODO: add validation for @expr _ => Valid::succeed(()), } } } +impl FieldDefinition { + pub fn validate_field( + &self, + type_of: &config::Type, + config: &Config, + ) -> Valid<(), BlueprintError> { + // 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); + + match &self.resolver { + Some(resolver) => parts_validator.validate_resolver(resolver), + None => Valid::succeed(()), + } + } +} + #[cfg(test)] mod test { use tailcall_valid::Validator; diff --git a/src/core/blueprint/operators/apollo_federation.rs b/src/core/blueprint/operators/apollo_federation.rs index 46c4a8e770..3aaf2b953c 100644 --- a/src/core/blueprint/operators/apollo_federation.rs +++ b/src/core/blueprint/operators/apollo_federation.rs @@ -4,8 +4,8 @@ use std::fmt::Write; use async_graphql::parser::types::ServiceDocument; use tailcall_valid::{Valid, Validator}; -use super::{compile_call, compile_expr, compile_graphql, compile_grpc, compile_http, compile_js}; -use crate::core::blueprint::{Blueprint, Definition, TryFoldConfig}; +use super::{compile_resolver, CompileResolver}; +use crate::core::blueprint::{Blueprint, BlueprintError, Definition, TryFoldConfig}; use crate::core::config::{ ApolloFederation, ConfigModule, EntityResolver, Field, GraphQLOperationType, Resolver, }; @@ -13,11 +13,11 @@ use crate::core::ir::model::IR; use crate::core::Type; pub struct CompileEntityResolver<'a> { - config_module: &'a ConfigModule, - entity_resolver: &'a EntityResolver, + pub config_module: &'a ConfigModule, + pub entity_resolver: &'a EntityResolver, } -pub fn compile_entity_resolver(inputs: CompileEntityResolver<'_>) -> Valid { +pub fn compile_entity_resolver(inputs: CompileEntityResolver<'_>) -> Valid { let CompileEntityResolver { config_module, entity_resolver } = inputs; let mut resolver_by_type = HashMap::new(); @@ -31,45 +31,26 @@ pub fn compile_entity_resolver(inputs: CompileEntityResolver<'_>) -> Valid compile_http( - config_module, - http, - // inner resolver should resolve only single instance of type, not a list - false, - ), - Resolver::Grpc(grpc) => compile_grpc(super::CompileGrpc { - config_module, - operation_type: &GraphQLOperationType::Query, - field, - grpc, - validate_with_schema: true, - }), - Resolver::Graphql(graphql) => compile_graphql( - config_module, - &GraphQLOperationType::Query, - type_name, - graphql, - ), - Resolver::Call(call) => { - compile_call(config_module, call, &GraphQLOperationType::Query, type_name) - } - Resolver::Js(js) => { - compile_js(super::CompileJs { js, script: &config_module.extensions().script }) - } - Resolver::Expr(expr) => { - compile_expr(super::CompileExpr { config_module, field, expr, validate: true }) - } Resolver::ApolloFederation(federation) => match federation { ApolloFederation::EntityResolver(entity_resolver) => { compile_entity_resolver(CompileEntityResolver { entity_resolver, ..inputs }) } - ApolloFederation::Service => Valid::fail( - "Apollo federation resolvers can't be a part of entity resolver" - .to_string(), - ), + ApolloFederation::Service => { + Valid::fail(BlueprintError::ApolloFederationResolversNoPartOfEntityResolver) + } }, + resolver => { + let inputs = CompileResolver { + config_module, + field, + operation_type: &GraphQLOperationType::Query, + object_name: type_name, + }; + + compile_resolver(&inputs, resolver).and_then(|resolver| { + Valid::from_option(resolver, BlueprintError::NoResolverFoundInSchema) + }) + } }; ir.map(|ir| { @@ -80,7 +61,7 @@ pub fn compile_entity_resolver(inputs: CompileEntityResolver<'_>) -> Valid Valid { +pub fn compile_service(mut sdl: String) -> Valid { writeln!(sdl).ok(); // Mark subgraph as Apollo federation v2 compatible according to [docs](https://www.apollographql.com/docs/apollo-server/using-federation/apollo-subgraph-setup/#2-opt-in-to-federation-2) @@ -111,11 +92,11 @@ pub fn update_federation<'a>() -> TryFoldConfig<'a, Blueprint> { } let Definition::Object(mut obj) = def else { - return Valid::fail("Query type is not an object inside the blueprint".to_string()); + return Valid::fail(BlueprintError::QueryTypeNotObject); }; let Some(config_type) = config_module.types.get(&query_name) else { - return Valid::fail(format!("Cannot find type {query_name} in the config")); + return Valid::fail(BlueprintError::TypeNotFoundInConfig(query_name.clone())); }; Valid::from_iter(obj.fields.iter_mut(), |b_field| { @@ -123,10 +104,15 @@ pub fn update_federation<'a>() -> TryFoldConfig<'a, Blueprint> { let name = &b_field.name; Valid::from_option( config_type.fields.get(name), - format!("Cannot find field {name} in the type"), + BlueprintError::FieldNotFoundInType(name.clone()), ) .and_then(|field| { - let Some(Resolver::ApolloFederation(federation)) = &field.resolver else { + let federation = field + .resolvers + .iter() + .find(|&resolver| matches!(resolver, Resolver::ApolloFederation(_))); + + let Some(Resolver::ApolloFederation(federation)) = federation else { return Valid::succeed(b_field); }; diff --git a/src/core/blueprint/operators/call.rs b/src/core/blueprint/operators/call.rs index 73afa763f7..8d543d8caa 100644 --- a/src/core/blueprint/operators/call.rs +++ b/src/core/blueprint/operators/call.rs @@ -1,35 +1,17 @@ use serde_json::Value; -use tailcall_valid::{Valid, ValidationError, Validator}; +use tailcall_valid::{Valid, Validator}; use crate::core::blueprint::*; use crate::core::config; -use crate::core::config::{Field, GraphQLOperationType, Resolver}; +use crate::core::config::{Field, GraphQLOperationType}; use crate::core::ir::model::IR; -use crate::core::try_fold::TryFold; - -pub fn update_call<'a>( - operation_type: &'a GraphQLOperationType, - object_name: &'a str, -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( - move |(config, field, _, _), b_field| { - let Some(Resolver::Call(call)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_call(config, call, operation_type, object_name) - .map(|resolver| b_field.resolver(Some(resolver))) - }, - ) -} pub fn compile_call( config_module: &ConfigModule, call: &config::Call, operation_type: &GraphQLOperationType, object_name: &str, -) -> Valid { +) -> Valid { Valid::from_iter(call.steps.iter(), |step| { get_field_and_field_name(step, config_module).and_then(|(field, field_name, type_of)| { let args = step.args.iter(); @@ -47,13 +29,12 @@ pub fn compile_call( .collect(); if empties.len().gt(&0) { - return Valid::fail(format!( - "no argument {} found", + return Valid::fail(BlueprintError::ArgumentNotFound( empties .into_iter() .map(|k| format!("'{}'", k)) .collect::>() - .join(", ") + .join(", "), )) .trace(field_name.as_str()); } @@ -68,16 +49,18 @@ pub fn compile_call( ) .and_then(|b_field| { if b_field.resolver.is_none() { - Valid::fail(format!("{} field has no resolver", field_name)) + Valid::fail(BlueprintError::FieldHasNoResolver(field_name.clone())) } else { Valid::succeed(b_field) } }) .fuse( - Valid::from( - DynamicValue::try_from(&Value::Object(step.args.clone().into_iter().collect())) - .map_err(|e| ValidationError::new(e.to_string())), - ) + match DynamicValue::try_from(&Value::Object( + step.args.clone().into_iter().collect(), + )) { + Ok(value) => Valid::succeed(value), + Err(e) => Valid::fail(BlueprintError::Error(e)), + } .map(IR::Dynamic), ) .map(|(mut b_field, args_expr)| { @@ -102,11 +85,11 @@ pub fn compile_call( b_field }), - "Steps can't be empty".to_string(), + BlueprintError::StepsCanNotBeEmpty, ) }) .and_then(|field| { - Valid::from_option(field.resolver, "Result resolver can't be empty".to_string()) + Valid::from_option(field.resolver, BlueprintError::ResultResolverCanNotBeEmpty) }) } @@ -125,20 +108,20 @@ fn get_type_and_field(call: &config::Step) -> Option<(String, String)> { fn get_field_and_field_name<'a>( call: &'a config::Step, config_module: &'a ConfigModule, -) -> Valid<(&'a Field, String, &'a config::Type), String> { +) -> Valid<(&'a Field, String, &'a config::Type), BlueprintError> { Valid::from_option( get_type_and_field(call), - "call must have query or mutation".to_string(), + BlueprintError::CallMustHaveQueryOrMutation, ) .and_then(|(type_name, field_name)| { Valid::from_option( config_module.config().find_type(&type_name), - format!("{} type not found on config", type_name), + BlueprintError::TypeNotFoundInConfig(type_name.clone()), ) .and_then(|query_type| { Valid::from_option( query_type.fields.get(&field_name), - format!("{} field not found", field_name), + BlueprintError::FieldNotFoundInType(field_name.clone()), ) .fuse(Valid::succeed(field_name)) .fuse(Valid::succeed(query_type)) diff --git a/src/core/blueprint/operators/enum_alias.rs b/src/core/blueprint/operators/enum_alias.rs index 22eebd4965..9ad1c83595 100644 --- a/src/core/blueprint/operators/enum_alias.rs +++ b/src/core/blueprint/operators/enum_alias.rs @@ -8,10 +8,13 @@ use crate::core::config::Field; use crate::core::ir::model::{Map, IR}; use crate::core::try_fold::TryFold; -pub fn update_enum_alias<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, String>::new( +pub fn update_enum_alias<'a>() -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, BlueprintError>::new( |(config, field, _, _), mut b_field| { let enum_type = config.enums.get(field.type_of.name()); if let Some(enum_type) = enum_type { diff --git a/src/core/blueprint/operators/expr.rs b/src/core/blueprint/operators/expr.rs index 42d767487c..7f0788999d 100644 --- a/src/core/blueprint/operators/expr.rs +++ b/src/core/blueprint/operators/expr.rs @@ -1,24 +1,23 @@ use async_graphql_value::ConstValue; -use tailcall_valid::{Valid, ValidationError, Validator}; +use tailcall_valid::{Valid, Validator}; use crate::core::blueprint::*; use crate::core::config; -use crate::core::config::{Expr, Field, Resolver}; +use crate::core::config::Expr; use crate::core::ir::model::IR; use crate::core::ir::model::IR::Dynamic; -use crate::core::try_fold::TryFold; fn validate_data_with_schema( config: &config::Config, field: &config::Field, gql_value: ConstValue, -) -> Valid<(), String> { +) -> Valid<(), BlueprintError> { match to_json_schema(&field.type_of, config) .validate(&gql_value) .to_result() { Ok(_) => Valid::succeed(()), - Err(err) => Valid::from_validation_err(err.transform(&(|a| a.to_owned()))), + Err(err) => Valid::from_validation_err(BlueprintError::from_validation_str(err)), } } @@ -29,15 +28,16 @@ pub struct CompileExpr<'a> { pub validate: bool, } -pub fn compile_expr(inputs: CompileExpr) -> Valid { +pub fn compile_expr(inputs: CompileExpr) -> Valid { let config_module = inputs.config_module; let field = inputs.field; let value = &inputs.expr.body; let validate = inputs.validate; - Valid::from( - DynamicValue::try_from(&value.clone()).map_err(|e| ValidationError::new(e.to_string())), - ) + match DynamicValue::try_from(&value.clone()) { + Ok(data) => Valid::succeed(data), + Err(err) => Valid::fail(BlueprintError::Error(err)), + } .and_then(|value| { if !value.is_const() { // TODO: Add validation for const with Mustache here @@ -53,23 +53,8 @@ pub fn compile_expr(inputs: CompileExpr) -> Valid { }; validation.map(|_| Dynamic(value.to_owned())) } - Err(e) => Valid::fail(format!("invalid JSON: {}", e)), + Err(e) => Valid::fail(BlueprintError::InvalidJson(e)), } } }) } - -pub fn update_const_field<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( - |(config_module, field, _, _), b_field| { - let Some(Resolver::Expr(expr)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_expr(CompileExpr { config_module, field, expr, validate: true }) - .map(|resolver| b_field.resolver(Some(resolver))) - }, - ) -} diff --git a/src/core/blueprint/operators/graphql.rs b/src/core/blueprint/operators/graphql.rs index ee25eb5fd1..4fe3189f2b 100644 --- a/src/core/blueprint/operators/graphql.rs +++ b/src/core/blueprint/operators/graphql.rs @@ -1,16 +1,13 @@ use std::collections::{HashMap, HashSet}; -use tailcall_valid::{Valid, ValidationError, Validator}; +use tailcall_valid::{Valid, Validator}; -use crate::core::blueprint::FieldDefinition; -use crate::core::config::{ - Config, ConfigModule, Field, GraphQL, GraphQLOperationType, Resolver, Type, -}; +use crate::core::blueprint::BlueprintError; +use crate::core::config::{Config, ConfigModule, GraphQL, GraphQLOperationType}; use crate::core::graphql::RequestTemplate; use crate::core::helpers; use crate::core::ir::model::{IO, IR}; use crate::core::ir::RelatedFields; -use crate::core::try_fold::TryFold; fn create_related_fields( config: &Config, @@ -61,22 +58,28 @@ pub fn compile_graphql( operation_type: &GraphQLOperationType, type_name: &str, graphql: &GraphQL, -) -> Valid { +) -> Valid { let args = graphql.args.as_ref(); + + let mustache = match helpers::headers::to_mustache_headers(&graphql.headers).to_result() { + Ok(mustache) => Valid::succeed(mustache), + Err(err) => Valid::from_validation_err(BlueprintError::from_validation_string(err)), + }; + Valid::succeed(graphql.url.as_str()) - .zip(helpers::headers::to_mustache_headers(&graphql.headers)) + .zip(mustache) .and_then(|(base_url, headers)| { - Valid::from( - RequestTemplate::new( - base_url.to_owned(), - operation_type, - &graphql.name, - args, - headers, - create_related_fields(config, type_name, &mut HashSet::new()), - ) - .map_err(|e| ValidationError::new(e.to_string())), - ) + match RequestTemplate::new( + base_url.to_owned(), + operation_type, + &graphql.name, + args, + headers, + create_related_fields(config, type_name, &mut HashSet::new()), + ) { + Ok(req_template) => Valid::succeed(req_template), + Err(err) => Valid::fail(BlueprintError::Error(err)), + } }) .map(|req_template| { let field_name = graphql.name.clone(); @@ -85,19 +88,3 @@ pub fn compile_graphql( IR::IO(IO::GraphQL { req_template, field_name, batch, dl_id: None, dedupe }) }) } - -pub fn update_graphql<'a>( - operation_type: &'a GraphQLOperationType, -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a Type, &'a str), FieldDefinition, String> { - TryFold::<(&ConfigModule, &Field, &Type, &'a str), FieldDefinition, String>::new( - |(config, field, type_of, _), b_field| { - let Some(Resolver::Graphql(graphql)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_graphql(config, operation_type, field.type_of.name(), graphql) - .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| b_field.validate_field(type_of, config).map_to(b_field)) - }, - ) -} diff --git a/src/core/blueprint/operators/grpc.rs b/src/core/blueprint/operators/grpc.rs index 3f730b109c..bcf7f979d6 100644 --- a/src/core/blueprint/operators/grpc.rs +++ b/src/core/blueprint/operators/grpc.rs @@ -5,16 +5,15 @@ use prost_reflect::FieldDescriptor; use tailcall_valid::{Valid, ValidationError, Validator}; use super::apply_select; -use crate::core::blueprint::FieldDefinition; +use crate::core::blueprint::BlueprintError; use crate::core::config::group_by::GroupBy; -use crate::core::config::{Config, ConfigModule, Field, GraphQLOperationType, Grpc, Resolver}; +use crate::core::config::{Config, ConfigModule, Field, GraphQLOperationType, Grpc}; use crate::core::grpc::protobuf::{ProtobufOperation, ProtobufSet}; use crate::core::grpc::request_template::RequestTemplate; +use crate::core::helpers; use crate::core::ir::model::{IO, IR}; use crate::core::json::JsonSchema; use crate::core::mustache::Mustache; -use crate::core::try_fold::TryFold; -use crate::core::{config, helpers}; fn to_url(grpc: &Grpc, method: &GrpcMethod) -> Valid { Valid::succeed(grpc.url.as_str()).and_then(|base_url| { @@ -64,12 +63,22 @@ fn validate_schema( field_schema: FieldSchema, operation: &ProtobufOperation, name: &str, -) -> Valid<(), String> { +) -> Valid<(), BlueprintError> { let input_type = &operation.input_type; let output_type = &operation.output_type; - Valid::from(JsonSchema::try_from(input_type)) - .zip(Valid::from(JsonSchema::try_from(output_type))) + let input_type = match JsonSchema::try_from(input_type) { + Ok(input_schema) => Valid::succeed(input_schema), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + }; + + let output_type = match JsonSchema::try_from(output_type) { + Ok(output_type) => Valid::succeed(output_type), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + }; + + input_type + .zip(output_type) .and_then(|(_input_schema, sub_type)| { // TODO: add validation for input schema - should compare result grpc.body to // schema @@ -77,7 +86,10 @@ fn validate_schema( // TODO: all of the fields in protobuf are optional actually // and if we want to mark some fields as required in GraphQL // JsonSchema won't match and the validation will fail - sub_type.is_a(&super_type, name) + match sub_type.is_a(&super_type, name).to_result() { + Ok(res) => Valid::succeed(res), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + } }) } @@ -85,20 +97,30 @@ fn validate_group_by( field_schema: &FieldSchema, operation: &ProtobufOperation, group_by: Vec, -) -> Valid<(), String> { +) -> Valid<(), BlueprintError> { let input_type = &operation.input_type; let output_type = &operation.output_type; - let mut field_descriptor: Result> = None.ok_or( - ValidationError::new(format!("field {} not found", group_by[0])), - ); + let mut field_descriptor: Result> = None + .ok_or(ValidationError::new(BlueprintError::FieldNotFound( + group_by[0].clone(), + ))); for item in group_by.iter().take(&group_by.len() - 1) { - field_descriptor = output_type - .get_field_by_json_name(item.as_str()) - .ok_or(ValidationError::new(format!("field {} not found", item))); + field_descriptor = + output_type + .get_field_by_json_name(item.as_str()) + .ok_or(ValidationError::new(BlueprintError::FieldNotFound( + item.clone(), + ))); } - let output_type = field_descriptor.and_then(|f| JsonSchema::try_from(&f)); + let output_type = field_descriptor + .and_then(|f| JsonSchema::try_from(&f).map_err(BlueprintError::from_validation_string)); - Valid::from(JsonSchema::try_from(input_type)) + let json_schema = match JsonSchema::try_from(input_type) { + Ok(schema) => Valid::succeed(schema), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + }; + + json_schema .zip(Valid::from(output_type)) .and_then(|(_input_schema, output_schema)| { // TODO: add validation for input schema - should compare result grpc.body to @@ -106,7 +128,13 @@ fn validate_group_by( let fields = &field_schema.field; // we're treating List types for gRPC as optional. let fields = JsonSchema::Opt(Box::new(JsonSchema::Arr(Box::new(fields.to_owned())))); - fields.is_a(&output_schema, group_by[0].as_str()) + match fields + .is_a(&output_schema, group_by[0].as_str()) + .to_result() + { + Ok(res) => Valid::succeed(res), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + } }) } @@ -130,7 +158,7 @@ impl Display for GrpcMethod { } impl TryFrom<&str> for GrpcMethod { - type Error = ValidationError; + type Error = ValidationError; fn try_from(value: &str) -> Result { let parts: Vec<&str> = value.rsplitn(3, '.').collect(); @@ -143,15 +171,14 @@ impl TryFrom<&str> for GrpcMethod { }; Ok(method) } - _ => Err(ValidationError::new(format!( - "Invalid method format: {}. Expected format is ..", - value - ))), + _ => Err(ValidationError::new( + BlueprintError::InvalidGrpcMethodFormat(value.to_string()), + )), } } } -pub fn compile_grpc(inputs: CompileGrpc) -> Valid { +pub fn compile_grpc(inputs: CompileGrpc) -> Valid { let config_module = inputs.config_module; let operation_type = inputs.operation_type; let field = inputs.field; @@ -164,14 +191,18 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid { let file_descriptor_set = config_module.extensions().get_file_descriptor_set(); if file_descriptor_set.file.is_empty() { - return Valid::fail("Protobuf files were not specified in the config".to_string()); + return Valid::fail(BlueprintError::ProtobufFilesNotSpecifiedInConfig); } - to_operation(&method, file_descriptor_set) + match to_operation(&method, file_descriptor_set) .fuse(to_url(grpc, &method)) .fuse(helpers::headers::to_mustache_headers(&grpc.headers)) .fuse(helpers::body::to_body(grpc.body.as_ref())) - .into() + .to_result() + { + Ok(data) => Valid::succeed(data), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + } }) .and_then(|(operation, url, headers, body)| { let validation = if validate_with_schema { @@ -210,33 +241,6 @@ pub fn compile_grpc(inputs: CompileGrpc) -> Valid { .and_then(apply_select) } -pub fn update_grpc<'a>( - operation_type: &'a GraphQLOperationType, -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, String>::new( - |(config_module, field, type_of, _name), b_field| { - let Some(Resolver::Grpc(grpc)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_grpc(CompileGrpc { - config_module, - operation_type, - field, - grpc, - validate_with_schema: true, - }) - .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| { - b_field - .validate_field(type_of, config_module) - .map_to(b_field) - }) - }, - ) -} - #[cfg(test)] mod tests { use std::convert::TryFrom; @@ -244,6 +248,7 @@ mod tests { use tailcall_valid::ValidationError; use super::GrpcMethod; + use crate::core::blueprint::BlueprintError; #[test] fn try_from_grpc_method() { @@ -266,7 +271,9 @@ mod tests { assert!(result.is_err()); assert_eq!( result.err().unwrap(), - ValidationError::new("Invalid method format: package_name.ServiceName. Expected format is ..".to_string()) + ValidationError::new(BlueprintError::InvalidGrpcMethodFormat( + "package_name.ServiceName".to_string() + )) ); } } diff --git a/src/core/blueprint/operators/http.rs b/src/core/blueprint/operators/http.rs index 6518388845..4b9b9a30d1 100644 --- a/src/core/blueprint/operators/http.rs +++ b/src/core/blueprint/operators/http.rs @@ -1,35 +1,44 @@ -use tailcall_valid::{Valid, ValidationError, Validator}; +use tailcall_valid::{Valid, Validator}; +use template_validation::validate_argument; use crate::core::blueprint::*; use crate::core::config::group_by::GroupBy; -use crate::core::config::{Field, Resolver}; +use crate::core::config::Field; use crate::core::endpoint::Endpoint; use crate::core::http::{HttpFilter, Method, RequestTemplate}; use crate::core::ir::model::{IO, IR}; -use crate::core::try_fold::TryFold; use crate::core::{config, helpers, Mustache}; pub fn compile_http( config_module: &config::ConfigModule, http: &config::Http, - is_list: bool, -) -> Valid { + field: &Field, +) -> Valid { + let is_list = field.type_of.is_list(); let dedupe = http.dedupe.unwrap_or_default(); + let mustache_headers = match helpers::headers::to_mustache_headers(&http.headers).to_result() { + Ok(mustache_headers) => Valid::succeed(mustache_headers), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + }; - Valid::<(), String>::fail("GroupBy is only supported for GET requests".to_string()) + Valid::<(), BlueprintError>::fail(BlueprintError::GroupByOnlyForGet) .when(|| !http.batch_key.is_empty() && http.method != Method::GET) .and( - Valid::<(), String>::fail( - "Batching capability was used without enabling it in upstream".to_string(), - ) - .when(|| { + Valid::<(), BlueprintError>::fail(BlueprintError::IncorrectBatchingUsage).when(|| { (config_module.upstream.get_delay() < 1 || config_module.upstream.get_max_size() < 1) && !http.batch_key.is_empty() }), ) + .and( + Valid::from_iter(http.query.iter(), |query| { + validate_argument(config_module, Mustache::parse(query.value.as_str()), field) + }) + .unit() + .trace("query"), + ) .and(Valid::succeed(http.url.as_str())) - .zip(helpers::headers::to_mustache_headers(&http.headers)) + .zip(mustache_headers) .and_then(|(base_url, headers)| { let query = http .query @@ -44,7 +53,7 @@ pub fn compile_http( }) .collect(); - RequestTemplate::try_from( + match RequestTemplate::try_from( Endpoint::new(base_url.to_string()) .method(http.method.clone()) .query(query) @@ -52,8 +61,10 @@ pub fn compile_http( .encoding(http.encoding.clone()), ) .map(|req_tmpl| req_tmpl.headers(headers)) - .map_err(|e| ValidationError::new(e.to_string())) - .into() + { + Ok(data) => Valid::succeed(data), + Err(e) => Valid::fail(BlueprintError::Error(e)), + } }) .map(|req_template| { // marge http and upstream on_request @@ -92,23 +103,3 @@ pub fn compile_http( }) .and_then(apply_select) } - -pub fn update_http<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, String>::new( - |(config_module, field, type_of, _), b_field| { - let Some(Resolver::Http(http)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_http(config_module, http, field.type_of.is_list()) - .map(|resolver| b_field.resolver(Some(resolver))) - .and_then(|b_field| { - b_field - .validate_field(type_of, config_module) - .map_to(b_field) - }) - }, - ) -} diff --git a/src/core/blueprint/operators/js.rs b/src/core/blueprint/operators/js.rs index 2eb35ee423..034f47fd84 100644 --- a/src/core/blueprint/operators/js.rs +++ b/src/core/blueprint/operators/js.rs @@ -1,33 +1,16 @@ use tailcall_valid::{Valid, Validator}; -use crate::core::blueprint::FieldDefinition; -use crate::core::config; -use crate::core::config::{ConfigModule, Field, Resolver, JS}; +use crate::core::blueprint::BlueprintError; +use crate::core::config::JS; use crate::core::ir::model::{IO, IR}; -use crate::core::try_fold::TryFold; pub struct CompileJs<'a> { pub js: &'a JS, pub script: &'a Option, } -pub fn compile_js(inputs: CompileJs) -> Valid { +pub fn compile_js(inputs: CompileJs) -> Valid { let name = &inputs.js.name; - Valid::from_option(inputs.script.as_ref(), "script is required".to_string()) + Valid::from_option(inputs.script.as_ref(), BlueprintError::ScriptIsRequired) .map(|_| IR::IO(IO::Js { name: name.to_string() })) } - -pub fn update_js_field<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, String>::new( - |(module, field, _, _), b_field| { - let Some(Resolver::Js(js)) = &field.resolver else { - return Valid::succeed(b_field); - }; - - compile_js(CompileJs { script: &module.extensions().script, js }) - .map(|resolver| b_field.resolver(Some(resolver))) - }, - ) -} diff --git a/src/core/blueprint/operators/mod.rs b/src/core/blueprint/operators/mod.rs index 77947e9571..0548e74111 100644 --- a/src/core/blueprint/operators/mod.rs +++ b/src/core/blueprint/operators/mod.rs @@ -8,6 +8,7 @@ mod http; mod js; mod modify; mod protected; +mod resolver; mod select; pub use apollo_federation::*; @@ -20,4 +21,5 @@ pub use http::*; pub use js::*; pub use modify::*; pub use protected::*; +pub use resolver::*; pub use select::*; diff --git a/src/core/blueprint/operators/modify.rs b/src/core/blueprint/operators/modify.rs index add132345a..29ca3c64f2 100644 --- a/src/core/blueprint/operators/modify.rs +++ b/src/core/blueprint/operators/modify.rs @@ -6,10 +6,13 @@ use crate::core::config::Field; use crate::core::ir::model::IR; use crate::core::try_fold::TryFold; -pub fn update_modify<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, String>::new( +pub fn update_modify<'a>() -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, BlueprintError>::new( |(config, field, type_of, _), mut b_field| { if let Some(modify) = field.modify.as_ref() { if let Some(new_name) = &modify.name { @@ -17,9 +20,7 @@ pub fn update_modify<'a>( let interface = config.find_type(name); if let Some(interface) = interface { if interface.fields.iter().any(|(name, _)| name == new_name) { - return Valid::fail( - "Field is already implemented from interface".to_string(), - ); + return Valid::fail(BlueprintError::FieldExistsInInterface); } } } diff --git a/src/core/blueprint/operators/protected.rs b/src/core/blueprint/operators/protected.rs index ee8e3f8c93..6f80a442d4 100644 --- a/src/core/blueprint/operators/protected.rs +++ b/src/core/blueprint/operators/protected.rs @@ -1,15 +1,19 @@ use tailcall_valid::{Valid, Validator}; -use crate::core::blueprint::{Auth, FieldDefinition, Provider}; +use crate::core::blueprint::{Auth, BlueprintError, FieldDefinition, Provider}; use crate::core::config::{self, ConfigModule, Field}; use crate::core::ir::model::IR; use crate::core::try_fold::TryFold; pub fn update_protected<'a>( type_name: &'a str, -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), FieldDefinition, String> -{ - TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, String>::new( +) -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &'a str), FieldDefinition, BlueprintError>::new( |(config, field, type_, _), mut b_field| { if field.protected.is_some() // check the field itself has marked as protected || type_.protected.is_some() // check the type that contains current field @@ -19,13 +23,11 @@ pub fn update_protected<'a>( .is_some() { if config.input_types().contains(type_name) { - return Valid::fail("Input types can not be protected".to_owned()); + return Valid::fail(BlueprintError::InputTypesCannotBeProtected); } if !config.extensions().has_auth() { - return Valid::fail( - "@protected operator is used but there is no @link definitions for auth providers".to_owned(), - ); + return Valid::fail(BlueprintError::ProtectedOperatorNoAuthProviders); } // Used to collect the providers that are used in the field @@ -58,7 +60,7 @@ pub fn update_protected<'a>( if let Some(provider) = providers.get(id) { Valid::succeed(Auth::Provider(provider.clone())) } else { - Valid::fail(format!("Auth provider {} not found", id)) + Valid::fail(BlueprintError::AuthProviderNotFound(id.clone())) } }) .map(|provider| { diff --git a/src/core/blueprint/operators/resolver.rs b/src/core/blueprint/operators/resolver.rs new file mode 100644 index 0000000000..6d296db87b --- /dev/null +++ b/src/core/blueprint/operators/resolver.rs @@ -0,0 +1,93 @@ +use tailcall_valid::{Valid, Validator}; + +use super::{compile_call, compile_expr, compile_graphql, compile_grpc, compile_http, compile_js}; +use crate::core::blueprint::{BlueprintError, FieldDefinition}; +use crate::core::config::{self, ConfigModule, Field, GraphQLOperationType, Resolver}; +use crate::core::directive::DirectiveCodec; +use crate::core::ir::model::IR; +use crate::core::try_fold::TryFold; + +pub struct CompileResolver<'a> { + pub config_module: &'a ConfigModule, + pub field: &'a Field, + pub operation_type: &'a GraphQLOperationType, + pub object_name: &'a str, +} + +pub fn compile_resolver( + inputs: &CompileResolver, + resolver: &Resolver, +) -> Valid, BlueprintError> { + let CompileResolver { config_module, field, operation_type, object_name } = inputs; + + match resolver { + Resolver::Http(http) => compile_http( + config_module, + http, + // inner resolver should resolve only single instance of type, not a list + field, + ) + .trace(config::Http::trace_name().as_str()), + Resolver::Grpc(grpc) => compile_grpc(super::CompileGrpc { + config_module, + operation_type, + field, + grpc, + validate_with_schema: true, + }) + .trace(config::Grpc::trace_name().as_str()), + Resolver::Graphql(graphql) => { + compile_graphql(config_module, operation_type, field.type_of.name(), graphql) + .trace(config::GraphQL::trace_name().as_str()) + } + Resolver::Call(call) => compile_call(config_module, call, operation_type, object_name) + .trace(config::Call::trace_name().as_str()), + Resolver::Js(js) => { + compile_js(super::CompileJs { js, script: &config_module.extensions().script }) + .trace(config::JS::trace_name().as_str()) + } + Resolver::Expr(expr) => { + compile_expr(super::CompileExpr { config_module, field, expr, validate: true }) + .trace(config::Expr::trace_name().as_str()) + } + Resolver::ApolloFederation(_) => { + // ignore the Federation resolvers since they have special meaning + // and should be executed only after the other config processing + return Valid::succeed(None); + } + } + .map(Some) +} + +pub fn update_resolver<'a>( + operation_type: &'a GraphQLOperationType, + object_name: &'a str, +) -> TryFold< + 'a, + (&'a ConfigModule, &'a Field, &'a config::Type, &'a str), + FieldDefinition, + BlueprintError, +> { + TryFold::<(&ConfigModule, &Field, &config::Type, &str), FieldDefinition, BlueprintError>::new( + |(config_module, field, type_of, _), b_field| { + let inputs = CompileResolver { config_module, field, operation_type, object_name }; + + Valid::from_iter(field.resolvers.iter(), |resolver| { + compile_resolver(&inputs, resolver) + }) + .map(|mut resolvers| match resolvers.len() { + 0 => None, + 1 => resolvers.pop().unwrap(), + _ => Some(IR::Merge(resolvers.into_iter().flatten().collect())), + }) + .map(|resolver| b_field.resolver(resolver)) + .and_then(|b_field| { + b_field + // TODO: there are `validate_field` for field, but not for types + // when we use federations's entities + .validate_field(type_of, config_module) + .map_to(b_field) + }) + }, + ) +} diff --git a/src/core/blueprint/operators/select.rs b/src/core/blueprint/operators/select.rs index f4e8eccc3a..e33eea75ce 100644 --- a/src/core/blueprint/operators/select.rs +++ b/src/core/blueprint/operators/select.rs @@ -1,10 +1,10 @@ use serde_json::Value; use tailcall_valid::Valid; -use crate::core::blueprint::DynamicValue; +use crate::core::blueprint::{BlueprintError, DynamicValue}; use crate::core::ir::model::IR; -pub fn apply_select(input: (IR, &Option)) -> Valid { +pub fn apply_select(input: (IR, &Option)) -> Valid { let (mut ir, select) = input; if let Some(select_value) = select { @@ -12,8 +12,8 @@ pub fn apply_select(input: (IR, &Option)) -> Valid { Ok(dynamic_value) => dynamic_value.prepend("args"), Err(e) => { return Valid::fail_with( - format!("syntax error when parsing `{:?}`", select), - e.to_string(), + BlueprintError::SyntaxErrorWhenParsing(format!("{:?}", select)), + BlueprintError::Error(e), ) } }; diff --git a/src/core/blueprint/schema.rs b/src/core/blueprint/schema.rs index 8ecaf24651..3acf956a9b 100644 --- a/src/core/blueprint/schema.rs +++ b/src/core/blueprint/schema.rs @@ -7,14 +7,14 @@ use crate::core::blueprint::*; use crate::core::config::{Config, Field, Type}; use crate::core::directive::DirectiveCodec; -fn validate_query(config: &Config) -> Valid<(), String> { +fn validate_query(config: &Config) -> Valid<(), BlueprintError> { Valid::from_option( config.schema.query.clone(), - "Query root is missing".to_owned(), + BlueprintError::QueryRootIsMissing, ) .and_then(|ref query_type_name| { let Some(query) = config.find_type(query_type_name) else { - return Valid::fail("Query type is not defined".to_owned()).trace(query_type_name); + return Valid::fail(BlueprintError::QueryTypeNotDefined).trace(query_type_name); }; let mut set = HashSet::new(); validate_type_has_resolvers(query_type_name, query, &config.types, &mut set) @@ -29,7 +29,7 @@ fn validate_type_has_resolvers( ty: &Type, types: &BTreeMap, visited: &mut HashSet, -) -> Valid<(), String> { +) -> Valid<(), BlueprintError> { if ty.scalar() || visited.contains(name) { return Valid::succeed(()); } @@ -48,8 +48,8 @@ pub fn validate_field_has_resolver( field: &Field, types: &BTreeMap, visited: &mut HashSet, -) -> Valid<(), String> { - Valid::<(), String>::fail("No resolver has been found in the schema".to_owned()) +) -> Valid<(), BlueprintError> { + Valid::<(), BlueprintError>::fail(BlueprintError::NoResolverFoundInSchema) .when(|| { if !field.has_resolver() { let type_name = field.type_of.name(); @@ -65,13 +65,12 @@ pub fn validate_field_has_resolver( .trace(name) } -fn validate_mutation(config: &Config) -> Valid<(), String> { +fn validate_mutation(config: &Config) -> Valid<(), BlueprintError> { let mutation_type_name = config.schema.mutation.as_ref(); if let Some(mutation_type_name) = mutation_type_name { let Some(mutation) = config.find_type(mutation_type_name) else { - return Valid::fail("Mutation type is not defined".to_owned()) - .trace(mutation_type_name); + return Valid::fail(BlueprintError::MutationTypeNotDefined).trace(mutation_type_name); }; let mut set = HashSet::new(); validate_type_has_resolvers(mutation_type_name, mutation, &config.types, &mut set) @@ -86,7 +85,7 @@ pub fn to_schema<'a>() -> TryFoldConfig<'a, SchemaDefinition> { .and(validate_mutation(config)) .and(Valid::from_option( config.schema.query.as_ref(), - "Query root is missing".to_owned(), + BlueprintError::QueryRootIsMissing, )) .zip(to_directive(config.server.to_directive())) .map(|(query_type_name, directive)| SchemaDefinition { diff --git a/src/core/blueprint/server.rs b/src/core/blueprint/server.rs index 930b927ae7..75d5b3e186 100644 --- a/src/core/blueprint/server.rs +++ b/src/core/blueprint/server.rs @@ -8,6 +8,7 @@ use http::header::{HeaderMap, HeaderName, HeaderValue}; use rustls_pki_types::CertificateDer; use tailcall_valid::{Valid, ValidationError, Validator}; +use super::BlueprintError; use crate::core::blueprint::Cors; use crate::core::config::{self, ConfigModule, HttpVersion, PrivateKey, Routes}; @@ -81,7 +82,7 @@ impl Server { } impl TryFrom for Server { - type Error = ValidationError; + type Error = ValidationError; fn try_from(config_module: config::ConfigModule) -> Result { let config_server = config_module.server.clone(); @@ -89,8 +90,7 @@ impl TryFrom for Server { let http_server = match config_server.clone().get_version() { HttpVersion::HTTP2 => { if config_module.extensions().cert.is_empty() { - return Valid::fail("Certificate is required for HTTP2".to_string()) - .to_result(); + return Valid::fail(BlueprintError::CertificateIsRequiredForHTTP2).to_result(); } let cert = config_module.extensions().cert.clone(); @@ -99,7 +99,7 @@ impl TryFrom for Server { .extensions() .keys .first() - .ok_or_else(|| ValidationError::new("Key is required for HTTP2".to_string()))? + .ok_or_else(|| ValidationError::new(BlueprintError::KeyIsRequiredForHTTP2))? .clone(); Valid::succeed(Http::HTTP2 { cert, key }) @@ -151,7 +151,9 @@ impl TryFrom for Server { } } -fn to_script(config_module: &crate::core::config::ConfigModule) -> Valid, String> { +fn to_script( + config_module: &crate::core::config::ConfigModule, +) -> Valid, BlueprintError> { config_module.extensions().script.as_ref().map_or_else( || Valid::succeed(None), |script| { @@ -168,7 +170,7 @@ fn to_script(config_module: &crate::core::config::ConfigModule) -> Valid) -> Valid, String> { +fn validate_cors(cors: Option) -> Valid, BlueprintError> { Valid::from(cors.map(|cors| cors.try_into()).transpose()) .trace("cors") .trace("headers") @@ -176,29 +178,35 @@ fn validate_cors(cors: Option) -> Valid, String .trace("schema") } -fn validate_hostname(hostname: String) -> Valid { +fn validate_hostname(hostname: String) -> Valid { if hostname == "localhost" { Valid::succeed(IpAddr::from([127, 0, 0, 1])) } else { - Valid::from(hostname.parse().map_err(|e: AddrParseError| { - ValidationError::new(format!("Parsing failed because of {}", e)) - })) + Valid::from( + hostname.parse().map_err(|e: AddrParseError| { + ValidationError::new(BlueprintError::ParsingFailed(e)) + }), + ) .trace("hostname") .trace("@server") .trace("schema") } } -fn handle_response_headers(resp_headers: Vec<(String, String)>) -> Valid { +fn handle_response_headers( + resp_headers: Vec<(String, String)>, +) -> Valid { Valid::from_iter(resp_headers.iter(), |(k, v)| { - let name = Valid::from( - HeaderName::from_bytes(k.as_bytes()) - .map_err(|e| ValidationError::new(format!("Parsing failed because of {}", e))), - ); - let value = Valid::from( - HeaderValue::from_str(v.as_str()) - .map_err(|e| ValidationError::new(format!("Parsing failed because of {}", e))), - ); + let name = match HeaderName::from_bytes(k.as_bytes()) { + Ok(name) => Valid::succeed(name), + Err(e) => Valid::fail(BlueprintError::InvalidHeaderName(e)), + }; + + let value = match HeaderValue::from_str(v.as_str()) { + Ok(value) => Valid::succeed(value), + Err(e) => Valid::fail(BlueprintError::InvalidHeaderValue(e)), + }; + name.zip(value) }) .map(|headers| headers.into_iter().collect::()) @@ -208,18 +216,17 @@ fn handle_response_headers(resp_headers: Vec<(String, String)>) -> Valid) -> Valid, String> { +fn handle_experimental_headers( + headers: BTreeSet, +) -> Valid, BlueprintError> { Valid::from_iter(headers.iter(), |h| { if !h.to_lowercase().starts_with("x-") { - Valid::fail( - format!( - "Experimental headers must start with 'x-' or 'X-'. Got: '{}'", - h - ) - .to_string(), - ) + Valid::fail(BlueprintError::ExperimentalHeaderInvalidFormat(h.clone())) } else { - Valid::from(HeaderName::from_str(h).map_err(|e| ValidationError::new(e.to_string()))) + match HeaderName::from_str(h) { + Ok(name) => Valid::succeed(name), + Err(e) => Valid::fail(BlueprintError::InvalidHeaderName(e)), + } } }) .map(HashSet::from_iter) diff --git a/src/core/blueprint/telemetry.rs b/src/core/blueprint/telemetry.rs index f7b131c2ec..95160dd78f 100644 --- a/src/core/blueprint/telemetry.rs +++ b/src/core/blueprint/telemetry.rs @@ -1,10 +1,10 @@ use std::str::FromStr; use http::header::{HeaderMap, HeaderName, HeaderValue}; -use tailcall_valid::{Valid, ValidationError, Validator}; +use tailcall_valid::{Valid, Validator}; use url::Url; -use super::TryFoldConfig; +use super::{BlueprintError, TryFoldConfig}; use crate::core::config::{ self, Apollo, ConfigModule, KeyValue, PrometheusExporter, StdoutExporter, }; @@ -31,29 +31,37 @@ pub struct Telemetry { pub request_headers: Vec, } -fn to_url(url: &str) -> Valid { - Valid::from(Url::parse(url).map_err(|e| ValidationError::new(e.to_string()))).trace("url") +fn to_url(url: &str) -> Valid { + match Url::parse(url).map_err(BlueprintError::UrlParse) { + Ok(url) => Valid::succeed(url), + Err(err) => Valid::fail(err), + } + .trace("url") } -fn to_headers(headers: Vec) -> Valid { +fn to_headers(headers: Vec) -> Valid { Valid::from_iter(headers.iter(), |key_value| { - Valid::from( - HeaderName::from_str(&key_value.key) - .map_err(|err| ValidationError::new(err.to_string())), - ) - .zip(Valid::from( - HeaderValue::from_str(&key_value.value) - .map_err(|err| ValidationError::new(err.to_string())), - )) + match HeaderName::from_str(&key_value.key).map_err(BlueprintError::InvalidHeaderName) { + Ok(name) => Valid::succeed(name), + Err(err) => Valid::fail(err), + } + .zip({ + match HeaderValue::from_str(&key_value.value) + .map_err(BlueprintError::InvalidHeaderValue) + { + Ok(value) => Valid::succeed(value), + Err(err) => Valid::fail(err), + } + }) }) .map(HeaderMap::from_iter) .trace("headers") } -pub fn to_opentelemetry<'a>() -> TryFold<'a, ConfigModule, Telemetry, String> { +pub fn to_opentelemetry<'a>() -> TryFold<'a, ConfigModule, Telemetry, BlueprintError> { TryFoldConfig::::new(|config, up| { if let Some(export) = config.telemetry.export.as_ref() { - let export = match export { + let export: Valid = match export { config::TelemetryExporter::Stdout(config) => { Valid::succeed(TelemetryExporter::Stdout(config.clone())) } @@ -80,20 +88,20 @@ pub fn to_opentelemetry<'a>() -> TryFold<'a, ConfigModule, Telemetry, String> { }) } -fn validate_apollo(apollo: Apollo) -> Valid { +fn validate_apollo(apollo: Apollo) -> Valid { validate_graph_ref(&apollo.graph_ref) .map(|_| apollo) .trace("apollo.graph_ref") } -fn validate_graph_ref(graph_ref: &str) -> Valid<(), String> { +fn validate_graph_ref(graph_ref: &str) -> Valid<(), BlueprintError> { let is_valid = regex::Regex::new(r"^[A-Za-z0-9-_]+@[A-Za-z0-9-_]+$") .unwrap() .is_match(graph_ref); if is_valid { Valid::succeed(()) } else { - Valid::fail(format!("`graph_ref` should be in the format @ where `graph_id` and `variant` can only contain letters, numbers, '-' and '_'. Found {graph_ref}").to_string()) + Valid::fail(BlueprintError::InvalidGraphRef(graph_ref.to_string())) } } @@ -102,13 +110,13 @@ mod tests { use tailcall_valid::Valid; use super::validate_graph_ref; + use crate::core::blueprint::BlueprintError; #[test] fn test_validate_graph_ref() { let success = || Valid::succeed(()); - let failure = |graph_ref| { - Valid::fail(format!("`graph_ref` should be in the format @ where `graph_id` and `variant` can only contain letters, numbers, '-' and '_'. Found {graph_ref}").to_string()) - }; + let failure = + |graph_ref: &str| Valid::fail(BlueprintError::InvalidGraphRef(graph_ref.to_string())); assert_eq!(validate_graph_ref("graph_id@variant"), success()); assert_eq!( diff --git a/src/core/blueprint/template_validation/mod.rs b/src/core/blueprint/template_validation/mod.rs new file mode 100644 index 0000000000..621ba90185 --- /dev/null +++ b/src/core/blueprint/template_validation/mod.rs @@ -0,0 +1,90 @@ +use tailcall_valid::{Valid, Validator}; + +use super::BlueprintError; +use crate::core::config::{Config, Field}; +use crate::core::mustache::Segment; +use crate::core::scalar::Scalar; +use crate::core::Mustache; + +// given a path, it follows path till leaf node and provides callback at leaf +// node. +fn path_validator<'a>( + config: &Config, + mut path_iter: impl Iterator, + type_of: &str, + leaf_validator: impl Fn(&str) -> bool, +) -> Valid<(), BlueprintError> { + match config.find_type(type_of) { + Some(type_def) => match path_iter.next() { + Some(field) => match type_def.fields.get(field) { + Some(field_type) => { + path_validator(config, path_iter, field_type.type_of.name(), leaf_validator) + } + None => Valid::fail(BlueprintError::FieldNotFound(field.to_string())), + }, + None => Valid::fail(BlueprintError::ValueIsNotOfScalarType(type_of.to_string())), + }, + None if leaf_validator(type_of) => Valid::succeed(()), + None => Valid::fail(BlueprintError::TypeNotFoundInConfig(type_of.to_string())), + } +} + +/// Function to validate the arguments in the HTTP resolver. +pub fn validate_argument( + config: &Config, + template: Mustache, + field: &Field, +) -> Valid<(), BlueprintError> { + let scalar_validator = + |type_: &str| Scalar::is_predefined(type_) || config.find_enum(type_).is_some(); + + Valid::from_iter(template.segments(), |segment| match segment { + Segment::Expression(expr) if expr.first().map_or(false, |v| v.contains("args")) => { + match expr.get(1) { + Some(arg_name) if field.args.get(arg_name).is_some() => { + let arg_type_of = field.args.get(arg_name).as_ref().unwrap().type_of.name(); + path_validator(config, expr.iter().skip(2), arg_type_of, scalar_validator) + .trace(arg_name) + } + Some(arg_name) => { + Valid::fail(BlueprintError::ArgumentNotFound(arg_name.to_string())) + .trace(arg_name) + } + None => Valid::fail(BlueprintError::TooFewPartsInTemplate), + } + } + _ => Valid::succeed(()), + }) + .unit() +} + +#[cfg(test)] +mod test { + use tailcall_valid::{Valid, Validator}; + + use super::validate_argument; + use crate::core::blueprint::BlueprintError; + use crate::core::Mustache; + use crate::include_config; + + #[test] + fn test_recursive_case() { + let config = include_config!("../fixture/recursive-arg.graphql"); + let config = config.unwrap(); + let template = Mustache::parse("{{.args.id.data}}"); + let field = config + .find_type("Query") + .and_then(|ty| ty.fields.get("posts")) + .unwrap(); + let validation_result = validate_argument(&config, template, field); + + assert!(validation_result.is_fail()); + assert_eq!( + validation_result, + Valid::fail(BlueprintError::ValueIsNotOfScalarType( + "PostData".to_string() + )) + .trace("id") + ); + } +} diff --git a/src/core/blueprint/union_resolver.rs b/src/core/blueprint/union_resolver.rs index 73604742cb..abbfb73a13 100644 --- a/src/core/blueprint/union_resolver.rs +++ b/src/core/blueprint/union_resolver.rs @@ -1,5 +1,6 @@ use tailcall_valid::{Valid, Validator}; +use super::BlueprintError; use crate::core::blueprint::FieldDefinition; use crate::core::config::{ConfigModule, Discriminate, Field, Type, Union}; use crate::core::ir::model::IR; @@ -10,19 +11,25 @@ fn compile_union_resolver( union_name: &str, union_definition: &Union, discriminate: &Option, -) -> Valid { +) -> Valid { let typename_field = discriminate.as_ref().map(|d| d.get_field()); - Discriminator::new( + match Discriminator::new( union_name.to_string(), union_definition.types.clone(), typename_field, ) + .to_result() + { + Ok(discriminator) => Valid::succeed(discriminator), + Err(e) => Valid::from_validation_err(BlueprintError::from_validation_string(e)), + } } pub fn update_union_resolver<'a>( -) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a Type, &'a str), FieldDefinition, String> { - TryFold::<(&ConfigModule, &Field, &Type, &str), FieldDefinition, String>::new( +) -> TryFold<'a, (&'a ConfigModule, &'a Field, &'a Type, &'a str), FieldDefinition, BlueprintError> +{ + TryFold::<(&ConfigModule, &Field, &Type, &str), FieldDefinition, BlueprintError>::new( |(config, field, _, _), mut b_field| { let Some(union_definition) = config.find_union(field.type_of.name()) else { return Valid::succeed(b_field); diff --git a/src/core/blueprint/upstream.rs b/src/core/blueprint/upstream.rs index e8304ac224..2709f0b530 100644 --- a/src/core/blueprint/upstream.rs +++ b/src/core/blueprint/upstream.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use derive_setters::Setters; use tailcall_valid::{Valid, ValidationError, Validator}; +use super::BlueprintError; use crate::core::config::{self, Batch, ConfigModule}; #[derive(PartialEq, Eq, Clone, Debug, schemars::JsonSchema)] @@ -50,7 +51,7 @@ impl Default for Upstream { } impl TryFrom<&ConfigModule> for Upstream { - type Error = ValidationError; + type Error = ValidationError; fn try_from(config_module: &ConfigModule) -> Result { let config_upstream = config_module.upstream.clone(); @@ -86,7 +87,7 @@ impl TryFrom<&ConfigModule> for Upstream { } } -fn get_batch(upstream: &config::Upstream) -> Valid, String> { +fn get_batch(upstream: &config::Upstream) -> Valid, BlueprintError> { upstream.batch.as_ref().map_or_else( || Valid::succeed(None), |batch| { @@ -99,7 +100,7 @@ fn get_batch(upstream: &config::Upstream) -> Valid, String> { ) } -fn get_proxy(upstream: &config::Upstream) -> Valid, String> { +fn get_proxy(upstream: &config::Upstream) -> Valid, BlueprintError> { if let Some(ref proxy) = upstream.proxy { Valid::succeed(Some(Proxy { url: proxy.url.clone() })) } else { diff --git a/src/core/cache/error.rs b/src/core/cache/error.rs index 02f068f749..a576c49849 100644 --- a/src/core/cache/error.rs +++ b/src/core/cache/error.rs @@ -1,14 +1,14 @@ use std::fmt::Display; use std::sync::Arc; -use derive_more::{DebugCustom, From}; +use derive_more::{Debug, From}; -#[derive(From, DebugCustom, Clone)] +#[derive(From, Debug, Clone)] pub enum Error { - #[debug(fmt = "Serde Json Error: {}", _0)] + #[debug("Serde Json Error: {}", _0)] SerdeJson(Arc), - #[debug(fmt = "Kv Error: {}", _0)] + #[debug("Kv Error: {}", _0)] #[from(ignore)] Kv(String), } diff --git a/src/core/config/config.rs b/src/core/config/config.rs index 070ec7499b..95f11893f7 100644 --- a/src/core/config/config.rs +++ b/src/core/config/config.rs @@ -17,7 +17,7 @@ use super::directive::Directive; use super::from_document::from_document; use super::{ AddField, Alias, Cache, Call, Discriminate, Expr, GraphQL, Grpc, Http, Link, Modify, Omit, - Protected, Resolver, Server, Telemetry, Upstream, JS, + Protected, ResolverSet, Server, Telemetry, Upstream, JS, }; use crate::core::config::npo::QueryPath; use crate::core::config::source::Source; @@ -117,7 +117,7 @@ pub struct Type { /// /// Apollo federation entity resolver. #[serde(flatten, default, skip_serializing_if = "is_default")] - pub resolver: Option, + pub resolvers: ResolverSet, /// /// Any additional directives #[serde(default, skip_serializing_if = "is_default")] @@ -136,6 +136,14 @@ impl Display for Type { } impl Type { + pub fn has_resolver(&self) -> bool { + self.resolvers.has_resolver() + } + + pub fn has_batched_resolver(&self) -> bool { + self.resolvers.is_batched() + } + pub fn fields(mut self, fields: Vec<(&str, Field)>) -> Self { let mut graphql_fields = BTreeMap::new(); for (name, field) in fields { @@ -226,7 +234,7 @@ pub struct Field { /// /// Resolver for the field #[serde(flatten, default, skip_serializing_if = "is_default")] - pub resolver: Option, + pub resolvers: ResolverSet, /// /// Any additional directives @@ -243,14 +251,11 @@ impl MergeRight for Field { impl Field { pub fn has_resolver(&self) -> bool { - self.resolver.is_some() + self.resolvers.has_resolver() } pub fn has_batched_resolver(&self) -> bool { - self.resolver - .as_ref() - .map(Resolver::is_batched) - .unwrap_or(false) + self.resolvers.is_batched() } pub fn int() -> Self { @@ -693,6 +698,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::core::config::Resolver; use crate::core::directive::DirectiveCodec; #[test] @@ -700,18 +706,16 @@ mod tests { let f1 = Field { ..Default::default() }; let f2 = Field { - resolver: Some(Resolver::Http(Http { + resolvers: Resolver::Http(Http { batch_key: vec!["id".to_string()], ..Default::default() - })), + }) + .into(), ..Default::default() }; let f3 = Field { - resolver: Some(Resolver::Http(Http { - batch_key: vec![], - ..Default::default() - })), + resolvers: Resolver::Http(Http { batch_key: vec![], ..Default::default() }).into(), ..Default::default() }; diff --git a/src/core/config/config_module/fixtures/subgraph-users.graphql b/src/core/config/config_module/fixtures/subgraph-users.graphql index d07194ec9e..22f0e6b63a 100644 --- a/src/core/config/config_module/fixtures/subgraph-users.graphql +++ b/src/core/config/config_module/fixtures/subgraph-users.graphql @@ -6,7 +6,7 @@ type Query { users: [User] @http(url: "http://jsonplaceholder.typicode.com/users") user(id: Int!): User @http(url: "http://jsonplaceholder.typicode.com/users/{{.args.id}}") addComment(postId: Int!, comment: CommentInput!): Boolean - @http(url: "http://jsonplaceholder.typicode.com/add-comment") + @http(url: "http://jsonplaceholder.typicode.com/add-comment", method: POST) } enum Role { diff --git a/src/core/config/config_module/merge.rs b/src/core/config/config_module/merge.rs index 0c145234d2..5fc7fff0d7 100644 --- a/src/core/config/config_module/merge.rs +++ b/src/core/config/config_module/merge.rs @@ -101,7 +101,7 @@ impl Contravariant for Field { default_value: self.default_value.or(other.default_value), protected: self.protected.merge_right(other.protected), discriminate: self.discriminate.merge_right(other.discriminate), - resolver: self.resolver.merge_right(other.resolver), + resolvers: self.resolvers.merge_right(other.resolvers), directives: self.directives.merge_right(other.directives), }) } @@ -123,7 +123,7 @@ impl Covariant for Field { default_value: self.default_value.or(other.default_value), protected: self.protected.merge_right(other.protected), discriminate: self.discriminate.merge_right(other.discriminate), - resolver: self.resolver.merge_right(other.resolver), + resolvers: self.resolvers.merge_right(other.resolvers), directives: self.directives.merge_right(other.directives), }) } @@ -139,7 +139,7 @@ impl Contravariant for Type { implements: self.implements.merge_right(other.implements), cache: self.cache.merge_right(other.cache), protected: self.protected.merge_right(other.protected), - resolver: self.resolver.merge_right(other.resolver), + resolvers: self.resolvers.merge_right(other.resolvers), directives: self.directives.merge_right(other.directives), }) } @@ -155,7 +155,7 @@ impl Covariant for Type { implements: self.implements.merge_right(other.implements), cache: self.cache.merge_right(other.cache), protected: self.protected.merge_right(other.protected), - resolver: self.resolver.merge_right(other.resolver), + resolvers: self.resolvers.merge_right(other.resolvers), directives: self.directives.merge_right(other.directives), }) } diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap index 4a3cb46200..e2dc41f5da 100644 --- a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap @@ -1,6 +1,7 @@ --- source: src/core/config/config_module/merge.rs expression: merged.to_sdl() +snapshot_kind: text --- schema @server(port: 8000) @upstream(batch: {delay: 100, headers: []}, httpCache: 42) { query: Query diff --git a/src/core/config/directives/call.rs b/src/core/config/directives/call.rs index 377b711833..c1f1f28b7a 100644 --- a/src/core/config/directives/call.rs +++ b/src/core/config/directives/call.rs @@ -38,7 +38,7 @@ pub struct Step { schemars::JsonSchema, DirectiveDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object")] +#[directive_definition(repeatable, locations = "FieldDefinition, Object")] pub struct Call { /// Steps are composed together to form a call. /// If you have multiple steps, the output of the previous step is passed as diff --git a/src/core/config/directives/expr.rs b/src/core/config/directives/expr.rs index 39dcced1ce..5a60ecea09 100644 --- a/src/core/config/directives/expr.rs +++ b/src/core/config/directives/expr.rs @@ -13,7 +13,7 @@ use tailcall_macros::{DirectiveDefinition, InputDefinition}; DirectiveDefinition, InputDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object")] +#[directive_definition(repeatable, locations = "FieldDefinition, Object")] #[serde(deny_unknown_fields)] /// The `@expr` operators allows you to specify an expression that can evaluate /// to a value. The expression can be a static value or built form a Mustache diff --git a/src/core/config/directives/graphql.rs b/src/core/config/directives/graphql.rs index e366bbaaa2..509c2a0646 100644 --- a/src/core/config/directives/graphql.rs +++ b/src/core/config/directives/graphql.rs @@ -16,7 +16,7 @@ use crate::core::is_default; DirectiveDefinition, InputDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object")] +#[directive_definition(repeatable, locations = "FieldDefinition, Object")] #[serde(deny_unknown_fields)] /// The @graphQL operator allows to specify GraphQL API server request to fetch /// data from. diff --git a/src/core/config/directives/grpc.rs b/src/core/config/directives/grpc.rs index d770ce6502..919ae53027 100644 --- a/src/core/config/directives/grpc.rs +++ b/src/core/config/directives/grpc.rs @@ -17,7 +17,7 @@ use crate::core::is_default; InputDefinition, DirectiveDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object")] +#[directive_definition(repeatable, locations = "FieldDefinition, Object")] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] /// The @grpc operator indicates that a field or node is backed by a gRPC API. diff --git a/src/core/config/directives/http.rs b/src/core/config/directives/http.rs index 8d13eb44ac..91e0ecc985 100644 --- a/src/core/config/directives/http.rs +++ b/src/core/config/directives/http.rs @@ -19,7 +19,7 @@ use crate::core::json::JsonSchema; DirectiveDefinition, InputDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object")] +#[directive_definition(repeatable, locations = "FieldDefinition, Object")] #[serde(deny_unknown_fields)] /// The @http operator indicates that a field or node is backed by a REST API. /// diff --git a/src/core/config/directives/js.rs b/src/core/config/directives/js.rs index 60f307befc..e27891a78d 100644 --- a/src/core/config/directives/js.rs +++ b/src/core/config/directives/js.rs @@ -12,7 +12,7 @@ use tailcall_macros::{DirectiveDefinition, InputDefinition}; DirectiveDefinition, InputDefinition, )] -#[directive_definition(locations = "FieldDefinition, Object", lowercase_name)] +#[directive_definition(repeatable, locations = "FieldDefinition, Object", lowercase_name)] pub struct JS { pub name: String, } diff --git a/src/core/config/directives/link.rs b/src/core/config/directives/link.rs index c9f2a372e0..5063812a2f 100644 --- a/src/core/config/directives/link.rs +++ b/src/core/config/directives/link.rs @@ -17,14 +17,41 @@ use crate::core::is_default; )] pub enum LinkType { #[default] + /// Points to another Tailcall Configuration file. The imported + /// configuration will be merged into the importing configuration. Config, + + /// Points to a Protobuf file. The imported Protobuf file will be used by + /// the `@grpc` directive. If your API exposes a reflection endpoint, you + /// should set the type to `Grpc` instead. Protobuf, + + /// Points to a JS file. The imported JS file will be used by the `@js` + /// directive. Script, + + /// Points to a Cert file. The imported Cert file will be used by the server + /// to serve over HTTPS. Cert, + + /// Points to a Key file. The imported Key file will be used by the server + /// to serve over HTTPS. Key, + + /// A trusted document that contains GraphQL operations (queries, mutations) + /// that can be exposed a REST API using the `@rest` directive. Operation, + + /// Points to a Htpasswd file. The imported Htpasswd file will be used by + /// the server to authenticate users. Htpasswd, + + /// Points to a Jwks file. The imported Jwks file will be used by the server + /// to authenticate users. Jwks, + + /// Points to a reflection endpoint. The imported reflection endpoint will + /// be used by the `@grpc` directive to resolve data from gRPC services. Grpc, } diff --git a/src/core/config/from_document.rs b/src/core/config/from_document.rs index 3ccd7b72b6..b05757900f 100644 --- a/src/core/config/from_document.rs +++ b/src/core/config/from_document.rs @@ -245,7 +245,7 @@ where .fuse(to_add_fields_from_directives(directives)) .fuse(to_federation_directives(directives)) .map( - |(resolver, cache, fields, protected, added_fields, unknown_directives)| { + |(resolvers, cache, fields, protected, added_fields, unknown_directives)| { let doc = description.to_owned().map(|pos| pos.node); let implements = implements.iter().map(|pos| pos.node.to_string()).collect(); config::Type { @@ -255,7 +255,7 @@ where implements, cache, protected, - resolver, + resolvers, directives: unknown_directives, } }, @@ -339,7 +339,7 @@ where .fuse(to_federation_directives(directives)) .map( |( - resolver, + resolvers, cache, omit, modify, @@ -357,7 +357,7 @@ where protected, discriminate, default_value, - resolver, + resolvers, directives, }, ) diff --git a/src/core/config/into_document.rs b/src/core/config/into_document.rs index b06ff84311..5df4f034ae 100644 --- a/src/core/config/into_document.rs +++ b/src/core/config/into_document.rs @@ -213,21 +213,14 @@ fn into_directives( } fn field_directives(field: &crate::core::config::Field) -> Vec> { - let directives = vec![ - field - .resolver - .as_ref() - .and_then(|d| d.to_directive()) - .map(pos), - field.modify.as_ref().map(|d| pos(d.to_directive())), - field.omit.as_ref().map(|d| pos(d.to_directive())), - field.cache.as_ref().map(|d| pos(d.to_directive())), - field.protected.as_ref().map(|d| pos(d.to_directive())), - ]; - - directives - .into_iter() - .flatten() + field + .resolvers + .iter() + .filter_map(|resolver| resolver.to_directive().map(pos)) + .chain(field.modify.as_ref().map(|d| pos(d.to_directive()))) + .chain(field.omit.as_ref().map(|d| pos(d.to_directive()))) + .chain(field.cache.as_ref().map(|d| pos(d.to_directive()))) + .chain(field.protected.as_ref().map(|d| pos(d.to_directive()))) .chain(into_directives(&field.directives)) .collect() } @@ -251,10 +244,9 @@ fn type_directives(type_def: &crate::core::config::Type) -> Vec>() diff --git a/src/core/config/npo/fixtures/entity-resolver.graphql b/src/core/config/npo/fixtures/entity-resolver.graphql new file mode 100644 index 0000000000..4cd04e44a7 --- /dev/null +++ b/src/core/config/npo/fixtures/entity-resolver.graphql @@ -0,0 +1,47 @@ +schema @server @upstream { + query: Query +} + +type T1 @http(url: "") { + t1: Int +} + +type T2 @http(url: "") { + t2: [N] @http(url: "") +} + +type T3 @http(url: "", batchKey: ["id"]) { + t3: [N] @http(url: "") +} + +type T4 @http(url: "", batchKey: ["id"]) { + t4: [N] @http(url: "", batchKey: ["id"]) +} + +type T5 @http(url: "", batchKey: ["id"]) { + t5: [String] @http(url: "", batchKey: ["id"]) +} + +type T6 @http(url: "", batchKey: ["id"]) { + t6: [Int] +} + +type N { + n: [Int] @http(url: "") +} + +type Query { + x: String + t1: T1 @http(url: "") + t2: T2 @http(url: "") + t3: T3 @http(url: "") + t4: T4 @http(url: "") + t5: T5 @http(url: "") + t6: T6 @http(url: "") + t1_ls: [T1] @http(url: "") + t2_ls: [T2] @http(url: "") + t3_ls: [T3] @http(url: "") + t4_ls: [T4] @http(url: "") + t5_ls: [T5] @http(url: "") + t6_ls: [T6] @http(url: "") +} diff --git a/src/core/config/npo/fixtures/multiple-deeply-nested.graphql b/src/core/config/npo/fixtures/multiple-deeply-nested.graphql new file mode 100644 index 0000000000..fe46113f8a --- /dev/null +++ b/src/core/config/npo/fixtures/multiple-deeply-nested.graphql @@ -0,0 +1,23 @@ +schema @server @upstream { + query: Query +} + +type Root { + nested: Nested1 + nested_list: [Nested1] +} + +type Nested1 { + a: DeepNested @http(url: "") + b: DeepNested @http(url: "", batchKey: ["id"]) + c: DeepNested @http(url: "") + d: [DeepNested] @http(url: "") +} + +type DeepNested { + test: [String] @http(url: "") +} + +type Query { + root: Root @http(url: "") +} diff --git a/src/core/config/npo/fixtures/multiple-type-usage.graphql b/src/core/config/npo/fixtures/multiple-type-usage.graphql new file mode 100644 index 0000000000..fe19d28962 --- /dev/null +++ b/src/core/config/npo/fixtures/multiple-type-usage.graphql @@ -0,0 +1,29 @@ +schema @server @upstream { + query: Query +} + +type T1 { + t1: Int +} + +type T2 { + t2: [N] @http(url: "") +} + +type T3 { + t3: [N] @http(url: "", batchKey: ["id"]) +} + +type N { + n: Int @http(url: "") +} + +type Query { + x: String + t1: T1 @http(url: "") + t2: T2 @http(url: "") + t3: T3 @http(url: "") + t1_ls: [T1] @http(url: "") + t2_ls: [T2] @http(url: "") + t3_ls: [T3] @http(url: "") +} diff --git a/src/core/config/npo/fixtures/nested.graphql b/src/core/config/npo/fixtures/nested.graphql new file mode 100644 index 0000000000..20b1f5fb20 --- /dev/null +++ b/src/core/config/npo/fixtures/nested.graphql @@ -0,0 +1,21 @@ +schema @server(port: 8030) { + query: Query +} + +type Query { + version: Int! @expr(body: 1) + foo: [Foo!]! @expr(body: [{fizz: "buzz"}]) + bar: [Foo!]! @expr(body: [{fizz: "buzz"}]) + buzz: [Foo!]! @expr(body: [{fizz: "buzz"}]) +} + +type Foo { + fizz: String! + c: Int +} + +type Foo { + fizz: String! + c: Int + spam: [String!]! @http(url: "https://example.com/spam", query: [{key: "id", value: "{{.value.fizz}}"}]) +} diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__cycles_with_resolvers.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__cycles_with_resolvers.snap index 3ec61ef370..9829be427a 100644 --- a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__cycles_with_resolvers.snap +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__cycles_with_resolvers.snap @@ -2,5 +2,4 @@ source: src/core/config/npo/tracker.rs expression: actual --- -query { f1 { f1 { f2 } } } query { f1 { f2 } } diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__entity_resolver.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__entity_resolver.snap new file mode 100644 index 0000000000..22e7d62088 --- /dev/null +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__entity_resolver.snap @@ -0,0 +1,15 @@ +--- +source: src/core/config/npo/tracker.rs +expression: actual +snapshot_kind: text +--- +query { t2 { t2 { n } } } +query { t2_ls { t2 } } +query { t3 { t3 { n } } } +query { t3_ls { t3 } } +query { t4 { t4 { n } } } +query { t4_ls { t4 { n } } } +query { __entities(representations: [{ __typename: "T1"}]) } +query { __entities(representations: [{ __typename: "T2"}]) } +query { __entities(representations: [{ __typename: "T3"}]) { t3 } } +query { __entities(representations: [{ __typename: "T4"}]) { t4 { n } } } diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_deeply_nested.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_deeply_nested.snap new file mode 100644 index 0000000000..2415c7d408 --- /dev/null +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_deeply_nested.snap @@ -0,0 +1,10 @@ +--- +source: src/core/config/npo/tracker.rs +expression: actual +snapshot_kind: text +--- +query { root { nested { d { test } } } } +query { root { nested_list { a } } } +query { root { nested_list { b { test } } } } +query { root { nested_list { c } } } +query { root { nested_list { d } } } diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_keys.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_keys.snap index 46866fe99c..a373ad037c 100644 --- a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_keys.snap +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_keys.snap @@ -1,6 +1,7 @@ --- source: src/core/config/npo/tracker.rs expression: actual +snapshot_kind: text --- query { f1 { f2 } } query { f1 { f3 } } diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_type_usage.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_type_usage.snap new file mode 100644 index 0000000000..8cbff01fc8 --- /dev/null +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__multiple_type_usage.snap @@ -0,0 +1,8 @@ +--- +source: src/core/config/npo/tracker.rs +expression: actual +--- +query { t2 { t2 { n } } } +query { t2_ls { t2 } } +query { t3 { t3 { n } } } +query { t3_ls { t3 { n } } } diff --git a/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__nested_config.snap b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__nested_config.snap new file mode 100644 index 0000000000..0722c0f3ee --- /dev/null +++ b/src/core/config/npo/snapshots/tailcall__core__config__npo__tracker__tests__nested_config.snap @@ -0,0 +1,7 @@ +--- +source: src/core/config/npo/tracker.rs +expression: actual +--- +query { bar { spam } } +query { buzz { spam } } +query { foo { spam } } diff --git a/src/core/config/npo/tracker.rs b/src/core/config/npo/tracker.rs index 01a672f52d..53d55378a7 100644 --- a/src/core/config/npo/tracker.rs +++ b/src/core/config/npo/tracker.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fmt::{Display, Formatter}; use tailcall_chunk::Chunk; @@ -8,16 +8,16 @@ use crate::core::config::Config; /// /// Represents a list of query paths that can issue a N + 1 query #[derive(Default, Debug, PartialEq)] -pub struct QueryPath<'a>(Vec>); +pub struct QueryPath(Vec>); -impl QueryPath<'_> { +impl QueryPath { pub fn size(&self) -> usize { self.0.len() } } -impl<'a> From>>> for QueryPath<'a> { - fn from(chunk: Chunk>>) -> Self { +impl<'a> From>>> for QueryPath { + fn from(chunk: Chunk>>) -> Self { QueryPath( chunk .as_vec() @@ -26,7 +26,7 @@ impl<'a> From>>> for QueryPath<'a> { chunk .as_vec() .iter() - .map(|field_name| field_name.as_str()) + .map(|chunk_name| chunk_name.to_string()) .collect() }) .collect(), @@ -34,7 +34,7 @@ impl<'a> From>>> for QueryPath<'a> { } } -impl Display for QueryPath<'_> { +impl Display for QueryPath { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let query_data: Vec = self .0 @@ -58,11 +58,11 @@ impl Display for QueryPath<'_> { }) .collect(); - let val = query_data.iter().rfold("".to_string(), |s, query| { + let val = query_data.iter().fold("".to_string(), |s, query| { if s.is_empty() { query.to_string() } else { - format!("{}\n{}", query, s) + format!("{}\n{}", s, query) } }); @@ -92,9 +92,6 @@ impl<'a> FieldName<'a> { fn new(name: &'a str) -> Self { Self(name) } - fn as_str(self) -> &'a str { - self.0 - } } impl Display for FieldName<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -102,11 +99,32 @@ impl Display for FieldName<'_> { } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +enum Name<'a> { + Field(FieldName<'a>), + Entity(TypeName<'a>), +} + +impl Display for Name<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Name::Field(field_name) => write!(f, "{}", field_name), + Name::Entity(type_name) => write!( + f, + "__entities(representations: [{{ __typename: \"{}\"}}])", + type_name + ), + } + } +} + /// A module that tracks the query paths that can issue a N + 1 calls to /// upstream. pub struct PathTracker<'a> { config: &'a Config, - cache: HashMap<(TypeName<'a>, bool), Chunk>>>, + // Caches resolved chunks for the specific type + // with is_list info since the result is different depending on this flag + cache: HashMap<(TypeName<'a>, bool), Chunk>>>, } impl<'a> PathTracker<'a> { @@ -114,58 +132,81 @@ impl<'a> PathTracker<'a> { PathTracker { config, cache: Default::default() } } - #[allow(clippy::too_many_arguments)] fn iter( &mut self, - path: Chunk>, + parent_name: Option>, type_name: TypeName<'a>, is_list: bool, - visited: HashSet<(TypeName<'a>, FieldName<'a>)>, - ) -> Chunk>> { - if let Some(chunks) = self.cache.get(&(type_name, is_list)) { - return chunks.clone(); - } + ) -> Chunk>> { + let chunks = if let Some(chunks) = self.cache.get(&(type_name, is_list)) { + chunks.clone() + } else { + // set empty value in the cache to prevent infinity recursion + self.cache.insert((type_name, is_list), Chunk::default()); + + let mut chunks = Chunk::default(); + if let Some(type_of) = self.config.find_type(type_name.as_str()) { + for (name, field) in type_of.fields.iter() { + let field_name = Name::Field(FieldName::new(name)); - let mut chunks = Chunk::default(); - if let Some(type_of) = self.config.find_type(type_name.as_str()) { - for (name, field) in type_of.fields.iter() { - let field_name = FieldName::new(name); - let path = path.clone().append(field_name); - if !visited.contains(&(type_name, field_name)) { if is_list && field.has_resolver() && !field.has_batched_resolver() { - chunks = chunks.append(path.clone()); + chunks = chunks.append(Chunk::new(field_name)); } else { - let mut visited = visited.clone(); - visited.insert((type_name, field_name)); let is_list = is_list | field.type_of.is_list(); chunks = chunks.concat(self.iter( - path, + Some(field_name), TypeName::new(field.type_of.name()), is_list, - visited, )) } } } - } - self.cache.insert((type_name, is_list), chunks.clone()); - chunks + self.cache.insert((type_name, is_list), chunks.clone()); + + chunks + }; + + // chunks contains only paths from the current type. + // Prepend every subpath with parent path + if let Some(path) = parent_name { + let vec = chunks.as_vec(); + + Chunk::from_iter(vec.into_iter().map(|chunk| chunk.prepend(path))) + } else { + chunks + } } - fn find_chunks(&mut self) -> Chunk>> { - match &self.config.schema.query { + fn find_chunks(&mut self) -> Chunk>> { + let mut chunks = match &self.config.schema.query { None => Chunk::default(), - Some(query) => self.iter( - Chunk::default(), - TypeName::new(query.as_str()), - false, - HashSet::new(), - ), + Some(query) => self.iter(None, TypeName::new(query.as_str()), false), + }; + + for (type_name, type_of) in &self.config.types { + if type_of.has_resolver() { + let parent_path = Name::Entity(TypeName(type_name.as_str())); + // entity resolver are used to fetch multiple instances at once + // and therefore the resolver itself should be batched to avoid n + 1 + if type_of.has_batched_resolver() { + // if batched resolver is present traverse inner fields + chunks = chunks.concat(self.iter( + Some(parent_path), + TypeName::new(type_name.as_str()), + // entities are basically returning list of data + true, + )); + } else { + chunks = chunks.append(Chunk::new(parent_path)); + } + } } + + chunks } - pub fn find(mut self) -> QueryPath<'a> { + pub fn find(mut self) -> QueryPath { QueryPath::from(self.find_chunks()) } } @@ -241,8 +282,35 @@ mod tests { #[test] fn test_multiple_keys() { let config = include_config!("fixtures/multiple-keys.graphql").unwrap(); - let actual = config.n_plus_one(); - insta::assert_snapshot!(actual); + assert_n_plus_one!(config); + } + + #[test] + fn test_multiple_type_usage() { + let config = include_config!("fixtures/multiple-type-usage.graphql").unwrap(); + + assert_n_plus_one!(config); + } + + #[test] + fn test_entity_resolver() { + let config = include_config!("fixtures/entity-resolver.graphql").unwrap(); + + assert_n_plus_one!(config); + } + + #[test] + fn test_nested_config() { + let config = include_config!("fixtures/nested.graphql").unwrap(); + + assert_n_plus_one!(config); + } + + #[test] + fn test_multiple_deeply_nested() { + let config = include_config!("fixtures/multiple-deeply-nested.graphql").unwrap(); + + assert_n_plus_one!(config); } } diff --git a/src/core/config/resolver.rs b/src/core/config/resolver.rs index e30d3150ed..686419a4d6 100644 --- a/src/core/config/resolver.rs +++ b/src/core/config/resolver.rs @@ -1,3 +1,5 @@ +use std::ops::Deref; + use async_graphql::parser::types::ConstDirective; use async_graphql::Positioned; use serde::{Deserialize, Serialize}; @@ -6,6 +8,7 @@ use tailcall_valid::{Valid, Validator}; use super::{Call, EntityResolver, Expr, GraphQL, Grpc, Http, JS}; use crate::core::directive::DirectiveCodec; +use crate::core::merge_right::MergeRight; #[derive(Clone, Debug, PartialEq, Eq)] pub enum ApolloFederation { @@ -53,3 +56,99 @@ impl Resolver { } } } + +#[derive(Default, Clone, Debug, PartialEq, Eq, schemars::JsonSchema)] +pub struct ResolverSet(pub Vec); + +impl ResolverSet { + pub fn has_resolver(&self) -> bool { + !self.0.is_empty() + } + + pub fn is_batched(&self) -> bool { + if self.0.is_empty() { + false + } else { + self.0.iter().all(Resolver::is_batched) + } + } +} + +// Implement custom serializer to provide backward compatibility for JSON/YAML +// formats when converting config to config file. In case the only one resolver +// is defined serialize it as flatten structure instead of `resolvers: []` +// TODO: this is not required in case Tailcall drop defining type schema in +// json/yaml files +impl Serialize for ResolverSet { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let resolvers = &self.0; + + if resolvers.len() == 1 { + resolvers.first().unwrap().serialize(serializer) + } else { + resolvers.serialize(serializer) + } + } +} + +// Implement custom deserializer to provide backward compatibility for JSON/YAML +// formats when parsing config files. In case the `resolvers` field is defined +// in config parse it as vec of [Resolver] and otherwise try to parse it as +// single [Resolver] TODO: this is not required in case Tailcall drop defining +// type schema in json/yaml files +impl<'de> Deserialize<'de> for ResolverSet { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + use serde_json::Value; + + let mut value = Value::deserialize(deserializer)?; + + if let Value::Object(obj) = &mut value { + if obj.is_empty() { + return Ok(ResolverSet::default()); + } + + if let Some(value) = obj.remove("resolvers") { + let resolvers = serde_json::from_value(value).map_err(Error::custom)?; + + return Ok(Self(resolvers)); + } + } + + let resolver: Resolver = serde_json::from_value(value).map_err(Error::custom)?; + + Ok(ResolverSet::from(resolver)) + } +} + +impl From for ResolverSet { + fn from(value: Resolver) -> Self { + Self(vec![value]) + } +} + +impl Deref for ResolverSet { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl MergeRight for ResolverSet { + fn merge_right(mut self, other: Self) -> Self { + for resolver in other.0.into_iter() { + if !self.0.contains(&resolver) { + self.0.push(resolver); + } + } + + self + } +} diff --git a/src/core/config/transformer/subgraph.rs b/src/core/config/transformer/subgraph.rs index b95dc2d5a3..24eed1f86c 100644 --- a/src/core/config/transformer/subgraph.rs +++ b/src/core/config/transformer/subgraph.rs @@ -43,7 +43,14 @@ impl Transform for Subgraph { let mut resolver_by_type = BTreeMap::new(); let valid = Valid::from_iter(config.types.iter_mut(), |(type_name, ty)| { - if let Some(resolver) = &ty.resolver { + if ty.resolvers.len() > 1 { + // TODO: should support multiple different resolvers actually, see https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities/define-keys#multiple-keys + return Valid::fail( + "Only single resolver for entity is currently supported".to_string(), + ); + } + + if let Some(resolver) = ty.resolvers.first() { resolver_by_type.insert(type_name.clone(), resolver.clone()); KeysExtractor::validate(&config_types, resolver, type_name).and_then(|_| { @@ -95,7 +102,7 @@ impl Transform for Subgraph { Field { type_of: Type::from(SERVICE_TYPE_NAME.to_owned()).into_required(), doc: Some("Apollo federation Query._service resolver".to_string()), - resolver: Some(Resolver::ApolloFederation(ApolloFederation::Service)), + resolvers: Resolver::ApolloFederation(ApolloFederation::Service).into(), ..Default::default() }, ); @@ -135,9 +142,10 @@ impl Transform for Subgraph { .into_required(), args: [(ENTITIES_ARG_NAME.to_owned(), arg)].into_iter().collect(), doc: Some("Apollo federation Query._entities resolver".to_string()), - resolver: Some(Resolver::ApolloFederation( - ApolloFederation::EntityResolver(entity_resolver), - )), + resolvers: Resolver::ApolloFederation(ApolloFederation::EntityResolver( + entity_resolver, + )) + .into(), ..Default::default() }, ); diff --git a/src/core/document.rs b/src/core/document.rs index 26f37c0ae5..baaf7b9ca8 100644 --- a/src/core/document.rs +++ b/src/core/document.rs @@ -252,10 +252,20 @@ fn print_type_def(type_def: &TypeDefinition) -> String { fn print_enum_value(value: &async_graphql::parser::types::EnumValueDefinition) -> String { let directives_str = print_pos_directives(&value.directives); - if directives_str.is_empty() { + let variant_def = if directives_str.is_empty() { format!(" {}", value.value) } else { format!(" {} {}", value.value, directives_str) + }; + + if let Some(desc) = &value.description { + format!( + " \"\"\"\n {}\n \"\"\"\n{}", + desc.node.as_str(), + variant_def + ) + } else { + variant_def } } diff --git a/src/core/generator/from_proto.rs b/src/core/generator/from_proto.rs index b85aca8efa..93bc676a82 100644 --- a/src/core/generator/from_proto.rs +++ b/src/core/generator/from_proto.rs @@ -367,7 +367,7 @@ impl Context { .to_string(); cfg_field.type_of = cfg_field.type_of.with_name(output_ty); - cfg_field.resolver = Some(Resolver::Grpc(Grpc { + cfg_field.resolvers = Resolver::Grpc(Grpc { url: url.to_string(), body, batch_key: vec![], @@ -375,7 +375,8 @@ impl Context { method: field_name.id(), dedupe: None, select: None, - })); + }) + .into(); let method_path = PathBuilder::new(&path).extend(PathField::Method, method_index as i32); diff --git a/src/core/generator/json/operation_generator.rs b/src/core/generator/json/operation_generator.rs index f30b489a6d..fe0aeb36aa 100644 --- a/src/core/generator/json/operation_generator.rs +++ b/src/core/generator/json/operation_generator.rs @@ -30,9 +30,7 @@ impl OperationTypeGenerator { // generate required http directive. let http_directive_gen = HttpDirectiveGenerator::new(&request_sample.url); - field.resolver = Some(Resolver::Http( - http_directive_gen.generate_http_directive(&mut field), - )); + let mut http_resolver = http_directive_gen.generate_http_directive(&mut field); if let GraphQLOperationType::Mutation = request_sample.operation_type { // generate the input type. @@ -42,16 +40,18 @@ impl OperationTypeGenerator { let prefix = format!("{}Input", PREFIX); let arg_name_gen = NameGenerator::new(prefix.as_str()); let arg_name = arg_name_gen.next(); - if let Some(Resolver::Http(http)) = &mut field.resolver { - http.body = Some(format!("{{{{.args.{}}}}}", arg_name)); - http.method = request_sample.method.to_owned(); - } + + http_resolver.body = Some(format!("{{{{.args.{}}}}}", arg_name)); + http_resolver.method = request_sample.method.to_owned(); + field.args.insert( arg_name, Arg { type_of: root_ty.into(), ..Default::default() }, ); } + field.resolvers = Resolver::Http(http_resolver).into(); + // if type is already present, then append the new field to it else create one. let req_op = request_sample .operation_type diff --git a/src/core/grpc/data_loader_request.rs b/src/core/grpc/data_loader_request.rs index a8af211bde..1f49188d6f 100644 --- a/src/core/grpc/data_loader_request.rs +++ b/src/core/grpc/data_loader_request.rs @@ -85,7 +85,7 @@ mod tests { "foo".to_string(), Type::default().fields(vec![( "bar", - Field::default().resolver(Resolver::Grpc(grpc)), + Field::default().resolvers(Resolver::Grpc(grpc).into()), )]), ); diff --git a/src/core/grpc/protobuf.rs b/src/core/grpc/protobuf.rs index ca0012b46b..979b7afb2f 100644 --- a/src/core/grpc/protobuf.rs +++ b/src/core/grpc/protobuf.rs @@ -276,7 +276,7 @@ pub mod tests { "foo".to_string(), Type::default().fields(vec![( "bar", - Field::default().resolver(Resolver::Grpc(grpc)), + Field::default().resolvers(Resolver::Grpc(grpc).into()), )]), ); Ok(reader diff --git a/src/core/grpc/request_template.rs b/src/core/grpc/request_template.rs index f4901f5cc5..b1cb5653e4 100644 --- a/src/core/grpc/request_template.rs +++ b/src/core/grpc/request_template.rs @@ -171,7 +171,7 @@ mod tests { "foo".to_string(), Type::default().fields(vec![( "bar", - Field::default().resolver(Resolver::Grpc(grpc)), + Field::default().resolvers(Resolver::Grpc(grpc).into()), )]), ); diff --git a/src/core/ir/eval.rs b/src/core/ir/eval.rs index 840893c84f..e7b0a8c179 100644 --- a/src/core/ir/eval.rs +++ b/src/core/ir/eval.rs @@ -11,6 +11,7 @@ use super::model::{Cache, CacheKey, Map, IR}; use super::{Error, EvalContext, ResolverContextLike, TypedValue}; use crate::core::auth::verify::{AuthVerifier, Verify}; use crate::core::json::{JsonLike, JsonObjectLike}; +use crate::core::merge_right::MergeRight; use crate::core::serde_value_ext::ValueExt; impl IR { @@ -95,6 +96,24 @@ impl IR { let ctx = &mut ctx.with_args(args); second.eval(ctx).await } + IR::Merge(vec) => { + let results: Vec<_> = join_all(vec.iter().map(|ir| { + let mut ctx = ctx.clone(); + + async move { ir.eval(&mut ctx).await } + })) + .await + .into_iter() + .collect::>()?; + + // TODO: This is a very opinionated merge. We should allow users to customize + // how they would like to merge the values. In future we should support more + // merging capabilities by adding an additional parameter to `Merge`. + Ok(results + .into_iter() + .reduce(|acc, result| acc.merge_right(result)) + .unwrap_or_default()) + } IR::Discriminate(discriminator, expr) => expr .eval(ctx) .await @@ -150,3 +169,72 @@ impl IR { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + mod merge { + use serde_json::json; + + use super::*; + use crate::core::blueprint::{Blueprint, DynamicValue}; + use crate::core::http::RequestContext; + use crate::core::ir::EmptyResolverContext; + + #[tokio::test] + async fn test_const_values() { + let a = DynamicValue::Value( + ConstValue::from_json(json!({ + "a": 1, + "c": { + "ca": false + } + })) + .unwrap(), + ); + + let b = DynamicValue::Value( + ConstValue::from_json(json!({ + "b": 2, + "c": { + "cb": 23 + } + })) + .unwrap(), + ); + + let c = DynamicValue::Value( + ConstValue::from_json(json!({ + "c" : { + "ca": true, + "cc": [1, 2] + }, + "d": "additional" + })) + .unwrap(), + ); + + let ir = IR::Merge([a, b, c].into_iter().map(IR::Dynamic).collect()); + let runtime = crate::cli::runtime::init(&Blueprint::default()); + let req_ctx = RequestContext::new(runtime); + let res_ctx = EmptyResolverContext {}; + let mut eval_ctx = EvalContext::new(&req_ctx, &res_ctx); + + let actual = ir.eval(&mut eval_ctx).await.unwrap(); + let expected = ConstValue::from_json(json!({ + "a": 1, + "b": 2, + "c": { + "ca": true, + "cb": 23, + "cc": [1, 2] + }, + "d": "additional" + })) + .unwrap(); + + assert_eq!(actual, expected); + } + } +} diff --git a/src/core/ir/model.rs b/src/core/ir/model.rs index 234e38beef..25ddec34e4 100644 --- a/src/core/ir/model.rs +++ b/src/core/ir/model.rs @@ -25,6 +25,8 @@ pub enum IR { Protect(Auth, Box), Map(Map), Pipe(Box, Box), + /// Merges the result of multiple IRs together + Merge(Vec), Discriminate(Discriminator, Box), /// Apollo Federation _entities resolver Entity(HashMap), @@ -174,6 +176,9 @@ impl IR { .collect(), ), IR::Service(sdl) => IR::Service(sdl), + IR::Merge(vec) => { + IR::Merge(vec.into_iter().map(|ir| ir.modify(modifier)).collect()) + } } } } diff --git a/src/core/jit/transform/auth_planner.rs b/src/core/jit/transform/auth_planner.rs index e372c4daf2..ba725aa222 100644 --- a/src/core/jit/transform/auth_planner.rs +++ b/src/core/jit/transform/auth_planner.rs @@ -78,5 +78,8 @@ pub fn update_ir(ir: &mut IR, vec: &mut Vec) { IR::Discriminate(_, ir) => { update_ir(ir, vec); } + IR::Merge(irs) => { + irs.iter_mut().for_each(|ir| update_ir(ir, vec)); + } } } diff --git a/src/core/jit/transform/check_cache.rs b/src/core/jit/transform/check_cache.rs index 83a7dc202c..5839261d29 100644 --- a/src/core/jit/transform/check_cache.rs +++ b/src/core/jit/transform/check_cache.rs @@ -27,14 +27,9 @@ fn check_cache(ir: &IR) -> Option { (Some(age1), Some(age2)) => Some(age1.min(age2)), _ => None, }, + IR::Merge(vec) => vec.iter().map(check_cache).min().unwrap_or_default(), IR::Discriminate(_, ir) => check_cache(ir), - IR::Entity(hash_map) => { - let mut ttl = Some(NonZeroU64::MAX); - for ir in hash_map.values() { - ttl = std::cmp::min(ttl, check_cache(ir)); - } - ttl - } + IR::Entity(hash_map) => hash_map.values().map(check_cache).min().unwrap_or_default(), IR::Dynamic(_) | IR::ContextPath(_) | IR::Map(_) | IR::Service(_) => None, } } diff --git a/src/core/jit/transform/check_const.rs b/src/core/jit/transform/check_const.rs index 6ecdd4e599..6b6cb95822 100644 --- a/src/core/jit/transform/check_const.rs +++ b/src/core/jit/transform/check_const.rs @@ -25,6 +25,7 @@ pub fn is_const(ir: &IR) -> bool { IR::Protect(_, ir) => is_const(ir), IR::Map(map) => is_const(&map.input), IR::Pipe(ir, ir1) => is_const(ir) && is_const(ir1), + IR::Merge(vec) => vec.iter().all(is_const), IR::Discriminate(_, ir) => is_const(ir), IR::Entity(hash_map) => hash_map.values().all(is_const), IR::Service(_) => true, diff --git a/src/core/jit/transform/check_dedupe.rs b/src/core/jit/transform/check_dedupe.rs index 296038a6f3..82870333b1 100644 --- a/src/core/jit/transform/check_dedupe.rs +++ b/src/core/jit/transform/check_dedupe.rs @@ -21,6 +21,7 @@ fn check_dedupe(ir: &IR) -> bool { IR::Path(ir, _) => check_dedupe(ir), IR::Protect(_, ir) => check_dedupe(ir), IR::Pipe(ir, ir1) => check_dedupe(ir) && check_dedupe(ir1), + IR::Merge(vec) => vec.iter().all(check_dedupe), IR::Discriminate(_, ir) => check_dedupe(ir), IR::Entity(hash_map) => hash_map.values().all(check_dedupe), IR::Dynamic(_) => true, diff --git a/src/core/jit/transform/check_protected.rs b/src/core/jit/transform/check_protected.rs index 2b16557e28..c2c86a8a04 100644 --- a/src/core/jit/transform/check_protected.rs +++ b/src/core/jit/transform/check_protected.rs @@ -25,6 +25,7 @@ pub fn is_protected(ir: &IR) -> bool { IR::Protect(_, _) => true, IR::Map(map) => is_protected(&map.input), IR::Pipe(ir, ir1) => is_protected(ir) || is_protected(ir1), + IR::Merge(vec) => vec.iter().all(is_protected), IR::Discriminate(_, ir) => is_protected(ir), IR::Entity(hash_map) => hash_map.values().any(is_protected), IR::Service(_) => false, diff --git a/src/core/merge_right.rs b/src/core/merge_right.rs index b0c928575b..71b40061ea 100644 --- a/src/core/merge_right.rs +++ b/src/core/merge_right.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use indexmap::IndexMap; use prost_reflect::prost_types::FileDescriptorProto; pub trait MergeRight { @@ -78,12 +79,55 @@ where } } +impl MergeRight for IndexMap +where + K: Eq + std::hash::Hash, + V: MergeRight + Default, +{ + fn merge_right(mut self, other: Self) -> Self { + use indexmap::map::Entry; + + for (other_name, other_value) in other { + match self.entry(other_name) { + Entry::Occupied(mut occupied_entry) => { + // try to support insertion order while merging index maps. + // if value is present on left, present it's position + // and if value is present only on the right then + // add it to the end of left map preserving the iteration order of the right map + let value = std::mem::take(occupied_entry.get_mut()); + + *occupied_entry.get_mut() = value.merge_right(other_value); + } + Entry::Vacant(vacant_entry) => { + vacant_entry.insert(other_value); + } + } + } + self + } +} + impl MergeRight for FileDescriptorProto { fn merge_right(self, other: Self) -> Self { other } } +impl MergeRight for async_graphql_value::ConstValue { + fn merge_right(self, other: Self) -> Self { + use async_graphql_value::ConstValue; + match (self, other) { + (ConstValue::List(a), ConstValue::List(b)) => ConstValue::List(a.merge_right(b)), + (ConstValue::List(mut vec), other) => { + vec.push(other); + ConstValue::List(vec) + } + (ConstValue::Object(a), ConstValue::Object(b)) => ConstValue::Object(a.merge_right(b)), + (_, other) => other, + } + } +} + impl MergeRight for serde_yaml::Value { fn merge_right(self, other: Self) -> Self { use serde_yaml::Value; @@ -135,9 +179,11 @@ impl MergeRight for serde_yaml::Value { mod tests { use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + use serde_json::json; + use super::MergeRight; - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] struct Test(u32); impl From for Test { @@ -336,4 +382,99 @@ mod tests { ]) ); } + + #[test] + fn test_index_map() { + use indexmap::IndexMap; + + let l: IndexMap = IndexMap::from_iter(vec![]); + let r: IndexMap = IndexMap::from_iter(vec![]); + assert_eq!(l.merge_right(r), IndexMap::<_, _>::from_iter(vec![])); + + let l: IndexMap = + IndexMap::from_iter(vec![(1, Test::from(1)), (2, Test::from(2))]); + let r: IndexMap = IndexMap::from_iter(vec![]); + assert_eq!( + l.merge_right(r), + IndexMap::<_, _>::from_iter(vec![(1, Test::from(1)), (2, Test::from(2))]) + ); + + let l: IndexMap = IndexMap::from_iter(vec![]); + let r: IndexMap = + IndexMap::from_iter(vec![(3, Test::from(3)), (4, Test::from(4))]); + assert_eq!( + l.merge_right(r), + IndexMap::<_, _>::from_iter(vec![(3, Test::from(3)), (4, Test::from(4))]) + ); + + let l: IndexMap = + IndexMap::from_iter(vec![(1, Test::from(1)), (2, Test::from(2))]); + let r: IndexMap = IndexMap::from_iter(vec![ + (2, Test::from(5)), + (3, Test::from(3)), + (4, Test::from(4)), + ]); + assert_eq!( + l.merge_right(r), + IndexMap::<_, _>::from_iter(vec![ + (1, Test::from(1)), + (2, Test::from(7)), + (3, Test::from(3)), + (4, Test::from(4)) + ]) + ); + } + + #[test] + fn test_const_value() { + use async_graphql_value::ConstValue; + + let a: ConstValue = serde_json::from_value(json!({ + "a": null, + "b": "string", + "c": 32, + "d": [1, 2, 3], + "e": { + "ea": null, + "eb": "string e", + "ec": 88, + "ed": {} + } + })) + .unwrap(); + + let b: ConstValue = serde_json::from_value(json!({ + "a": true, + "b": "another", + "c": 48, + "d": [4, 5, 6], + "e": { + "ec": 108, + "ed": { + "eda": false + } + }, + "f": "new f" + })) + .unwrap(); + + let expected: ConstValue = serde_json::from_value(json!({ + "a": true, + "b": "another", + "c": 48, + "d": [1, 2, 3, 4, 5, 6], + "e": { + "ea": null, + "eb": "string e", + "ec": 108, + "ed": { + "eda": false + } + }, + "f": "new f" + })) + .unwrap(); + + assert_eq!(a.merge_right(b), expected); + } } diff --git a/src/core/rest/error.rs b/src/core/rest/error.rs index 1b37b991b7..f0fde348b6 100644 --- a/src/core/rest/error.rs +++ b/src/core/rest/error.rs @@ -3,11 +3,11 @@ use std::str::ParseBoolError; use async_graphql::parser::types::{Directive, Type}; use async_graphql::{Name, ServerError}; -use derive_more::{DebugCustom, From}; +use derive_more::{Debug, From}; use serde_json; use tailcall_valid::ValidationError; -#[derive(From, thiserror::Error, DebugCustom)] +#[derive(From, thiserror::Error, Debug)] pub enum Error { #[error("Unexpected Named Type: {}", 0.to_string())] UnexpectedNamedType(Name), @@ -19,7 +19,7 @@ pub enum Error { SerdeJson(serde_json::Error), #[error("{msg}: {directive:?}")] - #[debug(fmt = "{msg}: {directive:?}")] + #[debug("{msg}: {directive:?}")] Missing { msg: String, directive: Directive }, #[error("Undefined query param: {}", _0)] @@ -35,7 +35,7 @@ pub enum Error { ParseBoolean(ParseBoolError), #[error("Undefined param : {key} in {input}")] - #[debug(fmt = "Undefined param : {key} in {input}")] + #[debug("Undefined param : {key} in {input}")] UndefinedParam { key: String, input: String }, #[error("Validation Error : {}", _0)] diff --git a/src/core/worker/error.rs b/src/core/worker/error.rs index 3490fea46a..9d5c687dbf 100644 --- a/src/core/worker/error.rs +++ b/src/core/worker/error.rs @@ -1,62 +1,62 @@ use std::fmt::Display; use std::sync::Arc; -use derive_more::{DebugCustom, From}; +use derive_more::{Debug, From}; use tokio::task::JoinError; -#[derive(From, DebugCustom, Clone)] +#[derive(From, Debug, Clone)] pub enum Error { - #[debug(fmt = "Failed to initialize worker")] + #[debug("Failed to initialize worker")] InitializationFailed, - #[debug(fmt = "Worker communication error")] + #[debug("Worker communication error")] Communication, - #[debug(fmt = "Serde Json Error: {}", _0)] + #[debug("Serde Json Error: {}", _0)] SerdeJson(Arc), - #[debug(fmt = "Request Clone Failed")] + #[debug("Request Clone Failed")] RequestCloneFailed, - #[debug(fmt = "Hyper Header To Str Error: {}", _0)] + #[debug("Hyper Header To Str Error: {}", _0)] HyperHeaderStr(Arc), - #[debug(fmt = "JS Runtime Stopped Error")] + #[debug("JS Runtime Stopped Error")] JsRuntimeStopped, - #[debug(fmt = "CLI Error : {}", _0)] + #[debug("CLI Error : {}", _0)] CLI(String), - #[debug(fmt = "Join Error : {}", _0)] + #[debug("Join Error : {}", _0)] Join(Arc), - #[debug(fmt = "Runtime not initialized")] + #[debug("Runtime not initialized")] RuntimeNotInitialized, - #[debug(fmt = "{} is not a function", _0)] + #[debug("{} is not a function", _0)] #[from(ignore)] InvalidFunction(String), - #[debug(fmt = "Rquickjs Error: {}", _0)] + #[debug("Rquickjs Error: {}", _0)] #[from(ignore)] Rquickjs(String), - #[debug(fmt = "Deserialize Failed: {}", _0)] + #[debug("Deserialize Failed: {}", _0)] #[from(ignore)] DeserializeFailed(String), - #[debug(fmt = "globalThis not initialized: {}", _0)] + #[debug("globalThis not initialized: {}", _0)] #[from(ignore)] GlobalThisNotInitialised(String), #[debug( - fmt = "Error: {}\nUnable to parse value from js function: {} maybe because it's not returning a string?", + "Error: {}\nUnable to parse value from js function: {} maybe because it's not returning a string?", _0, _1 )] FunctionValueParseError(String, String), - #[debug(fmt = "Error : {}", _0)] + #[debug("Error : {}", _0)] Anyhow(Arc), } diff --git a/tailcall-cloudflare/package-lock.json b/tailcall-cloudflare/package-lock.json index 26825ad5f7..efc4df36a3 100644 --- a/tailcall-cloudflare/package-lock.json +++ b/tailcall-cloudflare/package-lock.json @@ -781,13 +781,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz", - "integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.5.tgz", + "integrity": "sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q==", "dev": true, "dependencies": { - "@vitest/spy": "2.1.4", - "@vitest/utils": "2.1.4", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" }, @@ -796,12 +796,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz", - "integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.5.tgz", + "integrity": "sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ==", "dev": true, "dependencies": { - "@vitest/spy": "2.1.4", + "@vitest/spy": "2.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, @@ -822,9 +822,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", - "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.5.tgz", + "integrity": "sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw==", "dev": true, "dependencies": { "tinyrainbow": "^1.2.0" @@ -834,12 +834,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.4.tgz", - "integrity": "sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.5.tgz", + "integrity": "sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g==", "dev": true, "dependencies": { - "@vitest/utils": "2.1.4", + "@vitest/utils": "2.1.5", "pathe": "^1.1.2" }, "funding": { @@ -847,12 +847,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.4.tgz", - "integrity": "sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.5.tgz", + "integrity": "sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.4", + "@vitest/pretty-format": "2.1.5", "magic-string": "^0.30.12", "pathe": "^1.1.2" }, @@ -861,9 +861,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz", - "integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.5.tgz", + "integrity": "sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw==", "dev": true, "dependencies": { "tinyspy": "^3.0.2" @@ -873,12 +873,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.4.tgz", - "integrity": "sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.5.tgz", + "integrity": "sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.4", + "@vitest/pretty-format": "2.1.5", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" }, @@ -1093,6 +1093,12 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, "node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -1681,9 +1687,9 @@ } }, "node_modules/std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", "dev": true }, "node_modules/stoppable": { @@ -1858,13 +1864,14 @@ } }, "node_modules/vite-node": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.4.tgz", - "integrity": "sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.5.tgz", + "integrity": "sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w==", "dev": true, "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, @@ -1879,30 +1886,30 @@ } }, "node_modules/vitest": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.4.tgz", - "integrity": "sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.5.tgz", + "integrity": "sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A==", "dev": true, "dependencies": { - "@vitest/expect": "2.1.4", - "@vitest/mocker": "2.1.4", - "@vitest/pretty-format": "^2.1.4", - "@vitest/runner": "2.1.4", - "@vitest/snapshot": "2.1.4", - "@vitest/spy": "2.1.4", - "@vitest/utils": "2.1.4", + "@vitest/expect": "2.1.5", + "@vitest/mocker": "2.1.5", + "@vitest/pretty-format": "^2.1.5", + "@vitest/runner": "2.1.5", + "@vitest/snapshot": "2.1.5", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", - "std-env": "^3.7.0", + "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.4", + "vite-node": "2.1.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -1917,8 +1924,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.4", - "@vitest/ui": "2.1.4", + "@vitest/browser": "2.1.5", + "@vitest/ui": "2.1.5", "happy-dom": "*", "jsdom": "*" }, @@ -2867,74 +2874,74 @@ } }, "@vitest/expect": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz", - "integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.5.tgz", + "integrity": "sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q==", "dev": true, "requires": { - "@vitest/spy": "2.1.4", - "@vitest/utils": "2.1.4", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "@vitest/mocker": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz", - "integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.5.tgz", + "integrity": "sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ==", "dev": true, "requires": { - "@vitest/spy": "2.1.4", + "@vitest/spy": "2.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" } }, "@vitest/pretty-format": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", - "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.5.tgz", + "integrity": "sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw==", "dev": true, "requires": { "tinyrainbow": "^1.2.0" } }, "@vitest/runner": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.4.tgz", - "integrity": "sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.5.tgz", + "integrity": "sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g==", "dev": true, "requires": { - "@vitest/utils": "2.1.4", + "@vitest/utils": "2.1.5", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.4.tgz", - "integrity": "sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.5.tgz", + "integrity": "sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.4", + "@vitest/pretty-format": "2.1.5", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "@vitest/spy": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz", - "integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.5.tgz", + "integrity": "sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw==", "dev": true, "requires": { "tinyspy": "^3.0.2" } }, "@vitest/utils": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.4.tgz", - "integrity": "sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.5.tgz", + "integrity": "sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.4", + "@vitest/pretty-format": "2.1.5", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } @@ -3087,6 +3094,12 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true }, + "es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, "esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -3532,9 +3545,9 @@ } }, "std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", "dev": true }, "stoppable": { @@ -3640,42 +3653,43 @@ } }, "vite-node": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.4.tgz", - "integrity": "sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.5.tgz", + "integrity": "sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w==", "dev": true, "requires": { "cac": "^6.7.14", "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" } }, "vitest": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.4.tgz", - "integrity": "sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.5.tgz", + "integrity": "sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A==", "dev": true, "requires": { - "@vitest/expect": "2.1.4", - "@vitest/mocker": "2.1.4", - "@vitest/pretty-format": "^2.1.4", - "@vitest/runner": "2.1.4", - "@vitest/snapshot": "2.1.4", - "@vitest/spy": "2.1.4", - "@vitest/utils": "2.1.4", + "@vitest/expect": "2.1.5", + "@vitest/mocker": "2.1.5", + "@vitest/pretty-format": "^2.1.5", + "@vitest/runner": "2.1.5", + "@vitest/snapshot": "2.1.5", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", - "std-env": "^3.7.0", + "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.4", + "vite-node": "2.1.5", "why-is-node-running": "^2.3.0" } }, diff --git a/tailcall-fixtures/error.rs b/tailcall-fixtures/error.rs index 36cab7c3d3..4ac232d3be 100644 --- a/tailcall-fixtures/error.rs +++ b/tailcall-fixtures/error.rs @@ -1,16 +1,16 @@ use std::fmt::Display; -use derive_more::{DebugCustom, From}; +use derive_more::{Debug, From}; -#[derive(From, DebugCustom)] +#[derive(From, Debug)] pub enum Error { - #[debug(fmt = "Std Fmt Error: {}", _0)] + #[debug("Std Fmt Error: {}", _0)] StdFmt(std::fmt::Error), - #[debug(fmt = "Std IO Error: {}", _0)] + #[debug("Std IO Error: {}", _0)] IO(std::io::Error), - #[debug(fmt = "Failed to resolve filename: {}", _0)] + #[debug("Failed to resolve filename: {}", _0)] FilenameNotResolved(String), } diff --git a/tailcall-macros/src/resolver.rs b/tailcall-macros/src/resolver.rs index 99cd446c4f..6d08f780d0 100644 --- a/tailcall-macros/src/resolver.rs +++ b/tailcall-macros/src/resolver.rs @@ -60,15 +60,9 @@ pub fn expand_resolver_derive(input: DeriveInput) -> syn::Result { } Some(quote! { - valid = valid.and(<#ty>::from_directives(directives.iter()).map(|resolver| { - if let Some(resolver) = resolver { - let directive_name = <#ty>::trace_name(); - if !resolvable_directives.contains(&directive_name) { - resolvable_directives.push(directive_name); - } - result = Some(Self::#variant_name(resolver)); - } - })); + if <#ty>::directive_name() == directive.node.name.node { + return <#ty>::from_directive(&directive.node).map(|x| Some(Self::#variant_name(x))) + } }) }); @@ -100,23 +94,15 @@ pub fn expand_resolver_derive(input: DeriveInput) -> syn::Result { impl #name { pub fn from_directives( directives: &[Positioned], - ) -> Valid, String> { - let mut result = None; - let mut resolvable_directives = Vec::new(); - let mut valid = Valid::succeed(()); - - #(#variant_parsers)* - - valid.and_then(|_| { - if resolvable_directives.len() > 1 { - Valid::fail(format!( - "Multiple resolvers detected [{}]", - resolvable_directives.join(", ") - )) - } else { - Valid::succeed(result) - } + ) -> Valid { + Valid::from_iter(directives.iter(), |directive| { + #(#variant_parsers)* + + Valid::succeed(None) }) + .map(|resolvers| { + crate::core::config::ResolverSet(resolvers.into_iter().flatten().collect()) + }) } pub fn to_directive(&self) -> Option { diff --git a/tailcall-tracker/src/error.rs b/tailcall-tracker/src/error.rs index f6c2dbc214..8736bc9b21 100644 --- a/tailcall-tracker/src/error.rs +++ b/tailcall-tracker/src/error.rs @@ -1,27 +1,27 @@ -use derive_more::{DebugCustom, From}; +use derive_more::{Debug, From}; use reqwest::header::InvalidHeaderValue; -#[derive(From, DebugCustom)] +#[derive(From, Debug)] pub enum Error { - #[debug(fmt = "Reqwest Error: {}", _0)] + #[debug("Reqwest Error: {}", _0)] Reqwest(reqwest::Error), - #[debug(fmt = "Invalid Header Value: {}", _0)] + #[debug("Invalid Header Value: {}", _0)] InvalidHeaderValue(InvalidHeaderValue), - #[debug(fmt = "Serde JSON Error: {}", _0)] + #[debug("Serde JSON Error: {}", _0)] SerdeJson(serde_json::Error), - #[debug(fmt = "Url Parser Error: {}", _0)] + #[debug("Url Parser Error: {}", _0)] UrlParser(url::ParseError), - #[debug(fmt = "PostHog Error: {}", _0)] + #[debug("PostHog Error: {}", _0)] PostHog(posthog_rs::Error), - #[debug(fmt = "Tokio Join Error: {}", _0)] + #[debug("Tokio Join Error: {}", _0)] TokioJoin(tokio::task::JoinError), - #[debug(fmt = "IO Error: {}", _0)] + #[debug("IO Error: {}", _0)] IO(std::io::Error), } diff --git a/tailcall-typedefs-common/src/enum_definition.rs b/tailcall-typedefs-common/src/enum_definition.rs index 59b96413f4..6ffa67063f 100644 --- a/tailcall-typedefs-common/src/enum_definition.rs +++ b/tailcall-typedefs-common/src/enum_definition.rs @@ -4,9 +4,21 @@ use async_graphql::parser::types::{ use async_graphql::{Name, Positioned}; use schemars::schema::Schema; +#[derive(Debug)] +pub struct EnumVariant { + pub value: String, + pub description: Option>, +} + +impl EnumVariant { + pub fn new(value: String) -> Self { + Self { value, description: None } + } +} + #[derive(Debug)] pub struct EnumValue { - pub variants: Vec, + pub variants: Vec, pub description: Option>, } @@ -14,15 +26,16 @@ use crate::common::{get_description, pos}; pub fn into_enum_definition(enum_value: EnumValue, name: &str) -> TypeSystemDefinition { let mut enum_value_definition = vec![]; - for enum_value in enum_value.variants { - let formatted_value: String = enum_value + for enum_variant in enum_value.variants { + let formatted_value: String = enum_variant + .value .to_string() .chars() .filter(|ch| ch != &'"') .collect(); enum_value_definition.push(pos(EnumValueDefinition { value: pos(Name::new(formatted_value)), - description: None, + description: enum_variant.description, directives: vec![], })); } @@ -39,16 +52,49 @@ pub fn into_enum_definition(enum_value: EnumValue, name: &str) -> TypeSystemDefi pub fn into_enum_value(obj: &Schema) -> Option { match obj { Schema::Object(schema_object) => { - let description = get_description(schema_object); + let description = + get_description(schema_object).map(|description| pos(description.to_owned())); + + // if it has enum_values then it's raw enum if let Some(enum_values) = &schema_object.enum_values { return Some(EnumValue { variants: enum_values .iter() - .map(|val| val.to_string()) - .collect::>(), - description: description.map(|description| pos(description.to_owned())), + .map(|val| EnumVariant::new(val.to_string())) + .collect::>(), + description, }); } + + // in case enum has description docs for the variants they will be generated + // as schema with `one_of` entry, where every enum variant is separate enum + // entry + if let Some(subschema) = &schema_object.subschemas { + if let Some(one_ofs) = &subschema.one_of { + let variants = one_ofs + .iter() + .filter_map(|one_of| { + // try to parse one_of value as enum + into_enum_value(one_of).and_then(|mut en| { + // if it has only single variant it's our high-level enum + if en.variants.len() == 1 { + Some(EnumVariant { + value: en.variants.pop().unwrap().value, + description: en.description, + }) + } else { + None + } + }) + }) + .collect::>(); + + if !variants.is_empty() { + return Some(EnumValue { variants, description }); + } + } + } + None } _ => None, diff --git a/tailcall-typedefs/src/main.rs b/tailcall-typedefs/src/main.rs index 4023a02089..aecf725b24 100644 --- a/tailcall-typedefs/src/main.rs +++ b/tailcall-typedefs/src/main.rs @@ -1,7 +1,8 @@ mod gen_gql_schema; use std::env; -use std::path::PathBuf; +use std::ops::Deref; +use std::path::{Path, PathBuf}; use std::process::exit; use std::sync::Arc; @@ -15,8 +16,8 @@ use tailcall::core::config::Config; use tailcall::core::tracing::default_tracing_for_name; use tailcall::core::{scalar, FileIO}; -static JSON_SCHEMA_FILE: &str = "../generated/.tailcallrc.schema.json"; -static GRAPHQL_SCHEMA_FILE: &str = "../generated/.tailcallrc.graphql"; +static JSON_SCHEMA_FILE: &str = "generated/.tailcallrc.schema.json"; +static GRAPHQL_SCHEMA_FILE: &str = "generated/.tailcallrc.graphql"; #[tokio::main] async fn main() { @@ -51,9 +52,17 @@ async fn main() { } async fn mode_check() -> Result<()> { - let json_schema = get_file_path(); let rt = cli::runtime::init(&Default::default()); - let file_io = rt.file; + let file_io = rt.file.deref(); + + check_json(file_io).await?; + check_graphql(file_io).await?; + + Ok(()) +} + +async fn check_json(file_io: &dyn FileIO) -> Result<()> { + let json_schema = get_json_path(); let content = file_io .read( json_schema @@ -62,10 +71,26 @@ async fn mode_check() -> Result<()> { ) .await?; let content = serde_json::from_str::(&content)?; - let schema = get_updated_json().await?; + let schema = get_updated_json()?; match content.eq(&schema) { true => Ok(()), - false => Err(anyhow!("Schema mismatch")), + false => Err(anyhow!("Schema file '{}' mismatch", JSON_SCHEMA_FILE)), + } +} + +async fn check_graphql(file_io: &dyn FileIO) -> Result<()> { + let graphql_schema = get_graphql_path(); + let content = file_io + .read( + graphql_schema + .to_str() + .ok_or(anyhow!("Unable to determine path"))?, + ) + .await?; + let schema = get_updated_graphql(); + match content.eq(&schema) { + true => Ok(()), + false => Err(anyhow!("Schema file '{}' mismatch", GRAPHQL_SCHEMA_FILE)), } } @@ -74,27 +99,28 @@ async fn mode_fix() -> Result<()> { let file_io = rt.file; update_json(file_io.clone()).await?; - update_gql(file_io.clone()).await?; + update_graphql(file_io.clone()).await?; Ok(()) } -async fn update_gql(file_io: Arc) -> Result<()> { - let doc = gen_gql_schema::build_service_document(); +async fn update_graphql(file_io: Arc) -> Result<()> { + let schema = get_updated_graphql(); - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GRAPHQL_SCHEMA_FILE); + let path = get_graphql_path(); + tracing::info!("Updating Graphql Schema: {}", GRAPHQL_SCHEMA_FILE); file_io .write( path.to_str().ok_or(anyhow!("Unable to determine path"))?, - tailcall::core::document::print(doc).as_bytes(), + schema.as_bytes(), ) .await?; Ok(()) } async fn update_json(file_io: Arc) -> Result<()> { - let path = get_file_path(); - let schema = serde_json::to_string_pretty(&get_updated_json().await?)?; - tracing::info!("Updating JSON Schema: {}", path.to_str().unwrap()); + let path = get_json_path(); + let schema = serde_json::to_string_pretty(&get_updated_json()?)?; + tracing::info!("Updating JSON Schema: {}", JSON_SCHEMA_FILE); file_io .write( path.to_str().ok_or(anyhow!("Unable to determine path"))?, @@ -104,11 +130,19 @@ async fn update_json(file_io: Arc) -> Result<()> { Ok(()) } -fn get_file_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(JSON_SCHEMA_FILE) +fn get_root_path() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap() +} + +fn get_json_path() -> PathBuf { + get_root_path().join(JSON_SCHEMA_FILE) } -async fn get_updated_json() -> Result { +fn get_graphql_path() -> PathBuf { + get_root_path().join(GRAPHQL_SCHEMA_FILE) +} + +fn get_updated_json() -> Result { let mut schema: RootSchema = schemars::schema_for!(Config); let scalar = scalar::Scalar::iter() .map(|scalar| (scalar.name(), scalar.schema())) @@ -118,3 +152,9 @@ async fn get_updated_json() -> Result { let schema = json!(schema); Ok(schema) } + +fn get_updated_graphql() -> String { + let doc = gen_gql_schema::build_service_document(); + + tailcall::core::document::print(doc) +} diff --git a/tests/core/http.rs b/tests/core/http.rs index 09e8020ea3..55bb972cd0 100644 --- a/tests/core/http.rs +++ b/tests/core/http.rs @@ -101,6 +101,11 @@ impl HttpIO for Http { self.spec_path ))?; + if let Some(delay) = execution_mock.mock.delay { + // add delay to the request if there's a delay in the mock. + let _ = tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; + } + execution_mock.actual_hits.fetch_add(1, Ordering::Relaxed); // Clone the response from the mock to avoid borrowing issues. diff --git a/tests/core/model.rs b/tests/core/model.rs index bac0edc406..d237f480d1 100644 --- a/tests/core/model.rs +++ b/tests/core/model.rs @@ -28,6 +28,10 @@ mod default { 1 } + pub fn concurrency() -> usize { + 1 + } + pub fn assert_hits() -> bool { true } @@ -42,6 +46,8 @@ pub struct Mock { pub assert_hits: bool, #[serde(default = "default::expected_hits")] pub expected_hits: usize, + #[serde(default)] + pub delay: Option, } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] @@ -57,6 +63,8 @@ pub struct APIRequest { pub test_traces: bool, #[serde(default)] pub test_metrics: bool, + #[serde(default = "default::concurrency")] + pub concurrency: usize, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/tests/core/snapshots/auth-validations.md_error.snap b/tests/core/snapshots/auth-validations.md_error.snap new file mode 100644 index 0000000000..19dd962929 --- /dev/null +++ b/tests/core/snapshots/auth-validations.md_error.snap @@ -0,0 +1,86 @@ +--- +source: tests/core/spec.rs +expression: errors +--- +[ + { + "message": "Auth provider z not found", + "trace": [ + "Baz", + "x", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider y not found", + "trace": [ + "Baz", + "y", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider x not found", + "trace": [ + "Baz", + "z", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider x not found", + "trace": [ + "Foo", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider x not found", + "trace": [ + "Foo", + "baz", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider y not found", + "trace": [ + "Foo", + "baz", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider b not found", + "trace": [ + "Query", + "default", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider c not found", + "trace": [ + "Query", + "default", + "@protected" + ], + "description": null + }, + { + "message": "Auth provider z not found", + "trace": [ + "Zoo", + "a", + "@protected" + ], + "description": null + } +] diff --git a/tests/core/snapshots/non-scalar-value-in-query.md_error.snap b/tests/core/snapshots/non-scalar-value-in-query.md_error.snap new file mode 100644 index 0000000000..0e537df433 --- /dev/null +++ b/tests/core/snapshots/non-scalar-value-in-query.md_error.snap @@ -0,0 +1,60 @@ +--- +source: tests/core/spec.rs +expression: errors +--- +[ + { + "message": "too few parts in template", + "trace": [ + "Query", + "invalidArgument", + "@http", + "query" + ], + "description": null + }, + { + "message": "value 'Nested' is not of a scalar type", + "trace": [ + "Query", + "invalidArgumentType", + "@http", + "query", + "criteria" + ], + "description": null + }, + { + "message": "no argument 'criterias' found", + "trace": [ + "Query", + "unknownArgument", + "@http", + "query", + "criterias" + ], + "description": null + }, + { + "message": "Cannot find type Criteria in the config", + "trace": [ + "Query", + "unknownArgumentType", + "@http", + "query", + "criteria" + ], + "description": null + }, + { + "message": "field unknown_field not found", + "trace": [ + "Query", + "unknownField", + "@http", + "query", + "criteria" + ], + "description": null + } +] diff --git a/tests/core/snapshots/test-dedupe.md_0.snap b/tests/core/snapshots/test-dedupe.md_0.snap new file mode 100644 index 0000000000..888b1eb108 --- /dev/null +++ b/tests/core/snapshots/test-dedupe.md_0.snap @@ -0,0 +1,40 @@ +--- +source: tests/core/spec.rs +expression: response +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "posts": [ + { + "id": 1, + "userId": 1, + "user": { + "id": 1, + "name": "user-1" + }, + "duplicateUser": { + "id": 1, + "name": "user-1" + } + }, + { + "id": 2, + "userId": 2, + "user": { + "id": 2, + "name": "user-2" + }, + "duplicateUser": { + "id": 2, + "name": "user-2" + } + } + ] + } + } +} diff --git a/tests/core/snapshots/test-dedupe.md_client.snap b/tests/core/snapshots/test-dedupe.md_client.snap new file mode 100644 index 0000000000..fd9e443470 --- /dev/null +++ b/tests/core/snapshots/test-dedupe.md_client.snap @@ -0,0 +1,24 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +type Post { + body: String + id: Int + title: String + user: User + userId: Int! +} + +type Query { + posts: [Post] +} + +type User { + id: Int + name: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/test-dedupe.md_merged.snap b/tests/core/snapshots/test-dedupe.md_merged.snap new file mode 100644 index 0000000000..ee9bcdee4d --- /dev/null +++ b/tests/core/snapshots/test-dedupe.md_merged.snap @@ -0,0 +1,30 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema @server(port: 8000) @upstream(batch: {delay: 1, headers: []}) { + query: Query +} + +type Post { + body: String + id: Int + title: String + user: User + @http( + url: "http://jsonplaceholder.typicode.com/users" + batchKey: ["id"] + query: [{key: "id", value: "{{.value.userId}}"}] + dedupe: true + ) + userId: Int! +} + +type Query { + posts: [Post] @http(url: "http://jsonplaceholder.typicode.com/posts?id=1", dedupe: true) +} + +type User { + id: Int + name: String +} diff --git a/tests/core/snapshots/test-http-with-inline.md_error.snap b/tests/core/snapshots/test-http-with-inline.md_error.snap index f1751bc827..ff0c14bb99 100644 --- a/tests/core/snapshots/test-http-with-inline.md_error.snap +++ b/tests/core/snapshots/test-http-with-inline.md_error.snap @@ -1,14 +1,27 @@ --- source: tests/core/spec.rs expression: errors +snapshot_kind: text --- [ { - "message": "Cannot add field", + "message": "no value 'userId' found", "trace": [ "Query", - "@addField" + "post", + "@http", + "path" ], - "description": "Path: [post, user, name] contains resolver http at [Post.user]" + "description": null + }, + { + "message": "no value 'userId' found", + "trace": [ + "Query", + "@addField", + "@http", + "path" + ], + "description": null } ] diff --git a/tests/core/snapshots/test-merge-nested.md_merged.snap b/tests/core/snapshots/test-merge-nested.md_merged.snap index 52a0dab842..53cfa26fe5 100644 --- a/tests/core/snapshots/test-merge-nested.md_merged.snap +++ b/tests/core/snapshots/test-merge-nested.md_merged.snap @@ -18,5 +18,5 @@ type Foo { } type Query { - hi: Foo @expr(body: {a: "world"}) + hi: Foo @expr(body: "world") @expr(body: {a: "world"}) } diff --git a/tests/core/snapshots/test-multiple-resolvable-directives-on-field-validation.md_error.snap b/tests/core/snapshots/test-multiple-resolvable-directives-on-field-validation.md_error.snap new file mode 100644 index 0000000000..3d1c41589d --- /dev/null +++ b/tests/core/snapshots/test-multiple-resolvable-directives-on-field-validation.md_error.snap @@ -0,0 +1,47 @@ +--- +source: tests/core/spec.rs +expression: errors +snapshot_kind: text +--- +[ + { + "message": "no value 'id' found", + "trace": [ + "Query", + "user1", + "@http", + "query" + ], + "description": null + }, + { + "message": "no value 'name' found", + "trace": [ + "Query", + "user2", + "@http", + "query" + ], + "description": null + }, + { + "message": "no value 'address' found", + "trace": [ + "Query", + "user3", + "@http", + "query" + ], + "description": null + }, + { + "message": "no argument 'id' found", + "trace": [ + "Query", + "user3", + "@graphQL", + "args" + ], + "description": null + } +] diff --git a/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_0.snap b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_0.snap new file mode 100644 index 0000000000..9f53757f38 --- /dev/null +++ b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_0.snap @@ -0,0 +1,36 @@ +--- +source: tests/core/spec.rs +expression: response +snapshot_kind: text +--- +{ + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "data": { + "user1": { + "name": "from request 1", + "address": { + "street": "street request 1", + "city": "city request 1" + } + }, + "user2": { + "name": "name expr 2", + "address": { + "street": "street request 2", + "city": "city request 2" + } + }, + "user3": { + "name": "name request 3", + "address": { + "street": "Street from the graphql response", + "city": "city request 3" + } + } + } + } +} diff --git a/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_client.snap b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_client.snap new file mode 100644 index 0000000000..6cc167698c --- /dev/null +++ b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_client.snap @@ -0,0 +1,25 @@ +--- +source: tests/core/spec.rs +expression: formatted +snapshot_kind: text +--- +type Address { + city: String + street: String +} + +type Query { + user1: User + user2: User + user3: User +} + +type User { + address: Address + id: Int + name: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_error.snap b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_error.snap deleted file mode 100644 index 7e7969e515..0000000000 --- a/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_error.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tests/core/spec.rs -expression: errors ---- -[ - { - "message": "Multiple resolvers detected [@http, @expr]", - "trace": [ - "Query", - "user1" - ], - "description": null - }, - { - "message": "Multiple resolvers detected [@http, @call]", - "trace": [ - "Query", - "user2" - ], - "description": null - } -] diff --git a/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_merged.snap b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_merged.snap new file mode 100644 index 0000000000..36b39ceaef --- /dev/null +++ b/tests/core/snapshots/test-multiple-resolvable-directives-on-field.md_merged.snap @@ -0,0 +1,27 @@ +--- +source: tests/core/spec.rs +expression: formatter +snapshot_kind: text +--- +schema @server @upstream { + query: Query +} + +type Address { + city: String + street: String +} + +type Query { + user1: User @expr(body: {name: "name expr 1"}) @http(url: "http://jsonplaceholder.typicode.com/users/1") + user2: User @http(url: "http://jsonplaceholder.typicode.com/users/2") @expr(body: {name: "name expr 2"}) + user3: User + @http(url: "http://jsonplaceholder.typicode.com/users/3") + @graphQL(args: [{key: "id", value: "3"}], url: "http://upstream/graphql", name: "user") +} + +type User { + address: Address + id: Int + name: String +} diff --git a/tests/core/spec.rs b/tests/core/spec.rs index 1a0099eb0b..2866aefa4c 100644 --- a/tests/core/spec.rs +++ b/tests/core/spec.rs @@ -8,12 +8,12 @@ use std::{fs, panic}; use anyhow::Context; use colored::Colorize; use futures_util::future::join_all; -use http::Request; +use http::{Request, Response}; use hyper::Body; use serde::{Deserialize, Serialize}; use tailcall::core::app_context::AppContext; use tailcall::core::async_graphql_hyper::{GraphQLBatchRequest, GraphQLRequest}; -use tailcall::core::blueprint::Blueprint; +use tailcall::core::blueprint::{Blueprint, BlueprintError}; use tailcall::core::config::reader::ConfigReader; use tailcall::core::config::transformer::Required; use tailcall::core::config::{Config, ConfigModule, Source}; @@ -59,7 +59,10 @@ impl From> for SDLError { async fn is_sdl_error(spec: &ExecutionSpec, config_module: Valid) -> bool { if spec.sdl_error { // errors: errors are expected, make sure they match - let blueprint = config_module.and_then(|cfg| Valid::from(Blueprint::try_from(&cfg))); + let blueprint = config_module.and_then(|cfg| match Blueprint::try_from(&cfg) { + Ok(blueprint) => Valid::succeed(blueprint), + Err(e) => Valid::from_validation_err(BlueprintError::to_validation_string(e)), + }); match blueprint.to_result() { Ok(_) => { @@ -279,29 +282,63 @@ async fn run_test( app_ctx: Arc, request: &APIRequest, ) -> anyhow::Result> { - let body = request - .body - .as_ref() - .map(|body| Body::from(body.to_bytes())) - .unwrap_or_default(); - - let method = request.method.clone(); - let headers = request.headers.clone(); - let url = request.url.clone(); - let req = headers + let request_count = request.concurrency; + + let futures = (0..request_count).map(|_| { + let app_ctx = app_ctx.clone(); + let body = request + .body + .as_ref() + .map(|body| Body::from(body.to_bytes())) + .unwrap_or_default(); + + let method = request.method.clone(); + let headers = request.headers.clone(); + let url = request.url.clone(); + + tokio::spawn(async move { + let req = headers + .into_iter() + .fold( + Request::builder() + .method(method.to_hyper()) + .uri(url.as_str()), + |acc, (key, value)| acc.header(key, value), + ) + .body(body)?; + + if app_ctx.blueprint.server.enable_batch_requests { + handle_request::(req, app_ctx).await + } else { + handle_request::(req, app_ctx).await + } + }) + }); + + let responses = join_all(futures).await; + + // Unwrap the Result from join_all and the individual task results + let responses = responses .into_iter() - .fold( - Request::builder() - .method(method.to_hyper()) - .uri(url.as_str()), - |acc, (key, value)| acc.header(key, value), - ) - .body(body)?; - - // TODO: reuse logic from server.rs to select the correct handler - if app_ctx.blueprint.server.enable_batch_requests { - handle_request::(req, app_ctx).await - } else { - handle_request::(req, app_ctx).await + .map(|res| res.map_err(anyhow::Error::from).and_then(|inner| inner)) + .collect::, _>>()?; + + let mut base_response = None; + + // ensure all the received responses are the same. + for response in responses { + let (head, body) = response.into_parts(); + let body = hyper::body::to_bytes(body).await?; + + if let Some((_, base_body)) = &base_response { + if *base_body != body { + return Err(anyhow::anyhow!("Responses are not the same.")); + } + } else { + base_response = Some((head, body)); + } } + + let (head, body) = base_response.ok_or_else(|| anyhow::anyhow!("No Response received."))?; + Ok(Response::from_parts(head, Body::from(body))) } diff --git a/tests/execution/auth-validations.md b/tests/execution/auth-validations.md new file mode 100644 index 0000000000..a9aca9b59c --- /dev/null +++ b/tests/execution/auth-validations.md @@ -0,0 +1,36 @@ +--- +error: true +--- + +# auth multiple + +```graphql @config +schema @server @upstream @link(id: "a", src: ".htpasswd_a", type: Htpasswd) { + query: Query +} + +type Query { + default: String @expr(body: "data") @protected(id: ["a", "b", "c"]) + foo: Foo @expr(body: {bar: "baz"}) +} + +type Foo @protected(id: ["x"]) { + bar: String + baz: String @protected(id: ["y"]) +} + +type Zoo { + a: String @protected(id: ["z"]) +} + +type Baz { + x: String @protected(id: ["z"]) + y: String @protected(id: ["y"]) + z: String @protected(id: ["x"]) +} +``` + +```text @file:.htpasswd_a +testuser1:$apr1$e3dp9qh2$fFIfHU9bilvVZBl8TxKzL/ +testuser2:$2y$10$wJ/mZDURcAOBIrswCAKFsO0Nk7BpHmWl/XuhF7lNm3gBAFH3ofsuu +``` diff --git a/tests/execution/non-scalar-value-in-query.md b/tests/execution/non-scalar-value-in-query.md new file mode 100644 index 0000000000..bb482f4292 --- /dev/null +++ b/tests/execution/non-scalar-value-in-query.md @@ -0,0 +1,44 @@ +--- +error: true +--- + +# test objects in args + +```graphql @config +schema @server @upstream { + query: Query +} + +type Query { + invalidArgumentType(criteria: Nested): [Employee!]! + @http( + url: "http://localhost:8081/family/employees" + query: [{key: "nested", value: "{{.args.criteria}}", skipEmpty: true}] + ) + unknownField(criteria: Nested): [Employee!]! + @http( + url: "http://localhost:8081/family/employees" + query: [{key: "nested", value: "{{.args.criteria.unknown_field}}", skipEmpty: true}] + ) + unknownArgument(criteria: Nested): [Employee!]! + @http( + url: "http://localhost:8081/family/employees" + query: [{key: "nested", value: "{{.args.criterias}}", skipEmpty: true}] + ) + invalidArgument(criteria: Nested): [Employee!]! + @http(url: "http://localhost:8081/family/employees", query: [{key: "nested", value: "{{.args}}", skipEmpty: true}]) + unknownArgumentType(criteria: Criteria): [Employee!]! + @http( + url: "http://localhost:8081/family/employees" + query: [{key: "nested", value: "{{.args.criteria}}", skipEmpty: true}] + ) +} + +type Employee { + id: ID! +} + +input Nested { + hasChildren: Boolean +} +``` diff --git a/tests/execution/test-dedupe.md b/tests/execution/test-dedupe.md new file mode 100644 index 0000000000..f041c5615f --- /dev/null +++ b/tests/execution/test-dedupe.md @@ -0,0 +1,65 @@ +# testing dedupe functionality + +```graphql @config +schema @server(port: 8000) @upstream(batch: {delay: 1}) { + query: Query +} + +type Query { + posts: [Post] @http(url: "http://jsonplaceholder.typicode.com/posts?id=1", dedupe: true) +} + +type Post { + id: Int + title: String + body: String + userId: Int! + user: User + @http( + url: "http://jsonplaceholder.typicode.com/users" + query: [{key: "id", value: "{{.value.userId}}"}] + batchKey: ["id"] + dedupe: true + ) +} + +type User { + id: Int + name: String +} +``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/posts?id=1 + expectedHits: 1 + delay: 10 + response: + status: 200 + body: + - id: 1 + userId: 1 + - id: 2 + userId: 2 +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users?id=1&id=2 + expectedHits: 1 + delay: 10 + response: + status: 200 + body: + - id: 1 + name: user-1 + - id: 2 + name: user-2 +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + concurrency: 10 + body: + query: query { posts { id, userId user { id name } duplicateUser:user { id name } } } +``` diff --git a/tests/execution/test-multiple-resolvable-directives-on-field-validation.md b/tests/execution/test-multiple-resolvable-directives-on-field-validation.md new file mode 100644 index 0000000000..18f27eaa63 --- /dev/null +++ b/tests/execution/test-multiple-resolvable-directives-on-field-validation.md @@ -0,0 +1,34 @@ +--- +error: true +--- + +# Test validation for multiple resolvable directives on field + +```graphql @config +schema @server { + query: Query +} + +type User { + name: String + id: Int + address: Address +} + +type Address { + city: String + street: String +} + +type Query { + user1: User + @expr(body: {name: "{{.value.test}}"}) + @http(url: "http://jsonplaceholder.typicode.com/", query: [{key: "id", value: "{{.value.id}}"}]) + user2: User + @http(url: "http://jsonplaceholder.typicode.com/", query: [{key: "name", value: "{{.value.name}}"}]) + @expr(body: {name: "{{.args.expr}}"}) + user3: User + @http(url: "http://jsonplaceholder.typicode.com/", query: [{key: "id", value: "{{.value.address}}"}]) + @graphQL(args: [{key: "id", value: "{{.args.id}}"}], url: "http://upstream/graphql", name: "user") +} +``` diff --git a/tests/execution/test-multiple-resolvable-directives-on-field.md b/tests/execution/test-multiple-resolvable-directives-on-field.md index 3be44ee54f..c666e81b70 100644 --- a/tests/execution/test-multiple-resolvable-directives-on-field.md +++ b/tests/execution/test-multiple-resolvable-directives-on-field.md @@ -1,8 +1,4 @@ ---- -error: true ---- - -# test-multiple-resolvable-directives-on-field +# Multiple resolvable directives on field ```graphql @config schema @server { @@ -12,10 +8,80 @@ schema @server { type User { name: String id: Int + address: Address +} + +type Address { + city: String + street: String } type Query { - user1: User @expr(body: {name: "John"}) @http(url: "http://jsonplaceholder.typicode.com/users/1") - user2: User @http(url: "http://jsonplaceholder.typicode.com/users/2") @call(steps: [{query: "something"}]) + user1: User @expr(body: {name: "name expr 1"}) @http(url: "http://jsonplaceholder.typicode.com/users/1") + user2: User @http(url: "http://jsonplaceholder.typicode.com/users/2") @expr(body: {name: "name expr 2"}) + user3: User + @http(url: "http://jsonplaceholder.typicode.com/users/3") + @graphQL(args: [{key: "id", value: "3"}], url: "http://upstream/graphql", name: "user") } ``` + +```yml @mock +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users/1 + response: + status: 200 + body: + address: + city: city request 1 + street: street request 1 + id: 1 + name: from request 1 + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users/2 + response: + status: 200 + body: + address: + city: city request 2 + street: street request 2 + id: 2 + name: from request 2 + +- request: + method: GET + url: http://jsonplaceholder.typicode.com/users/3 + response: + status: 200 + body: + address: + city: city request 3 + id: 3 + name: name request 3 + +- request: + method: POST + url: http://upstream/graphql + textBody: '{ "query": "query { user(id: 3) { name address { street city } } }" }' + response: + status: 200 + body: + data: + user: + address: + street: Street from the graphql response +``` + +```yml @test +- method: POST + url: http://localhost:8080/graphql + body: + query: | + query { + user1 { name address { street city } } + user2 { name address { street city } } + user3 { name address { street city } } + } +```