The translator sequence

Roman Kudryashov(blogger) is a veteran backend developer from Moscow who uses Rust/Java/Kotlin in his daily work for service persistence layers, integration between microservices, and more. In the development process of Async-GraphQL to give a lot of help, then based on these experiences summarized in this tutorial (English original).

I came across Rust two years ago by chance and fell in love with it. Since then, I have never touched another programming language. Get everything done in Rust at work and you’re a real Rust fanatic. Rust is the perfect programming language I’ve come across in all my years of programming. Gc-free, concurrency security, and the advanced syntax provided by scripting languages like Python made me want to contribute to it as much as I could. NVG and Xactor are just a few of the first tests OF Rust. Async-graphql is a product of Rust 1.39 asynchronous stabilization.

The process of learning Rust is hard. You need to keep a mind of cultivation. When you can get over the mountains that hinder you, you may find the true beauty of Rust.

directory

  • introduce
    • An overview of
    • Technology stack
    • The development tools
  • implementation
    • Dependent libraries
    • The core function
    • Query and type definition
    • Solve the N+1 problem
    • The interface definition
    • Custom scalar
    • Definition changes (Mutation)
    • Defining subscriptions
    • Integration testing
    • GraphQL client
    • The API security
    • Define the enumeration
    • Date processing
    • Support for Apollo Federation
    • Apollo Server
    • Database interaction
    • Run and API tests
    • Subscribe to the test
    • CI/CD
  • conclusion
  • Useful links

In today’s article, I’ll describe how to create a GraphQL backend service using Rust and its ecosystem. This article provides examples of the implementation of the most common tasks when creating the GraphQL API. Finally, the three microservices will be combined into a single endpoint using Apollo Server and Apollo Federation. This allows clients to fetch data from any number of sources at the same time without knowing which data comes from which source.

introduce

An overview of

In terms of functionality, the project described is very similar to what I described in my last article, but it is now written in Rust. The architecture of the project is as follows:

Each component of the architecture answers several questions that might arise when implementing the GraphQL API. The entire model includes data about the planets and their moons in the solar system. The project has a multi-module structure and contains the following modules:

  • planets-service (Rust)

  • satellites-service (Rust)

  • auth-service (Rust)

  • apollo-server (JS)

There are two libraries in Rust to create the GraphQL backend: Juniper and Async-GraphQL, but only the latter supports Apollo Federation, so I chose it in the project (there are unresolved issues with Federation support in Juniper). Both libraries follow a code-first approach.

Similarly, PostgreSQL is used for persistence layer implementation, JWT for authentication, and Kafka for messaging.

Technology stack

The following table summarizes the main technology stacks used in this project:

type The name Web site Code warehouse
language Rust link link
GraphQL server library Async-graphql link link
GraphQL gateway Apollo Server link link
Web framework Actix-web link link
The database PostgreSQL link link
The message queue Apache Kafka link link
The container arrangement Docker Compose link link

There are also Rust libraries to rely on:

type The name Web site Code warehouse
ORM Diesel link link
Kafka client rust-rdkafka link link
Password hash library argonautica link link
JWT jsonwebtoken link link
test Testcontainers-rs link link

The development tools

To start your project locally, you just need Docker Compose. If you don’t have Docker, you may need to install the following:

  • Rust
  • Diesel CLI(runcargo install diesel_cli --no-default-features --features postgres)
  • LLVM(argonauticaRely on)
  • CMake (rust-rdkafkaRely on)
  • PostgreSQL
  • Apache Kafka
  • npm

implementation

Listing 1. The rootCargo.tomlSpecify three applications and a library:

Root Cargo. Toml

[workspace]
members = [
    "auth-service"."planets-service"."satellites-service"."common-utils",]Copy the code

Let’s start with planets-service.

Dependent libraries

This is a Cargo. Toml:

Listing 2.Cargo.toml

[package]
name = "planets-service"
version = "0.1.0 from"
edition = "2018"

