Compare commits
47 Commits
master
...
e31d99a240
| Author | SHA1 | Date | |
|---|---|---|---|
| e31d99a240 | |||
| 41fec1efa5 | |||
| 160c08d1c4 | |||
| 7aa6d7bf4d | |||
| 144f2f8e0c | |||
| cff64f7a6b | |||
| 5baff54cb9 | |||
| f94d2db8b1 | |||
| 48875a6e86 | |||
| 9387ae705b | |||
| 9871e21bc0 | |||
| fa8efbaa23 | |||
| d769a5b55c | |||
| 8e1fb1a974 | |||
| 6145b873f5 | |||
| cc668ae44d | |||
| e5097c22dd | |||
| 450468ef3d | |||
| 6e7c6467a7 | |||
| 7f815f8207 | |||
| 5df89200d4 | |||
| eb273dc277 | |||
| 5689db0ad7 | |||
| 5c70b8b8be | |||
| 4c547df04e | |||
| 602df8df22 | |||
| 5b69a3a7c0 | |||
| a38f78d261 | |||
| 17f90726e8 | |||
| 563f33212e | |||
| 8e5ac9f433 | |||
| f790fa2a0f | |||
| edcf3c1170 | |||
| 1985d2c57f | |||
| f0b3d8ad90 | |||
| da72ab1446 | |||
| 93c65cd155 | |||
| ba42d3d445 | |||
| 819332522a | |||
| 79a06e6844 | |||
| 97a496553a | |||
| 5a58625265 | |||
| 6d9ac07dfc | |||
| b6a7cf9417 | |||
| c4b39c9410 | |||
| f60cc368b6 | |||
| 65bab7fd44 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
11
.env.example
11
.env.example
@@ -0,0 +1,11 @@
|
||||
DATABASE_URL=sqlite:./dev.db
|
||||
PORT=3000
|
||||
JWT_SECRET=
|
||||
JWT_TTL_SECONDS=
|
||||
ALLOW_REGISTRATION=true
|
||||
OMDB_API_KEY=
|
||||
POSTER_FETCH_TIMEOUT_SECONDS=30
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY_ID=
|
||||
MINIO_SECRET_ACCESS_KEY=
|
||||
MINIO_BUCKET=
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,3 +6,9 @@
|
||||
|
||||
.env
|
||||
.env.prod
|
||||
|
||||
*.db
|
||||
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
92
.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json
generated
Normal file
92
.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json
generated
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
|
||||
}
|
||||
92
.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json
generated
Normal file
92
.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json
generated
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
|
||||
}
|
||||
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM reviews",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
|
||||
}
|
||||
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
|
||||
}
|
||||
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
|
||||
}
|
||||
32
.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json
generated
Normal file
32
.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399"
|
||||
}
|
||||
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403"
|
||||
}
|
||||
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1"
|
||||
}
|
||||
92
.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json
generated
Normal file
92
.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json
generated
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
|
||||
}
|
||||
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f"
|
||||
}
|
||||
12
.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json
generated
Normal file
12
.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5"
|
||||
}
|
||||
56
.sqlx/query-70ee6050284475b5641af712e5923ba2091b8b70b1885ca6518dfa4bb01fdac2.json
generated
Normal file
56
.sqlx/query-70ee6050284475b5641af712e5923ba2091b8b70b1885ca6518dfa4bb01fdac2.json
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "70ee6050284475b5641af712e5923ba2091b8b70b1885ca6518dfa4bb01fdac2"
|
||||
}
|
||||
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011"
|
||||
}
|
||||
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa"
|
||||
}
|
||||
56
.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json
generated
Normal file
56
.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
|
||||
}
|
||||
92
.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json
generated
Normal file
92
.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json
generated
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
|
||||
}
|
||||
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
|
||||
}
|
||||
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM reviews WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
|
||||
}
|
||||
1644
Cargo.lock
generated
1644
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -1,9 +1,10 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/adapters/auth",
|
||||
"crates/adapters/metadata",
|
||||
"crates/adapters/auth", "crates/adapters/event-publisher",
|
||||
"crates/adapters/metadata", "crates/adapters/poster-fetcher", "crates/adapters/poster-storage",
|
||||
"crates/adapters/rss",
|
||||
"crates/adapters/sqlite",
|
||||
"crates/adapters/template-askama",
|
||||
"crates/application",
|
||||
"crates/common",
|
||||
"crates/domain",
|
||||
@@ -13,6 +14,7 @@ resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
dotenvy = "0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0"
|
||||
@@ -22,6 +24,14 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
async-trait = "0.1"
|
||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
"uuid",
|
||||
"macros",
|
||||
] }
|
||||
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||
object_store = { version = "0.11", features = ["aws"] }
|
||||
|
||||
domain = { path = "crates/domain" }
|
||||
common = { path = "crates/common" }
|
||||
@@ -29,5 +39,9 @@ application = { path = "crates/application" }
|
||||
presentation = { path = "crates/presentation" }
|
||||
auth = { path = "crates/adapters/auth" }
|
||||
metadata = { path = "crates/adapters/metadata" }
|
||||
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
||||
poster-storage = { path = "crates/adapters/poster-storage" }
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
rss = { path = "crates/adapters/rss" }
|
||||
sqlite = { path = "crates/adapters/sqlite" }
|
||||
template-askama = { path = "crates/adapters/template-askama" }
|
||||
|
||||
@@ -4,3 +4,12 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
@@ -1,14 +1,104 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
use async_trait::async_trait;
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
|
||||
pub struct AuthConfig {
|
||||
secret: String,
|
||||
ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
impl AuthConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
|
||||
if secret.is_empty() {
|
||||
anyhow::bail!("JWT_SECRET must not be empty");
|
||||
}
|
||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(86400u64);
|
||||
Ok(Self { secret, ttl_seconds })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub struct JwtAuthService {
|
||||
config: AuthConfig,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for JwtAuthService {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64);
|
||||
let claims = Claims {
|
||||
sub: user_id.value().to_string(),
|
||||
exp: expires_at.timestamp() as u64,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(GeneratedToken { token, expires_at })
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?;
|
||||
let uuid = Uuid::parse_str(&data.claims.sub)
|
||||
.map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?;
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argon2PasswordHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for Argon2PasswordHasher {
|
||||
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain_password.as_bytes(), &salt)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.to_string();
|
||||
PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain_password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
10
crates/adapters/event-publisher/Cargo.toml
Normal file
10
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "event-publisher"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
209
crates/adapters/event-publisher/src/lib.rs
Normal file
209
crates/adapters/event-publisher/src/lib.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct EventPublisherConfig {
|
||||
pub channel_buffer: usize,
|
||||
}
|
||||
|
||||
impl EventPublisherConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(128);
|
||||
Self { channel_buffer }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventHandler: Send + Sync {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub struct ChannelEventPublisher {
|
||||
sender: mpsc::Sender<DomainEvent>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for ChannelEventPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
self.sender
|
||||
.send(event.clone())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventWorker {
|
||||
receiver: mpsc::Receiver<DomainEvent>,
|
||||
handlers: Vec<Box<dyn EventHandler>>,
|
||||
}
|
||||
|
||||
impl EventWorker {
|
||||
pub async fn run(mut self) {
|
||||
while let Some(event) = self.receiver.recv().await {
|
||||
match &event {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => {
|
||||
tracing::info!(
|
||||
review_id = %review_id.value(),
|
||||
movie_id = %movie_id.value(),
|
||||
user_id = %user_id.value(),
|
||||
rating = rating.value(),
|
||||
watched_at = %watched_at,
|
||||
"event: review_logged"
|
||||
);
|
||||
}
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
movie_id = %movie_id.value(),
|
||||
external_id = external_metadata_id.value(),
|
||||
"event: movie_discovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
for handler in &self.handlers {
|
||||
if let Err(e) = handler.handle(&event).await {
|
||||
tracing::error!("event handler error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("event worker shut down");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopEventPublisher;
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoopEventPublisher {
|
||||
async fn publish(&self, _event: &DomainEvent) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_event_channel(
|
||||
config: EventPublisherConfig,
|
||||
handlers: Vec<Box<dyn EventHandler>>,
|
||||
) -> (ChannelEventPublisher, EventWorker) {
|
||||
let (tx, rx) = mpsc::channel(config.channel_buffer);
|
||||
(
|
||||
ChannelEventPublisher { sender: tx },
|
||||
EventWorker {
|
||||
receiver: rx,
|
||||
handlers,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
};
|
||||
|
||||
struct RecordingHandler {
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for RecordingHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let label = match event {
|
||||
DomainEvent::MovieDiscovered { .. } => "movie_discovered",
|
||||
DomainEvent::ReviewLogged { .. } => "review_logged",
|
||||
};
|
||||
self.calls.lock().unwrap().push(label.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_handler_receives_event() {
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
let handler = RecordingHandler { calls: Arc::clone(&calls) };
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
|
||||
|
||||
let handle = tokio::spawn(worker.run());
|
||||
|
||||
let event = DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::generate(),
|
||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||
};
|
||||
publisher.publish(&event).await.unwrap();
|
||||
drop(publisher);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_handlers_all_receive_event() {
|
||||
let calls1 = Arc::new(Mutex::new(vec![]));
|
||||
let calls2 = Arc::new(Mutex::new(vec![]));
|
||||
let handler1 = RecordingHandler { calls: Arc::clone(&calls1) };
|
||||
let handler2 = RecordingHandler { calls: Arc::clone(&calls2) };
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, worker) = create_event_channel(
|
||||
config,
|
||||
vec![Box::new(handler1), Box::new(handler2)],
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(worker.run());
|
||||
|
||||
let event = DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::generate(),
|
||||
external_metadata_id: ExternalMetadataId::new("tt9999999".into()).unwrap(),
|
||||
};
|
||||
publisher.publish(&event).await.unwrap();
|
||||
drop(publisher);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(calls1.lock().unwrap().len(), 1);
|
||||
assert_eq!(calls2.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_error_does_not_stop_worker() {
|
||||
struct FailingHandler;
|
||||
#[async_trait]
|
||||
impl EventHandler for FailingHandler {
|
||||
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
let good = RecordingHandler { calls: Arc::clone(&calls) };
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, worker) = create_event_channel(
|
||||
config,
|
||||
vec![Box::new(FailingHandler), Box::new(good)],
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(worker.run());
|
||||
|
||||
let event = DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::generate(),
|
||||
external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(),
|
||||
};
|
||||
publisher.publish(&event).await.unwrap();
|
||||
drop(publisher);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
ports::{MetadataClient, MetadataSearchCriteria},
|
||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
|
||||
mod omdb;
|
||||
|
||||
pub(crate) struct ProviderMovie {
|
||||
pub imdb_id: ExternalMetadataId,
|
||||
pub title: MovieTitle,
|
||||
pub release_year: ReleaseYear,
|
||||
pub director: Option<String>,
|
||||
pub poster_url: Option<PosterUrl>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[async_trait]
|
||||
pub(crate) trait MetadataProvider: Send + Sync {
|
||||
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
pub struct MetadataClientImpl {
|
||||
provider: Box<dyn MetadataProvider>,
|
||||
}
|
||||
|
||||
impl MetadataClientImpl {
|
||||
pub fn new_omdb(api_key: String) -> Self {
|
||||
Self {
|
||||
provider: Box::new(omdb::OmdbProvider::new(api_key)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for MetadataClientImpl {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
criteria: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
let pm = self.provider.fetch(criteria).await?;
|
||||
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
|
||||
}
|
||||
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
|
||||
let pm = self.provider.fetch(&criteria).await?;
|
||||
Ok(pm.poster_url)
|
||||
}
|
||||
}
|
||||
|
||||
119
crates/adapters/metadata/src/omdb.rs
Normal file
119
crates/adapters/metadata/src/omdb.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::MetadataSearchCriteria,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{MetadataProvider, ProviderMovie};
|
||||
|
||||
pub(crate) struct OmdbProvider {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl OmdbProvider {
|
||||
pub(crate) fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
api_key,
|
||||
base_url: "http://www.omdbapi.com/".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OmdbResponse {
|
||||
#[serde(rename = "Title")]
|
||||
title: String,
|
||||
#[serde(rename = "Year")]
|
||||
year: String,
|
||||
#[serde(rename = "Director")]
|
||||
director: String,
|
||||
#[serde(rename = "Poster")]
|
||||
poster: String,
|
||||
#[serde(rename = "imdbID")]
|
||||
imdb_id: String,
|
||||
#[serde(rename = "Response")]
|
||||
response: String,
|
||||
#[serde(rename = "Error")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataProvider for OmdbProvider {
|
||||
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
|
||||
let mut url = reqwest::Url::parse(&self.base_url)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
{
|
||||
let mut params = url.query_pairs_mut();
|
||||
params.append_pair("apikey", &self.api_key);
|
||||
match criteria {
|
||||
MetadataSearchCriteria::ImdbId(id) => {
|
||||
params.append_pair("i", id.value());
|
||||
}
|
||||
MetadataSearchCriteria::Title { title, year } => {
|
||||
params.append_pair("t", title);
|
||||
if let Some(y) = year {
|
||||
params.append_pair("y", &y.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let http_resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let resp: OmdbResponse = http_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
if resp.response != "True" {
|
||||
let msg = resp.error.unwrap_or_default();
|
||||
return if msg.to_lowercase().contains("not found") {
|
||||
Err(DomainError::NotFound(msg))
|
||||
} else {
|
||||
Err(DomainError::InfrastructureError(msg))
|
||||
};
|
||||
}
|
||||
|
||||
let year: u16 = resp
|
||||
.year
|
||||
.chars()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
|
||||
})?;
|
||||
|
||||
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let title = MovieTitle::new(resp.title)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let release_year = ReleaseYear::new(year)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let director = match resp.director.as_str() {
|
||||
"N/A" | "" => None,
|
||||
d => Some(d.to_string()),
|
||||
};
|
||||
|
||||
let poster_url = match resp.poster.as_str() {
|
||||
"N/A" | "" => None,
|
||||
url => PosterUrl::new(url.to_string()).ok(),
|
||||
};
|
||||
|
||||
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
|
||||
}
|
||||
}
|
||||
10
crates/adapters/poster-fetcher/Cargo.toml
Normal file
10
crates/adapters/poster-fetcher/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "poster-fetcher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
13
crates/adapters/poster-fetcher/src/config.rs
Normal file
13
crates/adapters/poster-fetcher/src/config.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub struct PosterFetcherConfig {
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl PosterFetcherConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let timeout_seconds = std::env::var("POSTER_FETCH_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
Self { timeout_seconds }
|
||||
}
|
||||
}
|
||||
38
crates/adapters/poster-fetcher/src/lib.rs
Normal file
38
crates/adapters/poster-fetcher/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod config;
|
||||
pub use config::PosterFetcherConfig;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
|
||||
|
||||
pub struct ReqwestPosterFetcher {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ReqwestPosterFetcher {
|
||||
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_seconds))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PosterFetcherClient for ReqwestPosterFetcher {
|
||||
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.get(poster_url.value())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
16
crates/adapters/poster-storage/Cargo.toml
Normal file
16
crates/adapters/poster-storage/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "poster-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
object_store = { workspace = true }
|
||||
infer = "0.19.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
38
crates/adapters/poster-storage/src/config.rs
Normal file
38
crates/adapters/poster-storage/src/config.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use anyhow::Context;
|
||||
use object_store::{aws::AmazonS3Builder, ObjectStore};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct StorageConfig {
|
||||
endpoint: String,
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
bucket: String,
|
||||
region: String,
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
endpoint: std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?,
|
||||
access_key_id: std::env::var("MINIO_ACCESS_KEY_ID")
|
||||
.context("MINIO_ACCESS_KEY_ID required")?,
|
||||
secret_access_key: std::env::var("MINIO_SECRET_ACCESS_KEY")
|
||||
.context("MINIO_SECRET_ACCESS_KEY required")?,
|
||||
bucket: std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?,
|
||||
region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_store(self) -> anyhow::Result<Arc<dyn ObjectStore>> {
|
||||
let store = AmazonS3Builder::new()
|
||||
.with_endpoint(self.endpoint)
|
||||
.with_access_key_id(self.access_key_id)
|
||||
.with_secret_access_key(self.secret_access_key)
|
||||
.with_bucket_name(self.bucket)
|
||||
.with_region(self.region)
|
||||
.with_allow_http(true)
|
||||
.build()
|
||||
.context("Failed to build S3/Minio store")?;
|
||||
Ok(Arc::new(store))
|
||||
}
|
||||
}
|
||||
95
crates/adapters/poster-storage/src/lib.rs
Normal file
95
crates/adapters/poster-storage/src/lib.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
mod config;
|
||||
pub use config::StorageConfig;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::PosterStorage,
|
||||
value_objects::{MovieId, PosterPath},
|
||||
};
|
||||
use object_store::{Attribute, Attributes, PutOptions, path::Path, ObjectStore};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn detect_mime(bytes: &[u8]) -> &'static str {
|
||||
infer::get(bytes)
|
||||
.map(|t| t.mime_type())
|
||||
.unwrap_or("application/octet-stream")
|
||||
}
|
||||
|
||||
pub struct PosterStorageAdapter {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
}
|
||||
|
||||
impl PosterStorageAdapter {
|
||||
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn from_config(config: StorageConfig) -> anyhow::Result<Self> {
|
||||
Ok(Self::new(config.build_store()?))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PosterStorage for PosterStorageAdapter {
|
||||
async fn store_poster(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
image_bytes: &[u8],
|
||||
) -> Result<PosterPath, DomainError> {
|
||||
let path = Path::from(movie_id.value().to_string());
|
||||
let mime = detect_mime(image_bytes);
|
||||
let mut attributes = Attributes::new();
|
||||
attributes.insert(Attribute::ContentType, mime.into());
|
||||
let opts = PutOptions { attributes, ..Default::default() };
|
||||
self.store
|
||||
.put_opts(&path, image_bytes.to_vec().into(), opts)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
PosterPath::new(path.to_string())
|
||||
}
|
||||
|
||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||
let path = Path::from(poster_path.value().to_string());
|
||||
let result = self.store.get(&path).await.map_err(|e| match e {
|
||||
object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()),
|
||||
_ => DomainError::InfrastructureError(e.to_string()),
|
||||
})?;
|
||||
result
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use object_store::memory::InMemory;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn adapter() -> PosterStorageAdapter {
|
||||
PosterStorageAdapter::new(Arc::new(InMemory::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_and_retrieve_round_trip() {
|
||||
let adapter = adapter();
|
||||
let movie_id = MovieId::from_uuid(Uuid::new_v4());
|
||||
let bytes = b"fake-image-bytes";
|
||||
|
||||
let path = adapter.store_poster(&movie_id, bytes).await.unwrap();
|
||||
let retrieved = adapter.get_poster(&path).await.unwrap();
|
||||
|
||||
assert_eq!(retrieved, bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_not_found() {
|
||||
let adapter = adapter();
|
||||
let path = PosterPath::new("nonexistent".into()).unwrap();
|
||||
let result = adapter.get_poster(&path).await;
|
||||
assert!(matches!(result, Err(DomainError::NotFound(_))));
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rss-feed = { package = "rss", version = "2" }
|
||||
chrono = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
use application::ports::RssFeedRenderer;
|
||||
use domain::models::DiaryEntry;
|
||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||
|
||||
pub struct RssAdapter {
|
||||
feed_title: String,
|
||||
feed_link: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
impl RssAdapter {
|
||||
pub fn new(feed_title: String, feed_link: String) -> Self {
|
||||
Self { feed_title, feed_link }
|
||||
}
|
||||
}
|
||||
|
||||
impl RssFeedRenderer for RssAdapter {
|
||||
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String> {
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let title = format!(
|
||||
"{} ({})",
|
||||
e.movie().title().value(),
|
||||
e.movie().release_year().value()
|
||||
);
|
||||
let description = match e.review().comment() {
|
||||
Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()),
|
||||
None => format!("{}/5", e.review().rating().value()),
|
||||
};
|
||||
let pub_date = e
|
||||
.review()
|
||||
.watched_at()
|
||||
.and_utc()
|
||||
.format("%a, %d %b %Y %H:%M:%S +0000")
|
||||
.to_string();
|
||||
let guid = GuidBuilder::default()
|
||||
.value(e.review().id().value().to_string())
|
||||
.permalink(false)
|
||||
.build();
|
||||
ItemBuilder::default()
|
||||
.title(Some(title))
|
||||
.description(Some(description))
|
||||
.pub_date(Some(pub_date))
|
||||
.guid(Some(guid))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let channel = ChannelBuilder::default()
|
||||
.title(self.feed_title.clone())
|
||||
.link(self.feed_link.clone())
|
||||
.description(self.feed_title.clone())
|
||||
.items(items)
|
||||
.build();
|
||||
|
||||
Ok(channel.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM reviews",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
|
||||
}
|
||||
@@ -10,3 +10,10 @@ sqlx = { version = "0.8.6", features = [
|
||||
"uuid",
|
||||
"macros",
|
||||
] }
|
||||
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
24
crates/adapters/sqlite/migrations/0001_initial.sql
Normal file
24
crates/adapters/sqlite/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
external_metadata_id TEXT UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
release_year INTEGER NOT NULL,
|
||||
director TEXT,
|
||||
poster_path TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_movies_title_year
|
||||
ON movies (title, release_year);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
movie_id TEXT NOT NULL REFERENCES movies(id),
|
||||
user_id TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
comment TEXT,
|
||||
watched_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_movie_id ON reviews (movie_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_watched_at ON reviews (watched_at);
|
||||
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,14 +1,339 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection,
|
||||
collections::Paginated,
|
||||
},
|
||||
ports::MovieRepository,
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod migrations;
|
||||
mod models;
|
||||
mod users;
|
||||
|
||||
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
|
||||
|
||||
pub use users::SqliteUserRepository;
|
||||
|
||||
pub struct SqliteMovieRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
impl SqliteMovieRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
pub async fn migrate(&self) -> Result<(), DomainError> {
|
||||
migrations::run(&self.pool).await
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
||||
match movie_id {
|
||||
None => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
Some(id) => {
|
||||
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_diary_rows(
|
||||
&self,
|
||||
movie_id: Option<&str>,
|
||||
sort: &SortDirection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
// sqlx macros require literal ORDER BY values; separate branches also let the
|
||||
// query planner use the movie_id index instead of falling back to a filtered scan.
|
||||
match (movie_id, sort) {
|
||||
(None, SortDirection::Descending) => sqlx::query_as!(
|
||||
DiaryRow,
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
|
||||
(None, SortDirection::Ascending) => sqlx::query_as!(
|
||||
DiaryRow,
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY r.watched_at ASC
|
||||
LIMIT ? OFFSET ?",
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
|
||||
(Some(id), SortDirection::Descending) => sqlx::query_as!(
|
||||
DiaryRow,
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
id,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
|
||||
(Some(id), SortDirection::Ascending) => sqlx::query_as!(
|
||||
DiaryRow,
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ?
|
||||
ORDER BY r.watched_at ASC
|
||||
LIMIT ? OFFSET ?",
|
||||
id,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieRepository for SqliteMovieRepository {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
let id = external_metadata_id.value();
|
||||
sqlx::query_as!(
|
||||
MovieRow,
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = ?",
|
||||
id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as!(
|
||||
MovieRow,
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
let title = title.value();
|
||||
let year = year.value() as i64;
|
||||
sqlx::query_as!(
|
||||
MovieRow,
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE title = ? AND release_year = ?",
|
||||
title,
|
||||
year
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(MovieRow::to_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||
let id = movie.id().value().to_string();
|
||||
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
|
||||
let title = movie.title().value();
|
||||
let release_year = movie.release_year().value() as i64;
|
||||
let director = movie.director();
|
||||
let poster_path = movie.poster_path().map(|p| p.value().to_string());
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = excluded.external_metadata_id,
|
||||
title = excluded.title,
|
||||
release_year = excluded.release_year,
|
||||
director = excluded.director,
|
||||
poster_path = excluded.poster_path",
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
director,
|
||||
poster_path
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
let movie_id: Option<String> = filter.movie_id.as_ref().map(|id| id.value().to_string());
|
||||
let limit = filter.page.limit as i64;
|
||||
let offset = filter.page.offset as i64;
|
||||
|
||||
let (total, rows) = tokio::try_join!(
|
||||
self.count_diary_entries(movie_id.as_deref()),
|
||||
self.fetch_diary_rows(movie_id.as_deref(), &filter.sort_by, limit, offset)
|
||||
)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(DiaryRow::to_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: filter.page.limit,
|
||||
offset: filter.page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as!(
|
||||
ReviewRow,
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at
|
||||
FROM reviews WHERE id = ?",
|
||||
id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::to_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query!("DELETE FROM reviews WHERE id = ?", id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query!("DELETE FROM movies WHERE id = ?", id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
let id_str = movie_id.value().to_string();
|
||||
|
||||
let movie = sqlx::query_as!(
|
||||
MovieRow,
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||
.to_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as!(
|
||||
ReviewRow,
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
id_str
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::to_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ReviewHistory::new(movie, viewings))
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/adapters/sqlite/src/migrations.rs
Normal file
9
crates/adapters/sqlite/src/migrations.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use domain::errors::DomainError;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub(crate) async fn run(pool: &SqlitePool) -> Result<(), DomainError> {
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
126
crates/adapters/sqlite/src/models.rs
Normal file
126
crates/adapters/sqlite/src/models.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, Movie, Review},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
pub fn to_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ReviewRow {
|
||||
pub id: String,
|
||||
pub movie_id: String,
|
||||
pub user_id: String,
|
||||
pub rating: i64,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn to_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
Ok(Review::from_persistence(
|
||||
id, movie_id, user_id, rating, comment, watched_at, created_at,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Used by query_diary JOIN — r.id aliased to review_id to avoid ambiguity with m.id
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct DiaryRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub review_id: String,
|
||||
pub movie_id: String,
|
||||
pub user_id: String,
|
||||
pub rating: i64,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.to_domain()?;
|
||||
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
}
|
||||
.to_domain()?;
|
||||
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
||||
}
|
||||
151
crates/adapters/sqlite/src/users.rs
Normal file
151
crates/adapters/sqlite/src/users.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::User,
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, UserId},
|
||||
};
|
||||
|
||||
pub struct SqliteUserRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.value();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||
email_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
let id = uuid::Uuid::parse_str(&r.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let email = Email::new(r.email)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let hash = PasswordHash::new(r.password_hash)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
let id = user.id().value().to_string();
|
||||
let email = user.email().value();
|
||||
let hash = user.password_hash().value();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
let result = sqlx::query!(
|
||||
"INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
id,
|
||||
email,
|
||||
hash,
|
||||
created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash FROM users WHERE id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(r) => {
|
||||
let uuid = uuid::Uuid::parse_str(&r.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let email = Email::new(r.email)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let hash = PasswordHash::new(r.password_hash)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Some(User::from_persistence(UserId::from_uuid(uuid), email, hash)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)"
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let repo = SqliteUserRepository::new(pool.clone());
|
||||
(pool, repo)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_id_returns_none_when_not_found() {
|
||||
let (_, repo) = setup().await;
|
||||
let result = repo
|
||||
.find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_id_returns_user_when_found() {
|
||||
let (pool, repo) = setup().await;
|
||||
let id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind("test@example.com")
|
||||
.bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash")
|
||||
.bind("2026-01-01T00:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = repo
|
||||
.find_by_id(&UserId::from_uuid(id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().email().value(), "test@example.com");
|
||||
}
|
||||
}
|
||||
12
crates/adapters/template-askama/Cargo.toml
Normal file
12
crates/adapters/template-askama/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "template-askama"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
askama = { version = "0.16.0" }
|
||||
|
||||
serde = { workspace = true }
|
||||
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
86
crates/adapters/template-askama/src/lib.rs
Normal file
86
crates/adapters/template-askama/src/lib.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use askama::Template;
|
||||
use application::ports::{
|
||||
HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, RegisterPageData,
|
||||
};
|
||||
use domain::models::{DiaryEntry, collections::Paginated};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "diary.html")]
|
||||
struct DiaryTemplate<'a> {
|
||||
entries: &'a [DiaryEntry],
|
||||
current_offset: u32,
|
||||
limit: u32,
|
||||
has_more: bool,
|
||||
ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "register.html")]
|
||||
struct RegisterTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "new_review.html")]
|
||||
struct NewReviewTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
pub struct AskamaHtmlRenderer;
|
||||
|
||||
impl AskamaHtmlRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> {
|
||||
let has_more = (data.offset + data.limit) < data.total_count as u32;
|
||||
DiaryTemplate {
|
||||
entries: &data.items,
|
||||
current_offset: data.offset,
|
||||
limit: data.limit,
|
||||
has_more,
|
||||
ctx: &ctx,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String> {
|
||||
LoginTemplate {
|
||||
error: data.error,
|
||||
ctx: &data.ctx,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String> {
|
||||
RegisterTemplate {
|
||||
error: data.error,
|
||||
ctx: &data.ctx,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String> {
|
||||
NewReviewTemplate {
|
||||
error: data.error,
|
||||
ctx: &data.ctx,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
29
crates/adapters/template-askama/templates/base.html
Normal file
29
crates/adapters/template-askama/templates/base.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Movies Diary</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/" class="site-title">Movies Diary</a>
|
||||
<nav>
|
||||
{% if let Some(email) = ctx.user_email %}
|
||||
<a href="/reviews/new">Add Review</a>
|
||||
<span class="user-email">{{ email }}</span>
|
||||
<a href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
{% if ctx.register_enabled %}
|
||||
<a href="/register">Register</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
45
crates/adapters/template-askama/templates/diary.html
Normal file
45
crates/adapters/template-askama/templates/diary.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="diary">
|
||||
{% for entry in entries %}
|
||||
<article class="entry">
|
||||
{% if let Some(poster) = entry.movie().poster_path() %}
|
||||
<div class="poster">
|
||||
<img src="/posters/{{ poster.value() }}" alt="">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="entry-body">
|
||||
<div class="entry-title">
|
||||
{{ entry.movie().title().value() }}
|
||||
<span class="year">({{ entry.movie().release_year().value() }})</span>
|
||||
</div>
|
||||
{% if let Some(dir) = entry.movie().director() %}
|
||||
<div class="director">{{ dir }}</div>
|
||||
{% endif %}
|
||||
<div class="rating">{{ entry.review().rating().value() }}/5</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||
{% if let Some(uid) = ctx.user_id %}
|
||||
{% if *uid == entry.review().user_id().value() %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="empty">No movies logged yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<nav class="pagination">
|
||||
{% if current_offset > 0 %}
|
||||
<a href="/?offset={{ current_offset - limit }}">← Prev</a>
|
||||
{% endif %}
|
||||
{% if has_more %}
|
||||
<a href="/?offset={{ current_offset + limit }}">Next →</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock %}
|
||||
18
crates/adapters/template-askama/templates/login.html
Normal file
18
crates/adapters/template-askama/templates/login.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Login</h1>
|
||||
{% if let Some(err) = error %}
|
||||
<p class="error">{{ err }}</p>
|
||||
{% endif %}
|
||||
<form method="POST" action="/login">
|
||||
<label>
|
||||
Email<br>
|
||||
<input type="email" name="email" required autocomplete="email">
|
||||
</label>
|
||||
<label>
|
||||
Password<br>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
40
crates/adapters/template-askama/templates/new_review.html
Normal file
40
crates/adapters/template-askama/templates/new_review.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Log a Review</h1>
|
||||
{% if let Some(err) = error %}
|
||||
<p class="error">{{ err }}</p>
|
||||
{% endif %}
|
||||
<form method="POST" action="/reviews">
|
||||
<label>
|
||||
OMDB ID <span class="optional">(optional)</span><br>
|
||||
<input type="text" name="external_metadata_id" placeholder="tt0166924">
|
||||
</label>
|
||||
<hr>
|
||||
<label>
|
||||
Title<br>
|
||||
<input type="text" name="manual_title">
|
||||
</label>
|
||||
<label>
|
||||
Year<br>
|
||||
<input type="number" name="manual_release_year" min="1888" max="2100">
|
||||
</label>
|
||||
<label>
|
||||
Director<br>
|
||||
<input type="text" name="manual_director">
|
||||
</label>
|
||||
<hr>
|
||||
<label>
|
||||
Rating (0–5)<br>
|
||||
<input type="number" name="rating" min="0" max="5" required>
|
||||
</label>
|
||||
<label>
|
||||
Watched<br>
|
||||
<input type="datetime-local" name="watched_at" required>
|
||||
</label>
|
||||
<label>
|
||||
Comment<br>
|
||||
<textarea name="comment"></textarea>
|
||||
</label>
|
||||
<button type="submit">Log Review</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
18
crates/adapters/template-askama/templates/register.html
Normal file
18
crates/adapters/template-askama/templates/register.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Register</h1>
|
||||
{% if let Some(err) = error %}
|
||||
<p class="error">{{ err }}</p>
|
||||
{% endif %}
|
||||
<form method="POST" action="/register">
|
||||
<label>
|
||||
Email<br>
|
||||
<input type="email" name="email" required autocomplete="email">
|
||||
</label>
|
||||
<label>
|
||||
Password<br>
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
36
crates/application/src/commands.rs
Normal file
36
crates/application/src/commands.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct LogReviewCommand {
|
||||
pub external_metadata_id: Option<String>,
|
||||
|
||||
pub manual_title: Option<String>,
|
||||
pub manual_release_year: Option<u16>,
|
||||
pub manual_director: Option<String>,
|
||||
|
||||
pub user_id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncPosterCommand {
|
||||
pub movie_id: Uuid,
|
||||
pub external_metadata_id: String,
|
||||
}
|
||||
|
||||
pub struct LoginCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub struct RegisterCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
pub review_id: Uuid,
|
||||
pub requesting_user_id: Uuid,
|
||||
}
|
||||
13
crates/application/src/config.rs
Normal file
13
crates/application/src/config.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub allow_registration: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
Self { allow_registration }
|
||||
}
|
||||
}
|
||||
21
crates/application/src/context.rs
Normal file
21
crates/application/src/context.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
|
||||
PosterFetcherClient, PosterStorage, UserRepository,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
pub repository: Arc<dyn MovieRepository>,
|
||||
pub metadata_client: Arc<dyn MetadataClient>,
|
||||
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
pub poster_storage: Arc<dyn PosterStorage>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub auth_service: Arc<dyn AuthService>,
|
||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||
pub user_repository: Arc<dyn UserRepository>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod ports;
|
||||
pub mod queries;
|
||||
pub mod use_cases;
|
||||
|
||||
35
crates/application/src/ports.rs
Normal file
35
crates/application/src/ports.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::{DiaryEntry, collections::Paginated};
|
||||
|
||||
pub struct HtmlPageContext {
|
||||
pub user_email: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub register_enabled: bool,
|
||||
}
|
||||
|
||||
pub struct LoginPageData<'a> {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct RegisterPageData<'a> {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct NewReviewPageData<'a> {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub trait HtmlRenderer: Send + Sync {
|
||||
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
|
||||
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
|
||||
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
|
||||
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String>;
|
||||
}
|
||||
13
crates/application/src/queries.rs
Normal file
13
crates/application/src/queries.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use domain::models::SortDirection;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct GetDiaryQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub sort_by: Option<SortDirection>,
|
||||
pub movie_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct GetReviewHistoryQuery {
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
27
crates/application/src/use_cases/delete_review.rs
Normal file
27
crates/application/src/use_cases/delete_review.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use domain::{errors::DomainError, value_objects::{ReviewId, UserId}};
|
||||
use crate::{commands::DeleteReviewCommand, context::AppContext};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
|
||||
let review_id = ReviewId::from_uuid(cmd.review_id);
|
||||
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
|
||||
|
||||
let review = ctx
|
||||
.repository
|
||||
.get_review_by_id(&review_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
|
||||
|
||||
if review.user_id() != &requesting_user_id {
|
||||
return Err(DomainError::Unauthorized("not your review".into()));
|
||||
}
|
||||
|
||||
let movie_id = review.movie_id().clone();
|
||||
ctx.repository.delete_review(&review_id).await?;
|
||||
|
||||
let history = ctx.repository.get_review_history(&movie_id).await?;
|
||||
if history.viewings().is_empty() {
|
||||
ctx.repository.delete_movie(&movie_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
29
crates/application/src/use_cases/get_diary.rs
Normal file
29
crates/application/src/use_cases/get_diary.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, SortDirection,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetDiaryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetDiaryQuery,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
|
||||
let movie_id = query.movie_id.map(MovieId::from_uuid);
|
||||
|
||||
let filter = DiaryFilter {
|
||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||
page,
|
||||
movie_id,
|
||||
};
|
||||
|
||||
let paginated_results = ctx.repository.query_diary(&filter).await?;
|
||||
|
||||
Ok(paginated_results)
|
||||
}
|
||||
23
crates/application/src/use_cases/get_review_history.rs
Normal file
23
crates/application/src/use_cases/get_review_history.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ReviewHistory,
|
||||
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, queries::GetReviewHistoryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetReviewHistoryQuery,
|
||||
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
|
||||
let mut history = ctx.repository.get_review_history(&movie_id).await?;
|
||||
|
||||
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||
|
||||
ReviewHistoryAnalyzer::sort_chronologically(&mut history);
|
||||
|
||||
Ok((history, trend))
|
||||
}
|
||||
141
crates/application/src/use_cases/log_review.rs
Normal file
141
crates/application/src/use_cases/log_review.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
ports::MetadataSearchCriteria,
|
||||
value_objects::{Comment, ExternalMetadataId, MovieTitle, Rating, ReleaseYear, UserId},
|
||||
};
|
||||
|
||||
use crate::{commands::LogReviewCommand, context::AppContext};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
let rating = Rating::new(cmd.rating)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = resolve_movie(ctx, &cmd).await?;
|
||||
|
||||
ctx.repository.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review_event = ctx.repository.save_review(&review).await?;
|
||||
|
||||
publish_events(ctx, &movie, is_new_movie, review_event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_movie(
|
||||
ctx: &AppContext,
|
||||
cmd: &LogReviewCommand,
|
||||
) -> Result<(Movie, bool), DomainError> {
|
||||
if let Some(ext_id_str) = &cmd.external_metadata_id {
|
||||
if let Some(resolved) = resolve_external_movie(ctx, ext_id_str).await? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(title) = &cmd.manual_title {
|
||||
if let Some(resolved) = resolve_by_title(ctx, title, cmd.manual_release_year).await? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
resolve_manual_movie(ctx, cmd).await
|
||||
}
|
||||
|
||||
async fn resolve_external_movie(
|
||||
ctx: &AppContext,
|
||||
ext_id_str: &str,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
|
||||
|
||||
if let Some(m) = ctx.repository.get_movie_by_external_id(&tmdb_id).await? {
|
||||
return Ok(Some((m, false)));
|
||||
}
|
||||
|
||||
match ctx
|
||||
.metadata_client
|
||||
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
|
||||
.await
|
||||
{
|
||||
Ok(m) => Ok(Some((m, true))),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_by_title(
|
||||
ctx: &AppContext,
|
||||
title: &str,
|
||||
year: Option<u16>,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||
let criteria = MetadataSearchCriteria::Title { title: title.to_string(), year };
|
||||
match ctx.metadata_client.fetch_movie_metadata(&criteria).await {
|
||||
Ok(m) => Ok(Some((m, true))),
|
||||
Err(e) => {
|
||||
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_manual_movie(
|
||||
ctx: &AppContext,
|
||||
cmd: &LogReviewCommand,
|
||||
) -> Result<(Movie, bool), DomainError> {
|
||||
let title_str = cmd.manual_title.as_ref().ok_or_else(|| {
|
||||
DomainError::ValidationError(
|
||||
"Manual title required if TMDB fetch fails or is omitted".into(),
|
||||
)
|
||||
})?;
|
||||
let year_val = cmd.manual_release_year.ok_or_else(|| {
|
||||
DomainError::ValidationError(
|
||||
"Manual release year required if TMDB fetch fails or is omitted".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let title = MovieTitle::new(title_str.clone())?;
|
||||
let release_year = ReleaseYear::new(year_val)?;
|
||||
|
||||
let candidates = ctx
|
||||
.repository
|
||||
.get_movies_by_title_and_year(&title, &release_year)
|
||||
.await?;
|
||||
|
||||
let matched_movie = candidates
|
||||
.into_iter()
|
||||
.find(|m| m.is_manual_match(&title, &release_year, cmd.manual_director.as_deref()));
|
||||
|
||||
if let Some(existing_movie) = matched_movie {
|
||||
Ok((existing_movie, false))
|
||||
} else {
|
||||
let new_movie = Movie::new(None, title, release_year, cmd.manual_director.clone(), None);
|
||||
Ok((new_movie, true))
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_events(
|
||||
ctx: &AppContext,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie {
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let discovery_event = DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
};
|
||||
ctx.event_publisher.publish(&discovery_event).await?;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.event_publisher.publish(&review_event).await?;
|
||||
Ok(())
|
||||
}
|
||||
39
crates/application/src/use_cases/login.rs
Normal file
39
crates/application/src/use_cases/login.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{errors::DomainError, value_objects::Email};
|
||||
|
||||
use crate::{commands::LoginCommand, context::AppContext};
|
||||
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
|
||||
let email = Email::new(cmd.email)?;
|
||||
let user = ctx
|
||||
.user_repository
|
||||
.find_by_email(&email)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?;
|
||||
|
||||
let valid = ctx
|
||||
.password_hasher
|
||||
.verify(&cmd.password, user.password_hash())
|
||||
.await?;
|
||||
if !valid {
|
||||
return Err(DomainError::Unauthorized("Invalid credentials".into()));
|
||||
}
|
||||
|
||||
let generated = ctx.auth_service.generate_token(user.id()).await?;
|
||||
|
||||
Ok(LoginResult {
|
||||
token: generated.token,
|
||||
user_id: user.id().value(),
|
||||
email: user.email().value().to_string(),
|
||||
expires_at: generated.expires_at,
|
||||
})
|
||||
}
|
||||
7
crates/application/src/use_cases/mod.rs
Normal file
7
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod delete_review;
|
||||
pub mod get_diary;
|
||||
pub mod get_review_history;
|
||||
pub mod log_review;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod sync_poster;
|
||||
18
crates/application/src/use_cases/register.rs
Normal file
18
crates/application/src/use_cases/register.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use domain::{errors::DomainError, models::User, value_objects::Email};
|
||||
|
||||
use crate::{commands::RegisterCommand, context::AppContext};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
|
||||
if !ctx.config.allow_registration {
|
||||
return Err(DomainError::Unauthorized("Registration is disabled".into()));
|
||||
}
|
||||
|
||||
let email = Email::new(cmd.email)?;
|
||||
|
||||
if ctx.user_repository.find_by_email(&email).await?.is_some() {
|
||||
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||
}
|
||||
|
||||
let hash = ctx.password_hasher.hash(&cmd.password).await?;
|
||||
ctx.user_repository.save(&User::new(email, hash)).await
|
||||
}
|
||||
47
crates/application/src/use_cases/sync_poster.rs
Normal file
47
crates/application/src/use_cases/sync_poster.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
};
|
||||
|
||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
|
||||
|
||||
let mut movie = match ctx.repository.get_movie_by_id(&movie_id).await? {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Sync cancelled: Movie {} not found in local DB",
|
||||
movie_id.value()
|
||||
);
|
||||
return Err(DomainError::NotFound("Movie not found".into()));
|
||||
}
|
||||
};
|
||||
|
||||
let poster_url = match ctx
|
||||
.metadata_client
|
||||
.get_poster_url(&external_metadata_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(url)) => url,
|
||||
Ok(None) => return Ok(()),
|
||||
Err(e) => {
|
||||
tracing::warn!("Warning: Failed to find poster URL metadata: {:?}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||
|
||||
let stored_path = ctx
|
||||
.poster_storage
|
||||
.store_poster(&movie_id, &image_bytes)
|
||||
.await?;
|
||||
|
||||
movie.update_poster(stored_path);
|
||||
ctx.repository.upsert_movie(&movie).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,4 +13,7 @@ pub enum DomainError {
|
||||
|
||||
#[error("Infrastructure failure: {0}")]
|
||||
InfrastructureError(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::value_objects::{MovieId, Rating, ReviewId, UserId};
|
||||
use crate::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DomainEvent {
|
||||
@@ -11,4 +11,8 @@ pub enum DomainEvent {
|
||||
rating: Rating,
|
||||
watched_at: NaiveDateTime,
|
||||
},
|
||||
MovieDiscovered {
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub struct DiaryFilter {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Movie {
|
||||
id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
external_metadata_id: Option<ExternalMetadataId>,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
@@ -36,7 +36,7 @@ pub struct Movie {
|
||||
|
||||
impl Movie {
|
||||
pub fn new(
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
external_metadata_id: Option<ExternalMetadataId>,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
@@ -52,11 +52,33 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: MovieId,
|
||||
external_metadata_id: Option<ExternalMetadataId>,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
poster_path: Option<PosterPath>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
director,
|
||||
poster_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_poster(&mut self, poster_path: PosterPath) {
|
||||
self.poster_path = Some(poster_path);
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &MovieId {
|
||||
&self.id
|
||||
}
|
||||
pub fn external_metadata_id(&self) -> &ExternalMetadataId {
|
||||
&self.external_metadata_id
|
||||
pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> {
|
||||
self.external_metadata_id.as_ref()
|
||||
}
|
||||
pub fn title(&self) -> &MovieTitle {
|
||||
&self.title
|
||||
@@ -72,6 +94,24 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
impl Movie {
|
||||
pub fn is_manual_match(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
director: Option<&str>,
|
||||
) -> bool {
|
||||
if self.title != *title || self.release_year != *year {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (self.director(), director) {
|
||||
(Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Review {
|
||||
id: ReviewId,
|
||||
@@ -110,6 +150,26 @@ impl Review {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: ReviewId,
|
||||
movie_id: MovieId,
|
||||
user_id: UserId,
|
||||
rating: Rating,
|
||||
comment: Option<Comment>,
|
||||
watched_at: NaiveDateTime,
|
||||
created_at: NaiveDateTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &ReviewId {
|
||||
&self.id
|
||||
}
|
||||
@@ -135,14 +195,43 @@ impl Review {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiaryEntry {
|
||||
pub movie: Movie,
|
||||
pub review: Review,
|
||||
movie: Movie,
|
||||
review: Review,
|
||||
}
|
||||
|
||||
impl DiaryEntry {
|
||||
pub fn new(movie: Movie, review: Review) -> Self {
|
||||
Self { movie, review }
|
||||
}
|
||||
|
||||
pub fn movie(&self) -> &Movie {
|
||||
&self.movie
|
||||
}
|
||||
pub fn review(&self) -> &Review {
|
||||
&self.review
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReviewHistory {
|
||||
pub movie: Movie,
|
||||
pub viewings: Vec<Review>,
|
||||
movie: Movie,
|
||||
viewings: Vec<Review>,
|
||||
}
|
||||
|
||||
impl ReviewHistory {
|
||||
pub fn new(movie: Movie, viewings: Vec<Review>) -> Self {
|
||||
Self { movie, viewings }
|
||||
}
|
||||
|
||||
pub fn movie(&self) -> &Movie {
|
||||
&self.movie
|
||||
}
|
||||
pub fn viewings(&self) -> &[Review] {
|
||||
&self.viewings
|
||||
}
|
||||
pub fn viewings_mut(&mut self) -> &mut Vec<Review> {
|
||||
&mut self.viewings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -161,6 +250,10 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
|
||||
Self { id, email, password_hash }
|
||||
}
|
||||
|
||||
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
||||
self.password_hash = new_hash;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
|
||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, UserId},
|
||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||
ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieRepository: Send + Sync {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError>;
|
||||
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||
@@ -17,19 +32,34 @@ pub trait MovieRepository: Send + Sync {
|
||||
-> Result<Paginated<DiaryEntry>, DomainError>;
|
||||
|
||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub enum MetadataSearchCriteria {
|
||||
ImdbId(ExternalMetadataId),
|
||||
Title { title: String, year: Option<u16> },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MetadataClient: Send + Sync {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
criteria: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError>;
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PosterFetcherClient: Send + Sync {
|
||||
async fn fetch_poster_bytes(&self, poster_url: &str) -> Result<Vec<u8>, DomainError>;
|
||||
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -43,11 +73,24 @@ pub trait PosterStorage: Send + Sync {
|
||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
pub struct GeneratedToken {
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventPublisher: Send + Sync {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||
|
||||
@@ -12,35 +12,35 @@ pub enum Trend {
|
||||
impl ReviewHistoryAnalyzer {
|
||||
pub fn sort_chronologically(history: &mut ReviewHistory) {
|
||||
history
|
||||
.viewings
|
||||
.viewings_mut()
|
||||
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
|
||||
}
|
||||
|
||||
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
|
||||
history
|
||||
.viewings
|
||||
.viewings()
|
||||
.iter()
|
||||
.max_by_key(|r| r.watched_at())
|
||||
.map(|r| r.rating())
|
||||
}
|
||||
|
||||
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
|
||||
if history.viewings.len() < 2 {
|
||||
if history.viewings().len() < 2 {
|
||||
return Ok(Trend::Neutral);
|
||||
}
|
||||
|
||||
let mut sorted_history = history.clone();
|
||||
Self::sort_chronologically(&mut sorted_history);
|
||||
|
||||
let latest_review = sorted_history.viewings.pop().unwrap();
|
||||
let latest_review = sorted_history.viewings().last().unwrap();
|
||||
let latest_rating = latest_review.rating().value() as f32;
|
||||
|
||||
let previous_sum: u32 = sorted_history
|
||||
.viewings
|
||||
.viewings()
|
||||
.iter()
|
||||
.map(|r| r.rating().value() as u32)
|
||||
.sum();
|
||||
let historical_average = previous_sum as f32 / sorted_history.viewings.len() as f32;
|
||||
let historical_average = previous_sum as f32 / sorted_history.viewings().len() as f32;
|
||||
|
||||
if latest_rating > historical_average {
|
||||
Ok(Trend::Improved)
|
||||
|
||||
@@ -206,3 +206,23 @@ impl PasswordHash {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PosterUrl(String);
|
||||
|
||||
impl PosterUrl {
|
||||
pub fn new(url: String) -> Result<Self, DomainError> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
Err(DomainError::ValidationError(
|
||||
"Poster URL cannot be empty".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,34 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
metadata = { workspace = true }
|
||||
poster-fetcher = { workspace = true }
|
||||
poster-storage = { workspace = true }
|
||||
sqlite = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
template-askama = { workspace = true }
|
||||
event-publisher = { workspace = true }
|
||||
rss = { workspace = true }
|
||||
infer = "0.19.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
|
||||
162
crates/presentation/src/dtos.rs
Normal file
162
crates/presentation/src/dtos.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
let s = Option::<String>::deserialize(de)?;
|
||||
match s.as_deref() {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DiaryQueryParams {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub sort_by: Option<String>,
|
||||
pub movie_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LogReviewForm {
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub external_metadata_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub manual_title: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub manual_release_year: Option<u16>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub manual_director: Option<String>,
|
||||
pub rating: u8,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterForm {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ErrorQuery {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LogReviewRequest {
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub manual_title: Option<String>,
|
||||
pub manual_release_year: Option<u16>,
|
||||
pub manual_director: Option<String>,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MovieDto {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub release_year: u16,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReviewDto {
|
||||
pub id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DiaryEntryDto {
|
||||
pub movie: MovieDto,
|
||||
pub review: ReviewDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DiaryResponse {
|
||||
pub items: Vec<DiaryEntryDto>,
|
||||
pub total_count: u64,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReviewHistoryResponse {
|
||||
pub movie: MovieDto,
|
||||
pub viewings: Vec<ReviewDto>,
|
||||
pub trend: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diary_response_serializes_correctly() {
|
||||
let resp = DiaryResponse {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("\"total_count\":0"));
|
||||
assert!(json.contains("\"items\":[]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_query_params_fields_are_optional() {
|
||||
let params = DiaryQueryParams {
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: None,
|
||||
movie_id: None,
|
||||
};
|
||||
assert!(params.limit.is_none());
|
||||
assert!(params.sort_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_request_deserializes() {
|
||||
let json = r#"{"email":"a@b.com","password":"secret"}"#;
|
||||
let req: LoginRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.email, "a@b.com");
|
||||
}
|
||||
}
|
||||
33
crates/presentation/src/errors.rs
Normal file
33
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub struct ApiError(pub DomainError);
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
Self(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self.0 {
|
||||
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
|
||||
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||
DomainError::InfrastructureError(_) => {
|
||||
tracing::error!("Internal Infrastructure Error: {:?}", self.0);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
(status, error_message).into_response()
|
||||
}
|
||||
}
|
||||
174
crates/presentation/src/event_handlers.rs
Normal file
174
crates/presentation/src/event_handlers.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster};
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent};
|
||||
use event_publisher::EventHandler;
|
||||
|
||||
pub struct PosterSyncHandler {
|
||||
ctx: AppContext,
|
||||
max_retries: u32,
|
||||
}
|
||||
|
||||
impl PosterSyncHandler {
|
||||
pub fn new(ctx: AppContext, max_retries: u32) -> Self {
|
||||
Self { ctx, max_retries }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for PosterSyncHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (movie_id, external_metadata_id) = match event {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => (movie_id.value(), external_metadata_id.value().to_owned()),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let mut last_err: Option<DomainError> = None;
|
||||
for attempt in 0..=self.max_retries {
|
||||
let cmd = SyncPosterCommand {
|
||||
movie_id,
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
};
|
||||
match sync_poster::execute(&self.ctx, cmd).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
if attempt < self.max_retries {
|
||||
let delay = Duration::from_secs(2u64.pow(attempt));
|
||||
tracing::warn!(
|
||||
attempt = attempt + 1,
|
||||
max_attempts = self.max_retries + 1,
|
||||
delay_secs = delay.as_secs(),
|
||||
"poster sync failed, retrying: {e}"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let err = last_err.expect("loop runs at least once and always sets last_err on Err");
|
||||
tracing::error!(
|
||||
attempts = self.max_retries + 1,
|
||||
"poster sync failed after all attempts: {err}"
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use application::config::AppConfig;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
|
||||
ports::{
|
||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||
Rating, ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
|
||||
// Panic-stub ports: each method panics so any accidental dispatch into a service
|
||||
// fails the test loudly rather than silently succeeding.
|
||||
struct PanicRepo;
|
||||
struct PanicMetadata;
|
||||
struct PanicFetcher;
|
||||
struct PanicStorage;
|
||||
struct PanicAuth;
|
||||
struct PanicHasher;
|
||||
struct PanicUserRepo;
|
||||
struct NoopPublisher;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieRepository for PanicRepo {
|
||||
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
|
||||
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!("unexpected") }
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
|
||||
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!("unexpected") }
|
||||
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!("unexpected") }
|
||||
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!("unexpected") }
|
||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!("unexpected") }
|
||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!("unexpected") }
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for PanicMetadata {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> { panic!("unexpected") }
|
||||
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PosterFetcherClient for PanicFetcher {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PosterStorage for PanicStorage {
|
||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!("unexpected") }
|
||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for PanicAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!("unexpected") }
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for PanicHasher {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!("unexpected") }
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for PanicUserRepo {
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
||||
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
|
||||
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoopPublisher {
|
||||
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
|
||||
}
|
||||
|
||||
fn panic_ctx() -> AppContext {
|
||||
AppContext {
|
||||
repository: Arc::new(PanicRepo),
|
||||
metadata_client: Arc::new(PanicMetadata),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
poster_storage: Arc::new(PanicStorage),
|
||||
event_publisher: Arc::new(NoopPublisher),
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(PanicUserRepo),
|
||||
config: AppConfig { allow_registration: false },
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_logged_is_ignored() {
|
||||
let handler = PosterSyncHandler::new(panic_ctx(), 3);
|
||||
let event = DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::generate(),
|
||||
movie_id: MovieId::generate(),
|
||||
user_id: UserId::generate(),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
watched_at: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap(),
|
||||
};
|
||||
assert!(handler.handle(&event).await.is_ok());
|
||||
}
|
||||
}
|
||||
387
crates/presentation/src/extractors.rs
Normal file
387
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{header, header::AUTHORIZATION, request::Parts},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
|
||||
pub struct AuthenticatedUser(pub UserId);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let token = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
ApiError(DomainError::Unauthorized(
|
||||
"Missing or invalid auth token".into(),
|
||||
))
|
||||
})?;
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.validate_token(token)
|
||||
.await?;
|
||||
Ok(AuthenticatedUser(user_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OptionalCookieUser(pub Option<UserId>);
|
||||
pub struct RequiredCookieUser(pub UserId);
|
||||
|
||||
fn extract_token_from_cookie(parts: &Parts) -> Option<String> {
|
||||
parts
|
||||
.headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("token=").map(str::to_string))
|
||||
})
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for OptionalCookieUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let Some(token) = extract_token_from_cookie(parts) else {
|
||||
return Ok(OptionalCookieUser(None));
|
||||
};
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.ok();
|
||||
Ok(OptionalCookieUser(user_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for RequiredCookieUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = axum::response::Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let token = extract_token_from_cookie(parts)
|
||||
.ok_or_else(|| Redirect::to("/login").into_response())?;
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.map_err(|_| Redirect::to("/login").into_response())?;
|
||||
Ok(RequiredCookieUser(user_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn protected_handler(user: AuthenticatedUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
|
||||
fn test_router(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/protected", get(protected_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_header_returns_401() {
|
||||
use std::sync::Arc;
|
||||
use application::context::AppContext;
|
||||
|
||||
struct PanicRepo;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MovieRepository for PanicRepo {
|
||||
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
|
||||
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
}
|
||||
|
||||
struct PanicRenderer;
|
||||
impl crate::ports::HtmlRenderer for PanicRenderer {
|
||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||
}
|
||||
|
||||
struct PanicRssRenderer;
|
||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer {
|
||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
||||
}
|
||||
|
||||
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
|
||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
||||
|
||||
let state = crate::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
repository: Arc::new(PanicRepo),
|
||||
metadata_client: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
poster_storage: Arc::new(PanicStorage),
|
||||
event_publisher: Arc::new(PanicEvent),
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(PanicUserRepo),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer),
|
||||
rss_renderer: Arc::new(PanicRssRenderer),
|
||||
};
|
||||
|
||||
let app = test_router(state);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Reusable helpers for cookie extractor tests
|
||||
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
|
||||
match user.0 {
|
||||
Some(id) => id.value().to_string(),
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
|
||||
fn test_router_optional(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/optional", get(optional_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn test_router_required(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/required", get(required_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
struct RejectingAuth;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::AuthService for RejectingAuth {
|
||||
async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() }
|
||||
async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> {
|
||||
Err(domain::errors::DomainError::Unauthorized("bad token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_state() -> crate::state::AppState {
|
||||
use std::sync::Arc;
|
||||
use application::context::AppContext;
|
||||
struct PanicRepo2;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MovieRepository for PanicRepo2 {
|
||||
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
|
||||
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
}
|
||||
struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2;
|
||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta2 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher2 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage2 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
||||
struct PanicRenderer2;
|
||||
impl crate::ports::HtmlRenderer for PanicRenderer2 {
|
||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||
}
|
||||
struct PanicRssRenderer2;
|
||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
||||
}
|
||||
struct PanicAuth2;
|
||||
crate::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
repository: Arc::new(PanicRepo2),
|
||||
metadata_client: Arc::new(PanicMeta2),
|
||||
poster_fetcher: Arc::new(PanicFetcher2),
|
||||
poster_storage: Arc::new(PanicStorage2),
|
||||
event_publisher: Arc::new(PanicEvent2),
|
||||
auth_service: Arc::new(PanicAuth2),
|
||||
password_hasher: Arc::new(PanicHasher2),
|
||||
user_repository: Arc::new(PanicUserRepo2),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer2),
|
||||
rss_renderer: Arc::new(PanicRssRenderer2),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejecting_state() -> crate::state::AppState {
|
||||
use std::sync::Arc;
|
||||
use application::context::AppContext;
|
||||
struct PanicRepo3;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MovieRepository for PanicRepo3 {
|
||||
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
|
||||
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
|
||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||
}
|
||||
struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3;
|
||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta3 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher3 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
||||
struct PanicRenderer3;
|
||||
impl crate::ports::HtmlRenderer for PanicRenderer3 {
|
||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||
}
|
||||
struct PanicRssRenderer3;
|
||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
||||
}
|
||||
crate::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
repository: Arc::new(PanicRepo3),
|
||||
metadata_client: Arc::new(PanicMeta3),
|
||||
poster_fetcher: Arc::new(PanicFetcher3),
|
||||
poster_storage: Arc::new(PanicStorage3),
|
||||
event_publisher: Arc::new(PanicEvent3),
|
||||
auth_service: Arc::new(RejectingAuth),
|
||||
password_hasher: Arc::new(PanicHasher3),
|
||||
user_repository: Arc::new(PanicUserRepo3),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer3),
|
||||
rss_renderer: Arc::new(PanicRssRenderer3),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_without_cookie() {
|
||||
let app = test_router_optional(panic_state());
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_with_invalid_token() {
|
||||
let app = test_router_optional(rejecting_state());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/optional")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_without_cookie() {
|
||||
let app = test_router_required(panic_state());
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_with_invalid_token() {
|
||||
let app = test_router_required(rejecting_state());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/required")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
}
|
||||
542
crates/presentation/src/handlers.rs
Normal file
542
crates/presentation/src/handlers.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
pub mod html {
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
Form,
|
||||
};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, LoginCommand, LogReviewCommand, RegisterCommand},
|
||||
ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData},
|
||||
queries::GetDiaryQuery,
|
||||
use_cases::{delete_review, get_diary, log_review, login as login_uc, register as register_uc},
|
||||
};
|
||||
use domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
|
||||
|
||||
use crate::{
|
||||
dtos::{DiaryQueryParams, ErrorQuery, LoginForm, LogReviewForm, RegisterForm},
|
||||
errors::ApiError,
|
||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn build_page_context(state: &AppState, user_id: Option<UserId>) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
let user_email = if let Some(ref id) = user_id {
|
||||
state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.email().value().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
HtmlPageContext {
|
||||
user_email,
|
||||
user_id: uuid,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
msg.replace(' ', "+")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
.replace('"', "%22")
|
||||
}
|
||||
|
||||
fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) {
|
||||
let val = format!(
|
||||
"token={}; HttpOnly; Path=/; SameSite=Lax; Max-Age={}",
|
||||
token, max_age
|
||||
);
|
||||
(SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"))
|
||||
}
|
||||
|
||||
pub async fn get_index(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = GetDiaryQuery {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: params.sort_by.as_deref().map(|s| {
|
||||
if s == "asc" {
|
||||
SortDirection::Ascending
|
||||
} else {
|
||||
SortDirection::Descending
|
||||
}
|
||||
}),
|
||||
movie_id: params.movie_id,
|
||||
};
|
||||
let ctx = build_page_context(&state, user_id).await;
|
||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_diary_page(&page, ctx)
|
||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
pub async fn get_login_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = HtmlPageContext {
|
||||
user_email: None,
|
||||
user_id: None,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_login_page(LoginPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("login template failed");
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> impl IntoResponse {
|
||||
match login_uc::execute(
|
||||
&state.app_ctx,
|
||||
LoginCommand {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
||||
let cookie = set_cookie_header(&result.token, max_age);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_logout() -> impl IntoResponse {
|
||||
let cookie = (
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_static("token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||
);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_register_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
let ctx = HtmlPageContext {
|
||||
user_email: None,
|
||||
user_id: None,
|
||||
register_enabled: true,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_register_page(RegisterPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("register template failed");
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
pub async fn post_register(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<RegisterForm>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
let email = form.email.clone();
|
||||
let password = form.password.clone();
|
||||
match register_uc::execute(
|
||||
&state.app_ctx,
|
||||
RegisterCommand {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await {
|
||||
Ok(result) => {
|
||||
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
||||
let cookie = set_cookie_header(&result.token, max_age);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
Err(_) => Redirect::to("/login").into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/register?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_new_review_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, Some(user_id)).await;
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_new_review_page(NewReviewPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("new_review template failed");
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Form(form): Form<LogReviewForm>,
|
||||
) -> impl IntoResponse {
|
||||
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
|
||||
.or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M"));
|
||||
|
||||
let watched_at = match watched_at {
|
||||
Ok(dt) => dt,
|
||||
Err(_) => {
|
||||
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
||||
manual_title: form.manual_title,
|
||||
manual_release_year: form.manual_release_year,
|
||||
manual_director: form.manual_director,
|
||||
user_id: user_id.value(),
|
||||
rating: form.rating,
|
||||
comment: form.comment,
|
||||
watched_at,
|
||||
};
|
||||
|
||||
match log_review::execute(&state.app_ctx, cmd).await {
|
||||
Ok(_) => Redirect::to("/").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
match delete_review::execute(&state.app_ctx, cmd).await {
|
||||
Ok(()) => Redirect::to("/").into_response(),
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_review html error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod posters {
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use domain::value_objects::PosterPath;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_poster(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let poster_path = match PosterPath::new(path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
match state.app_ctx.poster_storage.get_poster(&poster_path).await {
|
||||
Ok(bytes) => {
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type())
|
||||
.unwrap_or("application/octet-stream");
|
||||
([(header::CONTENT_TYPE, mime)], bytes).into_response()
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rss {
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use application::{queries::GetDiaryQuery, use_cases::get_diary};
|
||||
use domain::{errors::DomainError, models::SortDirection};
|
||||
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
|
||||
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(50),
|
||||
offset: Some(0),
|
||||
sort_by: Some(SortDirection::Descending),
|
||||
movie_id: None,
|
||||
};
|
||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||
let xml = state
|
||||
.rss_renderer
|
||||
.render_feed(&page.items)
|
||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod api {
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, LoginCommand, LogReviewCommand, RegisterCommand, SyncPosterCommand},
|
||||
queries::{GetDiaryQuery, GetReviewHistoryQuery},
|
||||
use_cases::{delete_review, get_diary, get_review_history, log_review, login as login_uc, register as register_uc, sync_poster},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, Movie, Review, SortDirection},
|
||||
services::review_history::Trend,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dtos::{
|
||||
DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse,
|
||||
LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse,
|
||||
},
|
||||
errors::ApiError,
|
||||
extractors::AuthenticatedUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_diary(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||
let query = GetDiaryQuery {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: params.sort_by.as_deref().map(|s| {
|
||||
if s == "asc" {
|
||||
SortDirection::Ascending
|
||||
} else {
|
||||
SortDirection::Descending
|
||||
}
|
||||
}),
|
||||
movie_id: params.movie_id,
|
||||
};
|
||||
|
||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page.items.iter().map(entry_to_dto).collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_review_history(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
|
||||
let (history, trend) = get_review_history::execute(
|
||||
&state.app_ctx,
|
||||
GetReviewHistoryQuery { movie_id },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReviewHistoryResponse {
|
||||
movie: movie_to_dto(history.movie()),
|
||||
viewings: history.viewings().iter().map(review_to_dto).collect(),
|
||||
trend: match trend {
|
||||
Trend::Improved => "improved",
|
||||
Trend::Declined => "declined",
|
||||
Trend::Neutral => "neutral",
|
||||
}
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<LogReviewRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S")
|
||||
.map_err(|_| {
|
||||
ApiError(DomainError::ValidationError(
|
||||
"Invalid watched_at format, expected YYYY-MM-DDTHH:MM:SS".into(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
||||
manual_title: req.manual_title,
|
||||
manual_release_year: req.manual_release_year,
|
||||
manual_director: req.manual_director,
|
||||
user_id: user.0.value(),
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
};
|
||||
|
||||
log_review::execute(&state.app_ctx, cmd).await?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn sync_poster(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let movie = state
|
||||
.app_ctx
|
||||
.repository
|
||||
.get_movie_by_id(&MovieId::from_uuid(movie_id))
|
||||
.await?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?;
|
||||
|
||||
let external_id = movie
|
||||
.external_metadata_id()
|
||||
.ok_or_else(|| {
|
||||
ApiError(DomainError::ValidationError(
|
||||
"Movie has no external metadata ID, cannot sync poster".into(),
|
||||
))
|
||||
})?
|
||||
.value()
|
||||
.to_string();
|
||||
|
||||
sync_poster::execute(
|
||||
&state.app_ctx,
|
||||
SyncPosterCommand {
|
||||
movie_id,
|
||||
external_metadata_id: external_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let result = login_uc::execute(&state.app_ctx, LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
register_uc::execute(&state.app_ctx, RegisterCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
})
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn delete_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
match delete_review::execute(&state.app_ctx, cmd).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_review error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||
MovieDto {
|
||||
id: movie.id().value(),
|
||||
title: movie.title().value().to_string(),
|
||||
release_year: movie.release_year().value(),
|
||||
director: movie.director().map(|d| d.to_string()),
|
||||
poster_path: movie.poster_path().map(|p| p.value().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_to_dto(review: &Review) -> ReviewDto {
|
||||
ReviewDto {
|
||||
id: review.id().value(),
|
||||
rating: review.rating().value(),
|
||||
comment: review.comment().map(|c| c.value().to_string()),
|
||||
watched_at: review.watched_at().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
|
||||
DiaryEntryDto {
|
||||
movie: movie_to_dto(entry.movie()),
|
||||
review: review_to_dto(entry.review()),
|
||||
}
|
||||
}
|
||||
}
|
||||
8
crates/presentation/src/lib.rs
Normal file
8
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod event_handlers;
|
||||
pub mod dtos;
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod ports;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
@@ -1,3 +1,126 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use event_publisher::{EventPublisherConfig, NoopEventPublisher, create_event_channel};
|
||||
use presentation::event_handlers::PosterSyncHandler;
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
|
||||
use metadata::MetadataClientImpl;
|
||||
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
|
||||
use poster_storage::{PosterStorageAdapter, StorageConfig};
|
||||
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
|
||||
use presentation::{routes, state::AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let state = wire_dependencies()
|
||||
.await
|
||||
.context("Failed to wire dependencies")?;
|
||||
|
||||
let app = routes::build_router(state);
|
||||
|
||||
let listener = TcpListener::bind("0.0.0.0:3000").await?;
|
||||
tracing::info!("Listening on 0.0.0.0:3000");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wire_dependencies() -> anyhow::Result<AppState> {
|
||||
let auth_config = AuthConfig::from_env()?;
|
||||
let storage_config = StorageConfig::from_env()?;
|
||||
let app_config = AppConfig::from_env();
|
||||
let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?;
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let opts = SqliteConnectOptions::from_str(&database_url)
|
||||
.context("Invalid DATABASE_URL")?
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePool::connect_with(opts)
|
||||
.await
|
||||
.context("Failed to connect to SQLite database")?;
|
||||
|
||||
let movie_repo = SqliteMovieRepository::new(pool.clone());
|
||||
movie_repo
|
||||
.migrate()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||
.context("Database migration failed")?;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, MetadataClient, MovieRepository, PasswordHasher,
|
||||
PosterFetcherClient, PosterStorage, UserRepository,
|
||||
};
|
||||
let repository: Arc<dyn MovieRepository> = Arc::new(movie_repo);
|
||||
let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool));
|
||||
let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
|
||||
let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
|
||||
let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config)?);
|
||||
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
|
||||
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
|
||||
|
||||
// Build a context for the poster handler. sync_poster doesn't publish events,
|
||||
// so a noop publisher here is safe and avoids a circular dependency.
|
||||
let handler_ctx = AppContext {
|
||||
repository: Arc::clone(&repository),
|
||||
metadata_client: Arc::clone(&metadata_client),
|
||||
poster_fetcher: Arc::clone(&poster_fetcher),
|
||||
poster_storage: Arc::clone(&poster_storage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
auth_service: Arc::clone(&auth_service),
|
||||
password_hasher: Arc::clone(&password_hasher),
|
||||
user_repository: Arc::clone(&user_repository),
|
||||
config: app_config.clone(),
|
||||
};
|
||||
|
||||
let poster_handler = PosterSyncHandler::new(handler_ctx, 3);
|
||||
let (event_publisher, event_worker) = create_event_channel(
|
||||
EventPublisherConfig::from_env(),
|
||||
vec![Box::new(poster_handler)],
|
||||
);
|
||||
tokio::spawn(event_worker.run());
|
||||
|
||||
let app_ctx = AppContext {
|
||||
repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
poster_storage,
|
||||
event_publisher: Arc::new(event_publisher),
|
||||
auth_service,
|
||||
password_hasher,
|
||||
user_repository,
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
Ok(AppState {
|
||||
app_ctx,
|
||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new(
|
||||
"Movie Diary".into(),
|
||||
"http://localhost:3000".into(),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
2
crates/presentation/src/ports.rs
Normal file
2
crates/presentation/src/ports.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub use application::ports::HtmlRenderer;
|
||||
pub use application::ports::RssFeedRenderer;
|
||||
54
crates/presentation/src/routes.rs
Normal file
54
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use axum::{Router, routing};
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
|
||||
use crate::{handlers, state::AppState};
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(html_routes())
|
||||
.merge(api_routes())
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", routing::get(handlers::html::get_index))
|
||||
.route(
|
||||
"/login",
|
||||
routing::get(handlers::html::get_login_page)
|
||||
.post(handlers::html::post_login),
|
||||
)
|
||||
.route("/logout", routing::get(handlers::html::get_logout))
|
||||
.route(
|
||||
"/register",
|
||||
routing::get(handlers::html::get_register_page)
|
||||
.post(handlers::html::post_register),
|
||||
)
|
||||
.route("/reviews/new", routing::get(handlers::html::get_new_review_page))
|
||||
.route("/reviews", routing::post(handlers::html::post_review))
|
||||
.route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review))
|
||||
.route("/posters/{path}", routing::get(handlers::posters::get_poster))
|
||||
.route("/feed.rss", routing::get(handlers::rss::get_feed))
|
||||
}
|
||||
|
||||
fn api_routes() -> Router<AppState> {
|
||||
Router::new().nest(
|
||||
"/api",
|
||||
Router::new()
|
||||
.route("/diary", routing::get(handlers::api::get_diary))
|
||||
.route(
|
||||
"/movies/{id}/history",
|
||||
routing::get(handlers::api::get_review_history),
|
||||
)
|
||||
.route("/reviews", routing::post(handlers::api::post_review))
|
||||
.route("/reviews/{id}", routing::delete(handlers::api::delete_review))
|
||||
.route(
|
||||
"/movies/{id}/sync-poster",
|
||||
routing::post(handlers::api::sync_poster),
|
||||
)
|
||||
.route("/auth/login", routing::post(handlers::api::login))
|
||||
.route("/auth/register", routing::post(handlers::api::register)),
|
||||
)
|
||||
}
|
||||
12
crates/presentation/src/state.rs
Normal file
12
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::context::AppContext;
|
||||
|
||||
use crate::ports::{HtmlRenderer, RssFeedRenderer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub app_ctx: AppContext,
|
||||
pub html_renderer: Arc<dyn HtmlRenderer>,
|
||||
pub rss_renderer: Arc<dyn RssFeedRenderer>,
|
||||
}
|
||||
171
crates/presentation/tests/api_test.rs
Normal file
171
crates/presentation/tests/api_test.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, User},
|
||||
ports::{
|
||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
|
||||
},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use presentation::{routes, state::AppState};
|
||||
use sqlite::SqliteMovieRepository;
|
||||
use sqlx::SqlitePool;
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoopEventPublisher;
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoopEventPublisher {
|
||||
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicMeta;
|
||||
#[async_trait]
|
||||
impl MetadataClient for PanicMeta {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||
panic!("metadata not wired in tests")
|
||||
}
|
||||
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicFetcher;
|
||||
#[async_trait]
|
||||
impl PosterFetcherClient for PanicFetcher {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicStorage;
|
||||
#[async_trait]
|
||||
impl PosterStorage for PanicStorage {
|
||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for PanicHasher {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
|
||||
}
|
||||
|
||||
struct PanicAuth;
|
||||
#[async_trait]
|
||||
impl AuthService for PanicAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
|
||||
}
|
||||
|
||||
struct NobodyUserRepo;
|
||||
#[async_trait]
|
||||
impl UserRepository for NobodyUserRepo {
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
|
||||
}
|
||||
|
||||
async fn test_app() -> Router {
|
||||
let pool = SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory SQLite failed");
|
||||
let repo = SqliteMovieRepository::new(pool);
|
||||
repo.migrate().await.expect("migration failed");
|
||||
|
||||
let state = AppState {
|
||||
app_ctx: AppContext {
|
||||
repository: Arc::new(repo),
|
||||
metadata_client: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
poster_storage: Arc::new(PanicStorage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(NobodyUserRepo),
|
||||
config: AppConfig { allow_registration: false },
|
||||
},
|
||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new("Movie Diary".into(), "http://localhost:3000".into())),
|
||||
};
|
||||
|
||||
routes::build_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_diary_returns_empty_list() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["total_count"], 0);
|
||||
assert_eq!(json["items"], serde_json::json!([]));
|
||||
assert_eq!(json["limit"], 20);
|
||||
assert_eq!(json["offset"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_reviews_without_auth_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/reviews")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_auth_login_unknown_user_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user