AWS LambdaでGraphQL APIを作成しよう Rust編
Lambda関数の作成#
Cargo Lambda を使用して Lambda 関数を初期化します。以下のコマンドで新しい関数を作成します。
cargo lambda new {プロジェクト名}
今回はHTTP APIを作成するため、質問には y で回答してください。
ローカルでのテスト#
作成されたディレクトリで、次のコマンドを実行してみてください。
cargo lambda watch
こちらのコマンドを入力すると、ローカルで Lambda 関数のサーバーが起動します。Lambda の Function URL 機能に対応しているため、こちらから GraphQL API のテストをローカルで行うことができます。localhost:9000 にアクセスして正常にレスポンスが返ってくるか確認してみてください。
GraphQL API サーバーの作成#
必要なクレートを追加します。
cargo add async-graphql cargo add serde --features derive cargo add serde_json
playground の表示#
GraphQL API の作成を行う前に、テストを簡単に行うことができるように playground を表示するようにします。
以下のコードは、GET メソッドでアクセスを受けたときに playground (GraphiQL) を表示します。
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 が表示されることを確認してください。
cargo lambda watch
GraphQL API の実装#
GraphQL API は単一エンドポイント・POSTメソッドですべてのリクエストを処理します。
新たにファイルを作成します。query.rs でクエリルートを設定し、リゾルバーは resolvers/ 以下に保管します。
src/ ├── main.rs ├── query.rs ├── resolvers │ └── greet.rs └── resolvers.rs
クエリのリゾルバーを作成します。最初は定数を返すのみのリゾルバーとします。簡単なリゾルバーは SimpleObject でも実装できますが、今回は通常の
Object アトリビュートで作成します。
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() } }
リゾルバーをクエリルートに登録します。
pub mod greet;
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 メソッドで分岐している部分のブロックです。
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 で確認してみてください。
スキーマの作成時に .data() メソッドでコンテクストを追加できます。コンテクストの取得は型で行われるため、型は重要です。
let schema = Schema::build(query::QueryRoot, EmptyMutation, EmptySubscription) .data(event.headers().clone()) .finish();
クエリルートからコンテクストを渡すようにします。リゾルバーの new() 静的メソッドの引数も後で変更します。
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>>() の部分でわかるように、型を基にデータを取得しています。同じ型のデータがスキーマ作成時に渡された場合は後から書かれたものが上書きされます。
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/{クレート名}/ ディレクトリに実行可能バイナリが生成されます。
cargo lambda build --release
こちらのディレクトに内にある bootstrap ファイルがエントリーポイントとなります。こちらのファイルをデプロイしていきます。
CDK プロジェクトの初期化#
AWS CDK を使用して Lambda 関数をデプロイします。CDK プロジェクトを初期化します。
mkdir cdk cd cdk cdk init app --language typescript
Lambda スタックの作成#
まずは、Lambda 関数の機能である Function URL を使用してデプロイを行いたいと思います。以下のようなスタックを作成してください。
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 のエントリーポイントでスタックをインポートしてインスタンスを作成します。
#!/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 として出力されると思います。
cdk deploy Lambda [--profile]
Function URL はマネジメントコンソールからも確認できます。(エイリアスを使用しているためデフォルトの画面には表示されない点に注意)
こちらのURL (https://*.lambda-url.ap-northeast-1.on.aws)にアクセスして、playground が表示されれば成功です!ローカルのときと同じようにクエリを行ってみてください。
Lambda 関数の Function URL でも十分な機能はありますが、より高度な機能を利用したい場合は Amazon API Gateway を使用します。
API Gateway をデプロイするスタックを作成します。
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 のスタックを作成します。
#!/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が出力されるはずです。
cdk deploy APIGW [--profile]
コード例はこちらから確認できます。