[dependencies]
common-utils = { path = ".. /common-utils" }
async-graphql = "2.4.3"
async-graphql-actix-web = "2.4.3"
actix-web = "3.3.2 rainfall distribution on 10-12"
actix-rt = 1.1.1 ""
actix-web-actors = "3.0.0"
futures = "0.3.8"
async-trait = "0.1.42"
bigdecimal = { version = "0.1.2", features = ["serde"]}serde = { version = "1.0.118", features = ["derive"]}serde_json = "1.0.60"
diesel = { version = "1.4.5", features = ["postgres"."r2d2"."numeric"]}diesel_migrations = "1.4.0"
dotenv = "0.15.0"
strum = "0.20.0"
strum_macros = "0.20.1"
rdkafka = { version = "0.24.0", features = ["cmake-build"]}async-stream = "0.3.0"
lazy_static = "1.4.0"

[dev-dependencies]
jsonpath_lib = "0.2.6"
testcontainers = "0.9.1"
Copy the code

Async-graphql is the GraphQL server library, actix-Web is the Web services framework, and async-GraphQL-Actix-Web provides integration between them.

The core function

Let’s switch to Main.rs:

Listing 3.main.rs

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    let pool = create_connection_pool();
    run_migrations(&pool);

    let schema = create_schema_with_context(pool);

    HttpServer::new(move || App::new()
        .configure(configure_service)
        .data(schema.clone())
    )
        .bind("0.0.0.0:8001")?
        .run()
        .await
}
Copy the code

Here, configure the environment and HTTP server using the functionality defined in Lib.rs:

Listing 4.lib.rs

pub fn configure_service(cfg: &mut web::ServiceConfig) {
    cfg
        .service(web::resource("/")
            .route(web::post().to(index))
            .route(web::get().guard(guard::Header("upgrade"."websocket")).to(index_ws))
            .route(web::get().to(index_playground))
        );
}

async fn index(schema: web::Data<AppSchema>, http_req: HttpRequest, req: Request) -> Response {
    let mut query = req.into_inner();

    let maybe_role = common_utils::get_role(http_req);
    if let Some(role) = maybe_role {
        query = query.data(role);
    }

    schema.execute(query).await.into()
}

async fn index_ws(schema: web::Data<AppSchema>, req: HttpRequest, payload: web::Payload) -> Result<HttpResponse> {
    WSSubscription::start(Schema::clone(&*schema), &req, payload)
}

async fn index_playground() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(playground_source(GraphQLPlaygroundConfig::new("/").subscription_endpoint("/")))}pub fn create_schema_with_context(pool: PgPool) -> Schema<Query, Mutation, Subscription> {
    let arc_pool = Arc::new(pool);
    let cloned_pool = Arc::clone(&arc_pool);
    let details_batch_loader = Loader::new(DetailsBatchLoader {
        pool: cloned_pool
    }).with_max_batch_size(10);

    let kafka_consumer_counter = Mutex::new(0);

    Schema::build(Query, Mutation, Subscription)
        .data(arc_pool)
        .data(details_batch_loader)
        .data(kafka::create_producer())
        .data(kafka_consumer_counter)
        .finish()
}
Copy the code

These functions do the following:

  • index– handle GraphQLQuery and change
  • index_ws– handle GraphQLTo subscribe to
  • index_playground– Provides Graph Playground IDE
  • create_schema_with_context– Create GraphQL schema with global context data that is accessible at run time, such as database connection pools

Query and type definition

Let’s consider how to define a query:

Listing 5.Define a query

#[Object]
impl Query {
    async fn get_planets(&self, ctx: &Context<'_- > >)Vec<Planet> {
        repository::get_all(&get_conn_from_ctx(ctx)).expect("Can't get planets")
            .iter()
            .map(|p| { Planet::from(p) })
            .collect()
    }

    async fn get_planet(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }

    #[graphql(entity)]
    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }
}

