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