diff --git a/crates/rover-client/src/error.rs b/crates/rover-client/src/error.rs index aacce6239..6de57e62b 100644 --- a/crates/rover-client/src/error.rs +++ b/crates/rover-client/src/error.rs @@ -42,6 +42,13 @@ pub enum RoverClientError { #[error("No graph found. Either the graph@variant combination wasn't found or your API key is invalid.")] NoService, + /// This error occurs when the Studio API returns no composition errors AND + /// no check result. This response shouldn't be possible! + #[error( + "The response from the server was malformed, there was no data from the check operation." + )] + NoCheckData, + #[error("The graph `{graph_name}` is a non-federated graph. This operation is only possible for federated graphs")] ExpectedFederatedGraph { graph_name: String }, diff --git a/crates/rover-client/src/query/subgraph/check.graphql b/crates/rover-client/src/query/subgraph/check.graphql new file mode 100644 index 000000000..5ecc67924 --- /dev/null +++ b/crates/rover-client/src/query/subgraph/check.graphql @@ -0,0 +1,32 @@ + mutation CheckPartialSchemaQuery ( + $graph_id: ID! + $variant: String! + $implementingServiceName: String! + $partialSchema: PartialSchemaInput! + ) { + service(id: $graph_id) { + checkPartialSchema( + graphVariant: $variant + implementingServiceName: $implementingServiceName + partialSchema: $partialSchema + ) { + compositionValidationResult { + errors { + message + } + } + checkSchemaResult { + diffToPrevious { + severity + numberOfCheckedOperations + changes { + severity + code + description + } + } + targetUrl + } + } + } + } \ No newline at end of file diff --git a/crates/rover-client/src/query/subgraph/check.rs b/crates/rover-client/src/query/subgraph/check.rs new file mode 100644 index 000000000..2c5b5c3a3 --- /dev/null +++ b/crates/rover-client/src/query/subgraph/check.rs @@ -0,0 +1,104 @@ +use crate::blocking::StudioClient; +use crate::RoverClientError; +use graphql_client::*; + +use reqwest::Url; + +#[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/check.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. check_partial_schema_query +pub struct CheckPartialSchemaQuery; + +/// The main function to be used from this module. +/// This function takes a proposed schema and validates it against a pushed +/// schema. +pub fn run( + variables: check_partial_schema_query::Variables, + client: &StudioClient, +) -> Result { + let data = client.post::(variables)?; + get_check_response_from_data(data) +} + +pub enum CheckResponse { + CompositionErrors(Vec), + CheckResult(CheckResult) +} + +#[derive(Debug)] +pub struct CheckResult { + pub target_url: Option, + pub number_of_checked_operations: i64, + pub change_severity: check_partial_schema_query::ChangeSeverity, + pub changes: Vec, +} + +fn get_check_response_from_data( + data: check_partial_schema_query::ResponseData, +) -> Result { + let service = data.service.ok_or(RoverClientError::NoService)?; + + // for some reason this is a `Vec>` + // we convert this to just `Vec` because the `None` + // errors would be useless. + let composition_errors: Vec = service + .check_partial_schema + .composition_validation_result + .errors + .into_iter() + .filter(|e| e.is_some()) + .map(|e| e.unwrap()) + .collect(); + + if composition_errors.is_empty() { + // TODO: fix this error case + let check_schema_result = service + .check_partial_schema + .check_schema_result + .ok_or(RoverClientError::NoCheckData)?; + + let target_url = get_url(check_schema_result.target_url); + + let diff_to_previous = check_schema_result.diff_to_previous; + + let number_of_checked_operations = + diff_to_previous.number_of_checked_operations.unwrap_or(0); + + let change_severity = diff_to_previous.severity; + let changes = diff_to_previous.changes; + + let check_result = CheckResult { + target_url, + number_of_checked_operations, + change_severity, + changes, + }; + + Ok(CheckResponse::CheckResult(check_result)) + } else { + Ok(CheckResponse::CompositionErrors(composition_errors)) + } +} + +fn get_url(url: Option) -> Option { + match url { + Some(url) => { + let url = Url::parse(&url); + match url { + Ok(url) => Some(url), + // if the API returns an invalid URL, don't put it in the response + Err(_) => None, + } + } + None => None, + } +} diff --git a/crates/rover-client/src/query/subgraph/mod.rs b/crates/rover-client/src/query/subgraph/mod.rs index f272b77dd..08bc3180f 100644 --- a/crates/rover-client/src/query/subgraph/mod.rs +++ b/crates/rover-client/src/query/subgraph/mod.rs @@ -1,8 +1,11 @@ -/// "subgraph push" command execution -pub mod push; - /// "subgraph delete" command execution pub mod delete; +/// "subgraph check" command execution +pub mod check; + /// "subgraph fetch" command execution pub mod fetch; + +/// "subgraph push" command execution +pub mod push; diff --git a/src/command/subgraph/check.rs b/src/command/subgraph/check.rs new file mode 100644 index 000000000..14815b96c --- /dev/null +++ b/src/command/subgraph/check.rs @@ -0,0 +1,144 @@ +use anyhow::{Context, Result}; +use prettytable::{cell, row, Table}; +use serde::Serialize; +use structopt::StructOpt; + +use rover_client::query::subgraph::check; + +use crate::client::StudioClientConfig; +use crate::command::RoverStdout; +use crate::utils::loaders::load_schema_from_flag; +use crate::utils::parsers::{parse_graph_ref, parse_schema_source, GraphRef, SchemaSource}; + +#[derive(Debug, Serialize, StructOpt)] +pub struct Check { + /// @ of graph in Apollo Studio to validate. + /// @ 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 the implementing service to validate + #[structopt(required = true)] + #[serde(skip_serializing)] + service_name: String, + + /// Name of configuration profile to use + #[structopt(long = "profile", default_value = "default")] + #[serde(skip_serializing)] + profile_name: String, + + /// The schema file to push + /// Can pass `-` to use stdin instead of a file + #[structopt(long, short = "s", parse(try_from_str = parse_schema_source))] + #[serde(skip_serializing)] + schema: SchemaSource, +} + +impl Check { + pub fn run(&self, client_config: StudioClientConfig) -> Result { + let client = client_config.get_client(&self.profile_name)?; + + let sdl = load_schema_from_flag(&self.schema, std::io::stdin())?; + + let partial_schema = check::check_partial_schema_query::PartialSchemaInput { + sdl: Some(sdl), + // we never need to send the hash since the back end computes it from SDL + hash: None, + }; + let res = check::run( + check::check_partial_schema_query::Variables { + graph_id: self.graph.name.clone(), + variant: self.graph.variant.clone(), + partial_schema, + implementing_service_name: self.service_name.clone(), + }, + &client, + ) + .context("Failed to validate schema")?; + + tracing::info!( + "Checked the proposed subgraph against {}@{}", + &self.graph.name, + &self.graph.variant + ); + + match res { + check::CheckResponse::CompositionErrors(composition_errors) => { + handle_composition_errors(&composition_errors) + } + check::CheckResponse::CheckResult(check_result) => handle_checks(check_result), + } + } +} + +fn handle_checks(check_result: check::CheckResult) -> Result { + let num_changes = check_result.changes.len(); + + let msg = match num_changes { + 0 => "There were no changes detected in the composed schema.".to_string(), + _ => format!( + "Compared {} schema changes against {} operations", + check_result.changes.len(), + check_result.number_of_checked_operations + ), + }; + + tracing::info!("{}", &msg); + + let mut num_failures = 0; + + if !check_result.changes.is_empty() { + let mut table = Table::new(); + table.add_row(row!["Change", "Code", "Description"]); + for check in check_result.changes { + let change = match check.severity { + check::check_partial_schema_query::ChangeSeverity::NOTICE => "PASS", + check::check_partial_schema_query::ChangeSeverity::FAILURE => { + num_failures += 1; + "FAIL" + } + _ => unreachable!("Unknown change severity"), + }; + table.add_row(row![change, check.code, check.description]); + } + + eprintln!("{}", table); + } + + if let Some(url) = check_result.target_url { + tracing::info!("View full details here"); + tracing::info!("{}", url.to_string()); + } + + match num_failures { + 0 => Ok(RoverStdout::None), + 1 => Err(anyhow::anyhow!( + "Encountered 1 failure while checking your subgraph." + )), + _ => Err(anyhow::anyhow!( + "Encountered {} failures while checking your subgraph.", + num_failures + )), + } +} + +fn handle_composition_errors( + composition_errors: &[check::check_partial_schema_query::CheckPartialSchemaQueryServiceCheckPartialSchemaCompositionValidationResultErrors], +) -> Result { + let mut num_failures = 0; + for error in composition_errors { + num_failures += 1; + tracing::error!("{}", &error.message); + } + match num_failures { + 0 => Ok(RoverStdout::None), + 1 => Err(anyhow::anyhow!( + "Encountered 1 composition error while composing the subgraph." + )), + _ => Err(anyhow::anyhow!( + "Encountered {} composition errors while composing the subgraph.", + num_failures + )), + } +} diff --git a/src/command/subgraph/mod.rs b/src/command/subgraph/mod.rs index 1876b8b59..d6a863c52 100644 --- a/src/command/subgraph/mod.rs +++ b/src/command/subgraph/mod.rs @@ -1,3 +1,4 @@ +mod check; mod delete; mod fetch; mod push; @@ -17,14 +18,17 @@ pub struct Subgraph { #[derive(Debug, Serialize, StructOpt)] pub enum Command { - /// Push an implementing service schema from a local file - Push(push::Push), + /// Check changes to an implementing service + Check(check::Check), /// Delete an implementing service and trigger composition Delete(delete::Delete), /// Fetch an implementing service's schema from Apollo Studio Fetch(fetch::Fetch), + + /// Push an implementing service schema from a local file + Push(push::Push), } impl Subgraph { @@ -33,6 +37,7 @@ impl Subgraph { Command::Push(command) => command.run(client_config), Command::Delete(command) => command.run(client_config), Command::Fetch(command) => command.run(client_config), + Command::Check(command) => command.run(client_config), } } }