Skip to main content

api_handler/
main.rs

1//! # バックエンドAPIデータベース Lambda ハンドラー
2//!
3//! このLambda関数は、ユーザーのお問い合わせを管理するHTTP APIエンドポイントを提供します。
4//! データストレージにAmazon Aurora DSQLを使用し、JWT認証にAmazon Cognitoを使用します。
5//!
6//! ## 機能
7//! - Amazon CognitoによるJWTベースの認証
8//! - お問い合わせ管理のためのRESTful APIエンドポイント
9//! - SeaORMを介したAurora DSQLデータベースとの統合
10//! - Webアプリケーション向けのCORSサポート
11//!
12//! ## エンドポイント
13//! - `GET /inquiries` - 認証済みユーザーのお問い合わせ一覧を取得
14//! - `POST /inquiries` - 認証済みユーザーの新規お問い合わせを作成
15//! - `OPTIONS /inquiries` - CORSプリフライトリクエストの処理
16
17use aws_config::{BehaviorVersion, Region};
18use aws_sdk_dsql::auth_token::{AuthTokenGenerator, Config};
19use lambda_runtime::{run, service_fn, Error, LambdaEvent};
20use sea_orm::{
21    ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, QueryFilter,
22    QueryOrder, Set,
23};
24use sea_orm_entities::entity::inquiries::{self, Column, Entity as Inquiries};
25use serde::{Deserialize, Serialize};
26use serde_json::{json, Value};
27use std::collections::HashMap;
28use std::env;
29
30/// API GatewayからのLambdaリクエスト構造体
31#[derive(Debug, Deserialize)]
32struct Request {
33    /// HTTPメソッドと認証情報を含むリクエストコンテキスト
34    #[serde(rename = "requestContext")]
35    request_context: RequestContext,
36    /// リクエストボディ(POSTリクエストの場合に存在)
37    body: Option<String>,
38}
39
40/// API Gatewayからのリクエストコンテキスト
41#[derive(Debug, Deserialize)]
42struct RequestContext {
43    /// HTTP情報(メソッドなど)
44    http: Http,
45    /// 認証情報(JWTクレーム)
46    authorizer: Option<Authorizer>,
47}
48
49/// API GatewayからのHTTP情報
50#[derive(Debug, Deserialize)]
51struct Http {
52    /// HTTPメソッド(GET、POST、OPTIONSなど)
53    method: String,
54}
55
56/// API Gatewayからの認証情報
57#[derive(Debug, Deserialize)]
58struct Authorizer {
59    /// JWTトークン情報
60    jwt: Jwt,
61}
62
63/// JWTトークン構造体
64#[derive(Debug, Deserialize)]
65struct Jwt {
66    /// ユーザー情報を含むJWTクレーム
67    claims: Claims,
68}
69
70/// JWTクレーム構造体
71#[derive(Debug, Deserialize)]
72struct Claims {
73    /// ユーザーのメールアドレス
74    email: String,
75}
76
77/// API GatewayへのLambdaレスポンス構造体
78#[derive(Debug, Serialize)]
79struct Response {
80    /// HTTPステータスコード
81    #[serde(rename = "statusCode")]
82    status_code: u16,
83    /// レスポンスヘッダー(CORSヘッダーを含む)
84    headers: HashMap<String, String>,
85    /// JSON文字列としてのレスポンスボディ
86    body: String,
87}
88
89/// APIレスポンス用のお問い合わせモデル
90#[derive(Debug, Serialize)]
91struct Inquiry {
92    /// 一意の識別子(UUIDv7)
93    id: uuid::Uuid,
94    /// ユーザーのメールアドレス
95    email: String,
96    /// お問い合わせの件名
97    subject: String,
98    /// お問い合わせの本文
99    body: String,
100    /// 作成日時
101    created_at: chrono::DateTime<chrono::FixedOffset>,
102}
103
104/// GET /inquiries のレスポンスボディ
105#[derive(Debug, Serialize)]
106struct InquiryListResponse {
107    /// ユーザーのメールアドレス
108    email: String,
109    /// 返却されたお問い合わせの件数
110    count: u64,
111    /// お問い合わせ一覧
112    inquiries: Vec<Inquiry>,
113}
114
115/// POST /inquiries のリクエストボディ
116#[derive(Debug, Deserialize)]
117struct CreateInquiryRequest {
118    /// お問い合わせの件名
119    subject: String,
120    /// お問い合わせの本文
121    body: String,
122}
123
124/// POST /inquiries のレスポンスボディ
125#[derive(Debug, Serialize)]
126struct CreateInquiryResponse {
127    /// 作成されたお問い合わせ
128    inquiry: Inquiry,
129}
130
131impl From<inquiries::Model> for Inquiry {
132    fn from(model: inquiries::Model) -> Self {
133        Inquiry {
134            id: model.id,
135            email: model.email,
136            subject: model.subject,
137            body: model.body,
138            created_at: model.created_at,
139        }
140    }
141}
142
143impl Response {
144    /// 指定されたステータスコードとボディで新しいResponseを作成します
145    ///
146    /// # Arguments
147    /// * `status_code` - HTTPステータスコード
148    /// * `body` - JSON値としてのレスポンスボディ
149    /// * `cors_origin` - Access-Control-Allow-Originヘッダーに含めるCORSオリジン
150    fn new(status_code: u16, body: Value, cors_origin: &str) -> Self {
151        let mut headers = HashMap::new();
152        headers.insert("Content-Type".to_string(), "application/json".to_string());
153        headers.insert("Access-Control-Allow-Origin".to_string(), cors_origin.to_string());
154
155        Response {
156            status_code,
157            headers,
158            body: body.to_string(),
159        }
160    }
161
162    /// エラーレスポンスを作成します
163    ///
164    /// # Arguments
165    /// * `status_code` - HTTPステータスコード
166    /// * `error` - エラーコード
167    /// * `message` - 人間が読めるエラーメッセージ
168    /// * `cors_origin` - Access-Control-Allow-Originヘッダーに含めるCORSオリジン
169    fn error(status_code: u16, error: &str, message: &str, cors_origin: &str) -> Self {
170        Self::new(
171            status_code,
172            json!({
173                "error": error,
174                "message": message
175            }),
176            cors_origin,
177        )
178    }
179}
180
181/// Aurora DSQL認証トークンを生成します
182///
183/// AWS IAM認証情報を使用して、Aurora DSQLデータベースクラスターへの接続に必要な
184/// 一時的な認証トークンを生成します。
185///
186/// # Arguments
187/// * `endpoint` - DSQLクラスターのエンドポイントホスト名
188/// * `region` - クラスターが配置されているAWSリージョン
189///
190/// # Returns
191/// * `Ok(String)` - 生成された認証トークン
192/// * `Err(Error)` - トークン生成に失敗した場合
193async fn generate_token(endpoint: &str, region: &str) -> Result<String, Error> {
194    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
195    let signer = AuthTokenGenerator::new(
196        Config::builder()
197            .hostname(endpoint)
198            .region(Region::new(region.to_owned()))
199            .build()
200            .map_err(|e| anyhow::anyhow!("Failed to build DSQL config: {}", e))?,
201    );
202    let token = signer
203        .db_connect_admin_auth_token(&sdk_config)
204        .await
205        .map_err(|e| anyhow::anyhow!("Failed to generate DSQL token: {}", e))?;
206    Ok(token.to_string())
207}
208
209/// SeaORMデータベース接続を作成します
210///
211/// IAM認証を使用したAurora DSQL向けのSeaORM DatabaseConnectionを作成します。
212///
213/// # Arguments
214/// * `endpoint` - DSQLクラスターのエンドポイントホスト名
215/// * `region` - クラスターが配置されているAWSリージョン
216///
217/// # Returns
218/// * `Ok(DatabaseConnection)` - SeaORMデータベース接続
219/// * `Err(Error)` - 接続に失敗した場合
220async fn create_db(endpoint: &str, region: &str) -> Result<DatabaseConnection, Error> {
221    tracing::info!("Generating DSQL authentication token...");
222    let token = generate_token(endpoint, region).await?;
223
224    tracing::info!("Creating database connection...");
225    let encoded_token = urlencoding::encode(&token);
226    let db_url = format!(
227        "postgres://admin:{}@{}:5432/postgres?sslmode=require",
228        encoded_token, endpoint
229    );
230
231    let db = Database::connect(db_url)
232        .await
233        .map_err(|e| anyhow::anyhow!("Failed to connect to database: {}", e))?;
234
235    Ok(db)
236}
237
238/// GET /inquiries リクエストを処理します
239///
240/// 指定されたユーザーメールアドレスに対するすべてのお問い合わせをデータベースから取得します。
241/// 結果は作成日時の降順(新しい順)で並べられます。
242///
243/// # Arguments
244/// * `db` - SeaORMデータベース接続
245/// * `email` - JWTクレームからのユーザーメールアドレス
246/// * `cors_origin` - レスポンスヘッダー用のCORSオリジン
247///
248/// # Returns
249/// * `Ok(Response)` - お問い合わせ一覧を含むレスポンス(200 OK)
250/// * `Err(Error)` - データベースクエリに失敗した場合
251async fn handle_get_inquiries(
252    db: &DatabaseConnection,
253    email: &str,
254    cors_origin: &str,
255) -> Result<Response, Error> {
256    tracing::info!("Querying inquiries for email: {}", email);
257
258    let models = Inquiries::find()
259        .filter(Column::Email.eq(email))
260        .order_by_desc(Column::CreatedAt)
261        .all(db)
262        .await
263        .map_err(|e| {
264            tracing::error!("Database query failed: {}", e);
265            anyhow::anyhow!("Database query failed: {}", e)
266        })?;
267
268    let inquiries: Vec<Inquiry> = models.into_iter().map(Inquiry::from).collect();
269
270    let response_body = InquiryListResponse {
271        email: email.to_string(),
272        count: inquiries.len() as u64,
273        inquiries,
274    };
275
276    Ok(Response::new(200, serde_json::to_value(response_body)?, cors_origin))
277}
278
279/// POST /inquiries リクエストを処理します
280///
281/// 指定されたユーザーメールアドレスに対する新規お問い合わせを作成します。
282/// UUIDv7識別子を生成し、お問い合わせをデータベースに保存します。
283///
284/// # Arguments
285/// * `db` - SeaORMデータベース接続
286/// * `email` - JWTクレームからのユーザーメールアドレス
287/// * `body` - お問い合わせの件名と本文を含むリクエストボディ
288/// * `cors_origin` - レスポンスヘッダー用のCORSオリジン
289///
290/// # Returns
291/// * `Ok(Response)` - 作成されたお問い合わせを含むレスポンス(201 Created)
292/// * `Err(Error)` - ボディの解析またはデータベース挿入に失敗した場合
293async fn handle_post_inquiry(
294    db: &DatabaseConnection,
295    email: &str,
296    body: &str,
297    cors_origin: &str,
298) -> Result<Response, Error> {
299    tracing::info!("Creating inquiry for email: {}", email);
300
301    // リクエストボディを解析する
302    let create_request: CreateInquiryRequest = serde_json::from_str(body)
303        .map_err(|e| {
304            tracing::error!("Failed to parse request body: {}", e);
305            anyhow::anyhow!("Invalid request body: {}", e)
306        })?;
307
308    // 新規お問い合わせ用のUUIDv7を生成する
309    let id = uuid::Uuid::now_v7();
310    let now = chrono::Utc::now().fixed_offset();
311
312    // SeaORM ActiveModelを使用してお問い合わせをデータベースに挿入する
313    let new_inquiry = inquiries::ActiveModel {
314        id: Set(id),
315        email: Set(email.to_string()),
316        subject: Set(create_request.subject.clone()),
317        body: Set(create_request.body.clone()),
318        created_at: Set(now),
319    };
320
321    new_inquiry.insert(db).await.map_err(|e| {
322        tracing::error!("Failed to insert inquiry: {}", e);
323        anyhow::anyhow!("Failed to insert inquiry: {}", e)
324    })?;
325
326    // レスポンス用のお問い合わせを構築する
327    let inquiry = Inquiry {
328        id,
329        email: email.to_string(),
330        subject: create_request.subject,
331        body: create_request.body,
332        created_at: now,
333    };
334
335    let response_body = CreateInquiryResponse { inquiry };
336
337    Ok(Response::new(201, serde_json::to_value(response_body)?, cors_origin))
338}
339
340/// CORSプリフライト用のOPTIONSリクエストを処理します
341///
342/// Webアプリケーションからのクロスオリジンリクエストを許可するための適切なCORSヘッダーを返します。
343///
344/// # Arguments
345/// * `cors_origin` - レスポンスヘッダー用のCORSオリジン
346///
347/// # Returns
348/// CORSヘッダーを含むレスポンス(200 OK)
349fn handle_options(cors_origin: &str) -> Response {
350    tracing::info!("handle_options");
351    let mut headers = HashMap::new();
352    headers.insert("Content-Type".to_string(), "application/json".to_string());
353    headers.insert("Access-Control-Allow-Origin".to_string(), cors_origin.to_string());
354    headers.insert("Access-Control-Allow-Methods".to_string(), "GET,POST,OPTIONS".to_string());
355    headers.insert("Access-Control-Allow-Headers".to_string(), "Content-Type,Authorization".to_string());
356    headers.insert("Access-Control-Max-Age".to_string(), "3600".to_string());
357
358    Response {
359        status_code: 200,
360        headers,
361        body: "".to_string(),
362    }
363}
364
365/// メインのLambda関数ハンドラー
366///
367/// API Gatewayからの受信HTTPリクエストを処理し、JWTで認証してから
368/// HTTPメソッドに基づいて適切なハンドラーにルーティングします。
369///
370/// # Arguments
371/// * `event` - API Gatewayリクエストを含むLambdaイベント
372///
373/// # Returns
374/// * `Ok(Response)` - API Gatewayに返すHTTPレスポンス
375/// * `Err(Error)` - リクエスト処理に失敗した場合
376///
377/// # Authentication
378/// OPTIONS以外のすべてのリクエストには、Amazon CognitoからのJWT IDトークンが必要です。
379/// トークンにはユーザーを識別するために使用される`email`クレームが含まれている必要があります。
380async fn function_handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
381    let (event, _context) = event.into_parts();
382
383    // CORSオリジン(環境変数から読み込む)
384    let cors_origin = env::var("CORS_ORIGIN").unwrap_or_else(|_| "http://localhost:11029".to_string());
385
386    // 認証なしでOPTIONSリクエストを処理する
387    if event.request_context.http.method.as_str() == "OPTIONS" {
388        return Ok(handle_options(&cors_origin));
389    }
390
391    let dsql_endpoint = env::var("DSQL_ENDPOINT").map_err(|_| {
392        tracing::error!("DSQL_ENDPOINT environment variable is not set");
393        anyhow::anyhow!("DSQL_ENDPOINT environment variable is not set")
394    })?;
395
396    let dsql_region = env::var("DSQL_REGION").map_err(|_| {
397        tracing::error!("DSQL_REGION environment variable is not set");
398        anyhow::anyhow!("DSQL_REGION environment variable is not set")
399    })?;
400
401    // JWTクレームからメールアドレスを抽出する
402    let email = event
403        .request_context
404        .authorizer
405        .as_ref()
406        .and_then(|auth| {
407            let email = auth.jwt.claims.email.as_str();
408            if email.is_empty() {
409                None
410            } else {
411                Some(email)
412            }
413        });
414
415    let email = match email {
416        Some(email) => email,
417        None => {
418            return Ok(Response::error(
419                401,
420                "Unauthorized",
421                "Invalid or missing JWT token",
422                &cors_origin,
423            ));
424        }
425    };
426
427    // SeaORMデータベース接続を作成する
428    let db = create_db(&dsql_endpoint, &dsql_region).await?;
429
430    let result = match event.request_context.http.method.as_str() {
431        "GET" => handle_get_inquiries(&db, email, &cors_origin).await,
432        "POST" => {
433            let body = event.body.as_deref().unwrap_or("");
434            handle_post_inquiry(&db, email, body, &cors_origin).await
435        }
436        _ => Ok(Response::error(
437            405,
438            "Method Not Allowed",
439            "Method not allowed",
440            &cors_origin,
441        )),
442    };
443
444    // データベース接続を閉じる
445    if let Err(err) = db.close().await {
446        tracing::error!("Failed to close database connection: {:?}", err);
447    }
448
449    result.or_else(|e| {
450        tracing::error!("Error processing request: {:?}", e);
451        Ok(Response::error(
452            500,
453            "INTERNAL_SERVER_ERROR",
454            "An error occurred while processing your request",
455            &cors_origin,
456        ))
457    })
458}
459
460/// Lambda関数のエントリーポイント
461///
462/// ロギングを初期化してLambdaランタイムを起動します。
463#[tokio::main]
464async fn main() -> Result<(), Error> {
465    tracing_subscriber::fmt()
466        .with_max_level(tracing::Level::INFO)
467        .with_target(false)
468        .without_time()
469        .init();
470
471    run(service_fn(function_handler)).await
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    /// Response::newが正しいステータスコードとヘッダーでレスポンスを作成することをテストします。
479    #[test]
480    fn test_response_new_status_code() {
481        let resp = Response::new(200, serde_json::json!({"ok": true}), "https://example.com");
482        assert_eq!(resp.status_code, 200);
483    }
484
485    /// Response::newがCORSオリジンヘッダーを正しく設定することをテストします。
486    #[test]
487    fn test_response_new_cors_header() {
488        let origin = "https://example.com";
489        let resp = Response::new(200, serde_json::json!({}), origin);
490        assert_eq!(
491            resp.headers.get("Access-Control-Allow-Origin").map(String::as_str),
492            Some(origin)
493        );
494    }
495
496    /// Response::newがContent-Typeヘッダーをapplication/jsonに設定することをテストします。
497    #[test]
498    fn test_response_new_content_type() {
499        let resp = Response::new(200, serde_json::json!({}), "https://example.com");
500        assert_eq!(
501            resp.headers.get("Content-Type").map(String::as_str),
502            Some("application/json")
503        );
504    }
505
506    /// Response::errorが期待されるステータスコードとボディを生成することをテストします。
507    #[test]
508    fn test_response_error_status_and_body() {
509        let resp = Response::error(401, "Unauthorized", "Invalid token", "https://example.com");
510        assert_eq!(resp.status_code, 401);
511        let body: serde_json::Value = serde_json::from_str(&resp.body).unwrap();
512        assert_eq!(body["error"], "Unauthorized");
513        assert_eq!(body["message"], "Invalid token");
514    }
515
516    /// handle_optionsが必要なすべてのCORSヘッダーを含む200 OKを返すことをテストします。
517    #[test]
518    fn test_handle_options_status_and_headers() {
519        let origin = "https://example.com";
520        let resp = handle_options(origin);
521        assert_eq!(resp.status_code, 200);
522        assert_eq!(
523            resp.headers.get("Access-Control-Allow-Origin").map(String::as_str),
524            Some(origin)
525        );
526        assert!(resp.headers.contains_key("Access-Control-Allow-Methods"));
527        assert!(resp.headers.contains_key("Access-Control-Allow-Headers"));
528    }
529
530    /// InquiryをJSONにシリアライズできることをテストします。
531    #[test]
532    fn test_inquiry_serialization() {
533        let id = uuid::Uuid::now_v7();
534        let now = chrono::Utc::now().fixed_offset();
535        let inquiry = Inquiry {
536            id,
537            email: "test@example.com".to_string(),
538            subject: "Test subject".to_string(),
539            body: "Test body".to_string(),
540            created_at: now,
541        };
542        let json = serde_json::to_value(&inquiry).unwrap();
543        assert_eq!(json["email"], "test@example.com");
544        assert_eq!(json["subject"], "Test subject");
545        assert_eq!(json["body"], "Test body");
546    }
547
548    /// CreateInquiryRequestをJSONからデシリアライズできることをテストします。
549    #[test]
550    fn test_create_inquiry_request_deserialization() {
551        let json = r#"{"subject": "Hello", "body": "World"}"#;
552        let req: CreateInquiryRequest = serde_json::from_str(json).unwrap();
553        assert_eq!(req.subject, "Hello");
554        assert_eq!(req.body, "World");
555    }
556}