diff --git a/Cargo.lock b/Cargo.lock index a7bef2826b..f2ef9c148f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,21 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" +[[package]] +name = "buildstructor" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3907aac66c65520545ae3cb3c195306e20d5ed5c90bfbb992e061cf12a104d0" +dependencies = [ + "lazy_static", + "proc-macro2", + "quote", + "str_inflector", + "syn 2.0.72", + "thiserror", + "try_match", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -1565,6 +1580,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-getters" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6433aac097572ea8ccc60b3f2e756c661c9aeed9225cdd4d0cb119cb7ff6ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -4436,8 +4462,10 @@ dependencies = [ "apollo-parser", "ariadne", "backoff", + "buildstructor", "camino", "chrono", + "derive-getters", "git-url-parse", "git2", "graphql_client", @@ -5136,6 +5164,16 @@ dependencies = [ "wsl", ] +[[package]] +name = "str_inflector" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0b848d5a7695b33ad1be00f84a3c079fe85c9278a325ff9159e6c99cef4ef7" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "strict" version = "0.2.0" @@ -5780,6 +5818,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "try_match" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ae3c1941e8859e30d28e572683fbfa89ae5330748b45139aedf488389e2be4" +dependencies = [ + "try_match_inner", +] + +[[package]] +name = "try_match_inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0a91713132798caecb23c977488945566875e7b61b902fb111979871cbff34e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "typed-builder" version = "0.18.2" diff --git a/Cargo.toml b/Cargo.toml index 61c60802a1..3de0ea7dc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ backtrace = "0.3" backoff = "0.4" base64 = "0.22" billboard = "0.2" +buildstructor = "0.5.4" cargo_metadata = "0.18" calm_io = "0.1" camino = "1" @@ -80,6 +81,7 @@ ci_info = "0.14" console = "0.15" crossbeam-channel = "0.5" ctrlc = "3" +derive-getters = "0.4.0" dialoguer = "0.11" directories-next = "2.0" flate2 = "1" diff --git a/crates/rover-client/Cargo.toml b/crates/rover-client/Cargo.toml index 8be0b2f554..a69a5ccd37 100644 --- a/crates/rover-client/Cargo.toml +++ b/crates/rover-client/Cargo.toml @@ -13,7 +13,9 @@ apollo-federation-types = { workspace = true } apollo-parser = { workspace = true } apollo-encoder = { workspace = true } backoff = { workspace = true } +buildstructor = { workspace = true } chrono = { workspace = true, features = ["serde"] } +derive-getters = { workspace = true } git-url-parse = { workspace = true } git2 = { workspace = true, features = [ "vendored-openssl", diff --git a/crates/rover-client/src/operations/subgraph/fetch_all/fetch_all_query.graphql b/crates/rover-client/src/operations/subgraph/fetch_all/fetch_all_query.graphql new file mode 100644 index 0000000000..6233fde6db --- /dev/null +++ b/crates/rover-client/src/operations/subgraph/fetch_all/fetch_all_query.graphql @@ -0,0 +1,14 @@ +query SubgraphFetchAllQuery($graph_ref: ID!) { + variant(ref: $graph_ref) { + __typename + ... on GraphVariant { + subgraphs { + name + url + activePartialSchema { + sdl + } + } + } + } +} diff --git a/crates/rover-client/src/operations/subgraph/fetch_all/mod.rs b/crates/rover-client/src/operations/subgraph/fetch_all/mod.rs new file mode 100644 index 0000000000..3c7d12e259 --- /dev/null +++ b/crates/rover-client/src/operations/subgraph/fetch_all/mod.rs @@ -0,0 +1,5 @@ +mod runner; +mod types; + +pub use runner::run; +pub use types::SubgraphFetchAllInput; diff --git a/crates/rover-client/src/operations/subgraph/fetch_all/runner.rs b/crates/rover-client/src/operations/subgraph/fetch_all/runner.rs new file mode 100644 index 0000000000..6fb4fd42d5 --- /dev/null +++ b/crates/rover-client/src/operations/subgraph/fetch_all/runner.rs @@ -0,0 +1,136 @@ +use graphql_client::*; + +use crate::blocking::StudioClient; +use crate::operations::config::is_federated::{self, IsFederatedInput}; +use crate::RoverClientError; + +use super::types::*; + +#[derive(GraphQLQuery)] +// The paths are relative to the directory where your `Cargo.toml` is located. +// Both json and the GraphQL schema language are supported as sources for the schema +#[graphql( + query_path = "src/operations/subgraph/fetch_all/fetch_all_query.graphql", + schema_path = ".schema/schema.graphql", + response_derives = "Eq, PartialEq, Debug, Serialize, Deserialize", + deprecated = "warn" +)] +/// This struct is used to generate the module containing `Variables` and +/// `ResponseData` structs. +/// Snake case of this name is the mod name. i.e. subgraph_fetch_all_query +pub(crate) struct SubgraphFetchAllQuery; + +/// For a given graph return all of its subgraphs as a list +pub fn run( + input: SubgraphFetchAllInput, + client: &StudioClient, +) -> Result, RoverClientError> { + // This response is used to check whether the current graph is federated. + let is_federated = is_federated::run( + IsFederatedInput { + graph_ref: input.graph_ref.clone(), + }, + client, + )?; + if !is_federated { + return Err(RoverClientError::ExpectedFederatedGraph { + graph_ref: input.graph_ref, + can_operation_convert: false, + }); + } + let variables = input.clone().into(); + let response_data = client.post::(variables)?; + get_subgraphs_from_response_data(input, response_data) +} + +fn get_subgraphs_from_response_data( + input: SubgraphFetchAllInput, + response_data: SubgraphFetchAllResponseData, +) -> Result, RoverClientError> { + if let Some(maybe_variant) = response_data.variant { + match maybe_variant { + SubgraphFetchAllGraphVariant::GraphVariant(variant) => { + if let Some(subgraphs) = variant.subgraphs { + Ok(subgraphs + .into_iter() + .map(|subgraph| { + Subgraph::builder() + .name(subgraph.name.clone()) + .and_url(subgraph.url) + .sdl(subgraph.active_partial_schema.sdl) + .build() + }) + .collect()) + } else { + Err(RoverClientError::ExpectedFederatedGraph { + graph_ref: input.graph_ref, + can_operation_convert: true, + }) + } + } + _ => Err(RoverClientError::InvalidGraphRef), + } + } else { + Err(RoverClientError::GraphNotFound { + graph_ref: input.graph_ref, + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::shared::GraphRef; + + use super::*; + + #[test] + fn get_services_from_response_data_works() { + let sdl = "extend type User @key(fields: \"id\") {\n id: ID! @external\n age: Int\n}\n" + .to_string(); + let url = "http://my.subgraph.com".to_string(); + let input = mock_input(); + let json_response = json!({ + "variant": { + "__typename": "GraphVariant", + "subgraphs": [ + { + "name": "accounts", + "url": &url, + "activePartialSchema": { + "sdl": &sdl + } + }, + ] + } + }); + let data: SubgraphFetchAllResponseData = serde_json::from_value(json_response).unwrap(); + let expected_subgraph = Subgraph::builder() + .url(url) + .sdl(sdl) + .name("accounts".to_string()) + .build(); + let output = get_subgraphs_from_response_data(input, data); + + assert!(output.is_ok()); + assert_eq!(output.unwrap(), vec![expected_subgraph]); + } + + #[test] + fn get_services_from_response_data_errs_with_no_variant() { + let json_response = json!({ "variant": null }); + let data: SubgraphFetchAllResponseData = serde_json::from_value(json_response).unwrap(); + let output = get_subgraphs_from_response_data(mock_input(), data); + assert!(output.is_err()); + } + + fn mock_input() -> SubgraphFetchAllInput { + let graph_ref = GraphRef { + name: "mygraph".to_string(), + variant: "current".to_string(), + }; + + SubgraphFetchAllInput { graph_ref } + } +} diff --git a/crates/rover-client/src/operations/subgraph/fetch_all/types.rs b/crates/rover-client/src/operations/subgraph/fetch_all/types.rs new file mode 100644 index 0000000000..7462dd6ee7 --- /dev/null +++ b/crates/rover-client/src/operations/subgraph/fetch_all/types.rs @@ -0,0 +1,31 @@ +use buildstructor::Builder; +use derive_getters::Getters; + +use crate::shared::GraphRef; + +use super::runner::subgraph_fetch_all_query; + +pub(crate) type SubgraphFetchAllResponseData = subgraph_fetch_all_query::ResponseData; +pub(crate) type SubgraphFetchAllGraphVariant = + subgraph_fetch_all_query::SubgraphFetchAllQueryVariant; +pub(crate) type QueryVariables = subgraph_fetch_all_query::Variables; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SubgraphFetchAllInput { + pub graph_ref: GraphRef, +} + +impl From for QueryVariables { + fn from(input: SubgraphFetchAllInput) -> Self { + Self { + graph_ref: input.graph_ref.to_string(), + } + } +} + +#[derive(Clone, Builder, Debug, Eq, Getters, PartialEq)] +pub struct Subgraph { + name: String, + url: Option, + sdl: String, +} diff --git a/crates/rover-client/src/operations/subgraph/mod.rs b/crates/rover-client/src/operations/subgraph/mod.rs index a9f057c567..61c110f2bc 100644 --- a/crates/rover-client/src/operations/subgraph/mod.rs +++ b/crates/rover-client/src/operations/subgraph/mod.rs @@ -10,6 +10,9 @@ pub mod check; /// "subgraph fetch" command execution pub mod fetch; +/// "subgraph fetch_all" command execution +pub mod fetch_all; + /// "subgraph publish" command execution pub mod publish; diff --git a/src/utils/supergraph_config.rs b/src/utils/supergraph_config.rs index 2f8e1f35a3..206736b1e2 100644 --- a/src/utils/supergraph_config.rs +++ b/src/utils/supergraph_config.rs @@ -8,11 +8,11 @@ use apollo_federation_types::config::{ use apollo_parser::{cst, Parser}; use rayon::iter::{IntoParallelIterator, ParallelIterator}; -use rover_client::blocking::GraphQLClient; +use rover_client::blocking::{GraphQLClient, StudioClient}; use rover_client::operations::subgraph; use rover_client::operations::subgraph::fetch::SubgraphFetchInput; +use rover_client::operations::subgraph::fetch_all::SubgraphFetchAllInput; use rover_client::operations::subgraph::introspect::SubgraphIntrospectInput; -use rover_client::operations::subgraph::list::SubgraphListInput; use rover_client::operations::subgraph::{fetch, introspect}; use rover_client::shared::GraphRef; use rover_client::RoverClientError; @@ -32,30 +32,25 @@ pub struct RemoteSubgraphs(SupergraphConfig); impl RemoteSubgraphs { /// Fetches [`RemoteSubgraphs`] from Studio pub fn fetch( - client_config: &StudioClientConfig, - profile_opt: &ProfileOpt, + client: &StudioClient, federation_version: &FederationVersion, graph_ref: &GraphRef, ) -> RoverResult { - let client = &client_config.get_authenticated_client(profile_opt)?; - - let subgraphs = subgraph::list::run( - SubgraphListInput { + let subgraphs = subgraph::fetch_all::run( + SubgraphFetchAllInput { graph_ref: graph_ref.clone(), }, client, )?; let subgraphs = subgraphs - .subgraphs .iter() .map(|subgraph| { ( - subgraph.name.clone(), + subgraph.name().clone(), SubgraphConfig { - routing_url: subgraph.url.clone(), - schema: SchemaSource::Subgraph { - graphref: graph_ref.clone().to_string(), - subgraph: subgraph.name.clone(), + routing_url: subgraph.url().clone(), + schema: SchemaSource::Sdl { + sdl: subgraph.sdl().clone(), }, }, ) @@ -80,10 +75,10 @@ pub fn get_supergraph_config( profile_opt: &ProfileOpt, ) -> Result, RoverError> { // Read in Remote subgraphs + let studio_client = client_config.get_authenticated_client(profile_opt)?; let remote_subgraphs = match graph_ref { Some(graph_ref) => Some(RemoteSubgraphs::fetch( - &client_config, - profile_opt, + &studio_client, federation_version, graph_ref, )?), @@ -116,6 +111,268 @@ pub fn get_supergraph_config( Ok(supergraph_config) } +#[cfg(test)] +mod test_get_supergraph_config { + use std::fs::File; + use std::io::Write; + use std::path::PathBuf; + use std::str::FromStr; + + use apollo_federation_types::config::FederationVersion; + use camino::Utf8PathBuf; + use httpmock::MockServer; + use indoc::indoc; + use rstest::{fixture, rstest}; + use semver::Version; + use serde_json::{json, Value}; + use speculoos::assert_that; + use speculoos::prelude::OptionAssertions; + + use houston::Config; + use rover_client::shared::GraphRef; + + use crate::options::ProfileOpt; + use crate::utils::client::{ClientBuilder, StudioClientConfig}; + use crate::utils::parsers::FileDescriptorType; + use crate::utils::supergraph_config::get_supergraph_config; + + #[fixture] + #[once] + fn home_dir() -> Utf8PathBuf { + tempfile::tempdir() + .unwrap() + .path() + .to_path_buf() + .try_into() + .unwrap() + } + + #[fixture] + #[once] + fn api_key() -> String { + uuid::Uuid::new_v4().as_simple().to_string() + } + + #[fixture] + fn config(home_dir: &Utf8PathBuf, api_key: &String) -> Config { + Config::new(Some(home_dir), Some(api_key.to_string())).unwrap() + } + + #[fixture] + fn profile_opt() -> ProfileOpt { + ProfileOpt { + profile_name: "profile".to_string(), + } + } + + #[fixture] + #[once] + fn latest_fed2_version() -> FederationVersion { + let d = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("latest_plugin_versions.json"); + let fp = File::open(d).expect("could not open version file"); + let raw_version_file: Value = serde_json::from_reader(fp).expect("malformed JSON"); + let raw_version = raw_version_file + .get("supergraph") + .unwrap() + .get("versions") + .unwrap() + .get("latest-2") + .unwrap() + .as_str() + .unwrap(); + let version = Version::from_str(&raw_version.replace("v", "")).unwrap(); + FederationVersion::ExactFedTwo(version) + } + + #[rstest] + #[case::no_subgraphs_at_all(None, None, None)] + #[case::only_remote_subgraphs(Some(String::from("products")), None, Some(vec![(String::from("products"), String::from("remote"))]))] + #[case::only_local_subgraphs(None, Some(String::from("pandas")), Some(vec![(String::from("pandas"), String::from("local"))]))] + #[case::both_local_and_remote_subgraphs(Some(String::from("products")), Some(String::from("pandas")), Some(vec![(String::from("pandas"), String::from("local")), (String::from("products"), String::from("remote"))]))] + #[case::local_takes_precedence(Some(String::from("pandas")), Some(String::from("pandas")), Some(vec![(String::from("pandas"), String::from("local"))]))] + fn test_get_supergraph_config( + config: Config, + profile_opt: ProfileOpt, + latest_fed2_version: &FederationVersion, + #[case] remote_subgraph: Option, + #[case] local_subgraph: Option, + #[case] expected: Option>, + ) { + let server = MockServer::start(); + let sdl = "extend type User @key(fields: \"id\") {\n id: ID! @external\n age: Int\n}\n" + .to_string(); + let graphref = if let Some(name) = remote_subgraph { + let variant = String::from("current"); + let graphref_raw = format!("{name}@{variant}"); + let url = format!("http://{}.remote.com", name); + server.mock(|when, then| { + let body = json!({ + "data": { + "variant": { + "__typename": "GraphVariant", + "subgraphs": [ + { + "name": name, + "url": url, + "activePartialSchema": { + "sdl": sdl + } + } + ] + } + } + }); + when.method(httpmock::Method::POST) + .path("/") + .json_body_obj(&json!({ + "query": indoc!{ + r#" + query SubgraphFetchAllQuery($graph_ref: ID!) { + variant(ref: $graph_ref) { + __typename + ... on GraphVariant { + subgraphs { + name + url + activePartialSchema { + sdl + } + } + } + } + } + "# + }, + "variables": { + "graph_ref": graphref_raw, + }, + "operationName": "SubgraphFetchAllQuery" + })); + then.status(200) + .header("content-type", "application/json") + .json_body(body); + }); + + server.mock(|when, then| { + let body = json!({ + "data": { + "graph": { + "variant": { + "subgraphs": [ + { + "name": name + } + ] + } + } + } + }); + when.method(httpmock::Method::POST) + .path("/") + .json_body_obj(&json!({ + "query": indoc!{ + r#" + query IsFederatedGraph($graph_id: ID!, $variant: String!) { + graph(id: $graph_id) { + variant(name: $variant) { + subgraphs { + name + } + } + } + } + "# + }, + "variables": { + "graph_id": name, + "variant": "current" + }, + "operationName": "IsFederatedGraph" + })); + then.status(200) + .header("content-type", "application/json") + .json_body(body); + }); + Some(GraphRef::new(name, Some(variant)).unwrap()) + } else { + None + }; + + let studio_client_config = StudioClientConfig::new( + Some(server.base_url()), + config, + false, + ClientBuilder::default(), + ); + + let actual_result = if let Some(name) = local_subgraph { + let supergraph_config = format!( + indoc! { + r#" + federation_version: {} + subgraphs: + {}: + routing_url: http://{}.local.com + schema: + sdl: "{}" + "# + }, + latest_fed2_version.to_string(), + name, + name, + sdl.escape_default() + ); + let mut supergraph_config_path = + tempfile::NamedTempFile::new().expect("Could not create temporary file"); + supergraph_config_path + .as_file_mut() + .write_all(&supergraph_config.into_bytes()) + .expect("Could not write to temporary file"); + + get_supergraph_config( + &graphref, + &Some(FileDescriptorType::File( + Utf8PathBuf::from_path_buf(supergraph_config_path.path().to_path_buf()) + .unwrap(), + )), + latest_fed2_version, + studio_client_config, + &profile_opt, + ) + .expect("Could not construct SupergraphConfig") + } else { + get_supergraph_config( + &graphref, + &None, + latest_fed2_version, + studio_client_config, + &profile_opt, + ) + .expect("Could not construct SupergraphConfig") + }; + + if expected.is_none() { + assert_that!(actual_result).is_none() + } else { + assert_that(&actual_result).is_some(); + for (idx, subgraph) in actual_result + .unwrap() + .get_subgraph_definitions() + .unwrap() + .iter() + .enumerate() + { + let expected_result = expected.as_ref().unwrap(); + assert_that!(subgraph.name).is_equal_to(&expected_result[idx].0); + assert_that!(subgraph.url).is_equal_to(format!( + "http://{}.{}.com", + expected_result[idx].0, expected_result[idx].1 + )) + } + } + } +} + pub(crate) fn resolve_supergraph_yaml( unresolved_supergraph_yaml: &FileDescriptorType, client_config: StudioClientConfig, @@ -735,7 +992,11 @@ type _Service {\n sdl: String\n}"#; } #[rstest] - fn test_subgraph_studio_resolution(profile_opt: ProfileOpt, config: Config) -> Result<()> { + fn test_subgraph_studio_resolution( + profile_opt: ProfileOpt, + config: Config, + latest_fed2_version: &FederationVersion, + ) -> Result<()> { let graph_id = "testgraph"; let variant = "current"; let graphref = format!("{}@{}", graph_id, variant); @@ -837,7 +1098,7 @@ type _Service {\n sdl: String\n}"#; let supergraph_config = format!( indoc! {r#" - federation_version: 2 + federation_version: {} subgraphs: products: schema: @@ -845,7 +1106,7 @@ type _Service {\n sdl: String\n}"#; subgraph: products "# }, - graphref + latest_fed2_version, graphref ); let studio_client_config = StudioClientConfig::new( @@ -863,7 +1124,7 @@ type _Service {\n sdl: String\n}"#; let unresolved_supergraph_config = FileDescriptorType::File(supergraph_config_path.path().to_path_buf().try_into()?); - let resolved_config = super::resolve_supergraph_yaml( + let resolved_config = resolve_supergraph_yaml( &unresolved_supergraph_config, studio_client_config, &profile_opt,