Query a subgraph

ROH: 2022-12-16

Introduction


A subgraph extracts data from a blockchain, processing it and storing it so that it can be easily queried via GraphQL.

-The Graph Docs



In this lesson we’re going to use Rust to submit a GraphQL query to the graph-network-mainnet subgraph on The Graph’s Hosted Service.


query indexers {
  indexers(first:10, where: {allocatedTokens_gt: "0"}) {
    id
    defaultDisplayName
    stakedTokens
  }
}


The above query requests data about Indexers in The Graph protocol, specifically id, defaultDisplayName, and stakedTokens fields, with data filtered to the first 10 results.

  • id: Eth address of Indexer.
  • defaultDisplayName: Default display name is the current default name. Used for filtered queries.
    • potentially null
  • stakedTokens: CURRENT tokens staked in the protocol. Decreases on withdraw, not on lock.


Indexers are node operators in The Graph Network that stake Graph Tokens (GRT) in order to provide indexing and query processing services.

-The Graph Docs


Since this is a Rust guide, let’s do a quick overview of the concepts and topics we’re going to cover:

  • creating a new project with cargo
  • defining a custom struct
  • the Option type
  • dependencies on external crates
  • sending a POST request to an API
  • deserializing a string to JSON
  • for loop expression
  • the match control flow construct


Code


From your terminal/command line, create a new cargo project and open it with VSCode.

cargo new indexer_subgraph_query
cd indexer_subgraph_query
code .



With VSCode now open, click Cargo.toml in the sidebar then add the following dependencies (below [dependencies]). Make sure to save your changes.

serde = { version = "1.0.149", features = ["derive"] }
reqwest = { version = "0.11", features =  ["json"]}
tokio = { version = "1.23.0", features = ["full"] }


  • serde is a “framework for serializing and deserializing Rust data structures efficiently and generically”
  • reqwest “provides a convenient, higher-level HTTP Client”
  • tokio is an “event-driven, non-blocking I/O platform for writing asynchronous applications with the Rust programming language”



Next, using the VSCode integrated terminal, create a file called src/structs.rs.

touch src/structs.rs



To start, add the following use declarations to the file and save your changes.


use std::string::String;
use serde::Deserialize;


A use declaration creates one or more local name bindings synonymous with some other path. Usually a use declaration is used to shorten the path required to refer to a module item. These declarations may appear in modules and blocks, usually at the top.

-The Rust Reference



Next let’s define a few struct statements, then save your modifications.


#[allow(non_snake_case)]
#[derive(Debug, Deserialize, PartialEq)]
pub struct Indexer {
    pub id: String,
    pub defaultDisplayName: Option<String>,
    pub stakedTokens: Option<String>,
}

#[derive(Debug, Deserialize, PartialEq)]
pub struct IndexerData {
    pub indexers: Vec<Indexer>
}

#[derive(Debug, Deserialize, PartialEq)]
pub struct IndexerResponse {
    pub data: IndexerData
}


We created three structs:

  • IndexerResponse: highest-level struct defining the response from POST request
  • IndexerData: nested struct defining data field of IndexerResponse
  • Indexer: nested struct defining Vec element type for indexers field of IndexerData


Each indexer in the protocol would theoretically be assigned to an Indexer struct, with the IndexerData struct containing all Indexer structs. Finally, the IndexerResponse contains IndexerData in it’s data field.



Open src/main.rs in VSCode, delete the main function, and add the following use statements at the top of the file


use std::collections::HashMap;
mod structs;
use crate::structs::*;


While the first use declaration looks familiar to previous examples, the mod keyword is new. We’re importing the contents from src/structs.rs using a module mapped to file hierarcy.



Now that we’ve imported the necessary modules, let’s move onto the main function. Copy the following contents to your file, replacing the existing main function. Make sure to save your changes.


#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let network_subgraph_url = "https://api.thegraph.com/subgraphs/name/graphprotocol/graph-network-mainnet";
    
    let indexer_subgraph_query = "{indexers(first:10, where: {allocatedTokens_gt: \"0\"}) {id defaultDisplayName stakedTokens}}";
    
    let mut map = HashMap::new();
    map.insert("query", indexer_subgraph_query);

    let indexer_response: IndexerResponse = reqwest::Client::new()
    .post(network_subgraph_url)
    .json(&map)
    .send()
    .await?
    .json()
    .await?;

    for entry in indexer_response.data.indexers {
        println!("ID: {}", entry.id);
        
        match entry.defaultDisplayName {
            None => println!("defaultDisplayName: None found"),
            Some(x) => println!("defaultDisplayName: {}", x)
        };

        match entry.stakedTokens {
            None => println!("stakedTokens: None found"),
            Some(ref y) => println!("stakedTokens: {}", y)
        };

        println!("");
    }
    Ok(())
}


