This codebase is set up as a Cargo workspace. The rover
binary is built from the root bin
target, which is a thin wrapper around the rover-client
library crate.
Great thought and attention has been paid to Rover's design, and any new command surface must be considered holistically. clig.dev has heavily influenced the design of this project and is useful reading for any contributor to this codebase.
Rover commands are laid out as rover [NOUN] [VERB]
to create clear separation of concerns for multiple areas of graph management.
Generally, we are hesitant to add a new NOUN
to Rover's surface area, unless there is a clear and real need.
An example of a clear need is the graph
vs. subgraph
vs. supergraph
command structure. Each of these nouns has similar associated verbs.
Let's look at the fetch
commands as an example. rover graph fetch
and rover subgraph fetch
each take a positional required <GRAPH_REF>
argument, and subgraph fetch
also has a required --subgraph
flag. It looks like there doesn't need to be differentiation between these commands: we could have made this behavior implicit by making --subgraph
optional, and only returning a subgraph schema if the --subgraph
argument was provided.
The problem with this approach is that having two different return types from the same command leads to unexpected results and makes it difficult to understand the mental model needed to work with the graph registry. Additionally, it would have made it difficult to design commands that only exist for subgraphs
, and vice versa (such as rover subgraph check
).
In general, it's best to keep related commands together, and to avoid cognitive complexity wherever possible. New commands should either be associated with an existing top-level noun, or a new noun should be proposed.
-
Cargo.toml
: crate metadata, including definitions for both internal and external dependencies -
Cargo.lock
: an autogenerated lockfile for Rover's dependencies -
src
: therover
CLIsrc/bin/rover.rs
: the entry point for the CLI executablesrc/command
: logic for the CLI commandssrc/command/output.rs
: Enum containing all possiblestdout
options for Rover commandssrc/command/{command_name}/mod.rs
: Contains the definition of a command.
src/utils
: shared utility functionssrc/error
: application-level error handling including suggestions and error codessrc/cli.rs
: Module containing definition for all top-level commandssrc/lib.rs
: all the logic used by the CLI
-
tests
: Integration tests -
crates
crates/houston
: logic related to configuring rovercrates/robot-panic
: Fork ofhuman-panic
to create panic handlers that allows users to submit crash reports as GitHub issuescrates/rover-client
: logic for querying apollo servicescrates/rover-std
: wrappers aroundstd
modules with standardized logging and behaviorcrates/sputnik
: logic for capturing anonymous usage datacrates/timber
: output formatting and logging logiccrates/xtask
: logic for building and testing Rover
-
.cargo
: Sets upcargo xtask
commands in the workspace -
docs
source/*.md
: Individual documentation pagessource/assets
: Images and other resourcesstatic/_redirects
: Netlify redirects
-
netlify.toml
: Configuration for Rover's docs -
installers
binstall
: Rover's cross-platform installer that downloads and installs prebuilt binariesnpm
: Rover's npm installer that downloads and installs prebuilt binaries
-
.github
ISSUE_TEMPLATE
: Issues templates for our GitHub repositoryworkflows/lint.yml
: GitHub Action that checks for idiomatic code style, proper formatting, and broken markdown linksworkflows/release.yml
: GitHub Action that builds cross-platform binaries and creates a GitHub release when a version is taggedworkflows/test.yml
: Runs integration and unit tests on each commit that is pushed to GitHub
Prior to adding a new command to Rover, you should familiarize yourself with Rover's existing architecture and to make sure that you have discussed the design of the new command in a GitHub issue before submitting a pull request.
Let's walk through what it would look like to add a new hello
subcommand to the rover graph
command namespace.
The first thing we want to do when creating a new command is to create an entry point for it. The current project does not have a graph hello
command as you can see here:
$ cargo run -- graph hello
Finished dev [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/rover graph hello`
error: The subcommand 'hello' wasn't recognized
Did you mean 'help'?
If you believe you received this message in error, try re-running with 'rover graph -- hello'
USAGE:
rover graph [OPTIONS] <SUBCOMMAND>
For more information try --help
Each of Rover's "nouns" has its own module in src/command
. The noun we'll be adding a verb command to is graph
. If you open src/command/graph/mod.rs
, you can see an example of how each of the graph
commands is laid out.
Each command has its own file, which is included with a mod command;
statement. The entry for rover graph publish
and its help text are laid out in mod.rs
in a struct with the clap::Parser
trait automatically derived. (You can read more about clap here).
The actual logic for rover graph publish
lives in src/command/graph/publish.rs
Before we can add a command to Rover's API, allowing us to run it, we need to:
- Define the command and its possible arguments
- Providing a basic
run
function.
We can do these in the src/command
directory.
Subcommands each have their own files or directories under src/command
. Files directly in src/command
are flat commands with no subcommands, like rover info
in src/command/info.rs
. Commands with subcommands include files for each of their subcommands, like rover graph publish
in src/command/graph/publish.rs
. Here, each argument is laid out in the Publish
struct, and a run
method is added to the struct.
A minimal command in Rover would be laid out exactly like this:
use serde::{Deserialize, Serialize};
use clap::Parser;
use crate::{RoverResult, RoverOutput};
#[derive(Debug, Serialize, Parser)]
pub struct MyNewCommand { }
impl MyNewCommand {
pub fn run(&self) -> RoverResult<RoverOutput> {
Ok(RoverOutput::EmptySuccess)
}
}
For our graph hello
command, we'll add a new hello.rs
file under src/command/graph
with the following contents:
use serde::Serialize;
use clap::Parser;
use crate::{RoverResult, RoverOutput};
#[derive(Debug, Serialize, Parser)]
pub struct Hello { }
impl Hello {
pub fn run(&self) -> RoverResult<RoverOutput> {
eprintln!("Hello, world!");
Ok(RoverOutput::EmptySuccess)
}
}
In this file, the pub struct Hello
struct declaration is where we define the arguments and options available for our Hello
command.
In its current state, this file would not be compiled, because the module is not included in the parent module.
To fix this, we can include the newly created hello
module in src/command/graph/mod.rs
:
mod hello;
Then, we can add a Hello
value to the Command
enum like so:
#[derive(Debug, Serialize, Parser)]
pub enum Command {
...
/// Say hello to a graph!
Hello(hello::Hello),
}
hello::Hello
, the value associated with the Hello
variant of Command
, is the struct that we created in the previous step. The doc comment here /// Say hello to a graph
is also important, because it's the description for the command that will be shown when running rover graph --help
.
Running cargo check
or an editor extension (like Rust Analyzer for VS Code) will warn you that pattern &Hello not covered
for the impl
block below the enum definition. This means that for the run
function in the mod.rs
file we're in, we're not matching all possible variants of the Command
enum.
Add the following line to the match
block. This tells clap
that when we encounter the graph hello
command, we want to use the Hello::run
function that we defined earlier to execute it:
Command::Hello(command) => command.run(),
After adding that, there should be no errors when running cargo check
, and we can run our basic command using cargo run
:
$ cargo run -- graph hello
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/rover graph hello`
Hello, world!
Rover uses a library called Clap to build commands. We apply the clap::Parser
trait using the #[derive(Parser)]
syntax above each command. This lets clap
know that this is a command definition, and the values and implementations for this struct will be related to the command defined by Hello
.
All commands under the graph
namespace accept a required, positional argument <GRAPH_REF>
that describes the graph and variant a user is operating on. Additionally, it takes an optional --profile
flag that can swap out the API token a user is using to interact with the graph registry.
To add these to our new graph hello
command, we can copy and paste the field from any other graph
command like so:
use crate::options::{ProfileOpt, GraphRefOpt};
#[derive(Debug, Serialize, Parser)]
pub struct Hello {
#[clap(flatten)]
graph: GraphRefOpt,
#[clap(flatten)]
profile: ProfileOpt,
}
Now if we run the command again, it will complain if we don't provide a graph ref:
$ cargo run -- graph hello
Running `target/debug/rover graph hello`
error: The following required arguments were not provided:
<GRAPH_REF>
USAGE:
rover graph hello [OPTIONS] <GRAPH_REF>
For more information try --help
Most of Rover's commands make requests to Apollo Studio's API, or to another GraphQL API. Rather than handling the request logic in the repository's main package, Rover is structured so that this logic lives in crates/rover-client
. This is helpful for separation of concerns and testing.
To access functionality from rover-client
in our rover graph hello
command, we'll need to pass down a client from the entry to our command in src/command/graph/mod.rs
.
You can do this by changing the Command::Hello(command) => command.run(),
line to Command::Hello(command) => command.run(client_config),
.
Then you'll need to change Hello::run
to accept a client_config: StudioClientConfig
parameter in src/command/graph/hello.rs
, and add use crate::utils::client::StudioClientConfig
and use rover_client::shared::GraphRef
import statements. Then, at the top of the run function, you can create a StudioClient
by adding let client = client_config.get_authenticated_client(&self.profile.name)?;
. You can see examples of this in the other commands.
Now that we've successfully scaffolded out a new rover graph hello
command that is ready to start making requests to the Apollo Studio API, we can take a look at the help command.
$ cargo run -- graph hello --help
rover-graph-hello 0.1.5
Hello, World!
USAGE:
rover graph hello [OPTIONS] <GRAPH_REF>
FLAGS:
-h, --help Prints help information
OPTIONS:
-l, --log <log-level> Specify Rover's log level [possible values: error, warn, info,
debug, trace]
--profile <profile-name> Name of configuration profile to use [default: default]
ARGS:
<GRAPH_REF> <NAME>@<VARIANT> of graph in Apollo Studio to publish to. @<VARIANT> may be left off, defaulting
to @current
<GRAPH_REF>
and --profile <profile-name>
should look familiar to you, but -h, --help
, and -l --log <log-level>
might seem a bit magical.
The --help
flag is automatically created by clap
, and the --log
flag is defined as a global flag in src/cli.rs
on the top level Rover
struct.
Whenever you create a new command, make sure to add #[serde(skip_serializing)]
to any flag or parameter that might contain personally identifiable information (PII). Commands and their parameters without this attribute are automatically sent to Apollo's telemetry endpoint.
The only piece of the rover-client
crate that we need to be concerned with for now is the src/operations
directory. This is where all the queries to Apollo Studio live. This directory is roughly organized by the command names as well, but there might be some queries in these directories that are used by multiple commands.
You can see in the src/operations/graph
directory a number of .rs
files paired with .graphql
files. The .graphql
files are the files where the GraphQL operations live, and the matching .rs
files contain the logic needed to execute those operations.
For our basic graph hello
command, we're going to make a request to Apollo Studio that inquires about the existence of a particular graph, and nothing else. For this, we can use the Query.service
field.
Create a hello_query.graphql
file in crates/rover-client/src/operations/graph
and paste the following into it:
query GraphHello($graph_id: ID!) {
service(id: $graph_id) {
deletedAt
}
}
This basic GraphQL operation uses a graph's unique ID (which we get from the GraphRef
we defined earlier) to fetch the graph from the registry, along with a field describing when it was deleted. Using this information, we can determine if a graph exists (if the service
field is null
) and if it was deleted and no longer usable.
This project uses graphql-client to generate types for each raw .graphql
query that we write.
First, create an empty directory at crates/rover-client/src/operations/graph/hello
, and then in that directory, create a mod.rs
file to initialize the module.
To start compiling this file, we need to export the module in crates/rover-client/src/operations/graph/mod.rs
:
...
/// "graph hello" command execution
pub mod hello;
Back in our hello
module, we'll create a runner.rs
, and add
mod runner
pub use runner::run;
to our mod.rs
file.
Then, in runner.rs
, import the following types:
use crate::blocking::StudioClient;
use crate::RoverClientError;
use graphql_client::*;
Then, we'll create a new struct that will have auto-generated types for the hello_query.graphql
file that we created earlier:
#[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/graph/hello/hello_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.
pub struct GraphHello;
Because the type we'll be returning is autogenerated to be a Timestamp
, we'll need to add the following line:
type Timestamp = String;
From here, we'll want an entrypoint to actually run the query. To do so, we'll create a public run
function:
pub fn run(
variables: graph_hello::Variables,
client: &StudioClient,
) -> Result<Timestamp, RoverClientError> {
Ok("stub".to_string())
}
Before we go any further, lets make sure everything is set up properly. We're going back to src/command/graph/hello.rs
to add a call to our newly created run
function.
It should look something like this (you should make sure you are following the style of other commands when creating new ones):
pub fn run(&self, client_config: StudioClientConfig) -> RoverResult<RoverOutput> {
let client = client_config.get_client(&self.profile.name)?;
let graph_ref = self.graph.graph_ref.to_string();
eprintln!(
"Checking deletion of graph {} using credentials from the {} profile.",
Style::Link.paint(&graph_ref),
Style::Command.paint(&self.profile.name)
);
let deleted_at = hello::run(
hello::graph_hello::Variables {
graph_id: self.graph.graph_ref.clone(),
},
&client,
)?;
println!("{:?}", deleted_at);
// TODO: Add a new output type!
Ok(RoverOutput::None)
}
Because we've just stubbed out a fake response without actually executing the query, this command should just print out stub
every time you run it with a valid graph ref.
To actually execute the query, we'll modify our rover-client
hello.rs to look like this:
pub fn run(
variables: graph_hello::Variables,
client: &StudioClient,
) -> Result<Timestamp, RoverClientError> {
let graph = variables.graph_id.clone();
let data = client.post::<GraphHello>(variables)?;
build_response(data, graph)
}
fn build_response(
data: graph_hello::ResponseData,
graph: String,
) -> Result<Timestamp, RoverClientError> {
let service = data.service.ok_or(RoverClientError::NoService { graph })?;
service.deleted_at.ok_or(RoverClientError::AdhocError {
msg: "Graph has never been deleted".to_string(),
})
}
This should get you to the point where you can run rover graph hello <GRAPH_REF>
and see if and when the last graph was deleted. From here, you should be able to follow the examples of other commands to write out tests for the build_response
function.
Unfortunately this is not the cleanest API and doesn't match the pattern set by the rest of the commands. Each rover-client
operation has an input type and an output type, along with a run
function that takes in a reqwest::blocking::Client
.
You'll want to define all of the types scoped to this command in types.rs
, and re-export them from the top level hello
module, and nothing else.
Now that you can actually execute the hello::run
query and return its result, you should create a new variant of RoverOutput
in src/command/output.rs
that is not None
. Your new variant should print the descriptor using the print_descriptor
function, and print the raw content using print_content
.
To do so, change the line Ok(RoverOutput::None)
to Ok(RoverOutput::DeletedAt(deleted_at))
, add a new DeletedAt(String)
variant to RoverOutput
, and then match on it in pub fn print(&self)
and pub fn get_json(&self)
:
pub fn print(&self) {
match self {
...
RoverOutput::DeletedAt(timestamp) => {
print_descriptor("Deleted At");
print_content(×tamp);
}
...
}
}
pub fn get_json(&self) -> Value {
match self {
...
RoverOutput::DeletedAt(timestamp) => {
json!({ "deleted_at": timestamp.to_string() })
}
...
}
}
Rover places a very strong emphasis on good error handling, with properly structured errors, accompanying error codes, and actionable suggestions to resolve errors. Each workspace crate uses thiserror
to create a top level error enum in error.rs
that defines all of the possible errors that can occur in that crate.
Then, in Rover, we create a RoverError
struct defined in src/error/mod.rs
that formats each of these errors, and adds some extra metadata to them for end users. Each time a new error is added to any workspace crate, you'll receive a compiler error complaining about an unmatched variant in src/error/metadata/mod.rs
. This new error type should then be mapped to either an existing variant of the Suggestion
enum (src/error/metadata/suggestion.rs
), or a new one should be created. Additionally, a new error code should likely be created in code.rs
, along with a longer form description of that error code in a markdown file in src/error/codes
.
Most environment variables within Rover are preceded with APOLLO_
. To support a new environment variable following this format, open src/utils/env.rs
and add a new variant to the enum there. It should be as easy as following the patterns set out there and passing the variable where you need it to go. The top level Rover
struct has a global RoverEnv
instance that will slurp up all of the system's environment variables into a HashMap
that can then be accessed in any command. RoverEnv
also provides the ability to mock specific environment variables for use in testing.