fn find_planet_by_id_internal(ctx: &Context<'_>, id: ID) -> Option<Planet> {
    let id = id.to_string().parse::<i32>().expect("Can't get id from String");
    repository::get(id, &get_conn_from_ctx(ctx)).ok()
        .map(|p| { Planet::from(&p) })
}
Copy the code

Each query uses Repository to fetch data from the database and convert the obtained records into a GraphQL DTO (this allows us to retain a single responsibility for each structure). Get_planets and get_planet queries can be accessed from any GraphQL IDE, for example:

Listing 6. Sample query

{
  getPlanets {
    name
    type
  }
}
Copy the code

Planet objects are defined as follows:

Listing 7.GraphQL type definition

#[derive(Serialize, Deserialize)]
struct Planet {
    id: ID,
    name: String,
    planet_type: PlanetType,
}

#[Object]
impl Planet {
    async fn id(&self) -> &ID {
        &self.id
    }

    async fn name(&self) - > &String{&self.name
    }

    /// From an astronomical point of view
    #[graphql(name = "type")]
    async fn planet_type(&self) -> &PlanetType {
        &self.planet_type
    }

    #[graphql(deprecation = "Now it is not in doubt. Do not use this field")]
    async fn is_rotating_around_sun(&self) - >bool {
        true
    }

    async fn details(&self, ctx: &Context<'_>) -> Details {
        let loader = ctx.data::<Loader<i32, Details, DetailsBatchLoader>>().expect("Can't get loader");
        let planet_id = self.id.to_string().parse::<i32>().expect("Can't convert id");
        loader.load(planet_id).await}}Copy the code

Here, we define a Resolver for each field. Also, in some fields, a description (Rust document comments) and a reason for deprecation are specified. These will be displayed in the GraphQL IDE.

Solve the N+1 problem

If the Planet details function is implemented to query the Planet object with the corresponding ID directly from the database, this will cause an N+1 problem if you make a request like this:

Listing 8: An example of a GraphQL request that might consume too much resources

{
  getPlanets {
    name
    details {
      meanRadius
    }
  }
}
Copy the code

This will perform a separate SQL query on the Details field of each Plant object, because Details is the type associated with planet and stored in its own table.

With the DataLoader implementation of Async-GraphQL, Resolver can be defined as follows:

async fn details(&self, ctx: &Context<'_- > >)Result<Details> {
    let data_loader = ctx.data::<DataLoader<DetailsLoader>>().expect("Can't get data loader");
    let planet_id = self.id.to_string().parse::<i32>().expect("Can't convert id");
    let details = data_loader.load_one(planet_id).await? ; details.ok_or_else(||"Not found".into())
}
Copy the code

The data_loader is an application-scoped object defined by:

Listing 10.DataLoader definition

let details_data_loader = DataLoader::new(DetailsLoader {
    pool: cloned_pool
}).max_batch_size(10)
Copy the code

DetailsLoader implementation:

Listing 11. DetailsLoader definition

pub struct DetailsLoader {
    pub pool: Arc<PgPool>
}

#[async_trait::async_trait]
impl Loader<i32> for DetailsLoader {
    type Value = Details;
    type Error = Error;

    async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, Self::Value>, Self::Error> {
        let conn = self.pool.get().expect("Can't get DB connection");
        let details = repository::get_details(keys, &conn).expect("Can't get planets' details");

        Ok(details.iter() .map(|details_entity| (details_entity.planet_id, Details::from(details_entity))) .collect::<HashMap<_, _ > > ())}}Copy the code

This approach helps us prevent the N+1 problem, as each DetailSloader.load call executes only one SQL query, returning multiple DetailsEntity.

The interface definition

The GraphQL interface and its implementation are defined as follows:

Listing 12.GraphQL interface definition

#[derive(Interface, Clone)]
#[graphql(
    field(name = "mean_radius", type = "&CustomBigDecimal"),
    field(name = "mass", type = "&CustomBigInt"), a)]
pub enum Details {
    InhabitedPlanetDetails(InhabitedPlanetDetails),
    UninhabitedPlanetDetails(UninhabitedPlanetDetails),
}

