Home|Blog|Speaker Deck
LanguageEnglish

Create a GraphQL API on AWS Lambda with Rust

2024-08-102024-08-22
Icon of ITITIcon of AWSAWSIcon of AWS LambdaAWS LambdaIcon of GraphQLGraphQLIcon of RustRust
OGP Create a GraphQL API on AWS Lambda with Rust
OGP Create a GraphQL API on AWS Lambda with Rust

Creating a Lambda Function#

Initialize a Lambda function using Cargo Lambda. Create a new function with the following command.

bash

cargo lambda new {project_name}

Since we're creating an HTTP API this time, please answer the questions with y.

Local Testing#

In the created directory, try running the following command.

bash

cargo lambda watch

When you enter this command, a Lambda function server will start locally. Since it supports Lambda's Function URL feature, you can test the GraphQL API locally from here. Try accessing localhost:9000 to verify that you get a proper response.

Creating a GraphQL API Server#

Add the necessary crates.

bash

cargo add async-graphql
cargo add serde --features derive
cargo add serde_json

Displaying the Playground#

Before creating the GraphQL API, let's set up the playground display so we can easily test it.

The following code displays the playground (Favicon of GraphiQLGraphiQL) when accessed via GET method.

rust

use async_graphql::http::GraphiQLSource;
use lambda_http::{run, service_fn, tracing, Body, Error, Request, Response};
use serde_json::json;

async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
    if event.method() == Method::GET {
        let playground_html = GraphiQLSource::build().finish();
        let response = Response::builder()
            .status(200)
            .header("content-type", "text/html")
            .body(playground_html.into())
            .map_err(Box::new)?;
        Ok(response)
    } else if event.method() Method::POST {
        todo!("GraphQL API");
    } else {
        let response = Response::builder()
            .status(405)
            .header("content-type", "application/json")
            .body(json!({"error":"Method Not Allowed"}).to_string().into())
            .map_err(Box::new)?;
        Ok(response)
    }
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();
    run(service_fn(function_handler)).await
}

After running the following command, access localhost:9000 from your browser and verify that the GraphiQL Playground is displayed.

rust

cargo lambda watch
GraphQL (GraphiQL) Playgroud
GraphQL (GraphiQL) Playgroud

Implementing the GraphQL API#

GraphQL API handles all requests through a single endpoint using the POST method.

Create new files. Set up the query root in Favicon of query.rsquery.rs, and store resolvers under resolvers/.

rust

src/
├── main.rs
├── query.rs
├── resolvers
│   └── greet.rs
└── resolvers.rs

Create a resolver for the query. Initially, we'll make a resolver that only returns constants. While simple resolvers can be implemented with Favicon of SimpleObjectSimpleObject, this time we'll create one using the regular Favicon of ObjectObject attribute.

rust

pub struct Greet {
    pub message: String,
    pub language: String,
}

impl Greet {
    pub fn new() -> Result<Self, async_graphql::Error> {
        Ok(Greet {
            message: "Hello, GraphQL!".to_string(),
            language: "Rust".to_string(),
        })
    }
}

#[async_graphql::Object]
impl Greet {
    /// A delightful message from the server
    pub async fn message(&self) -> String {
        self.message.to_string()
    }

    /// Languages that implement GraphQL
    pub async fn language(&self) -> String {
        self.language.to_string()
    }
}

Register the resolver to the query root.

rust

pub mod greet;
rust

use async_graphql::*;

pub struct QueryRoot;

use crate::resolvers;

#[async_graphql::Object]
impl QueryRoot {
    /// Returns a greeting message along with the programming language.
    pub async fn greet(&self) -> Result<resolvers::greet::Greet, async_graphql::Error> {
        resolvers::greet::Greet::new()
    }
}

Configure the entry point to process GraphQL requests. This is the block that branches on the POST method in the following code.

rust

use async_graphql::{http::GraphiQLSource, EmptyMutation, EmptySubscription, Schema};
use lambda_http::{run, service_fn, tracing, Body, Error, Request, Response};
use serde_json::json;

