Trading card city builder game?

add endpoint for get pack

eldridge.cam e9ced0a6 78a25e03

verified
Waiting for spindle ...
+276 -4
+20
.sqlx/query-1f545edf3e4cdfc3e6df6a2091c3aca4ef068783c27738dd5bafd4348705e260.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM packs WHERE account_id = 'foxfriends' AND opened_at IS NOT NULL", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + false 17 + ] 18 + }, 19 + "hash": "1f545edf3e4cdfc3e6df6a2091c3aca4ef068783c27738dd5bafd4348705e260" 20 + }
+28
.sqlx/query-f407778375bbf66b6a52e9436d5ba8dea93f5456ef42d5c102f3c9d273ba853e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, card_type_id\n FROM cards\n INNER JOIN pack_contents ON pack_contents.card_id = cards.id\n WHERE pack_contents.pack_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "card_type_id", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Int8" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "f407778375bbf66b6a52e9436d5ba8dea93f5456ef42d5c102f3c9d273ba853e" 28 + }
+1 -1
packages/cartography/fixtures/account.sql
··· 1 - INSERT INTO accounts (id) VALUES ('foxfriends'); 1 + INSERT INTO accounts (id) VALUES ('foxfriends'), ('not-foxfriends');
+176
packages/cartography/src/api/operations/get_pack.rs
··· 1 + use crate::api::errors::{ErrorDetailResponse, JsonError, PackNotFoundError}; 2 + use crate::api::{errors::internal_server_error, middleware::authorization::Authorization}; 3 + use crate::dto::*; 4 + use axum::{Extension, Json, extract::Path}; 5 + 6 + #[derive(serde::Serialize, utoipa::ToSchema)] 7 + #[cfg_attr(test, derive(serde::Deserialize))] 8 + pub struct GetPackResponse { 9 + pack: Pack, 10 + pack_cards: Option<Vec<Card>>, 11 + } 12 + 13 + #[utoipa::path( 14 + get, 15 + path = "/api/v1/packs/{pack_id}", 16 + description = "Get a pack. The contents of unopened packs cannot be seen.", 17 + tags = ["Pack", "Player"], 18 + security(("trust" = [])), 19 + responses( 20 + (status = OK, description = "Successfully retrieved pack.", body = GetPackResponse), 21 + (status = NOT_FOUND, description = "Pack not found, or not your pack.", body = ErrorDetailResponse), 22 + ), 23 + params( 24 + ("pack_id" = i64, Path, description = "The ID of the pack to retrieve.") 25 + ) 26 + )] 27 + pub async fn get_pack( 28 + db: Extension<sqlx::PgPool>, 29 + authorization: Extension<Authorization>, 30 + Path(pack_id): Path<i64>, 31 + ) -> axum::response::Result<Json<GetPackResponse>> { 32 + let account_id = authorization.authorized_account_id()?; 33 + let mut conn = db.acquire().await.map_err(internal_server_error)?; 34 + 35 + let pack = sqlx::query_as!( 36 + Pack, 37 + r#" 38 + SELECT packs.id, packs.pack_banner_id, packs.opened_at 39 + FROM packs 40 + WHERE id = $1 AND account_id = $2 41 + "#, 42 + pack_id, 43 + account_id, 44 + ) 45 + .fetch_optional(&mut *conn) 46 + .await 47 + .map_err(internal_server_error)? 48 + .ok_or(JsonError(PackNotFoundError { pack_id }))?; 49 + 50 + if pack.opened_at.is_none() { 51 + return Ok(Json(GetPackResponse { 52 + pack, 53 + pack_cards: None, 54 + })); 55 + } 56 + let pack_cards = sqlx::query_as!( 57 + Card, 58 + r#" 59 + SELECT id, card_type_id 60 + FROM cards 61 + INNER JOIN pack_contents ON pack_contents.card_id = cards.id 62 + WHERE pack_contents.pack_id = $1 63 + "#, 64 + pack_id, 65 + ) 66 + .fetch_all(&mut *conn) 67 + .await 68 + .map_err(internal_server_error)?; 69 + Ok(Json(GetPackResponse { 70 + pack, 71 + pack_cards: Some(pack_cards), 72 + })) 73 + } 74 + 75 + #[cfg(test)] 76 + mod tests { 77 + use crate::test::prelude::*; 78 + use axum::http::{Request, StatusCode}; 79 + use sqlx::PgPool; 80 + 81 + use super::GetPackResponse; 82 + 83 + #[sqlx::test( 84 + migrator = "MIGRATOR", 85 + fixtures(path = "../../../fixtures", scripts("seed", "account", "packs")) 86 + )] 87 + pub fn get_pack_unopened(pool: PgPool) { 88 + let pack = sqlx::query!( 89 + "SELECT id FROM packs WHERE account_id = 'foxfriends' AND opened_at IS NULL" 90 + ) 91 + .fetch_one(&pool) 92 + .await 93 + .unwrap(); 94 + 95 + let app = crate::app::Config::test(pool).into_router(); 96 + 97 + let request = Request::get(format!("/api/v1/packs/{}", pack.id)) 98 + .header("Authorization", "Trust foxfriends") 99 + .empty() 100 + .unwrap(); 101 + 102 + let Ok(response) = app.oneshot(request).await; 103 + assert_success!(response); 104 + 105 + let response: GetPackResponse = response.json().await.unwrap(); 106 + assert!(response.pack.opened_at.is_none()); 107 + assert!(response.pack_cards.is_none()); 108 + } 109 + 110 + #[sqlx::test( 111 + migrator = "MIGRATOR", 112 + fixtures(path = "../../../fixtures", scripts("seed", "account", "packs")) 113 + )] 114 + pub fn get_pack_opened(pool: PgPool) { 115 + let pack = sqlx::query!( 116 + "SELECT id FROM packs WHERE account_id = 'foxfriends' AND opened_at IS NOT NULL" 117 + ) 118 + .fetch_one(&pool) 119 + .await 120 + .unwrap(); 121 + 122 + let app = crate::app::Config::test(pool).into_router(); 123 + 124 + let request = Request::get(format!("/api/v1/packs/{}", pack.id)) 125 + .header("Authorization", "Trust foxfriends") 126 + .empty() 127 + .unwrap(); 128 + 129 + let Ok(response) = app.oneshot(request).await; 130 + assert_success!(response); 131 + 132 + let response: GetPackResponse = response.json().await.unwrap(); 133 + assert!(response.pack.opened_at.is_some()); 134 + assert!(response.pack_cards.is_some()); 135 + assert_eq!(response.pack_cards.as_ref().unwrap().len(), 5); 136 + } 137 + 138 + #[sqlx::test( 139 + migrator = "MIGRATOR", 140 + fixtures(path = "../../../fixtures", scripts("account")) 141 + )] 142 + pub fn get_pack_not_found(pool: PgPool) { 143 + let app = crate::app::Config::test(pool).into_router(); 144 + 145 + let request = Request::get("/api/v1/packs/1") 146 + .header("Authorization", "Trust foxfriends") 147 + .empty() 148 + .unwrap(); 149 + 150 + let Ok(response) = app.oneshot(request).await; 151 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 152 + } 153 + 154 + #[sqlx::test( 155 + migrator = "MIGRATOR", 156 + fixtures(path = "../../../fixtures", scripts("seed", "account", "packs")) 157 + )] 158 + pub fn get_pack_not_owned(pool: PgPool) { 159 + let pack = sqlx::query!( 160 + "SELECT id FROM packs WHERE account_id = 'foxfriends' AND opened_at IS NULL" 161 + ) 162 + .fetch_one(&pool) 163 + .await 164 + .unwrap(); 165 + 166 + let app = crate::app::Config::test(pool).into_router(); 167 + 168 + let request = Request::get(format!("/api/v1/packs/{}", pack.id)) 169 + .header("Authorization", "Trust not-foxfriends") 170 + .empty() 171 + .unwrap(); 172 + 173 + let Ok(response) = app.oneshot(request).await; 174 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 175 + } 176 + }
+2
packages/cartography/src/api/operations/mod.rs
··· 11 11 mod list_fields; 12 12 pub use list_fields::*; 13 13 14 + mod get_pack; 14 15 mod list_packs; 15 16 mod open_pack; 17 + pub use get_pack::*; 16 18 pub use list_packs::*; 17 19 pub use open_pack::*;
+43 -3
packages/cartography/src/api/operations/open_pack.rs
··· 1 1 use crate::actor::AddCardToDeck; 2 - use crate::api::errors::{JsonError, PackNotFoundError}; 2 + use crate::api::errors::{ErrorDetailResponse, JsonError, PackNotFoundError}; 3 3 use crate::api::{errors::internal_server_error, middleware::authorization::Authorization}; 4 4 use crate::bus::{Bus, BusExt}; 5 5 use crate::dto::*; ··· 17 17 post, 18 18 path = "/api/v1/packs/{pack_id}/open", 19 19 description = "Open a pack. This request is idempotent: opening a pack a second time does nothing, but returns the pack.", 20 - tag = "Game", 20 + tags = ["Game", "Pack", "Player"], 21 21 security(("trust" = [])), 22 22 responses( 23 23 (status = OK, description = "Successfully opened pack.", body = OpenPackResponse), 24 + (status = NOT_FOUND, description = "Pack not found, or not your pack.", body = ErrorDetailResponse), 24 25 ), 25 26 params( 26 27 ("pack_id" = i64, Path, description = "The ID of the pack to open.") ··· 99 100 use crate::actor::AddCardToDeck; 100 101 use crate::bus::{Bus, BusExt}; 101 102 use crate::test::prelude::*; 102 - use axum::http::Request; 103 + use axum::http::{Request, StatusCode}; 103 104 use kameo::actor::Spawn; 104 105 use sqlx::PgPool; 105 106 ··· 180 181 received.is_empty(), 181 182 "previously opened cards do not get re-broadcast" 182 183 ); 184 + } 185 + 186 + #[sqlx::test( 187 + migrator = "MIGRATOR", 188 + fixtures(path = "../../../fixtures", scripts("account")) 189 + )] 190 + pub fn open_pack_not_found(pool: PgPool) { 191 + let app = crate::app::Config::test(pool).into_router(); 192 + 193 + let request = Request::post("/api/v1/packs/1/open") 194 + .header("Authorization", "Trust foxfriends") 195 + .empty() 196 + .unwrap(); 197 + 198 + let Ok(response) = app.oneshot(request).await; 199 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 200 + } 201 + 202 + #[sqlx::test( 203 + migrator = "MIGRATOR", 204 + fixtures(path = "../../../fixtures", scripts("seed", "account", "packs")) 205 + )] 206 + pub fn open_pack_not_owned(pool: PgPool) { 207 + let pack = sqlx::query!( 208 + "SELECT id FROM packs WHERE account_id = 'foxfriends' AND opened_at IS NULL" 209 + ) 210 + .fetch_one(&pool) 211 + .await 212 + .unwrap(); 213 + 214 + let app = crate::app::Config::test(pool).into_router(); 215 + 216 + let request = Request::post(format!("/api/v1/packs/{}/open", pack.id)) 217 + .header("Authorization", "Trust not-foxfriends") 218 + .empty() 219 + .unwrap(); 220 + 221 + let Ok(response) = app.oneshot(request).await; 222 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 183 223 } 184 224 }
+6
packages/cartography/src/app.rs
··· 17 17 operations::list_fields, 18 18 19 19 operations::list_packs, 20 + operations::get_pack, 20 21 operations::open_pack, 21 22 ), 22 23 components( ··· 27 28 tags( 28 29 (name = "Global", description = "Publicly available global data about the Cartography game."), 29 30 (name = "Player", description = "Player specific data; typically requires authorization."), 31 + (name = "Pack", description = "Operations on packs."), 30 32 (name = "Game", description = "Actions with effects on gameplay."), 31 33 ), 32 34 modifiers(&SecurityAddon) ··· 102 104 .route( 103 105 "/api/v1/players/{player_id}/packs", 104 106 axum::routing::post(operations::list_packs), 107 + ) 108 + .route( 109 + "/api/v1/packs/{pack_id}", 110 + axum::routing::get(operations::get_pack), 105 111 ) 106 112 .route( 107 113 "/api/v1/packs/{pack_id}/open",