Logo
Home|Blog|Speaker Deck
LanguageEnglish

AWS LambdaでGraphQL APIを作成しよう Rust編

2024-08-102024-08-22
Icon of ITITIcon of AWSAWSIcon of AWS LambdaAWS LambdaIcon of GraphQLGraphQLIcon of RustRust
OGP AWS LambdaでGraphQL APIを作成しよう Rust編

Lambda関数の作成#

Cargo Lambda を使用して Lambda 関数を初期化します。以下のコマンドで新しい関数を作成します。

bash

cargo lambda new {プロジェクト名}

今回はHTTP APIを作成するため、質問には y で回答してください。

ローカルでのテスト#

作成されたディレクトリで、次のコマンドを実行してみてください。

bash

cargo lambda watch

こちらのコマンドを入力すると、ローカルで Lambda 関数のサーバーが起動します。Lambda の Function URL 機能に対応しているため、こちらから GraphQL API のテストをローカルで行うことができます。localhost:9000 にアクセスして正常にレスポンスが返ってくるか確認してみてください。

GraphQL API サーバーの作成#

必要なクレートを追加します。

bash

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

playground の表示#

GraphQL API の作成を行う前に、テストを簡単に行うことができるように playground を表示するようにします。

以下のコードは、GET メソッドでアクセスを受けたときに playground (faviconGraphiQL) を表示します。

rustsrc/main.rs

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
}

以下のコマンドを実行後、ブラウザから localhost:9000 にアクセスして、GraphiQL Playground が表示されることを確認してください。

rust

cargo lambda watch
GraphQL (GraphiQL) Playgroud

GraphQL API の実装#

GraphQL API は単一エンドポイント・POSTメソッドですべてのリクエストを処理します。

新たにファイルを作成します。query.rs でクエリルートを設定し、リゾルバーは resolvers/ 以下に保管します。

rust

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

クエリのリゾルバーを作成します。最初は定数を返すのみのリゾルバーとします。簡単なリゾルバーは faviconSimpleObject でも実装できますが、今回は通常の faviconObject アトリビュートで作成します。

rustsrc/resolvers/greet.rs

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()
    }
}

リゾルバーをクエリルートに登録します。

rustsrc/resolvers.rs

pub mod greet;
rustsrc/query.rs

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()
    }
}

エントリポイントで GraphQL のリクエストを処理するように設定します。以下のコードの POST メソッドで分岐している部分のブロックです。

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
}

正常にクエリができるか playground で確認してみてください。

Playgroud でクエリを実行する

コンテクストの追加

スキーマの作成時に .data() メソッドでコンテクストを追加できます。コンテクストの取得は型で行われるため、型は重要です。

rustsrc/main.rs

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

クエリルートからコンテクストを渡すようにします。リゾルバーの new() 静的メソッドの引数も後で変更します。

rustsrc/query.rs

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)
    }
}

リゾルバーでコンテクストを受け取り、使用します。.data::<HeaderMap<HeaderValue>>() の部分でわかるように、型を基にデータを取得しています。同じ型のデータがスキーマ作成時に渡された場合は後から書かれたものが上書きされます。

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()
    }
}

Lambda 関数のデプロイ#

今回は Lambda 関数を AWS CDK を使用してデプロイしたいと思います。

Lambda 関数のビルド#

以下のコマンドで、target/lambda/{クレート名}/ ディレクトリに実行可能バイナリが生成されます。

bash

cargo lambda build --release

こちらのディレクトに内にある bootstrap ファイルがエントリーポイントとなります。こちらのファイルをデプロイしていきます。

CDK プロジェクトの初期化#

AWS CDK を使用して Lambda 関数をデプロイします。CDK プロジェクトを初期化します。

bash

mkdir cdk
cd cdk
cdk init app --language typescript

Lambda スタックの作成#

まずは、Lambda 関数の機能である Function URL を使用してデプロイを行いたいと思います。以下のようなスタックを作成してください。

typescriptcdk/lib/lambda.ts

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
    })
  }
}

CDK のエントリーポイントでスタックをインポートしてインスタンスを作成します。

typescriptcdk/bin/cdk.ts

#!/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
  }
})

Lambda スタックのデプロイ#

以下のコマンドで関数をデプロイしてみてください。標準出力から Lambda の Function URL が CloudFormation Output として出力されると思います。

bash

cdk deploy Lambda [--profile]

Function URL はマネジメントコンソールからも確認できます。(エイリアスを使用しているためデフォルトの画面には表示されない点に注意)

Function URLs

こちらのURL (https://*.lambda-url.ap-northeast-1.on.aws)にアクセスして、playground が表示されれば成功です!ローカルのときと同じようにクエリを行ってみてください。

Function URLs 経由で GraphQL を実行する

おまけ: API Gateway でのデプロイ

Lambda 関数の Function URL でも十分な機能はありますが、より高度な機能を利用したい場合は Amazon API Gateway を使用します。

API Gateway をデプロイするスタックを作成します。

typescriptcdk/lib/apigw.ts

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
    })
  }
}

エントリーポイントで API Gateway のスタックを作成します。

typescriptcdk/bin/cdk.ts

#!/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
  }
})

デプロイを行います。今回も CloudFormation Output でエンドポイントのURLが出力されるはずです。

typescript

cdk deploy APIGW [--profile]

コード例はfaviconこちらから確認できます。

Profile IconIkuma Yamashita

Rust が好きです。仕事ではインフラエンジニア、趣味ではアプリケーションエンジニアです。イラストなどを嗜む。