mod query;
mod resolvers;

async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
    let schema = Schema::build(query::QueryRoot, EmptyMutation, EmptySubscription).finish();

    if event.method() == Method::GET {
        let playground_html = GraphiQLSource::build().finish();
        let response = Response::builder()
            .status(200)
            .header("content-type", "text/html")
            .body(playground_html.into())
            .map_err(Box::new)?;
        Ok(response)
    } else if event.method() == Method::POST {
        let request_body = event.body();

        let gql_request = match serde_json::from_slice::<async_graphql::Request>(request_body) {
            Ok(request) => request,
            Err(err) => {
                return Ok(Response::builder()
                    .status(400)
                    .header("content-type", "application/json")
                    .body(
                        json!({"error": format!("Invalid request body: {}", err)})
                            .to_string()
                            .into(),
                    )
                    .map_err(Box::new)?);
            }
        };

        let gql_response = schema.execute(gql_request).await;

        let response_body = match serde_json::to_string(&gql_response) {
            Ok(body) => body,
            Err(err) => {
                return Ok(Response::builder()
                    .status(500)
                    .header("content-type", "application/json")
                    .body(
                        json!({"error": format!("Failed to serialize response: {}", err)})
                            .to_string()
                            .into(),
                    )
                    .map_err(Box::new)?);
            }
        };

        Ok(Response::builder()
            .status(200)
            .header("content-type", "application/json")
            .body(response_body.into())
            .map_err(Box::new)?)
    } else {
        let response = Response::builder()
            .status(405)
            .header("content-type", "application/json")
            .body(json!({"error":"Method Not Allowed"}).to_string().into())
            .map_err(Box::new)?;
        Ok(response)
    }
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();
    run(service_fn(function_handler)).await
}

Try verifying in the playground whether you can query properly.

Execute a query in the Playground
Execute a query in the Playground
Adding Context

You can add context using the .data() method when creating the schema. Since context retrieval is done by type, the type is important.

rust

let schema = Schema::build(query::QueryRoot, EmptyMutation, EmptySubscription)
    .data(event.headers().clone())
    .finish();

Set it up so that context is passed from the query root. We'll also change the arguments of the resolver's new() static method later.

rust

use async_graphql::*;

pub struct QueryRoot;

use crate::resolvers;

#[async_graphql::Object]
impl QueryRoot {
    /// Returns a greeting message along with the programming language.
    pub async fn greet(
        &self,
        ctx: &async_graphql::Context<'_>,
    ) -> Result<resolvers::greet::Greet, async_graphql::FieldError> {
        resolvers::greet::Greet::new(ctx)
    }
}

Receive and use context in the resolver. As you can see in the .data::&lt;HeaderMap&lt;HeaderValue&gt;&gt;() part, data is retrieved based on type. If data of the same type is passed during schema creation, the later one will overwrite the earlier one.

rust

use lambda_http::http::{HeaderMap, HeaderValue};

pub struct Greet {
    pub message: String,
    pub language: String,
    pub content_type: String,
}

impl Greet {
    pub fn new(ctx: &async_graphql::Context) -> Result<Self, async_graphql::FieldError> {
        Ok(Greet {
            message: "Hello, GraphQL!".to_string(),
            language: "Rust".to_string(),
            content_type: ctx
                .data::<HeaderMap<HeaderValue>>()
                .unwrap()
                .get("content-type")
                .unwrap()
                .to_str()
                .unwrap_or_default()
                .to_string(),
        })
    }
}

#[async_graphql::Object]
impl Greet {
    /// A delightful message from the server
    pub async fn message(&self) -> String {
        self.message.to_string()
    }

    /// Languages that implement GraphQL
    pub async fn language(&self) -> String {
        self.language.to_string()
    }

    pub async fn content_type(&self) -> String {
        self.content_type.to_string()
    }
}

Deploying the Lambda Function#

This time, we'll deploy the Lambda function using AWS CDK.

Building the Lambda Function#

The following command generates an executable binary in the target/lambda/{crate_name}/ directory.

