Skip to content

Latest commit

 

History

History
451 lines (317 loc) · 20.8 KB

ARCHITECTURE.md

File metadata and controls

451 lines (317 loc) · 20.8 KB

Architecture

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.

CLI Design

Great thought and attention has been paid to Rover's design, and any new command surface must be considered holistically.

Command layout

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.

Project Structure

  • Cargo.toml: crate metadata, including definitions for both internal and external dependencies

  • Cargo.lock: an autogenerated lockfile for Rover's dependencies

  • src: the rover CLI

    • src/bin/rover.rs: the entry point for the CLI executable
    • src/command: logic for the CLI commands
      • src/command/output.rs: Enum containing all possible stdout options for Rover commands
      • src/command/{command_name}/mod.rs: Contains the definition of a command.
    • src/utils: shared utility functions
    • src/error: application-level error handling including suggestions and error codes
    • src/cli.rs: Module containing definition for all top-level commands
    • src/lib.rs: all the logic used by the CLI
  • tests: Integration tests

  • crates

    • crates/houston: logic related to configuring rover
    • crates/robot-panic: Fork of human-panic to create panic handlers that allows users to submit crash reports as GitHub issues
    • crates/rover-client: logic for querying apollo services
    • crates/rover-std: wrappers around std modules with standardized logging and behavior
    • crates/sputnik: logic for capturing anonymous usage data
    • crates/timber: output formatting and logging logic
    • crates/xtask: logic for building and testing Rover
  • .cargo: Sets up cargo xtask commands in the workspace

  • docs

    • source/*.md: Individual documentation pages
    • source/assets: Images and other resources
    • static/_redirects: Netlify redirects
  • netlify.toml: Configuration for Rover's docs

  • installers

    • binstall: Rover's cross-platform installer that downloads and installs prebuilt binaries
    • npm: Rover's npm installer that downloads and installs prebuilt binaries
  • .github

    • ISSUE_TEMPLATE: Issues templates for our GitHub repository
    • workflows/lint.yml: GitHub Action that checks for idiomatic code style, proper formatting, and broken markdown links
    • workflows/release.yml: GitHub Action that builds cross-platform binaries and creates a GitHub release when a version is tagged
    • workflows/test.yml: Runs integration and unit tests on each commit that is pushed to GitHub

Guide to adding a new command

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.

Example: rover graph hello

Let's walk through what it would look like to add a new hello subcommand to the rover graph command namespace.

Scaffold the new command

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!
Accepting required arguments and optional flags

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
Setting up a command to work with rover-client

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.

Auto-generated help command

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.

Important note on telemetry

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.

Adding a query to Apollo Studio

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.

Writing a GraphQL operation

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.

Writing the request handler

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.

Clean up the API

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.

RoverOutput

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(&timestamp);
        }
    ...
    }
}

pub fn get_json(&self) -> Value {
    match self {
    ...
        RoverOutput::DeletedAt(timestamp) => {
            json!({ "deleted_at": timestamp.to_string() })
        }
    ...
    }
}
Error handling

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.

Environment Variables

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.