Skip to main content

api_handler/
handlers.rs

1//! # HTTPリクエストハンドラーモジュール
2//!
3//! このモジュールは、コンタクトフォームAPIのHTTPリクエストハンドラーを提供します。
4//! [`crate::main`] のルーター (`function_handler`) から呼び出され、
5//! データベース操作を行ってJSONレスポンスを構築します。
6//!
7//! ## 提供するハンドラー
8//!
9//! | 関数 | HTTPメソッド | パス | 説明 |
10//! |------|------------|------|------|
11//! | [`handle_get_messages`] | GET | /messages | お問い合わせ一覧取得 |
12//! | [`handle_post_message_new`] | POST | /message/new | 新規お問い合わせ作成 |
13//!
14//! ## 認可モデル
15//!
16//! 全ハンドラーは認証済みユーザーのみ操作でき、JWTクレームから取得した
17//! `email` と `cognito_sub`(Cognito ユーザーの UUID)でデータをフィルタリングします。
18//! これにより、ユーザーは自分自身のお問い合わせにのみアクセス・作成できます。
19//!
20//! ## データモデル
21//!
22//! お問い合わせデータは [`sea_orm_entities::entity::messages`] エンティティで管理され、
23//! PostgreSQL テーブルに永続化されます。
24
25#[cfg(feature = "openapi")]
26use crate::models::ErrorResponseBody;
27use crate::models::{
28    CreateMessageRequest, CreateMessageResponse, CreatedMessage, Message, MessageListResponse,
29    Response,
30};
31use lambda_runtime::Error;
32use sea_orm::{
33    ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, DatabaseConnection,
34    EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, Statement,
35};
36use sea_orm_entities::entity::messages::{self, Column, Entity as Messages};
37
38#[cfg_attr(
39    feature = "openapi",
40    utoipa::path(
41        get,
42        path = "/messages",
43        tag = "messages",
44        responses(
45            (status = 200, description = "Get messages", body = MessageListResponse),
46            (status = 401, description = "Unauthorized", body = ErrorResponseBody),
47            (status = 500, description = "Internal server error", body = ErrorResponseBody),
48        ),
49        security(
50            ("CognitoAuthorizer" = []),
51            ("BearerAuth" = []),
52        )
53    )
54)]
55/// 認証済みユーザーのお問い合わせ一覧を取得する
56///
57/// JWTクレームから取得した `email` と `cognito_sub` でデータベースをフィルタリングし、
58/// 当該ユーザーが送信したお問い合わせを作成日時の降順(新しい順)で返します。
59///
60/// ## データベースクエリ
61///
62/// ```sql
63/// SELECT cognito_id, is_from_user, body, created_at
64/// FROM messages
65/// WHERE cognito_id = $1
66/// ORDER BY created_at DESC
67/// ```
68///
69/// # Arguments
70///
71/// * `db` - SeaORM データベース接続。Aurora DSQL への接続が確立済みである必要があります。
72/// * `email` - JWTクレームから取得した認証済みユーザーのメールアドレス。
73/// * `cognito_id` - JWTクレームの `sub` フィールドから取得した Cognito ユーザーの UUID。
74/// * `cors_origin` - レスポンスの `Access-Control-Allow-Origin` ヘッダーに設定するオリジン。
75///
76/// # Returns
77///
78/// * `Ok(Response)` - HTTP 200 と [`MessageListResponse`] のJSON(`email`, `count`, `messages` フィールドを含む)
79/// * `Err(Error)` - データベースクエリエラーまたはJSONシリアライズエラー
80///
81/// # Errors
82///
83/// - データベースクエリ失敗時: `"Database query failed: ..."` をログに記録し `Err` を返します。
84/// - JSONシリアライズ失敗時: [`serde_json::to_value`] のエラーを `?` で伝播します。
85pub(crate) async fn handle_get_messages(
86    db: &DatabaseConnection,
87    email: &str,
88    cognito_id: uuid::Uuid,
89    cors_origin: &str,
90) -> Result<Response, Error> {
91    tracing::info!("Querying messages for email: {}", email);
92
93    let messages: Vec<Message> = Messages::find()
94        .filter(Column::CognitoId.eq(cognito_id))
95        .select_only()
96        .column(Column::CognitoId)
97        .column(Column::IsFromUser)
98        .column(Column::Body)
99        .column(Column::CreatedAt)
100        .order_by_desc(Column::CreatedAt)
101        .into_model::<Message>()
102        .all(db)
103        .await
104        .map_err(|e| {
105            tracing::error!("Database query failed: {}", e);
106            anyhow::anyhow!("Database query failed: {}", e)
107        })?;
108
109    let response_body = MessageListResponse {
110        email: email.to_string(),
111        count: messages.len() as u64,
112        messages,
113    };
114
115    Ok(Response::new(
116        200,
117        serde_json::to_value(response_body)?,
118        cors_origin,
119    ))
120}
121
122#[cfg_attr(
123    feature = "openapi",
124    utoipa::path(
125        post,
126        path = "/message/new",
127        tag = "messages",
128        request_body = CreateMessageRequest,
129        responses(
130            (status = 201, description = "Create message", body = CreateMessageResponse),
131            (status = 401, description = "Unauthorized", body = ErrorResponseBody),
132            (status = 500, description = "Internal server error", body = ErrorResponseBody),
133        ),
134        security(
135            ("CognitoAuthorizer" = []),
136            ("BearerAuth" = []),
137        )
138    )
139)]
140/// 新規お問い合わせを作成する
141///
142/// リクエストボディから [`CreateMessageRequest`] をデシリアライズし、
143/// UUID v7 の ID と現在時刻を付与してデータベースに保存します。
144/// 保存したお問い合わせを [`CreateMessageResponse`] として HTTP 201 で返します。
145///
146/// ## データベース操作
147///
148/// ```sql
149/// INSERT INTO messages (id, cognito_id, created_at, body, row_log, is_from_user)
150/// VALUES ($1, $2, $3, $4, $5, $6)
151/// ```
152///
153/// ## ID の生成
154///
155/// お問い合わせ ID には UUID v7 ([`uuid::Uuid::now_v7`]) を使用します。
156/// UUID v7 はタイムスタンプベースのため、作成順ソートが可能です。
157///
158/// # Arguments
159///
160/// * `db` - SeaORM データベース接続。Aurora DSQL への接続が確立済みである必要があります。
161/// * `cognito_id` - JWTクレームの `sub` フィールドから取得した Cognito ユーザーの UUID。
162///   お問い合わせのオーナーとして `messages.cognito_id` 列に保存されます。
163/// * `body` - リクエストボディの文字列(JSON形式)。[`CreateMessageRequest`] にデシリアライズされます。
164///   `body`(本文)フィールドを含む必要があります。
165/// * `row_log` - API Gateway からの生のイベントログ。
166/// * `cors_origin` - レスポンスの `Access-Control-Allow-Origin` ヘッダーに設定するオリジン。
167///
168/// # Returns
169///
170/// * `Ok(Response)` - HTTP 201 と [`CreateMessageResponse`] のJSON(作成されたお問い合わせ情報を含む)
171/// * `Err(Error)` - データベース挿入エラー、またはJSONシリアライズエラー
172///
173/// # Errors
174///
175/// - リクエストボディのJSON解析失敗時: `"Failed to parse request body: ..."` をログに記録し `Err` を返します。
176/// - データベース挿入失敗時: `"Failed to insert inquiry: ..."` をログに記録し `Err` を返します。
177/// - JSONシリアライズ失敗時: [`serde_json::to_value`] のエラーを `?` で伝播します。
178pub(crate) async fn handle_post_message_new(
179    db: &DatabaseConnection,
180    cognito_id: uuid::Uuid,
181    body: &str,
182    row_log: &str,
183    cors_origin: &str,
184) -> Result<Response, Error> {
185    tracing::info!("Creating message for cognito_id: {}", cognito_id);
186
187    let create_request: CreateMessageRequest = match serde_json::from_str(body) {
188        Ok(req) => req,
189        Err(e) => {
190            tracing::error!("Failed to parse request body: {}", e);
191            return Ok(Response::error(
192                400,
193                "Bad Request",
194                &format!("Invalid request body: {}", e),
195                cors_origin,
196            ));
197        }
198    };
199
200    let id = uuid::Uuid::now_v7();
201    let now = Some(chrono::Utc::now().fixed_offset());
202
203    let new_message = messages::ActiveModel {
204        id: Set(id),
205        cognito_id: Set(cognito_id),
206        created_at: Set(now),
207        body: Set(create_request.body.clone()),
208        row_log: Set(row_log.to_string()),
209        is_from_user: Set(true),
210    };
211
212    new_message.insert(db).await.map_err(|e| {
213        tracing::error!("Failed to insert message: {}", e);
214        anyhow::anyhow!("Failed to insert message: {}", e)
215    })?;
216
217    let message = CreatedMessage {
218        id,
219        cognito_id,
220        is_from_user: true,
221        body: create_request.body,
222        created_at: now,
223    };
224
225    let response_body = CreateMessageResponse { message };
226
227    Ok(Response::new(
228        201,
229        serde_json::to_value(response_body)?,
230        cors_origin,
231    ))
232}
233
234#[cfg_attr(
235    feature = "openapi",
236    utoipa::path(
237        get,
238        path = "/health",
239        tag = "health",
240        responses(
241            (status = 200, description = "Health check OK", body = String, content_type = "text/plain"),
242            (status = 500, description = "Internal server error", body = ErrorResponseBody),
243        )
244    )
245)]
246pub(crate) async fn handle_get_health(
247    db: &DatabaseConnection,
248    cors_origin: &str,
249) -> Result<Response, Error> {
250    let result = db
251        .query_one(Statement::from_string(
252            DatabaseBackend::Postgres,
253            "SELECT 'OK' AS status",
254        ))
255        .await
256        .map_err(|e| anyhow::anyhow!("Health check query failed: {}", e))?
257        .ok_or_else(|| anyhow::anyhow!("Health check query returned no rows"))?;
258
259    let status: String = result
260        .try_get("", "status")
261        .map_err(|e| anyhow::anyhow!("Failed to read health check result: {}", e))?;
262
263    Ok(Response::text(200, status, cors_origin))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use chrono::Duration;
270    use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
271    use sea_orm_entities::entity::messages::{Column, Entity as Messages};
272
273    const ORDERING_OFFSET_SECS: i64 = 1;
274
275    async fn connect_local_test_db() -> DatabaseConnection {
276        let database_url = std::env::var("LOCAL_TEST_DATABASE_URL").unwrap_or_else(|_| {
277            "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable".to_string()
278        });
279        Database::connect(database_url)
280            .await
281            .expect("Failed to connect local PostgreSQL test DB")
282    }
283
284    async fn cleanup_test_messages(db: &DatabaseConnection, cognito_id: uuid::Uuid) {
285        Messages::delete_many()
286            .filter(Column::CognitoId.eq(cognito_id))
287            .exec(db)
288            .await
289            .expect("test message cleanup should succeed");
290    }
291
292    #[tokio::test]
293    #[ignore = "ローカルのDBが必要なためデフォルトでは実行しない"]
294    async fn test_handle_post_message_new_with_local_postgres() {
295        let db = connect_local_test_db().await;
296        let cognito_id = uuid::Uuid::now_v7();
297        let body = r#"{"body":"body from test"}"#;
298        let row_log = r#"{"rawPath":"/message/new","body":"{\"body\":\"body from test\"}"}"#;
299        cleanup_test_messages(&db, cognito_id).await;
300
301        let db_for_test = db.clone();
302        let test_result = tokio::spawn(async move {
303            let response = handle_post_message_new(
304                &db_for_test,
305                cognito_id,
306                body,
307                row_log,
308                "https://example.com",
309            )
310            .await
311            .expect("handle_post_message_new should succeed");
312
313            assert_eq!(response.status_code, 201);
314            let response_body: serde_json::Value =
315                serde_json::from_str(&response.body).expect("response body should be valid JSON");
316            assert_eq!(
317                response_body["message"]["cognito_id"],
318                cognito_id.to_string()
319            );
320            assert_eq!(response_body["message"]["is_from_user"], true);
321            assert_eq!(response_body["message"]["body"], "body from test");
322
323            let inquiry_id = uuid::Uuid::parse_str(
324                response_body["message"]["id"]
325                    .as_str()
326                    .expect("response should include message id"),
327            )
328            .expect("message id should be valid UUID");
329            let saved = Messages::find_by_id(inquiry_id)
330                .one(&db_for_test)
331                .await
332                .expect("DB query should succeed")
333                .expect("inserted message should exist");
334            assert_eq!(saved.cognito_id, cognito_id);
335            assert_eq!(saved.row_log, row_log);
336        })
337        .await;
338
339        cleanup_test_messages(&db, cognito_id).await;
340
341        if let Err(err) = test_result {
342            if err.is_panic() {
343                std::panic::resume_unwind(err.into_panic());
344            }
345            panic!("test task failed: {err}");
346        }
347    }
348
349    #[tokio::test]
350    #[ignore = "ローカルのDBが必要なためデフォルトでは実行しない"]
351    async fn test_handle_get_messages_with_local_postgres() {
352        let db = connect_local_test_db().await;
353        let cognito_id = uuid::Uuid::now_v7();
354        let email = format!("local-get-{}@example.com", uuid::Uuid::now_v7());
355        let now = chrono::Utc::now().fixed_offset();
356        cleanup_test_messages(&db, cognito_id).await;
357
358        messages::ActiveModel {
359            id: Set(uuid::Uuid::now_v7()),
360            cognito_id: Set(cognito_id),
361            created_at: Set(Some(now - Duration::seconds(ORDERING_OFFSET_SECS))),
362            body: Set("older body".to_string()),
363            row_log: Set("older row log".to_string()),
364            is_from_user: Set(true),
365        }
366        .insert(&db)
367        .await
368        .expect("older test message insert should succeed");
369
370        messages::ActiveModel {
371            id: Set(uuid::Uuid::now_v7()),
372            cognito_id: Set(cognito_id),
373            created_at: Set(Some(now)),
374            body: Set("newer body".to_string()),
375            row_log: Set("newer row log".to_string()),
376            is_from_user: Set(false),
377        }
378        .insert(&db)
379        .await
380        .expect("newer test message insert should succeed");
381
382        let response = handle_get_messages(&db, &email, cognito_id, "https://example.com")
383            .await
384            .expect("handle_get_messages should succeed");
385
386        assert_eq!(response.status_code, 200);
387        let response_body: serde_json::Value =
388            serde_json::from_str(&response.body).expect("response body should be valid JSON");
389        assert_eq!(response_body["email"], email);
390        assert_eq!(response_body["count"], 2);
391        let messages = response_body["messages"]
392            .as_array()
393            .expect("messages should be an array");
394        assert_eq!(messages.len(), 2);
395        assert_eq!(messages[0]["body"], "newer body");
396        assert_eq!(messages[0]["is_from_user"], false);
397        assert!(messages[0].get("row_log").is_none());
398        assert!(messages[0].get("id").is_none());
399        assert_eq!(messages[1]["body"], "older body");
400        cleanup_test_messages(&db, cognito_id).await;
401    }
402}