···5566[dependencies]
77anyhow = "1.0.102"
88-axum = "0.8.9"
88+axum = { version = "0.8.9", features = ["macros"] }
99dotenvy = "0.15.7"
1010rand = "0.10.1"
1111serde = { version = "1.0.228", features = ["derive"] }
+69-5
backend/src/main.rs
···11+use std::{collections::HashMap, sync::Arc};
22+13use axum::{
24 Json, Router,
35 extract::{Path, State},
···57 response::IntoResponse,
68 routing::{get, post},
79};
1010+use serde::Deserialize;
811use sqlx::PgPool;
912use tower_http::cors::CorsLayer;
1013use tower_http::trace::TraceLayer;
1114use tracing_subscriber;
12151316mod game;
1414-use crate::game::{GameState, default_catalog};
1717+use crate::game::{Action, GameError, GameState, apply_action, default_catalog};
15181619// === App State ===
1720// Ce qui est partage entre tous les handlers (clone-able, passe via State)
···1922#[derive(Clone)]
2023struct AppState {
2124 db: PgPool,
2525+ catalog: Arc<HashMap<String, Action>>,
2226}
23272428// === Errors ===
2529// Un type d'erreur applicatif simple avec anyhow en interne
2626-2730enum AppError {
2831 NotFound(String),
2932 Internal(anyhow::Error),
3333+ BadRequest(String),
3434+ Conflict(String),
3535+}
3636+3737+impl From<GameError> for AppError {
3838+ fn from(err: GameError) -> Self {
3939+ match err {
4040+ GameError::CardNotInGame => AppError::NotFound("Card not in game".to_string()),
4141+ GameError::CardNotInHand => AppError::BadRequest("Card not in hand".to_string()),
4242+ GameError::GameOver => AppError::Conflict("Game over".to_string()),
4343+ }
4444+ }
4545+}
4646+4747+impl From<serde_json::Error> for AppError {
4848+ fn from(err: serde_json::Error) -> Self {
4949+ AppError::Internal(err.into())
5050+ }
3051}
31523253// Convertit nos erreurs en reponses HTTP automatiquement
···3859 StatusCode::INTERNAL_SERVER_ERROR,
3960 format!("Internal error: {err}"),
4061 ),
6262+ AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
6363+ AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
4164 };
4265 (status, Json(serde_json::json!({ "error": message }))).into_response()
4366 }
···5780}
58815982async fn create_game(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
6060- let ids = default_catalog().keys().cloned().collect::<Vec<_>>();
8383+ let ids = state.catalog.keys().cloned().collect::<Vec<_>>();
6184 let game = GameState::new(&ids);
6262- let game_json = serde_json::to_value(&game).unwrap();
8585+ let game_json = serde_json::to_value(&game)?;
63866487 let row = sqlx::query_scalar!(
6588 r#"INSERT INTO games (state) VALUES ($1) RETURNING id"#,
···89112 })))
90113}
91114115115+#[derive(Deserialize)]
116116+#[serde(deny_unknown_fields)]
117117+struct PlayActionRequest {
118118+ action_id: String,
119119+}
120120+121121+#[axum::debug_handler]
122122+async fn play_action(
123123+ Path(game_id): Path<i32>,
124124+ State(state): State<AppState>,
125125+ Json(payload): Json<PlayActionRequest>,
126126+) -> Result<Json<serde_json::Value>, AppError> {
127127+ let game = sqlx::query!(r#"SELECT id, state from games WHERE id = $1"#, game_id)
128128+ .fetch_optional(&state.db)
129129+ .await?
130130+ .ok_or_else(|| AppError::NotFound(format!("Game {} not found", game_id)))?;
131131+132132+ let game_state: GameState = serde_json::from_value(game.state)?;
133133+134134+ let new_state = apply_action(&game_state, &payload.action_id, &state.catalog)?;
135135+136136+ let new_state_as_value = serde_json::to_value(&new_state)?;
137137+138138+ sqlx::query!(
139139+ "UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2",
140140+ new_state_as_value,
141141+ game_id
142142+ )
143143+ .execute(&state.db)
144144+ .await?;
145145+146146+ Ok(Json(serde_json::json!({
147147+ "id": game_id,
148148+ "state": new_state
149149+ })))
150150+}
151151+92152// === Main ===
9315394154#[tokio::main]
···108168 // Lance les migrations SQL au demarrage
109169 sqlx::migrate!().run(&pool).await?;
110170111111- let state = AppState { db: pool };
171171+ let state = AppState {
172172+ db: pool,
173173+ catalog: Arc::new(default_catalog()),
174174+ };
112175113176 // Routes
114177 let app = Router::new()
115178 .route("/health", get(health))
116179 .route("/games", post(create_game))
117180 .route("/games/{id}", get(get_game))
181181+ .route("/games/{id}/actions", post(play_action))
118182 .layer(CorsLayer::permissive()) // autorise tout en dev
119183 .layer(TraceLayer::new_for_http())
120184 .with_state(state);
+9-9
todo.md
···156156catalogue. Comme `AppState` est cloné à chaque requête, il faut éviter de
157157cloner la `HashMap` à chaque fois.
158158159159-- [ ] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`.
160160-- [ ] Dans `main`, construire le catalogue une fois et l'envelopper dans
159159+- [x] Ajouter `catalog: Arc<HashMap<String, Action>>` à `AppState`.
160160+- [x] Dans `main`, construire le catalogue une fois et l'envelopper dans
161161 `Arc::new(...)` avant de le passer à `AppState`.
162162163163## 13. Étendre `AppError`
164164165165Pour mapper `GameError` vers du HTTP:
166166167167-- [ ] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)`
167167+- [x] Ajouter au moins `BadRequest(String)` (→ 400) et `Conflict(String)`
168168 (→ 409, pour les erreurs de règle comme `GameOver`/`CardNotInHand`).
169169-- [ ] Implémenter `From<GameError> for AppError` — choix: lesquelles
169169+- [x] Implémenter `From<GameError> for AppError` — choix: lesquelles
170170 mappent vers 400 vs 409 vs 422? (`GameOver` = 409 conflict, le client
171171 ne peut plus jouer; `UnknownAction` = 400 bad input; etc.)
172172173173## 14. Handler `POST /games/{id}/actions`
174174175175-- [ ] Définir un type `PlayActionRequest { action_id: String }` avec
175175+- [x] Définir un type `PlayActionRequest { action_id: String }` avec
176176 `Deserialize` et `deny_unknown_fields`.
177177-- [ ] Handler async:
177177+- [x] Handler async:
178178 1. Charger la ligne `games` par id (`fetch_optional` → 404 si absent).
179179 2. Désérialiser le `state` JSONB en `GameState`.
180180 3. Appeler `apply_action(&game_state, &body.action_id, &state.catalog)`.
181181 4. Sérialiser le nouvel état.
182182 5. `UPDATE games SET state = $1, updated_at = NOW() WHERE id = $2`.
183183 6. Retourner le nouvel état en JSON.
184184-- [ ] Enregistrer la route: `.route("/games/{id}/actions", post(...))`.
184184+- [x] Enregistrer la route: `.route("/games/{id}/actions", post(...))`.
185185186186Pas de transaction pour l'instant (jeu solo, on accepte le risque de race).
187187À durcir plus tard avec `pool.begin()` + `SELECT ... FOR UPDATE`.
188188189189## 15. Mettre à jour `create_game`
190190191191-- [ ] `GameState::new(...)` prend maintenant la liste d'ids du catalogue.
191191+- [x] `GameState::new(...)` prend maintenant la liste d'ids du catalogue.
192192 Récupérer `state.catalog.keys()` et les passer.
193193-- [ ] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend
193193+- [x] La réponse renvoie le nouvel état (avec `hand` peuplée) — le frontend
194194 verra ses 3 cartes initiales.
195195196196## 16. Test bout en bout (manuel)