Configurando o GraphQL

Agora que temos contexto de como funciona o Actix, será muito mais simples criar um novo serviço, assim o objetivo neste capítulo será focar na parte GraphQL deste novo serviço. Antes vamos entender um pouco o que é GraphQL e porque vamos utlizar essa tecnologia.

GraphQL

GraphQL é uma tecnologia desenvolvida pelo Facebook que consiste em uma linguagem de queries para APIs e um runtime para executar estas queries. Além disso, GraphQL provê ferramentas para entender e descrever os dados das APIs, da ao cliente o poder de decidir quais dados quer consumir e facilita e evolução de APIs. De forma resumida, são quatro etapas que envolvem o GraphQL:

  1. Descrever seus dados via tipos:
type Project {
  name: String
  tagline: String
  contributors: [User]
}
  1. Receber um request com os dados a serem consumidos:
{
  project(name: "GraphQL") {
    tagline
  }
}
  1. Realizar as consultas a todos os serviços/APIs necessários
  2. Responder exatamente o que o cliente pediu.
{
    "data": {
        "project": {
            "tagline": "A query language for APIs"
        }
    }
}

Assim, o motivo de escolhermos GraphQL como tecnologia para este serviço é a necessidade de consultar diversas fontes para um mesmo request, como mais de uma API e caching.

Queries Básicas

Vamos começar com o básico, fazer o sistema responder 404 NOT_FOUND para rotas diversas e depois iniciar com uma query que responderá um simples pong quando chamarmos a query ping. Para isso, nosso primeiro passo é criar o serviço com cargo new recommendations-gql --bin, e adicionar as dependências básicas ao nosso Cargo.toml:

[dependencies]
actix-web = "2.0.0"
actix-rt = "1.0.0"
juniper = "0.14.2"
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
serde_derive = "1.0.104"

Agora precisamos adicionar o caso de status code 404. Para esse caso vamos utilizar outro recurso que não utilizamos antes que é o default_service. Ele nos permite responder um valor default para qualquer rota não encontrada, neste caso escrevemos 404:

// main.rs
use actix_web::{web, App, HttpServer};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(move || {
        App::new()
            .default_service(web::to(|| async { "404" }))
    })
    .bind("127.0.0.1:4000")?
    .run()
    .await
}

Pronto! ao acessar localhost:4000 recebemos um 404. Próximo passo é adicionarmos a query de ping.

Ping em GraphQL

Primeiro passo para o ping seria pensarmos o schema correspondente na estrutura GraphQL. Assim, nosso objetivo é realizar uma query nomeada ping que retorne uma string contento "pong". Para isso sabemos que precisaremos que uma função ping que retornar um Result<String, Error> que contém pong, algo como:

#![allow(unused)]
fn main() {
fn ping() -> Result<String, Error> {
    Ok(String::from("pong"))
}
}

Mas esta função precisa estar dentro de um contexto Query do graphql e para isso criamos uma struct chamada de QueryRoot, que corresponde a raiz das queries, e aplicamos a macro #[juniper::object] que transforma essa implementação de queries, impl QueryRoot, em um objeto graphql do tipo Query. Note que o tipo de retorno é um FieldResult, que corresponde a um tipo Result que já abstraiu o tipo do Error para um erro que o GraphQL possa entender.

#![allow(unused)]
fn main() {
use juniper::FieldResult;

pub struct QueryRoot;

#[juniper::object]
impl QueryRoot {
    fn ping() -> FieldResult<String> {
        Ok(String::from("pong"))
    }
}
}

Segundo passo seria declarar um tipo Schema que poderá ser utilizado pelos handlers do Graphql para validar as queries, as mutations e os tipos. Esse Schema é composto de duas partes, a QueryRoot e a MutationRoot, que são designadas a um nó contendo os schemas chamado RootNode, pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;. Como ainda não temos nenhuma mutation, nosso MutationRoot é bastante simples:

#![allow(unused)]
fn main() {
pub struct MutationRoot;

#[juniper::object]
impl MutationRoot {}
}

Queries e Mutations

O objetivo de uma query é "perguntar" para o sistema algum conjunto de infotmações, enquanto o objetivo da mutation é "mutar" alguma informação que o sistema possui, mas suas declarações são bastante parecidas com as das queries.

Por último precisamos de uma função que retorne o schema que criamos, que chamaremos de create_schema:

#![allow(unused)]
fn main() {
use juniper::FieldResult;
use juniper::RootNode;

pub struct QueryRoot;

#[juniper::object]
impl QueryRoot {
    fn ping() -> FieldResult<String> {
        Ok(String::from("pong"))
    }
}

pub struct MutationRoot;

#[juniper::object]
impl MutationRoot {}

pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;

pub fn create_schema() -> Schema {
    Schema::new(QueryRoot {}, MutationRoot {})
}
}

Com isso podemos criar o módulo schema em schema.rs. Próximo passo é disponibilizar este Schema para a aplicação, podemos fazer isso utilizando a função data de actix_web::App:

#[macro_use]
extern crate juniper;
extern crate serde_json;

use actix_web::{web, App, HttpServer};

mod schemas;