#[derive(SimpleObject, Clone)]
pub struct InhabitedPlanetDetails {
    mean_radius: CustomBigDecimal,
    mass: CustomBigInt,
    /// In billions
    population: CustomBigDecimal,
}

#[derive(SimpleObject, Clone)]
pub struct UninhabitedPlanetDetails {
    mean_radius: CustomBigDecimal,
    mass: CustomBigInt,
}
Copy the code

You can also see here that if the object doesn’t have any fields for a complex Resolver, it can be implemented using the SimpleObject macro.

Custom scalar

This project contains two examples of custom scalar definitions, both of which are wrappers for numeric types (because you cannot implement external characteristics on external types due to orphan rules). The wrapper implementation is as follows:

Listing 13.Custom scalars: Wrap BigInt

#[derive(Clone)]
pub struct CustomBigInt(BigDecimal);

#[Scalar(name = "BigInt")]
impl ScalarType for CustomBigInt {
    fn parse(value: Value) -> InputValueResult<Self> {
        match value {
            Value::String(s) => {
                letparsed_value = BigDecimal::from_str(&s)? ;Ok(CustomBigInt(parsed_value))
            }
            _ => Err(InputValueError::expected_type(value)),
        }
    }

    fn to_value(&self) -> Value {
        Value::String(format!("{:e}", &self))}}impl LowerExp for CustomBigInt {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let val = &self.0.to_f64().expect("Can't convert BigDecimal");
        LowerExp::fmt(val, f)
    }
}
Copy the code

Listing 14.Custom scalars: Wrap BigDecimal

#[derive(Clone)]
pub struct CustomBigDecimal(BigDecimal);

#[Scalar(name = "BigDecimal")]
impl ScalarType for CustomBigDecimal {
    fn parse(value: Value) -> InputValueResult<Self> {
        match value {
            Value::String(s) => {
                letparsed_value = BigDecimal::from_str(&s)? ;Ok(CustomBigDecimal(parsed_value))
            }
            _ => Err(InputValueError::expected_type(value)),
        }
    }

    fn to_value(&self) -> Value {
        Value::String(self.0.to_string())
    }
}
Copy the code

The previous example also supports using exponents to represent large numbers.

Definition changes (Mutation)

The changes are defined as follows:

Listing 15.Define the change

pub struct Mutation;

#[Object]
impl Mutation {
    #[graphql(guard(RoleGuard(role = "Role::Admin"))))
    async fn create_planet(&self, ctx: &Context<'_>, planet: PlanetInput) -> Result<Planet, Error> {
        let new_planet = NewPlanetEntity {
            name: planet.name,
            planet_type: planet.planet_type.to_string(),
        };

        let details = planet.details;
        let new_planet_details = NewDetailsEntity {
            mean_radius: details.mean_radius.0,
            mass: BigDecimal::from_str(&details.mass.0.to_string()).expect("Can't get BigDecimal from string"),
            population: details.population.map(|wrapper| { wrapper.0 }),
            planet_id: 0};letcreated_planet_entity = repository::create(new_planet, new_planet_details, &get_conn_from_ctx(ctx))? ;let producer = ctx.data::<FutureProducer>().expect("Can't get Kafka producer");
        let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");
        kafka::send_message(producer, message).await;

        Ok(Planet::from(&created_planet_entity))
    }
}
Copy the code

The following structures need to be defined for the Mutation. Create_planet input parameter:

Listing 16:Defining input types

#[derive(InputObject)]
struct PlanetInput {
    name: String.#[graphql(name = "type")]
    planet_type: PlanetType,
    details: DetailsInput,
}
Copy the code

Create_planet is protected by RoleGuard, which ensures that only users with the Admin role can access it. To perform the mutation, look like this:

Mutation {createPlanet(Planet: {name: "test_planet" type: {meanRadius: "10.5", mass: "8.8e24", population: "0.5"}}) {id}}Copy the code

