LanguageEnglish

Creating a GraphQL API with AWS Lambda: Rust Edition

2024-08-10
2024-08-22

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, answer the questions with y.

Local Testing#

In the created directory, run the following command.

bash
cargo lambda watch

This command starts a local server for your Lambda function. It supports Lambda's Function URL feature, allowing you to test your GraphQL API locally. Access localhost:9000 to verify that you receive 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 a playground to make testing easier.

The following code displays the playground (faviconGraphiQL) when receiving a GET request.

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 command below, access localhost:9000 in your browser to confirm that the GraphiQL Playground appears.

rust
cargo lambda watch

Implementing the GraphQL API#

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

Create new files. Set up the query root in faviconquery.rs and store resolvers under the resolvers/ directory.

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

Create a query resolver. Initially, it will only return constants. Simple resolvers can be implemented with faviconSimpleObject, but we'll use the regular faviconObject attribute for this example.

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 handle GraphQL requests. This is the block that branches on the POST method in the code below.

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
}

Verify that queries work correctly in the playground.

  • Adding Context

Deploying the Lambda Function#

For this example, 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 serves as the entry point. We'll deploy this file.

Initializing a CDK Project#

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

bash
mkdir cdk
cd cdk
cdk init app --language typescript

Creating a Lambda Stack#

First, let's deploy using Lambda's Function URL feature. Create a stack like this:

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 in 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 in the standard output.

bash
cdk deploy Lambda [--profile]

The Function URL can also be checked from the management console. (Note that it doesn't appear on the default screen since we're using an alias)

If you access this URL (https://*.lambda-url.ap-northeast-1.on.aws) and the playground appears, you've succeeded! Try running queries just like you did locally.

  • Bonus: Deployment with API Gateway

Code examples can be found faviconhere.

Ikuma Yamashita
Cloud engineer. Works on infrastructure-related tasks professionally, but has been spotted dedicating private time exclusively to systems programming. Shows a preference for using Rust. Enjoys illustration as a hobby.