use crate::schemas::{create_schema, Schema};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema());

    HttpServer::new(move || {
        App::new()
            .data(schema.clone())
            .default_service(web::to(|| async { "404" }))
    })
    .bind("127.0.0.1:4000")?
    .run()
    .await
}

Estamos utilizando a definição let schema: std::sync::Arc<Schema> = para fazer um vínculo da variável schema ao contexto de main, note, também, que seu tipo precis ser std::sync::Arc, pois todas as threads do graphql estarão acessado esse Schema. O próximo passo é definirmos as rotas dos handlers para o GraphQL, fazemos isso no módulo handlers e exportamos estas infos pela função routes:

#[macro_use]
extern crate juniper;
extern crate serde_json;

use actix_web::{web, App, HttpServer};

mod handlers;
mod schemas;

use crate::handlers::routes;
use crate::schemas::{create_schema, Schema};


#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema());

    HttpServer::new(move || {
        App::new()
            .data(schema.clone())
            .configure(routes)
            .default_service(web::to(|| async { "404" }))
    })
    .bind("127.0.0.1:4000")?
    .run()
    .await
}

Assim, a função routes do módulo handlers possuirá a seguinte estrutura:

#![allow(unused)]
fn main() {
pub fn routes(config: &mut web::ServiceConfig) {
    config
        .route("/graphql", web::post().to(graphql))
        .route("/graphiql", web::get().to(graphiql));
}
}

Nossa configuração possuirá duas rotas /graphql, que é a rota na qual fazemos um post com nossa query, e /graphiql, que será uma rota que nos exibirá uma página web interativa da nossa aplicação:

página interativa do graphiql

Note que a direita na rota graphiql existe uma aba chamada de Documentation Explorer, ela nos permite saber as queries e as mutations disponíveis, assim como seus tipos de entrada e tipos de retorno.

Agora, o handler graphiqlé bastante simples, sua única função é encondar em HTML o handler graphql:

#![allow(unused)]
fn main() {
pub async fn graphiql() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(graphiql_source("/graphql"))
}
}

Com isso, podemos finalmente entender o que o handler graphql faz:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use actix_web::{web, Error, HttpResponse};
use juniper::http::graphiql::graphiql_source;
use juniper::http::GraphQLRequest;

use crate::schemas::{Schema};

pub async fn graphql(
    schema: web::Data<Arc<Schema>>,
    data: web::Json<GraphQLRequest>,
) -> Result<HttpResponse, Error> {
    let res = web::block(move || {
        let res = data.execute(&schema, &());
        Ok::<_, serde_json::error::Error>(serde_json::to_string(&res)?)
    })
    .await
    .map_err(Error::from)?;

    Ok(HttpResponse::Ok()
        .content_type("application/json")
        .body(res))
}
}

O handler graphql recebe como argumento dois campos 1. schema através do tipo actix web::Data<Arc<Schema>> e o request data do tipo web::Json<GraphQLRequest>. A magia acontece na linha data.execute(&schema, &()), na qual o GraphQL executa nosso request, data, com base no schema. Depois disso, encodamos o resultado para Json e respondemos como um Result<HttpResponse, Error> oriundo de Ok(HttpResponse::Ok().content_type("application/json").body(res)). Se você executar este código será possível fazer a query ping em localhost:4000/graphql:

QUery ping em /graphql

Testando o endpoint

Como na parte anterior do livro falamos de como criar testes de integração no diretório tests/, agora vamos partir para outra estratégia, que é criar testes de integração dentro do src/, pois isto nos permite tirar proveito da flag #[cfg(test)]. Para fazermos isso, precisamos criar um módulo test, anotoado com a flag #[cfg(test)] em main.rs:

#![allow(unused)]
fn main() {
...
mod handlers;
mod schemas;
#[cfg(test)] mod test;
...
}

Depois disso é preciso criar o arquivo src/test/mod.rs, que declarará o nome dos submodulos de teste, neste caso um simples pub mod ping;. Para testarmos o ping precisamos criar o módulo que declaramos em src/test/ping.rs:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod ping_readiness {
    use crate::handlers::routes;
    use crate::schemas::{create_schema, Schema};

    use actix_web::{test, App};
    use bytes::Bytes;

    #[actix_rt::test]
    async fn test_ping_pong() {
        let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema());

        let mut app =
            test::init_service(App::new().data(schema.clone()).configure(routes)).await;

        let req = test::TestRequest::post()
            .uri("/graphql")
            .header("Content-Type", "application/json")
            .set_payload("{\"query\": \"query ping { ping }\"}")
            .to_request();
        let resp = test::read_response(&mut app, req).await;

        assert_eq!(resp, Bytes::from_static(b"{\"data\":{\"ping\":\"pong\"}}"));
    }
}

}

A estrutura do teste é praticamente igual a estrutura que estavamos utilizando anteriormente, as únicas diferenças são let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema());, que a rota agora é /graphql e que o payload é um json contendo um campo query seguido de sua query "{\"query\": \"query ping { ping }\"}".

Agora vamos a implementação da primeira query de consulta, que chamaremos de bestPrices.