You need to get the JWT from auth-Service and specify Authorization as the HTTP request header (described later).

Defining subscriptions

In the Mutation definition above, you can see that a message was sent during planet creation:

Listing 18.Send a message to Kafka

let producer = ctx.data::<FutureProducer>().expect("Can't get Kafka producer");
let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");
kafka::send_message(producer, message).await;
Copy the code

The consumer can notify the API client of events by listening to Kafka subscriptions:

Listing 19.Subscribe to define

pub struct Subscription;

#[Subscription]
impl Subscription {
    async fn latest_planet<'ctx> (&self, ctx: &'ctx Context<'_- > >)impl Stream<Item=Planet> + 'ctx {
        let kafka_consumer_counter = ctx.data::<Mutex<i32>>().expect("Can't get Kafka consumer counter");
        let consumer_group_id = kafka::get_kafka_consumer_group_id(kafka_consumer_counter);
        let consumer = kafka::create_consumer(consumer_group_id);

        async_stream::stream! {
            let mut stream = consumer.start();

            while let Some(value) = stream.next().await {
                yield match value {
                    Ok(message) => {
                        let payload = message.payload().expect("Kafka message should contain payload");
                        let message = String::from_utf8_lossy(payload).to_string();
                        serde_json::from_str(&message).expect("Can't deserialize a planet")}Err(e) => panic!("Error while Kafka message processing: {}", e) }; }}}}Copy the code

Subscriptions can be used like queries and mutations:

Listing 20. Subscription usage example

subscription {
  latestPlanet {
    id
    name
    type
    details {
      meanRadius
    }
  }
}
Copy the code

The URL to subscribe to is ws://localhost:8001.

Integration testing

Tests for queries and changes can be written like this:

Listing 21.Query test

#[actix_rt::test]
async fn test_get_planets() {
    let docker = Cli::default();
    let (_pg_container, pool) = common::setup(&docker);

    let mut service = test::init_service(App::new()
        .configure(configure_service)
        .data(create_schema_with_context(pool))
    ).await;

    let query = " { getPlanets { id name type details { meanRadius mass ... on InhabitedPlanetDetails { population } } } } ".to_string();

    let request_body = GraphQLCustomRequest {
        query,
        variables: Map::new(),
    };

    let request = test::TestRequest::post().uri("/").set_json(&request_body).to_request();

    let response: GraphQLCustomResponse = test::read_response_json(&mut service, request).await;

    fn get_planet_as_json(all_planets: &serde_json::Value, index: i32) -> &serde_json::Value {
        jsonpath::select(all_planets, &format!("$.getPlanets[{}]", index)).expect("Can't get planet by JSON path") [0]}let mercury_json = get_planet_as_json(&response.data, 0);
    common::check_planet(mercury_json, 1."Mercury"."TERRESTRIAL_PLANET"."2439.7");

    let earth_json = get_planet_as_json(&response.data, 2);
    common::check_planet(earth_json, 3."Earth"."TERRESTRIAL_PLANET"."6371.0");

    let neptune_json = get_planet_as_json(&response.data, 7);
    common::check_planet(neptune_json, 8."Neptune"."ICE_GIANT"."24622.0");
}
Copy the code

If part of a query can be reused in another query, you can use fragments:

Listing 22.Query tests (using fragments)

const PLANET_FRAGMENT: &str = " fragment planetFragment on Planet { id name type details { meanRadius mass ... on InhabitedPlanetDetails { population } } } ";

#[actix_rt::test]
async fn test_get_planet_by_id() {...let query = " { getPlanet(id: 3) { ... planetFragment } } ".to_string() + PLANET_FRAGMENT;

    letrequest_body = GraphQLCustomRequest { query, variables: Map::new(), }; . }Copy the code

To use variables, you can write tests as follows:

Listing 23.Query tests (using fragments and variables)