bash

cargo lambda build --release

The bootstrap file in this directory will be the entry point. We'll deploy this file.

Initializing the CDK Project#

We'll deploy the Lambda function using AWS CDK. Initialize a CDK project.

bash

mkdir cdk
cd cdk
cdk init app --language typescript

Creating the Lambda Stack#

First, let's deploy using Function URL, a feature of Lambda functions. Please create a stack like the following.

typescript

import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as path from 'path'

export class LambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const lambdaFunction = new lambda.Function(this, 'LambdaFunction', {
      functionName: 'rust-graphql-function-url',
      code: lambda.Code.fromAsset(
        path.resolve(__dirname, '../../target/lambda/lambda-rust-graphql/')
      ),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED_AL2023
    })

    const lambdaVersion = new lambda.Version(this, 'LambdaVersion', {
      lambda: lambdaFunction
    })

    const lambdaAlias = new lambda.Alias(this, 'LambdaAlias', {
      aliasName: 'latest',
      version: lambdaVersion.latestVersion
    })

    const lambdaFunctionURL = new lambda.FunctionUrl(this, 'FunctionURL', {
      function: lambdaAlias,
      authType: lambda.FunctionUrlAuthType.NONE
    })

    new cdk.CfnOutput(this, 'FunctionURLOutput', {
      value: lambdaFunctionURL.url
    })
  }
}

Import the stack at the CDK entry point and create an instance.

typescript

#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { LambdaStack } from '../lib/lambda'

const app = new cdk.App()
new LambdaStack(app, 'Lambda', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
})

Deploying the Lambda Stack#

Try deploying the function with the following command. The Lambda Function URL should be output as a CloudFormation Output to standard output.

bash

cdk deploy Lambda [--profile]

You can also check the Function URL from the management console. (Note that it won't be displayed on the default screen because we're using an alias)

Function URLs
Function URLs

Access this URL (https://*.lambda-url.ap-northeast-1.on.aws), and if the playground is displayed, it's a success! Try querying just like you did locally.

Execute GraphQL via Function URLs
Execute GraphQL via Function URLs
Bonus: Deploying with API Gateway

While Lambda's Function URL provides sufficient functionality, if you want to use more advanced features, you can use Amazon API Gateway.

Create a stack to deploy API Gateway.

typescript

import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2'
import * as path from 'path'
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'

export class APIGWStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const lambdaFunction = new lambda.Function(this, 'LambdaFunction', {
      functionName: 'rust-graphql-apigw',
      code: lambda.Code.fromAsset(
        path.resolve(__dirname, '../../target/lambda/lambda-rust-graphql/')
      ),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED_AL2023
    })

    const lambdaVersion = new lambda.Version(this, 'LambdaVersion', {
      lambda: lambdaFunction
    })

    const lambdaAlias = new lambda.Alias(this, 'LambdaAlias', {
      aliasName: 'latest',
      version: lambdaVersion.latestVersion
    })

    const api = new apigwv2.HttpApi(this, 'APIGW', {
      apiName: 'rust-graphql-apigw'
    })

    api.addRoutes({
      integration: new HttpLambdaIntegration(
        'APILambdaIntegration',
        lambdaAlias
      ),
      path: '/{all+}'
    })

    new cdk.CfnOutput(this, 'APIGWEndpoint', {
      value: api.apiEndpoint
    })
  }
}

Create the API Gateway stack at the entry point.

typescript

#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { LambdaStack } from '../lib/lambda'
import { APIGWStack } from '../lib/apigw'

const app = new cdk.App()

new LambdaStack(app, 'Lambda', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
})

new APIGWStack(app, 'APIGW', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
})

Perform the deployment. This time as well, the endpoint URL should be output as a CloudFormation Output.

typescript

cdk deploy APIGW [--profile]

Profile IconIkuma Yamashita

I like Rust. For work, I'm an infrastructure engineer, and as a hobby, I'm an application engineer. I enjoy drawing illustrations and other creative pursuits.