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(run
cargo install diesel_cli --no-default-features --features postgres
) - LLVM(
argonautica
Rely on) - CMake (
rust-rdkafka
Rely on) - PostgreSQL
- Apache Kafka
- npm
implementation
Listing 1. The rootCargo.toml
Specify 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 changeindex_ws
– handle GraphQLTo subscribe toindex_playground
– Provides Graph Playground IDEcreate_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-service
In the definitionPlanet
type
#[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-service
In thePlant
Object 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.Planet
Object 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 the
apollo-server
directory - define
NODE_ENV
Environment variables, for exampleset NODE_ENV=local
(Windows) npm install
npm run start-gateway
- Enter the
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