#[actix_rt::test]
async fn test_get_planet_by_id_with_variable() {...let query = " query testPlanetById($planetId: String!) { getPlanet(id: $planetId) { ... planetFragment } }".to_string() + PLANET_FRAGMENT;

    let jupiter_id = 5;
    let mut variables = Map::new();
    variables.insert("planetId".to_string(), jupiter_id.into());

    letrequest_body = GraphQLCustomRequest { query, variables, }; . }Copy the code

In this project, the TestContainer-RS library was used to prepare the test environment, creating a temporary PostgreSQL database.

GraphQL client

You can use the code snippet from the previous section to create a client for the external GraphQL API. In addition, there are libraries available for this purpose, such as GraphQL-Client, but I haven’t used them yet.

The API security

The GraphQL API has several security threats of varying degrees (see this list for more information), so let’s consider some of them.

Limit the depth and complexity of queries

If the Satellite object holds the Planet field, you might have the following query:

Listing 24. An example of an expensive query

{ getPlanet(id: "1") { satellites { planet { satellites { planet { satellites { ... # Deeper nesting! } } } } } } }Copy the code

To invalidate such a query, we can specify:

Listing 25.Examples that limit the depth and complexity of queries

pub fn create_schema_with_context(pool: PgPool) -> Schema<Query, Mutation, Subscription> {
    ...

    Schema::build(Query, Mutation, Subscription)
        .limit_depth(3)
        .limit_complexity(15)... }Copy the code

Note that if you specify depth or complexity limits, API documents may not display in the GraphQL IDE because the IDE attempts to perform introspective queries with considerable depth and complexity.

certification

This function is implemented in auth-service using argonautica and jsonWebToken libraries. The former library is responsible for hashing the user’s password using the Argon2 algorithm. Authentication and authorization functions are for demonstration purposes only; please do more research for production use.

Let’s look at the implementation of login:

Listing 26.To realize the login

pub struct Mutation;

#[Object]
impl Mutation {

    async fn sign_in(&self, ctx: &Context<'_>, input: SignInInput) -> Result<String, Error> {
        let maybe_user = repository::get_user(&input.username, &get_conn_from_ctx(ctx)).ok();

        if let Some(user) = maybe_user {
            if let Ok(matching) = verify_password(&user.hash, &input.password) {
                if matching {
                    let role = AuthRole::from_str(user.role.as_str()).expect("Can't convert &str to AuthRole");
                    return Ok(common_utils::create_token(user.username, role)); }}}Err(Error::new("Can't authenticate a user"))}}#[derive(InputObject)]
struct SignInInput {
    username: String,
    password: String,}Copy the code

You can see the implementation of verify_password in the utils module and the implementation of create_token in the common_utils module. As you might expect, the sign_in function issues a JWT, which can be further used for authorization in other services.

To get the JWT, you need to make the following changes:

Listing 27. Getting JWT

mutation {
  signIn(input: { username: "john_doe", password: "password" })
}
Copy the code

With john_doe/password, the JWT obtained can be used to access the protected resource on further requests (see the next section).

authentication

To request protected data, you need to add headers to HTTP requests in the Authorization: Bearer $JWT format. The index function extracts the user’s role from the request and adds it to the query data:

Listing 28.Character extraction

async fn index(schema: web::Data<AppSchema>, http_req: HttpRequest, req: Request) -> Response {
    let mut query = req.into_inner();

    let maybe_role = common_utils::get_role(http_req);
    if let Some(role) = maybe_role {
        query = query.data(role);
    }

    schema.execute(query).await.into()
}
Copy the code

The following attributes apply to the create_planet change defined earlier:

Listing 29. Using field guards

#[graphql(guard(RoleGuard(role = "Role::Admin"))))
Copy the code

The guard itself implements the following:

Listing 30. Guard implementation

struct RoleGuard {
    role: Role,
}

#[async_trait::async_trait]
impl Guard for RoleGuard {
    async fn check(&self, ctx: &Context<'_- > >)Result< > () {if ctx.data_opt::<Role>() == Some(&self.role) {
            Ok(())}else {
            Err("Forbidden".into())
        }
    }
}
Copy the code

So that if you do not specify a role, the server will return Forbidden messages.

Define the enumeration

The GraphQL enumeration can be defined as follows:

Listing 31.Define the enumeration

#[derive(SimpleObject)]
struct Satellite{... life_exists: LifeExists, }#[derive(Copy, Clone, Eq, PartialEq, Debug, Enum, EnumString)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum LifeExists {
    Yes,
    OpenQuestion,
    NoData,
}
Copy the code

Date processing

Async-graphql supports date/time types in chrono library, so you can define the following fields as usual:

Listing 32.Date field definition

#[derive(SimpleObject)]
struct Satellite{... first_spacecraft_landing_date:Option<NaiveDate>,
}
Copy the code

Support ApolloFederation

One of the goals of Immersed-Service is to demonstrate how to parse distributed GraphQL entities (Planets) in two (or more) services and then access them via Apollo Server.

The Plant type was previously defined by planets-service:

Listing 33.inplanets-serviceIn the definitionPlanettype

#[derive(Serialize, Deserialize)]
struct Planet {
    id: ID,
    name: String,
    planet_type: PlanetType,
}
Copy the code

Also, in planets- Service, the Planet type is an entity:

Listing 34. Planet entity definition

#[Object]
impl Query {
    #[graphql(entity)]
    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }
}
Copy the code

Satellit-service extends the Satellites field to Planet objects:

Listing 35.satellites-serviceIn thePlantObject extension

struct Planet {
    id: ID
}

#[Object(extends)]
impl Planet {
    #[graphql(external)]
    async fn id(&self) -> &ID {
        &self.id
    }

    async fn satellites(&self, ctx: &Context<'_- > >)Vec<Satellite> {
        let id = self.id.to_string().parse::<i32>().expect("Can't get id from String");
        repository::get_by_planet_id(id, &get_conn_from_ctx(ctx)).expect("Can't get satellites of planet")
            .iter()
            .map(|e| { Satellite::from(e) })
            .collect()
    }
}
Copy the code

You should also provide lookup functions for extension types (just creating a new instance of Planet here) :

Listing 36.PlanetObject to find

#[Object]
impl Query {

    #[graphql(entity)]
    async fn get_planet_by_id(&self, id: ID) -> Planet {
        Planet { id }
    }
}
Copy the code

Async-graphql generates two additional queries (_service and _entities) that will be used by Apollo Server. These queries are internal, meaning Apollo Server does not make them public. Of course, services with Apollo Federation support can still run independently.

ApolloServer

Apollo Server and Apollo Federation can achieve two main goals:

  • Create a single endpoint to access the GraphQL API provided by multiple services

  • Create a single GraphQL schema from a distributed service

This means that even if you don’t use federated entities, front-end developers can use a single endpoint instead of multiple endpoints, which is much easier to use.

There is another way to create a single GraphQL schema, pattern stitching, but I didn’t use this method.

This module includes the following code:

Listing 37.Meta information and dependencies

{
  "name": "api-gateway"."main": "gateway.js"."scripts": {
    "start-gateway": "nodemon gateway.js"
  },
  "devDependencies": {
    "concurrently": "5.3.0"."nodemon": "2.0.6"
  },
  "dependencies": {
    "@apollo/gateway": "0.21.3"."apollo-server": "2.19.0"."graphql": "15.4.0"}}Copy the code

Listing 38. Apollo Server definition

const {ApolloServer} = require("apollo-server");
const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");

class AuthenticatedDataSource extends RemoteGraphQLDataSource {
    willSendRequest({request, context}) {
        if (context.authHeaderValue) {
            request.http.headers.set('Authorization', context.authHeaderValue); }}}let node_env = process.env.NODE_ENV;

function get_service_url(service_name, port) {
    let host;
    switch (node_env) {
        case 'docker':
            host = service_name;
            break;
        case 'local': {
            host = 'localhost';
            break}}return "http://" + host + ":" + port;
}

const gateway = new ApolloGateway({
    serviceList: [{name: "planets-service".url: get_service_url("planets-service".8001)},
        {name: "satellites-service".url: get_service_url("satellites-service".8002)},
        {name: "auth-service".url: get_service_url("auth-service".8003)},].buildService({name, url}) {
        return newAuthenticatedDataSource({url}); }});const server = new ApolloServer({
    gateway, subscriptions: false.context: ({req}) = > ({
        authHeaderValue: req.headers.authorization
    })
});

server.listen({host: "0.0.0.0".port: 4000}).then(({url}) = > {
    console.log(` 🚀 Server ready at${url}`);
});
Copy the code

If the above code can be simplified, please feel free to contact me for changes.

Authorization in Apollo-service works just as it did in Rust services (you just specify the Authorization header and its value).

With the Federation specification, applications written in any language or framework can be added to Apollo Server as downstream services. A list of libraries that provide such support is provided in this document.

In implementing this module, I encountered some limitations:

  • Apollo Gateway does not support subscriptions (but they can still be used in a standalone Rust GraphQL application)

  • Services attempting to extend the GraphQL interface need to understand the implementation

Database interaction

The persistence layer is implemented using PostgreSQL and Diesel. If you don’t use Docker locally, you should run Diesel Setup in each service folder. This creates an empty database, and Migrations are then applied to create tables and insert data.

Run and API tests

As mentioned earlier, you have two options for starting your project locally.

  • Using Docker Compose (Docker-comemess.yml)

    There are also two options

    • Development mode (using locally generated images)

      docker-compose up

    • Production mode (using published images)

      docker-compose -f docker-compose.yml up

  • Do not use the Docker

    Start each service with Cargo Run, then start Apollo Server:

    • Enter theapollo-serverdirectory
    • defineNODE_ENVEnvironment variables, for exampleset NODE_ENV=local(Windows)
    • npm install
    • npm run start-gateway

When apollo-server runs successfully, the following information should be printed:

Listing 39. Apollo Server startup log

[nodemon] 2.0.6 [nodemon] to restart at any time, enter rs' watching path(s): *.* [Nodemon] watching Extensions: js, MJS,json [Nodemon] Starting 'node gateway.js' Server ready at http://0.0.0.0:4000/Copy the code

You can open http://localhost:4000 in your browser and use the built-in Playground IDE.

Here you can perform queries, changes, and subscriptions defined in downstream services. In addition, these services also have their own Playground IDE.

Subscribe to the test

To test that the subscription is working properly, you can open two tabs in the GraphQL IDE, with the first request as follows.

Listing 40. Subscription request

subscription {
  latestPlanet {
    name
    type
  }
}
Copy the code

The second request specifies the Authorization header described above and performs such a change.

Listing 41. Change request

mutation { createPlanet( planet: { name: "Pluto" type: DWARF_PLANET details: { meanRadius: "1188", mass: "1.303e22"}}) {id}}Copy the code

Subscribed clients are notified of the Plant creation.

CI/CD

CI/CD is configured using GitHub Actions (Workflow) to run tests of applications, build their Docker images, and deploy them on the Google Cloud Platform.

You can try deployed services here.

Note: In a production environment, to prevent changes to the initial data, the password is different from the one specified previously.

conclusion

In this article, I consider how to solve the most common problems that can arise when developing the GraphQL API in Rust. In addition, I showed how the GraphQL microservice API developed using Rust can be combined to provide a unified GraphQL interface. In such an architecture, an entity can be distributed among several microservices, which is implemented through the Apollo Server, Apollo Federation, and Async-GraphQL libraries. The source code for the project is on GitHub. If you find any errors in the article or source code, please feel free to contact me. Thanks for reading!

Useful links

  • graphql.org
  • spec.graphql.org
  • Graphql.org/learn/best-…
  • howtographql.com
  • Async-graphql
  • Async-graphql User manual
  • Awesome GraphQL
  • Public GraphQL APIs
  • Apollo Federation demo