Our main function begins with a #[tokio::main] annotation.

The #[tokio::main] function is a macro. It transforms the async fn main() into a synchronous fn main() that initializes a runtime instance and executes the async main function.

Tokio Docs


Next we define the async function and some error handling with Result enum below the annotation.

There is no return value for the function so we specify the unit type as the Result enum’s Ok branch generic type. We also specify reqwest::Error as the Result enum’s Err branch generic type parameter.

  • See Chapter 10 of The Rust Programming Language book for a deeper dive into Generic Types.
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {}


Inside the main function body write two let statements to bring some string variables into the current scope.

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let network_subgraph_url = "https://api.thegraph.com/subgraphs/name/graphprotocol/graph-network-mainnet";
    
    let indexer_subgraph_query = "{indexers(first:10, where: {allocatedTokens_gt: \"0\"}) {id defaultDisplayName stakedTokens}}";
}
  • network_subgraph_url: URL where POST request will be sent
  • indexer_subgraph_query: GraphQL query to be sent in POST request


Next we create an empty HashMap then insert our indexer_subgraph_query at the query key.

  • We’re taking advantage of type inference so don’t need to explicitly declare type signatures.
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let network_subgraph_url = "https://api.thegraph.com/subgraphs/name/graphprotocol/graph-network-mainnet";
    
    let indexer_subgraph_query = "{indexers(first:10, where: {allocatedTokens_gt: \"0\"}) {id defaultDisplayName stakedTokens}}";
    
    let mut map = HashMap::new();
    map.insert("query", indexer_subgraph_query);
}


Now we can send a POST request to the Hosted Service. We’re going to use reqwest to do the heavy lifting for us.

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let network_subgraph_url = "https://api.thegraph.com/subgraphs/name/graphprotocol/graph-network-mainnet";
    
    let indexer_subgraph_query = "{indexers(first:10, where: {allocatedTokens_gt: \"0\"}) {id defaultDisplayName stakedTokens}}";
    
    let mut map = HashMap::new();
    map.insert("query", indexer_subgraph_query);

    let indexer_response: IndexerResponse = reqwest::Client::new()
    .post(network_subgraph_url)
    .json(&map)
    .send()
    .await?
    .json()
    .await?;

    Ok(())
}

Notice how we send the HashMap using the RequestBuilder json method helper then await the response. We then attempt to deserialize the reponse body as JSON into our custom data type IndexerResponse. The serde crate is doing the hard work for us.

  • ? operator is used with both awaits to unwrap valid values.
  • We also cap off the function with the Ok variant of the Result enum from std::Result, setting the generic type to () unit type.


Finally, we add a for loop to iterate through the data contained within indexer_response and print out values to our command line.

Within the loop we’re using two match control flow constructs to apply different behavior depending on the Option enum variant encountered.

  • If the Option variant is Some(value) we print value
  • If the Option variant is None we print None found

We also use the ref keyword to bind by reference (borrow rather than move) during pattern matching on stakedTokens.

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let network_subgraph_url = "https://api.thegraph.com/subgraphs/name/graphprotocol/graph-network-mainnet";
    
    let indexer_subgraph_query = "{indexers(first:10, where: {allocatedTokens_gt: \"0\"}) {id defaultDisplayName stakedTokens}}";
    
    let mut map = HashMap::new();
    map.insert("query", indexer_subgraph_query);

    let indexer_response: IndexerResponse = reqwest::Client::new()
    .post(network_subgraph_url)
    .json(&map)
    .send()
    .await?
    .json()
    .await?;

    for entry in indexer_response.data.indexers {
        println!("ID: {}", entry.id);
        
        match entry.defaultDisplayName {
            None => println!("defaultDisplayName: None found"),
            Some(x) => println!("defaultDisplayName: {}", x)
        };

        match entry.stakedTokens {
            None => println!("stakedTokens: None found"),
            Some(ref y) => println!("stakedTokens: {}", y)
        };

        println!("");
    }
    Ok(())
}



Now that the main function is complete, save your changes and run the program from the integrated terminal in VSCode.

cargo run


Closing thoughts

Is the result what you expect?

  • Did you predict the format of the response?
  • Or was something different than you anticipated?

Try tinkering with the GraphQL query and update your structs as necessary.

Good luck Rustacean!