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