diff --git a/Cargo.lock b/Cargo.lock index c2e865f88..6d4fadbfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1459,6 +1459,7 @@ dependencies = [ "assert_fs", "atty", "binstall", + "chrono", "console", "heck", "houston", @@ -1483,6 +1484,7 @@ name = "rover-client" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "graphql_client", "http", "online", diff --git a/Cargo.toml b/Cargo.toml index 1667b3fc3..860e982ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ structopt = "0.3.21" tracing = "0.1.22" regex = "1" url = "2.2.0" +chrono = "0.4" [dev-dependencies] assert_cmd = "1.0.1" diff --git a/crates/rover-client/Cargo.toml b/crates/rover-client/Cargo.toml index b6e007798..bb6e13360 100644 --- a/crates/rover-client/Cargo.toml +++ b/crates/rover-client/Cargo.toml @@ -14,6 +14,7 @@ serde = "1" serde_json = "1" thiserror = "1" tracing = "0.1" +chrono = "0.4" [build-dependencies] online = "0.2.2" diff --git a/crates/rover-client/src/query/subgraph/list.graphql b/crates/rover-client/src/query/subgraph/list.graphql new file mode 100644 index 000000000..d733b65aa --- /dev/null +++ b/crates/rover-client/src/query/subgraph/list.graphql @@ -0,0 +1,15 @@ +query ListSubgraphsQuery($graphId: ID!, $variant: String!) { + frontendUrlRoot + service(id: $graphId) { + implementingServices(graphVariant: $variant) { + __typename + ... on FederatedImplementingServices { + services { + name + url + updatedAt + } + } + } + } +} diff --git a/crates/rover-client/src/query/subgraph/list.rs b/crates/rover-client/src/query/subgraph/list.rs new file mode 100644 index 000000000..1cf86530e --- /dev/null +++ b/crates/rover-client/src/query/subgraph/list.rs @@ -0,0 +1,194 @@ +use crate::blocking::StudioClient; +use crate::RoverClientError; +use chrono::prelude::*; +use graphql_client::*; + +type Timestamp = String; + +#[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/query/subgraph/list.graphql", + schema_path = ".schema/schema.graphql", + response_derives = "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. list_subgraphs_query +pub struct ListSubgraphsQuery; + +#[derive(Clone, PartialEq, Debug)] +pub struct SubgraphInfo { + pub name: String, + pub url: Option, // optional, and may not be a real url + pub updated_at: Option>, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct ListDetails { + pub subgraphs: Vec, + pub root_url: String, + pub graph_name: String, +} + +/// Fetches list of subgraphs for a given graph, returns name & url of each +pub fn run( + variables: list_subgraphs_query::Variables, + client: &StudioClient, +) -> Result { + let graph_name = variables.graph_id.clone(); + let response_data = client.post::(variables)?; + let root_url = response_data.frontend_url_root.clone(); + let subgraphs = get_subgraphs_from_response_data(response_data, &graph_name)?; + Ok(ListDetails { + subgraphs: format_subgraphs(&subgraphs), + root_url, + graph_name, + }) +} + +type RawSubgraphInfo = list_subgraphs_query::ListSubgraphsQueryServiceImplementingServicesOnFederatedImplementingServicesServices; +fn get_subgraphs_from_response_data( + response_data: list_subgraphs_query::ResponseData, + graph_name: &str, +) -> Result, RoverClientError> { + let service_data = match response_data.service { + Some(data) => Ok(data), + None => Err(RoverClientError::NoService), + }?; + + // get list of services + let services = match service_data.implementing_services { + Some(services) => Ok(services), + // TODO (api-error) + // this case is unreachable, since even non-federated graphs will return + // an implementing service, just under the NonFederatedImplementingService + // fragment spread + None => Err(RoverClientError::HandleResponse { + msg: "There was no response for implementing services of this graph. This error shouldn't ever happen.".to_string() + }), + }?; + + // implementing_services.services + match services { + list_subgraphs_query::ListSubgraphsQueryServiceImplementingServices::FederatedImplementingServices (services) => { + Ok(services.services) + }, + list_subgraphs_query::ListSubgraphsQueryServiceImplementingServices::NonFederatedImplementingService => { + Err(RoverClientError::ExpectedFederatedGraph { graph_name: graph_name.to_string() }) + } + } +} + +/// puts the subgraphs into a vec of SubgraphInfo, sorted by updated_at +/// timestamp. Newer updated services will show at top of list +fn format_subgraphs(subgraphs: &[RawSubgraphInfo]) -> Vec { + let mut subgraphs: Vec = subgraphs + .iter() + .map(|subgraph| SubgraphInfo { + name: subgraph.name.clone(), + url: subgraph.url.clone(), + updated_at: subgraph.updated_at.clone().parse::>().ok(), + }) + .collect(); + + // sort and reverse, so newer items come first. We use _unstable here, since + // we don't care which order equal items come in the list (it's unlikely that + // we'll even have equal items after all) + subgraphs.sort_unstable_by(|a, b| a.updated_at.cmp(&b.updated_at).reverse()); + + subgraphs +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_subgraphs_from_response_data_works() { + let json_response = json!({ + "frontendUrlRoot": "https://studio.apollographql.com/", + "service": { + "implementingServices": { + "__typename": "FederatedImplementingServices", + "services": [ + { + "name": "accounts", + "url": "https://localhost:3000", + "updatedAt": "2020-09-24T18:53:08.683Z" + }, + { + "name": "products", + "url": "https://localhost:3001", + "updatedAt": "2020-09-16T19:22:06.420Z" + } + ] + } + } + }); + let data: list_subgraphs_query::ResponseData = + serde_json::from_value(json_response).unwrap(); + let output = get_subgraphs_from_response_data(data, &"service".to_string()); + + let expected_json = json!([ + { + "name": "accounts", + "url": "https://localhost:3000", + "updatedAt": "2020-09-24T18:53:08.683Z" + }, + { + "name": "products", + "url": "https://localhost:3001", + "updatedAt": "2020-09-16T19:22:06.420Z" + } + ]); + let expected_service_list: Vec = + serde_json::from_value(expected_json).unwrap(); + + assert!(output.is_ok()); + assert_eq!(output.unwrap(), expected_service_list); + } + + #[test] + fn get_subgraphs_from_response_data_errs_with_no_services() { + let json_response = json!({ + "frontendUrlRoot": "https://harambe.com", + "service": { + "implementingServices": null + } + }); + let data: list_subgraphs_query::ResponseData = + serde_json::from_value(json_response).unwrap(); + let output = get_subgraphs_from_response_data(data, &"service".to_string()); + assert!(output.is_err()); + } + + #[test] + fn format_subgraphs_builds_and_sorts_subgraphs() { + let raw_info_json = json!([ + { + "name": "accounts", + "url": "https://localhost:3000", + "updatedAt": "2020-09-24T18:53:08.683Z" + }, + { + "name": "shipping", + "url": "https://localhost:3002", + "updatedAt": "2020-09-16T17:22:06.420Z" + }, + { + "name": "products", + "url": "https://localhost:3001", + "updatedAt": "2020-09-16T19:22:06.420Z" + } + ]); + let raw_subgraph_list: Vec = + serde_json::from_value(raw_info_json).unwrap(); + let formatted = format_subgraphs(&raw_subgraph_list); + assert_eq!(formatted[0].name, "accounts".to_string()); + assert_eq!(formatted[2].name, "shipping".to_string()); + } +} diff --git a/crates/rover-client/src/query/subgraph/mod.rs b/crates/rover-client/src/query/subgraph/mod.rs index 08bc3180f..017a8c95d 100644 --- a/crates/rover-client/src/query/subgraph/mod.rs +++ b/crates/rover-client/src/query/subgraph/mod.rs @@ -9,3 +9,6 @@ pub mod fetch; /// "subgraph push" command execution pub mod push; + +/// "subgraph list" +pub mod list; diff --git a/docs/source/subgraphs.md b/docs/source/subgraphs.md index 527ffc3f2..c1ba4c955 100644 --- a/docs/source/subgraphs.md +++ b/docs/source/subgraphs.md @@ -68,6 +68,40 @@ rover subgraph introspect http://localhost:4000 > accounts-schema.graphql > For more on passing values via `stdout`, see [Essential concepts](./essentials#using-stdout). +## Listing subgraphs for a graph + +> This requires first [authenticating Rover with Apollo Studio](./configuring/#authenticating-with-apollo-studio). + +A federated graph is composed of multiple subgraphs. You can use Rover to list +the subgraphs available to work with in Apollo Studio using the `subgraph list` +command. + +```bash +rover subgraph list my-graph@dev +``` + +This command lists all subgraphs for a variant, including their routing urls +and when they were last updated (in local time), along with a link to view them +in Apollo Studio. + +``` +Subgraphs: + ++----------+-------------- --------------+----------------------------+ +| Name | Routing Url | Last Updated | ++----------+-----------------------------+----------------------------+ +| reviews | https://reviews.my-app.com | 2020-10-21 12:23:28 -04:00 | ++----------+----------------------------------------+-----------------+ +| books | https://books.my-app.com | 2020-09-20 13:58:27 -04:00 | ++----------+----------------------------------------+-----------------+ +| accounts | https://accounts.my-app.com | 2020-09-20 12:23:36 -04:00 | ++----------+----------------------------------------+-----------------+ +| products | https://products.my-app.com | 2020-09-20 12:23:28 -04:00 | ++----------+----------------------------------------+-----------------+ + +View full details at https://studio.apollographql.com/graph/my-graph/service-list +``` + ## Pushing a subgraph schema to Apollo Studio > This requires first [authenticating Rover with Apollo Studio](./configuring/#authenticating-with-apollo-studio). diff --git a/src/command/output.rs b/src/command/output.rs index cdabd23cf..fcdaf3f99 100644 --- a/src/command/output.rs +++ b/src/command/output.rs @@ -1,4 +1,8 @@ +use std::fmt::Debug; + use atty::{self, Stream}; +use prettytable::{cell, row, Table}; +use rover_client::query::subgraph::list::ListDetails; /// RoverStdout defines all of the different types of data that are printed /// to `stdout`. Every one of Rover's commands should return `anyhow::Result` @@ -12,6 +16,7 @@ use atty::{self, Stream}; pub enum RoverStdout { SDL(String), SchemaHash(String), + SubgraphList(ListDetails), None, } @@ -36,6 +41,35 @@ impl RoverStdout { } println!("{}", &hash); } + RoverStdout::SubgraphList(details) => { + println!("Subgraphs:\n"); + + let mut table = Table::new(); + table.add_row(row!["Name", "Routing Url", "Last Updated"]); + + for subgraph in &details.subgraphs { + // if the url is None or empty (""), then set it to "N/A" + let url = subgraph.url.clone().unwrap_or_else(|| "N/A".to_string()); + let url = if url.is_empty() { + "N/A".to_string() + } else { + url + }; + let formatted_updated_at: String = if let Some(dt) = subgraph.updated_at { + dt.format("%Y-%m-%d %H:%M:%S %Z").to_string() + } else { + "N/A".to_string() + }; + + table.add_row(row![subgraph.name, url, formatted_updated_at]); + } + + println!("{}", table); + println!( + "View full details at {}/graph/{}/service-list", + details.root_url, details.graph_name + ); + } RoverStdout::None => (), } } diff --git a/src/command/subgraph/list.rs b/src/command/subgraph/list.rs new file mode 100644 index 000000000..b0ef80553 --- /dev/null +++ b/src/command/subgraph/list.rs @@ -0,0 +1,46 @@ +use serde::Serialize; +use structopt::StructOpt; + +use rover_client::query::subgraph::list; + +use crate::client::StudioClientConfig; +use crate::command::RoverStdout; +use crate::utils::parsers::{parse_graph_ref, GraphRef}; +use crate::Result; + +#[derive(Debug, Serialize, StructOpt)] +pub struct List { + /// @ of graph in Apollo Studio to list subgraphs from. + /// @ may be left off, defaulting to @current + #[structopt(name = "GRAPH_REF", parse(try_from_str = parse_graph_ref))] + #[serde(skip_serializing)] + graph: GraphRef, + + /// Name of configuration profile to use + #[structopt(long = "profile", default_value = "default")] + #[serde(skip_serializing)] + profile_name: String, +} + +impl List { + pub fn run(&self, client_config: StudioClientConfig) -> Result { + let client = client_config.get_client(&self.profile_name)?; + + tracing::info!( + "Listing subgraphs for {}@{}, mx. {}!", + &self.graph.name, + &self.graph.variant, + &self.profile_name + ); + + let list_details = list::run( + list::list_subgraphs_query::Variables { + graph_id: self.graph.name.clone(), + variant: self.graph.variant.clone(), + }, + &client, + )?; + + Ok(RoverStdout::SubgraphList(list_details)) + } +} diff --git a/src/command/subgraph/mod.rs b/src/command/subgraph/mod.rs index a9d79b8bf..8fb0f7e9b 100644 --- a/src/command/subgraph/mod.rs +++ b/src/command/subgraph/mod.rs @@ -1,6 +1,7 @@ mod check; mod delete; mod fetch; +mod list; mod push; use serde::Serialize; @@ -29,6 +30,9 @@ pub enum Command { /// Push a subgraph's schema from a local file Push(push::Push), + + /// List all subgraphs for a federated graph. + List(list::List), } impl Subgraph { @@ -38,6 +42,7 @@ impl Subgraph { Command::Delete(command) => command.run(client_config), Command::Fetch(command) => command.run(client_config), Command::Check(command) => command.run(client_config), + Command::List(command) => command.run(client_config), } } }