Skip to main content

api_handler/
main.rs

1//! # バックエンドAPIデータベース Lambda ハンドラー
2//!
3//! このモジュールは、AWS Lambda上で動作するコンタクトフォームAPIのエントリーポイントです。
4//! Amazon API Gateway HTTP API からのリクエストを受け取り、JWTトークンによる認証を行った後、
5//! HTTPメソッドに基づいて適切なハンドラーにルーティングします。
6//!
7//! ## アーキテクチャ概要
8//!
9//! ```text
10//! クライアント
11//!   └─▶ Amazon API Gateway HTTP API (JWT Authorizer)
12//!         └─▶ AWS Lambda (このモジュール)
13//!               └─▶ Amazon Aurora DSQL (SeaORM経由)
14//! ```
15//!
16//! ## 認証フロー
17//!
18//! 1. クライアントは Amazon Cognito からJWT IDトークンを取得する
19//! 2. JWT IDトークンを `Authorization: Bearer <token>` ヘッダーに付与してリクエストを送信する
20//! 3. API Gateway の JWT Authorizer がトークンを検証する
21//! 4. 検証済みのJWTクレーム(`email`、`sub`)が `requestContext.authorizer.jwt.claims` に格納される
22//! 5. このモジュールがクレームを読み取り、ユーザーを識別する
23//!
24//! ## サポートするエンドポイント
25//!
26//! | メソッド | パス | 説明 |
27//! |--------|------|------|
28//! | GET | /messages | 認証済みユーザーのお問い合わせ一覧を取得 |
29//! | POST | /message/new | 新規お問い合わせを作成 |
30//!
31//! ## 環境変数
32//!
33//! | 変数名 | 必須 | 説明 |
34//! |--------|------|------|
35//! | `DSQL_ENDPOINT` | ✓ | Aurora DSQLクラスターのエンドポイント |
36//! | `DSQL_REGION` | ✓ | Aurora DSQLクラスターのAWSリージョン |
37//! | `CORS_ORIGIN` | ✓ | 許可するCORSオリジン |
38use lambda_runtime::{Error, LambdaEvent, run, service_fn};
39use sea_orm::DatabaseConnection;
40use std::{env, sync::LazyLock};
41
42mod db;
43mod handlers;
44mod models;
45
46use db::create_db;
47use handlers::{handle_get_health, handle_get_messages, handle_post_message_new};
48use models::{Request, Response};
49
50static CORS_ORIGIN: LazyLock<String> = LazyLock::new(|| {
51    env::var("CORS_ORIGIN").expect("環境変数 'CORS_ORIGIN' が設定されていません。")
52});
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55enum Route {
56    GetHealth,
57    GetMessages,
58    PostMessageNew,
59    MethodNotAllowed,
60    NotFound,
61}
62
63fn route_request(event: &Request) -> Route {
64    let path = event
65        .raw_path
66        .as_deref()
67        .or(event.request_context.http.path.as_deref())
68        .unwrap_or("");
69
70    let method = event.request_context.http.method.as_str();
71
72    match path {
73        "/health" => {
74            if method == "GET" {
75                Route::GetHealth
76            } else {
77                Route::MethodNotAllowed
78            }
79        }
80        "/messages" => {
81            if method == "GET" {
82                Route::GetMessages
83            } else {
84                Route::MethodNotAllowed
85            }
86        }
87        "/message/new" => {
88            if method == "POST" {
89                Route::PostMessageNew
90            } else {
91                Route::MethodNotAllowed
92            }
93        }
94        _ => Route::NotFound,
95    }
96}
97
98/// メインのLambda関数ハンドラー
99///
100/// API Gatewayからの受信HTTPリクエストを処理し、JWTで認証してから
101/// HTTPメソッドに基づいて適切なハンドラーにルーティングします。
102///
103/// # 処理フロー
104///
105/// 1. リクエストコンテキストからJWTクレームを抽出する
106/// 2. `email` クレームと `sub` クレームの存在・妥当性を検証する
107/// 3. `DSQL_ENDPOINT` / `DSQL_REGION` 環境変数からデータベース接続を確立する
108/// 4. HTTPメソッドに応じて [`handle_get_messages`] または [`handle_post_message_new`] にディスパッチする
109/// 5. 処理完了後、データベース接続を閉じる
110/// 6. ハンドラーでエラーが発生した場合は HTTP 500 を返す
111///
112/// # Arguments
113///
114/// * `event` - API Gatewayリクエストを含むLambdaイベント。
115///   [`Request`] 構造体にデシリアライズされ、リクエストコンテキスト(JWTクレームを含む)と
116///   オプションのリクエストボディを持ちます。
117///
118/// # Returns
119///
120/// * `Ok(Response)` - API Gatewayに返すHTTPレスポンス。
121///   正常時はハンドラーが返すレスポンスをそのまま返します。
122///   内部エラー時は HTTP 500 レスポンスを返します。
123/// * `Err(Error)` - Lambda ランタイムレベルの致命的なエラー(通常は発生しない)
124///
125/// # エラーレスポンス
126///
127/// | ステータスコード | エラー | 発生条件 |
128/// |--------------|--------|---------|
129/// | 401 | Unauthorized | JWTクレームに `email` または `sub` が存在しない、もしくは `sub` がUUID形式でない |
130/// | 405 | Method Not Allowed | GET/POST以外のHTTPメソッドが使用された |
131/// | 500 | INTERNAL_SERVER_ERROR | データベース接続エラー、クエリエラーなどの内部エラー |
132///
133/// # Authentication
134///
135/// すべてのリクエストには、Amazon CognitoからのJWT IDトークンが必要です。
136/// トークンにはユーザーを識別するために使用される`email`クレームと`sub`クレームが含まれている必要があります。
137/// `sub` クレームはCognito ユーザーの一意識別子(UUID v4形式)です。
138async fn function_handler(db: &DatabaseConnection, event: LambdaEvent<Request>) -> Result<Response, Error> {
139    let (event, _context) = event.into_parts();
140    let route = route_request(&event);
141    let row_log = serde_json::to_string(&event).unwrap_or_else(|_| {
142        tracing::warn!("Failed to serialize API Gateway event for row_log");
143        "{}".to_string()
144    });
145
146    let cors_origin = &*CORS_ORIGIN;
147
148    let result = match route {
149        Route::GetHealth => handle_get_health(db, cors_origin).await,
150        Route::GetMessages | Route::PostMessageNew => {
151            let auth_info = event.request_context.authorizer.as_ref().and_then(|auth| {
152                let email = auth.jwt.claims.email.as_deref()?;
153                if email.is_empty() {
154                    return None;
155                }
156                let cognito_sub = auth.jwt.claims.cognito_sub.as_deref()?;
157                if cognito_sub.is_empty() {
158                    return None;
159                }
160                match uuid::Uuid::parse_str(cognito_sub) {
161                    Ok(cognito_sub) => Some((email, cognito_sub)),
162                    Err(err) => {
163                        tracing::warn!("Invalid cognito_sub in JWT claims: {}", err);
164                        None
165                    }
166                }
167            });
168
169            let (email, cognito_sub) = match auth_info {
170                Some(auth_info) => auth_info,
171                None => {
172                    return Ok(Response::error(
173                        401,
174                        "Unauthorized",
175                        "Invalid or missing required JWT claims (email and sub)",
176                        &cors_origin,
177                    ));
178                }
179            };
180
181            match route {
182                Route::GetMessages => {
183                    handle_get_messages(db, email, cognito_sub, &cors_origin).await
184                }
185                Route::PostMessageNew => {
186                    let body = event.body.as_deref().unwrap_or("");
187                    handle_post_message_new(db, cognito_sub, body, &row_log, &cors_origin).await
188                }
189                _ => unreachable!(),
190            }
191        }
192        Route::MethodNotAllowed => Ok(Response::error(
193            405,
194            "Method Not Allowed",
195            "Method not allowed",
196            &cors_origin,
197        )),
198        Route::NotFound => Ok(Response::error(
199            404,
200            "Not Found",
201            "The requested resource was not found",
202            &cors_origin,
203        )),
204    };
205
206    result.or_else(|e| {
207        tracing::error!("Error processing request: {:?}", e);
208        Ok(Response::error(
209            500,
210            "INTERNAL_SERVER_ERROR",
211            "An error occurred while processing your request",
212            &cors_origin,
213        ))
214    })
215}
216
217/// Lambda関数のエントリーポイント
218///
219/// ロギングを初期化してLambdaランタイムを起動します。
220///
221/// ## 初期化処理
222///
223/// 1. `tracing_subscriber` を INFO レベルで初期化します。ログにはターゲット名と時刻は含めません。
224/// 2. Lambda ランタイムを起動し、[`function_handler`] をサービス関数として登録します。
225/// 3. ランタイムはAWS Lambda環境からイベントを受け取り、[`function_handler`] を呼び出します。
226///
227/// # Returns
228///
229/// * `Ok(())` - ランタイムが正常に終了した場合(通常は発生しない)
230/// * `Err(Error)` - ランタイムの初期化または実行中に致命的なエラーが発生した場合
231#[tokio::main]
232async fn main() -> Result<(), Error> {
233    tracing_subscriber::fmt()
234        .with_max_level(tracing::Level::INFO)
235        .with_target(false)
236        .without_time()
237        .init();
238
239    let dsql_endpoint = env::var("DSQL_ENDPOINT").map_err(|_| {
240        tracing::error!("DSQL_ENDPOINT environment variable is not set");
241        anyhow::anyhow!("DSQL_ENDPOINT environment variable is not set")
242    })?;
243
244    let dsql_region = env::var("DSQL_REGION").map_err(|_| {
245        tracing::error!("DSQL_REGION environment variable is not set");
246        anyhow::anyhow!("DSQL_REGION environment variable is not set")
247    })?;
248
249    let db = create_db("lambda", &dsql_endpoint, &dsql_region).await?;
250
251    // CORS_ORIGIN を明示的に初期化
252    LazyLock::force(&CORS_ORIGIN);
253
254    run(service_fn(|event| {
255        let db = db.clone();
256        async move { function_handler(&db, event).await }
257    })).await
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::models::{Claims, Http, Jwt, RequestContext};
264
265    #[test]
266    fn test_route_request_health_is_unauthenticated() {
267        let request = Request {
268            request_context: RequestContext {
269                http: Http {
270                    method: "GET".to_string(),
271                    path: Some("/health".to_string()),
272                },
273                authorizer: None,
274            },
275            raw_path: Some("/health".to_string()),
276            body: None,
277        };
278
279        assert_eq!(route_request(&request), Route::GetHealth);
280    }
281
282    #[test]
283    fn test_route_request_messages_get() {
284        let request = Request {
285            request_context: RequestContext {
286                http: Http {
287                    method: "GET".to_string(),
288                    path: Some("/messages".to_string()),
289                },
290                authorizer: None,
291            },
292            raw_path: Some("/messages".to_string()),
293            body: None,
294        };
295
296        assert_eq!(route_request(&request), Route::GetMessages);
297    }
298
299    #[test]
300    fn test_route_request_not_found() {
301        let request = Request {
302            request_context: RequestContext {
303                http: Http {
304                    method: "GET".to_string(),
305                    path: Some("/non-existent".to_string()),
306                },
307                authorizer: None,
308            },
309            raw_path: Some("/non-existent".to_string()),
310            body: None,
311        };
312
313        assert_eq!(route_request(&request), Route::NotFound);
314    }
315
316    #[test]
317    fn test_route_request_method_not_allowed() {
318        let request = Request {
319            request_context: RequestContext {
320                http: Http {
321                    method: "POST".to_string(),
322                    path: Some("/messages".to_string()),
323                },
324                authorizer: None,
325            },
326            raw_path: Some("/messages".to_string()),
327            body: None,
328        };
329
330        assert_eq!(route_request(&request), Route::MethodNotAllowed);
331    }
332
333    #[test]
334    fn test_route_request_message_new() {
335        let request = Request {
336            request_context: RequestContext {
337                http: Http {
338                    method: "POST".to_string(),
339                    path: Some("/message/new".to_string()),
340                },
341                authorizer: Some(models::Authorizer {
342                    jwt: Jwt {
343                        claims: Claims {
344                            email: Some("test@example.com".to_string()),
345                            cognito_sub: Some(uuid::Uuid::now_v7().to_string()),
346                        },
347                    },
348                }),
349            },
350            raw_path: Some("/message/new".to_string()),
351            body: Some(r#"{"body":"hello"}"#.to_string()),
352        };
353
354        assert_eq!(route_request(&request), Route::PostMessageNew);
355    }
356}