From 2ffb7163276f9c1deac440976fe8b8ca212287aa Mon Sep 17 00:00:00 2001 From: meskill <8974488+meskill@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:02:00 +0000 Subject: [PATCH] feat(router): load config from tailcall subgraph --- generated/.tailcallrc.graphql | 1 + generated/.tailcallrc.schema.json | 3 +- src/core/config/link.rs | 1 + src/core/config/reader.rs | 27 ++++--- src/core/federation/mod.rs | 1 + src/core/federation/subgraph_reader.rs | 42 +++++++++++ src/core/mod.rs | 1 + .../federation-router.md_client.snap | 67 +++++++++++++++++ .../federation-router.md_merged.snap | 35 +++++++++ .../snapshots/test-nested-link.md_merged.snap | 1 - tests/execution/federation-router.md | 74 +++++++++++++++++++ 11 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/core/federation/mod.rs create mode 100644 src/core/federation/subgraph_reader.rs create mode 100644 tests/core/snapshots/federation-router.md_client.snap create mode 100644 tests/core/snapshots/federation-router.md_merged.snap create mode 100644 tests/execution/federation-router.md diff --git a/generated/.tailcallrc.graphql b/generated/.tailcallrc.graphql index d41a58121e..7d34786d2b 100644 --- a/generated/.tailcallrc.graphql +++ b/generated/.tailcallrc.graphql @@ -917,6 +917,7 @@ enum LinkType { Htpasswd Jwks Grpc + SubGraph } enum HttpVersion { diff --git a/generated/.tailcallrc.schema.json b/generated/.tailcallrc.schema.json index 654c30eedf..d31284d83f 100644 --- a/generated/.tailcallrc.schema.json +++ b/generated/.tailcallrc.schema.json @@ -832,7 +832,8 @@ "Operation", "Htpasswd", "Jwks", - "Grpc" + "Grpc", + "SubGraph" ] }, "Method": { diff --git a/src/core/config/link.rs b/src/core/config/link.rs index a71003b331..27f26faf6b 100644 --- a/src/core/config/link.rs +++ b/src/core/config/link.rs @@ -25,6 +25,7 @@ pub enum LinkType { Htpasswd, Jwks, Grpc, + SubGraph } /// The @link directive allows you to import external resources, such as diff --git a/src/core/config/reader.rs b/src/core/config/reader.rs index 46eee6107f..cd5a8656c8 100644 --- a/src/core/config/reader.rs +++ b/src/core/config/reader.rs @@ -8,12 +8,15 @@ use rustls_pki_types::{ use url::Url; use super::{ConfigModule, Content, Link, LinkType}; -use crate::core::config::{Config, ConfigReaderContext, Source}; use crate::core::merge_right::MergeRight; use crate::core::proto_reader::ProtoReader; use crate::core::resource_reader::{Cached, Resource, ResourceReader}; use crate::core::rest::EndpointSet; use crate::core::runtime::TargetRuntime; +use crate::core::{ + config::{Config, ConfigReaderContext, Source}, + federation::subgraph_reader::SubGraphReader, +}; /// Reads the configuration from a file or from an HTTP URL and resolves all /// linked extensions to create a ConfigModule. @@ -21,6 +24,7 @@ pub struct ConfigReader { runtime: TargetRuntime, resource_reader: ResourceReader, proto_reader: ProtoReader, + subgraph_reader: SubGraphReader, } impl ConfigReader { @@ -29,7 +33,8 @@ impl ConfigReader { Self { runtime: runtime.clone(), resource_reader: resource_reader.clone(), - proto_reader: ProtoReader::init(resource_reader, runtime), + proto_reader: ProtoReader::init(resource_reader.clone(), runtime), + subgraph_reader: SubGraphReader::new(resource_reader), } } @@ -43,7 +48,6 @@ impl ConfigReader { let links: Vec = config_module .config() .links - .clone() .iter() .filter_map(|link| { if link.src.is_empty() { @@ -58,7 +62,6 @@ impl ConfigReader { } let mut extensions = config_module.extensions().clone(); - // let mut base_config = config_module.config().clone(); for link in links.iter() { let path = Self::resolve_path(&link.src, parent_dir); @@ -69,14 +72,12 @@ impl ConfigReader { let content = source.content; let config = Config::from_source(Source::detect(&source.path)?, &content)?; - config_module = config_module.merge_right(config.clone().into()); + let link_config_module = self + // recursively resolve links in the linked config + .ext_links(ConfigModule::from(config), Path::new(&link.src).parent()) + .await?; - if !config.links.is_empty() { - let cfg_module = self - .ext_links(ConfigModule::from(config), Path::new(&link.src).parent()) - .await?; - config_module = config_module.merge_right(cfg_module.clone()); - } + config_module = config_module.merge_right(link_config_module); } LinkType::Protobuf => { let meta = self.proto_reader.read(path).await?; @@ -129,6 +130,10 @@ impl ConfigReader { extensions.add_proto(m); } } + LinkType::SubGraph => { + let subgraph_config_module = self.subgraph_reader.fetch(link.src.as_str()).await?; + config_module = config_module.merge_right(subgraph_config_module); + } } } diff --git a/src/core/federation/mod.rs b/src/core/federation/mod.rs new file mode 100644 index 0000000000..893618f2a7 --- /dev/null +++ b/src/core/federation/mod.rs @@ -0,0 +1 @@ +pub mod subgraph_reader; diff --git a/src/core/federation/subgraph_reader.rs b/src/core/federation/subgraph_reader.rs new file mode 100644 index 0000000000..678871f293 --- /dev/null +++ b/src/core/federation/subgraph_reader.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::core::{ + config::{Config, ConfigModule}, + resource_reader::{Cached, ResourceReader}, + valid::Validator, +}; + +#[derive(Clone)] +pub struct SubGraphReader { + reader: ResourceReader, +} + +#[derive(Serialize, Deserialize)] +struct AdminQuery { + config: String, +} + +impl SubGraphReader { + pub fn new(reader: ResourceReader) -> Self { + Self { reader } + } + + pub async fn fetch(&self, src: &str) -> anyhow::Result { + let url = Url::parse(&format!("{src}/graphql"))?; + + let mut request = reqwest::Request::new(reqwest::Method::POST, url); + + let _ = request + .body_mut() + .insert(reqwest::Body::from(r#"{"query": "{ config }"}"#)); + + let file_read = self.reader.read_file(request).await?; + let admin_query: AdminQuery = serde_json::from_str(&file_read.content)?; + + let config = Config::from_sdl(&admin_query.config).to_result()?; + let config_module = ConfigModule::from(config); + + Ok(config_module) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 0ecaf7355d..5bed62ac5c 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -41,6 +41,7 @@ mod transform; pub mod try_fold; pub mod valid; pub mod worker; +pub mod federation; // Re-export everything from `tailcall_macros` as `macros` use std::borrow::Cow; diff --git a/tests/core/snapshots/federation-router.md_client.snap b/tests/core/snapshots/federation-router.md_client.snap new file mode 100644 index 0000000000..3ba6698790 --- /dev/null +++ b/tests/core/snapshots/federation-router.md_client.snap @@ -0,0 +1,67 @@ +--- +source: tests/core/spec.rs +expression: formatted +--- +scalar Bytes + +scalar Date + +scalar DateTime + +scalar Email + +scalar Empty + +scalar Int128 + +scalar Int16 + +scalar Int32 + +scalar Int64 + +scalar Int8 + +scalar JSON + +scalar PhoneNumber + +type Post { + body: String! + id: Int! + title: String! + user: User + userId: Int! +} + +type Query { + posts: [Post] + user(id: Int!): User + users: [User] + version: String +} + +scalar UInt128 + +scalar UInt16 + +scalar UInt32 + +scalar UInt64 + +scalar UInt8 + +scalar Url + +type User { + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} + +schema { + query: Query +} diff --git a/tests/core/snapshots/federation-router.md_merged.snap b/tests/core/snapshots/federation-router.md_merged.snap new file mode 100644 index 0000000000..a8ae630cf4 --- /dev/null +++ b/tests/core/snapshots/federation-router.md_merged.snap @@ -0,0 +1,35 @@ +--- +source: tests/core/spec.rs +expression: formatter +--- +schema + @server(port: 8000) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", batch: {delay: 100, headers: []}, httpCache: 42) + @link(src: "http://localhost:4000", meta: {name: "Users"}, type: SubGraph) + @link(src: "http://localhost:5000", meta: {name: "Posts"}, type: SubGraph) { + query: Query +} + +type Post { + body: String! + id: Int! + title: String! + user: User @http(path: "/users/{{.value.userId}}") + userId: Int! +} + +type Query { + posts: [Post] @http(path: "/posts") + user(id: Int!): User @http(path: "/users/{{.args.id}}") + users: [User] @http(path: "/users") + version: String @expr(body: "test") +} + +type User { + email: String! + id: Int! + name: String! + phone: String + username: String! + website: String +} diff --git a/tests/core/snapshots/test-nested-link.md_merged.snap b/tests/core/snapshots/test-nested-link.md_merged.snap index d7c84aeccc..9f14f236bb 100644 --- a/tests/core/snapshots/test-nested-link.md_merged.snap +++ b/tests/core/snapshots/test-nested-link.md_merged.snap @@ -6,7 +6,6 @@ schema @server @upstream(baseURL: "http://jsonplaceholder.typicode.com") @link(src: "graphql-with-link.graphql", type: Config) - @link(src: "link-enum.graphql", type: Config) @link(src: "link-enum.graphql", type: Config) { query: Query } diff --git a/tests/execution/federation-router.md b/tests/execution/federation-router.md new file mode 100644 index 0000000000..f8093a3107 --- /dev/null +++ b/tests/execution/federation-router.md @@ -0,0 +1,74 @@ +# Tailcall Federation router + +```graphql @config +schema + @link(src: "http://localhost:4000", type: SubGraph, meta: {name: "Users"}) + @link(src: "http://localhost:5000", type: SubGraph, meta: {name: "Posts"}) +{ + query: Query +} + +type Query { + version: String @expr(body: "test") +} +``` + +```yml @mock +- request: + method: POST + url: http://localhost:4000/graphql + textBody: {"query": "{ config }"} + response: + status: 200 + body: + config: | + schema + @server(port: 8000) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: 42, batch: {delay: 100}) { + query: Query + } + + type Query { + users: [User] @http(path: "/users") + user(id: Int!): User @http(path: "/users/{{.args.id}}") + } + + type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String + } + + type Post { + userId: Int! + user: User @http(path: "/users/{{.value.userId}}") + } + +- request: + method: POST + url: http://localhost:5000/graphql + textBody: {"query": "{ config }"} + response: + status: 200 + body: + config: | + schema + @server(port: 8000) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: 42, batch: {delay: 100}) { + query: Query + } + + type Query { + posts: [Post] @http(path: "/posts") + } + + type Post { + id: Int! + userId: Int! + title: String! + body: String! + } +```