Skip to main content

api_handler/
models.rs

1//! # データモデルモジュール
2//!
3//! このモジュールは、コンタクトフォームAPIのリクエスト・レスポンスに使用するデータ構造を定義します。
4//! Lambda 関数が受け取る API Gateway リクエストの構造と、クライアントに返すレスポンスの構造を
5//! 型安全に扱えるようにします。
6//!
7//! ## リクエスト構造
8//!
9//! API Gateway HTTP API が Lambda に渡すペイロードは次の形式です(バージョン2.0):
10//!
11//! ```json
12//! {
13//!   "requestContext": {
14//!     "http": { "method": "GET" },
15//!     "authorizer": {
16//!       "jwt": {
17//!         "claims": {
18//!           "email": "user@example.com",
19//!           "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
20//!         }
21//!       }
22//!     }
23//!   },
24//!   "body": "{\"subject\":\"...\",\"body\":\"...\"}"
25//! }
26//! ```
27//!
28//! ## レスポンス構造
29//!
30//! Lambda から API Gateway に返すレスポンスは次の形式です:
31//!
32//! ```json
33//! {
34//!   "statusCode": 200,
35//!   "headers": {
36//!     "Content-Type": "application/json",
37//!     "Access-Control-Allow-Origin": "https://example.com"
38//!   },
39//!   "body": "{\"email\":\"...\",\"count\":1,\"messages\":[...]}"
40//! }
41//! ```
42//!
43//! ## OpenAPI スキーマ
44//!
45//! `openapi` フィーチャーが有効な場合、[`utoipa::ToSchema`] が derive されます。
46//! [`crate::bin::generate-openapi`] バイナリがこれを使用して OpenAPI 定義を生成します。
47
48use sea_orm::FromQueryResult;
49use sea_orm_entities::entity::messages;
50use serde::{Deserialize, Serialize};
51use serde_json::{Value, json};
52use std::collections::HashMap;
53
54/// API Gateway HTTP API からの Lambda イベントペイロード
55///
56/// API Gateway HTTP API ペイロードフォーマットバージョン 2.0 に対応しています。
57/// `serde(rename = "requestContext")` によって JSON の `requestContext` フィールドと対応します。
58#[derive(Debug, Deserialize, Serialize)]
59pub(crate) struct Request {
60    /// リクエストコンテキスト。HTTPメソッドや認可情報を含む。
61    #[serde(rename = "requestContext")]
62    pub(crate) request_context: RequestContext,
63    #[serde(rename = "rawPath")]
64    pub(crate) raw_path: Option<String>,
65    /// リクエストボディ(JSON文字列)。POST リクエストの場合に設定される。
66    /// API Gateway は Base64エンコードなしの場合は文字列として渡す。
67    pub(crate) body: Option<String>,
68}
69
70/// API Gateway リクエストコンテキスト
71///
72/// `requestContext` フィールドに対応し、HTTPメソッドと認可情報を保持します。
73#[derive(Debug, Deserialize, Serialize)]
74pub(crate) struct RequestContext {
75    /// HTTPメソッド情報(`GET`、`POST` など)
76    pub(crate) http: Http,
77    /// JWT Authorizer による認可情報。JWT Authorizer が設定されている場合に存在する。
78    /// Lambda 関数が直接呼び出された場合や認証なしの場合は `None`。
79    pub(crate) authorizer: Option<Authorizer>,
80}
81
82/// HTTP メソッド情報
83#[derive(Debug, Deserialize, Serialize)]
84pub(crate) struct Http {
85    /// HTTPメソッド文字列(例: `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`)
86    pub(crate) method: String,
87    /// HTTPパス(例: `"/health"`, `"/messages"`, `"/message/new"`)。
88    pub(crate) path: Option<String>,
89}
90
91/// API Gateway JWT Authorizer の認可情報
92///
93/// `requestContext.authorizer` フィールドに対応します。
94/// JWT Authorizer が検証済みのトークンクレームをここに設定します。
95#[derive(Debug, Deserialize, Serialize)]
96pub(crate) struct Authorizer {
97    /// JWT トークンの情報
98    pub(crate) jwt: Jwt,
99}
100
101/// JWT トークン情報
102///
103/// JWT Authorizer が検証したトークンのクレーム情報を保持します。
104#[derive(Debug, Deserialize, Serialize)]
105pub(crate) struct Jwt {
106    /// JWT クレームセット
107    pub(crate) claims: Claims,
108}
109
110/// JWT クレームセット
111///
112/// Cognito JWT IDトークンに含まれるクレームのうち、本 API が使用するものを定義します。
113/// `sub` クレームは `cognito_sub` にリネームされます(`serde(rename = "sub")`)。
114#[derive(Debug, Deserialize, Serialize)]
115pub(crate) struct Claims {
116    /// 認証済みユーザーのメールアドレス。
117    /// Cognito ユーザープールで `email` 属性が必須の場合に存在します。
118    pub(crate) email: Option<String>,
119    /// Cognito ユーザーの一意識別子(UUID v4 形式)。
120    /// JWT 標準の `sub` クレームに対応します(`serde(rename = "sub")` により `sub` から読み取る)。
121    #[serde(rename = "sub")]
122    pub(crate) cognito_sub: Option<String>,
123}
124
125/// Lambda から API Gateway に返すHTTPレスポンス
126///
127/// API Gateway Lambda プロキシ統合のレスポンス形式に従います。
128/// `statusCode`、`headers`、`body` フィールドが必要です。
129/// `serde(rename = "statusCode")` により JSON の `statusCode` にシリアライズされます。
130#[derive(Debug, Serialize)]
131pub(crate) struct Response {
132    /// HTTP ステータスコード(例: 200, 201, 401, 500)
133    #[serde(rename = "statusCode")]
134    pub(crate) status_code: u16,
135    /// レスポンスヘッダー。常に `Content-Type: application/json` と
136    /// `Access-Control-Allow-Origin: <cors_origin>` を含む。
137    pub(crate) headers: HashMap<String, String>,
138    /// レスポンスボディ(JSON文字列)。シリアライズ済みの JSON 文字列を格納する。
139    pub(crate) body: String,
140}
141
142/// メッセージ一覧に返す詳細情報
143#[derive(Debug, Serialize, FromQueryResult)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145pub(crate) struct Message {
146    /// Cognito ユーザーの一意識別子(UUID)。
147    pub(crate) cognito_id: uuid::Uuid,
148    /// 利用者からの投稿なら true、管理者側なら false。
149    pub(crate) is_from_user: bool,
150    /// メッセージ本文。
151    pub(crate) body: String,
152    /// メッセージ作成日時。
153    pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
154}
155
156/// GET /messages のレスポンスボディ
157#[derive(Debug, Serialize)]
158#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
159pub(crate) struct MessageListResponse {
160    /// 認証済みユーザーのメールアドレス。リクエストの JWT クレームから取得。
161    pub(crate) email: String,
162    /// メッセージ件数。
163    pub(crate) count: u64,
164    /// メッセージ一覧。作成日時の降順(新しい順)で格納される。
165    pub(crate) messages: Vec<Message>,
166}
167
168/// POST /message/new のリクエストボディ
169#[derive(Debug, Deserialize)]
170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
171pub(crate) struct CreateMessageRequest {
172    /// 追加するメッセージ本文。
173    pub(crate) body: String,
174}
175
176/// 作成済みメッセージのレスポンスボディ
177#[derive(Debug, Serialize)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179pub(crate) struct CreateMessageResponse {
180    /// 新規作成されたメッセージ。
181    pub(crate) message: CreatedMessage,
182}
183
184/// 新規作成したメッセージの詳細情報
185#[derive(Debug, Serialize)]
186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
187pub(crate) struct CreatedMessage {
188    /// メッセージ ID。
189    pub(crate) id: uuid::Uuid,
190    /// Cognito ユーザー ID。
191    pub(crate) cognito_id: uuid::Uuid,
192    /// 利用者からの投稿なら true。
193    pub(crate) is_from_user: bool,
194    /// メッセージ本文。
195    pub(crate) body: String,
196    /// メッセージ作成日時。
197    pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
198}
199
200/// エラーレスポンスボディ
201///
202/// エラー発生時(4xx, 5xx)のレスポンスボディ形式を定義します。
203/// `openapi` フィーチャー有効時のみ OpenAPI スキーマとして使用されます。
204/// 実際のエラーレスポンスは [`Response::error`] メソッドで生成されます。
205///
206/// # 使用例
207///
208/// ```json
209/// {
210///   "error": "Unauthorized",
211///   "message": "Invalid or missing required JWT claims"
212/// }
213/// ```
214#[derive(Debug, Serialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216#[allow(dead_code)]
217pub(crate) struct ErrorResponseBody {
218    /// エラーの種類を示す短い識別子(例: `"Unauthorized"`, `"INTERNAL_SERVER_ERROR"`)
219    pub(crate) error: String,
220    /// エラーの詳細メッセージ。デバッグや表示に使用する。
221    pub(crate) message: String,
222}
223
224impl From<messages::Model> for CreatedMessage {
225    fn from(model: messages::Model) -> Self {
226        CreatedMessage {
227            id: model.id,
228            cognito_id: model.cognito_id,
229            is_from_user: model.is_from_user,
230            body: model.body,
231            created_at: model.created_at,
232        }
233    }
234}
235
236impl Response {
237    /// 正常レスポンスを生成する
238    ///
239    /// 指定されたステータスコード、ボディ、CORSオリジンから [`Response`] を構築します。
240    /// `Content-Type: application/json` と `Access-Control-Allow-Origin: <cors_origin>` ヘッダーを
241    /// 自動的に設定します。
242    ///
243    /// # Arguments
244    ///
245    /// * `status_code` - HTTP ステータスコード(例: 200, 201)
246    /// * `body` - レスポンスボディの値。[`serde_json::Value`] から文字列に変換されて格納される。
247    /// * `cors_origin` - `Access-Control-Allow-Origin` ヘッダーに設定するオリジン文字列
248    ///
249    /// # Returns
250    ///
251    /// 構築された [`Response`] インスタンス
252    pub(crate) fn new(status_code: u16, body: Value, cors_origin: &str) -> Self {
253        Self::new_with_content_type(
254            status_code,
255            body.to_string(),
256            "application/json",
257            cors_origin,
258        )
259    }
260
261    pub(crate) fn new_with_content_type(
262        status_code: u16,
263        body: String,
264        content_type: &str,
265        cors_origin: &str,
266    ) -> Self {
267        let mut headers = HashMap::new();
268        headers.insert("Content-Type".to_string(), content_type.to_string());
269        headers.insert(
270            "Access-Control-Allow-Origin".to_string(),
271            cors_origin.to_string(),
272        );
273
274        Response {
275            status_code,
276            headers,
277            body,
278        }
279    }
280
281    pub(crate) fn text(status_code: u16, body: impl Into<String>, cors_origin: &str) -> Self {
282        Self::new_with_content_type(
283            status_code,
284            body.into(),
285            "text/plain; charset=utf-8",
286            cors_origin,
287        )
288    }
289
290    /// エラーレスポンスを生成する
291    ///
292    /// [`Self::new`] のラッパーで、エラーレスポンス用の JSON ボディ
293    /// `{"error": "<error>", "message": "<message>"}` を自動的に構築します。
294    ///
295    /// # Arguments
296    ///
297    /// * `status_code` - HTTP エラーステータスコード(例: 401, 405, 500)
298    /// * `error` - エラーの種類を示す短い識別子(例: `"Unauthorized"`, `"INTERNAL_SERVER_ERROR"`)
299    /// * `message` - エラーの詳細メッセージ
300    /// * `cors_origin` - `Access-Control-Allow-Origin` ヘッダーに設定するオリジン文字列
301    ///
302    /// # Returns
303    ///
304    /// `{"error": "<error>", "message": "<message>"}` をボディとする [`Response`] インスタンス
305    pub(crate) fn error(status_code: u16, error: &str, message: &str, cors_origin: &str) -> Self {
306        Self::new(
307            status_code,
308            json!({
309                "error": error,
310                "message": message
311            }),
312            cors_origin,
313        )
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_response_new_status_code() {
323        let resp = Response::new(200, serde_json::json!({"ok": true}), "https://example.com");
324        assert_eq!(resp.status_code, 200);
325    }
326
327    #[test]
328    fn test_response_new_cors_header() {
329        let origin = "https://example.com";
330        let resp = Response::new(200, serde_json::json!({}), origin);
331        assert_eq!(
332            resp.headers
333                .get("Access-Control-Allow-Origin")
334                .map(String::as_str),
335            Some(origin)
336        );
337    }
338
339    #[test]
340    fn test_response_new_content_type() {
341        let resp = Response::new(200, serde_json::json!({}), "https://example.com");
342        assert_eq!(
343            resp.headers.get("Content-Type").map(String::as_str),
344            Some("application/json")
345        );
346    }
347
348    #[test]
349    fn test_response_error_status_and_body() {
350        let resp = Response::error(401, "Unauthorized", "Invalid token", "https://example.com");
351        assert_eq!(resp.status_code, 401);
352        let body: serde_json::Value = serde_json::from_str(&resp.body).unwrap();
353        assert_eq!(body["error"], "Unauthorized");
354        assert_eq!(body["message"], "Invalid token");
355    }
356
357    #[test]
358    fn test_message_serialization() {
359        let cognito_id = uuid::Uuid::now_v7();
360        let message = Message {
361            cognito_id,
362            is_from_user: true,
363            body: "Test body".to_string(),
364            created_at: None,
365        };
366        let json = serde_json::to_value(&message).unwrap();
367        assert_eq!(json["cognito_id"], cognito_id.to_string());
368        assert_eq!(json["is_from_user"], true);
369        assert_eq!(json["body"], "Test body");
370    }
371
372    #[test]
373    fn test_create_message_request_deserialization() {
374        let json = r#"{"body": "World"}"#;
375        let req: CreateMessageRequest = serde_json::from_str(json).unwrap();
376        assert_eq!(req.body, "World");
377    }
378
379    #[test]
380    fn test_claims_deserialization_with_missing_sub() {
381        let json = r#"{"email":"test@example.com"}"#;
382        let claims: Claims = serde_json::from_str(json).unwrap();
383        assert_eq!(claims.email.as_deref(), Some("test@example.com"));
384        assert_eq!(claims.cognito_sub, None);
385    }
386
387    #[test]
388    fn test_response_text_content_type() {
389        let resp = Response::text(200, "OK", "https://example.com");
390        assert_eq!(
391            resp.headers.get("Content-Type").map(String::as_str),
392            Some("text/plain; charset=utf-8")
393        );
394        assert_eq!(resp.body, "OK");
395    }
396}
397
398#[cfg(test)]
399mod prop_tests {
400    use super::*;
401    use proptest::prelude::*;
402
403    proptest! {
404        /// Response::new はどんなステータスコードとオリジンでも必ず生成できる
405        #[test]
406        fn prop_response_new_status_code_is_preserved(
407            status_code in 100u16..=599u16,
408            origin in "https?://[a-z]{1,10}\\.[a-z]{2,4}",
409        ) {
410            let resp = Response::new(status_code, serde_json::json!({}), &origin);
411            prop_assert_eq!(resp.status_code, status_code);
412        }
413
414        /// Response::new は常に Access-Control-Allow-Origin ヘッダーを設定する
415        #[test]
416        fn prop_response_new_cors_header_matches_origin(
417            origin in "https?://[a-z]{1,10}\\.[a-z]{2,4}",
418        ) {
419            let resp = Response::new(200, serde_json::json!({}), &origin);
420            prop_assert_eq!(
421                resp.headers.get("Access-Control-Allow-Origin").map(String::as_str),
422                Some(origin.as_str())
423            );
424        }
425
426        /// Response::new は常に Content-Type: application/json を設定する
427        #[test]
428        fn prop_response_new_content_type_is_always_json(
429            status_code in 100u16..=599u16,
430            origin in "https?://[a-z]{1,10}\\.[a-z]{2,4}",
431        ) {
432            let resp = Response::new(status_code, serde_json::json!({}), &origin);
433            prop_assert_eq!(
434                resp.headers.get("Content-Type").map(String::as_str),
435                Some("application/json")
436            );
437        }
438
439        /// Response::error はエラーと message フィールドを JSON body に含む
440        #[test]
441        fn prop_response_error_body_contains_error_and_message(
442            status_code in 400u16..=599u16,
443            error in "[A-Za-z_]{1,30}",
444            message in "[A-Za-z0-9 ]{1,100}",
445            origin in "https?://[a-z]{1,10}\\.[a-z]{2,4}",
446        ) {
447            let resp = Response::error(status_code, &error, &message, &origin);
448            let body: serde_json::Value = serde_json::from_str(&resp.body).unwrap();
449            prop_assert_eq!(body["error"].as_str(), Some(error.as_str()));
450            prop_assert_eq!(body["message"].as_str(), Some(message.as_str()));
451        }
452
453        /// CreateMessageRequest は任意の body 文字列を受け入れる
454        #[test]
455        fn prop_create_message_request_roundtrip(body in ".*") {
456            let json = serde_json::json!({ "body": body });
457            let req: CreateMessageRequest = serde_json::from_value(json).unwrap();
458            prop_assert_eq!(&req.body, &body);
459        }
460
461        /// Message は任意の本文を正しくシリアライズする
462        #[test]
463        fn prop_message_serialization_preserves_fields(body in "[A-Za-z0-9 ]{1,200}") {
464            let cognito_id = uuid::Uuid::now_v7();
465            let cognito_id_string = cognito_id.to_string();
466            let message = Message {
467                cognito_id,
468                is_from_user: true,
469                body: body.clone(),
470                created_at: None,
471            };
472            let json = serde_json::to_value(&message).unwrap();
473            prop_assert_eq!(json["cognito_id"].as_str(), Some(cognito_id_string.as_str()));
474            prop_assert_eq!(json["is_from_user"].as_bool(), Some(true));
475            prop_assert_eq!(json["body"].as_str(), Some(body.as_str()));
476        }
477    }
478}