this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): implemented action post

+91 -15
+12
backend/Cargo.lock
··· 51 51 checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" 52 52 dependencies = [ 53 53 "axum-core", 54 + "axum-macros", 54 55 "bytes", 55 56 "form_urlencoded", 56 57 "futures-util", ··· 94 95 "tower-layer", 95 96 "tower-service", 96 97 "tracing", 98 + ] 99 + 100 + [[package]] 101 + name = "axum-macros" 102 + version = "0.5.1" 103 + source = "registry+https://github.com/rust-lang/crates.io-index" 104 + checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" 105 + dependencies = [ 106 + "proc-macro2", 107 + "quote", 108 + "syn", 97 109 ] 98 110 99 111 [[package]]
+1 -1
backend/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 anyhow = "1.0.102" 8 - axum = "0.8.9" 8 + axum = { version = "0.8.9", features = ["macros"] } 9 9 dotenvy = "0.15.7" 10 10 rand = "0.10.1" 11 11 serde = { version = "1.0.228", features = ["derive"] }
+69 -5
backend/src/main.rs
··· 1 + use std::{collections::HashMap, sync::Arc}; 2 + 1 3 use axum::{ 2 4 Json, Router, 3 5 extract::{Path, State}, ··· 5 7 response::IntoResponse, 6 8 routing::{get, post}, 7 9 }; 10 + use serde::Deserialize; 8 11 use sqlx::PgPool; 9 12 use tower_http::cors::CorsLayer; 10 13 use tower_http::trace::TraceLayer; 11 14 use tracing_subscriber; 12 15 13 16 mod game; 14 - use crate::game::{GameState, default_catalog}; 17 + use crate::game::{Action, GameError, GameState, apply_action, default_catalog}; 15 18 16 19 // === App State === 17 20 // Ce qui est partage entre tous les handlers (clone-able, passe via State) ··· 19 22 #[derive(Clone)] 20 23 struct AppState { 21 24 db: PgPool, 25 + catalog: Arc<HashMap<String, Action>>, 22 26 } 23 27 24 28 // === Errors === 25 29 // Un type d'erreur applicatif simple avec anyhow en interne 26 - 27 30 enum AppError { 28 31 NotFound(String), 29 32 Internal(anyhow::Error), 33 + BadRequest(String), 34 + Conflict(String), 35 + } 36 + 37 + impl From<GameError> for AppError { 38 + fn from(err: GameError) -> Self { 39 + match err { 40 + GameError::CardNotInGame => AppError::NotFound("Card not in game".to_string()), 41 + GameError::CardNotInHand => AppError::BadRequest("Card not in hand".to_string()), 42 + GameError::GameOver => AppError::Conflict("Game over".to_string()), 43 + } 44 + } 45 + } 46 + 47 + impl From<serde_json::Error> for AppError { 48 + fn from(err: serde_json::Error) -> Self { 49 + AppError::Internal(err.into()) 50 + } 30 51 } 31 52 32 53 // Convertit nos erreurs en reponses HTTP automatiquement ··· 38 59 StatusCode::INTERNAL_SERVER_ERROR, 39 60 format!("Internal error: {err}"), 40 61 ), 62 + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), 63 + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), 41 64 }; 42 65 (status, Json(serde_json::json!({ "error": message }))).into_response() 43 66 } ··· 57 80 } 58 81 59 82 async fn create_game(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> { 60 - let ids = default_catalog().keys().cloned().collect::<Vec<_>>(); 83 + let ids = state.catalog.keys().cloned().collect::<Vec<_>>(); 61 84 let game = GameState::new(&ids); 62 - let game_json = serde_json::to_value(&game).unwrap(); 85 + let game_json = serde_json::to_value(&game)?; 63 86 64 87 let row = sqlx::query_scalar!( 65 88 r#"INSERT INTO games (state) VALUES ($1) RETURNING id"#, ··· 89 112 }))) 90 113 } 91 114 115 + #[derive(Deserialize)] 116 + #[serde(deny_unknown_fields)] 117 + struct PlayActionRequest { 118 + action_id: String, 119 + } 120 + 121 + #[axum::debug_handler] 122 + async fn play_action( 123 + Path(game_id): Path<i32>, 124 + State(state): State<AppState>, 125 + Json(payload): Json<PlayActionRequest>, 126 + ) -> Result<Json<serde_json::Value>, AppError> { 127 + let game = sqlx::query!(r#"SELECT id, state from games WHERE id = $1"#, game_id) 128 + .fetch_optional(&state.db) 129 + .await? 130 + .ok_or_else(|| AppError::NotFound(format!("Game {} not found", game_id)))?; 131 + 132 + let game_state: GameState = serde_json::from_value(game.state)?; 133 + 134 + let new_state = apply_action(&game_state, &payload.action_id, &state.catalog)?; 135 + 136 + let new_state_as_value = serde_json::to_value(&new_state)?; 137 + 138 + sqlx::query!( 139 + "UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2", 140 + new_state_as_value, 141 + game_id 142 + ) 143 + .execute(&state.db) 144 + .await?; 145 + 146 + Ok(Json(serde_json::json!({ 147 + "id": game_id, 148 + "state": new_state 149 + }))) 150 + } 151 + 92 152 // === Main === 93 153 94 154 #[tokio::main] ··· 108 168 // Lance les migrations SQL au demarrage 109 169 sqlx::migrate!().run(&pool).await?; 110 170 111 - let state = AppState { db: pool }; 171 + let state = AppState { 172 + db: pool, 173 + catalog: Arc::new(default_catalog()), 174 + }; 112 175 113 176 // Routes 114 177 let app = Router::new() 115 178 .route("/health", get(health)) 116 179 .route("/games", post(create_game)) 117 180 .route("/games/{id}", get(get_game)) 181 + .route("/games/{id}/actions", post(play_action)) 118 182 .layer(CorsLayer::permissive()) // autorise tout en dev 119 183 .layer(TraceLayer::new_for_http()) 120 184 .with_state(state);
+9 -9
todo.md
··· 156 156 catalogue. Comme `AppState` est cloné à chaque requête, il faut éviter de 157 157 cloner la `HashMap` à chaque fois. 158 158 159 - - [ ] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`. 160 - - [ ] Dans `main`, construire le catalogue une fois et l'envelopper dans 159 + - [x] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`. 160 + - [x] Dans `main`, construire le catalogue une fois et l'envelopper dans 161 161 `Arc::new(...)` avant de le passer à `AppState`. 162 162 163 163 ## 13. Étendre `AppError` 164 164 165 165 Pour mapper `GameError` vers du HTTP: 166 166 167 - - [ ] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)` 167 + - [x] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)` 168 168 (→ 409, pour les erreurs de règle comme `GameOver`/`CardNotInHand`). 169 - - [ ] Implémenter `From<GameError> for AppError` — choix: lesquelles 169 + - [x] Implémenter `From<GameError> for AppError` — choix: lesquelles 170 170 mappent vers 400 vs 409 vs 422? (`GameOver` = 409 conflict, le client 171 171 ne peut plus jouer; `UnknownAction` = 400 bad input; etc.) 172 172 173 173 ## 14. Handler `POST /games/{id}/actions` 174 174 175 - - [ ] Définir un type `PlayActionRequest { action_id: String }` avec 175 + - [x] Définir un type `PlayActionRequest { action_id: String }` avec 176 176 `Deserialize` et `deny_unknown_fields`. 177 - - [ ] Handler async: 177 + - [x] Handler async: 178 178 1. Charger la ligne `games` par id (`fetch_optional` → 404 si absent). 179 179 2. Désérialiser le `state` JSONB en `GameState`. 180 180 3. Appeler `apply_action(&game_state, &body.action_id, &state.catalog)`. 181 181 4. Sérialiser le nouvel état. 182 182 5. `UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2`. 183 183 6. Retourner le nouvel état en JSON. 184 - - [ ] Enregistrer la route: `.route("/games/{id}/actions", post(...))`. 184 + - [x] Enregistrer la route: `.route("/games/{id}/actions", post(...))`. 185 185 186 186 Pas de transaction pour l'instant (jeu solo, on accepte le risque de race). 187 187 À durcir plus tard avec `pool.begin()` + `SELECT ... FOR UPDATE`. 188 188 189 189 ## 15. Mettre à jour `create_game` 190 190 191 - - [ ] `GameState::new(...)` prend maintenant la liste d'ids du catalogue. 191 + - [x] `GameState::new(...)` prend maintenant la liste d'ids du catalogue. 192 192 Récupérer `state.catalog.keys()` et les passer. 193 - - [ ] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend 193 + - [x] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend 194 194 verra ses 3 cartes initiales. 195 195 196 196 ## 16. Test bout en bout (manuel)