feat(squad): expose extension-aware squad read/write over HTTP

Add two thin transport routes wrapping the existing extension services
(no new domain logic; Core still owns validation, ownership, the atomic
canonical+extension transaction, the server fingerprint, and staleness):

  GET /squad/ext?namespace=<ns>  -> read_squad_with_ext
      returns {squad, players, extension:{state: fresh|stale|missing,
      schema_version, payload, stored_fingerprint, current_fingerprint}}
  PUT /squad/replace             -> replace_squad_with_extension
      body {name, formation, slots[], client_reported, extension};
      resolves the active squad in place (creates if none); returns
      {squad_id, canonical_fingerprint, slots_written}

A game host needs these to read/persist the FIFA squad extension atomically
over HTTP; the service functions existed but were unreachable. Adds an
integration test (replace -> read Fresh, verbatim payload, idempotent PUT
converges to the same fingerprint, missing-namespace -> Missing) and clears
a pre-existing len_zero lint so the crate is clippy-clean.
This commit is contained in:
funman300
2026-08-12 02:47:15 +00:00
parent 615c5fd7a5
commit 9b2c6b82f2
3 changed files with 222 additions and 5 deletions
+138 -4
View File
@@ -1,15 +1,20 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::squad::SaveSquadRequest,
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
error::{AppError, AppResult},
models::game_ext::OpaqueExtensionWrite,
models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement},
services::{
club as club_svc, profile as profile_svc, squad as squad_svc,
squad::SquadExtState, squad_rules::{ClientReportedEvaluation, DefaultSquadRules},
},
};
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
@@ -98,3 +103,132 @@ fn squad_response(
"chemistry": chemistry,
})
}
// ─────────── Game-extension-aware squad transport (host composition) ─────────
//
// These two routes expose the already-existing extension services
// (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game
// host can read/write the canonical squad AND its opaque game extension in one
// Core round-trip. They add no domain logic — Core still owns validation,
// ownership, the atomic transaction, the server fingerprint, and staleness; it
// never interprets the extension payload.
#[derive(Deserialize)]
pub struct ExtQuery {
/// Opaque adapter namespace, e.g. `"fifa17.squad"`.
pub namespace: String,
}
/// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque
/// extension with an explicit Fresh/Stale/Missing verdict. Never projects a
/// stale blob; the caller decides policy.
pub async fn get_squad_ext(
State(state): State<AppState>,
game: GameId,
Query(q): Query<ExtQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players, state_ext) =
squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?;
let extension = match state_ext {
SquadExtState::Fresh(row) => json!({
"state": "fresh",
"schema_version": row.schema_version,
"payload": row.payload,
"stored_fingerprint": row.canonical_fingerprint,
}),
SquadExtState::Stale { stored, current_fingerprint } => json!({
"state": "stale",
"schema_version": stored.schema_version,
"payload": stored.payload,
"stored_fingerprint": stored.canonical_fingerprint,
"current_fingerprint": current_fingerprint,
}),
SquadExtState::Missing => json!({ "state": "missing" }),
};
Ok(Json(json!({
"squad": squad,
"players": players,
"extension": extension,
})))
}
#[derive(Deserialize)]
pub struct SlotReq {
pub owned_card_id: String,
pub slot: i64,
#[serde(default)]
pub is_captain: bool,
#[serde(default)]
pub is_on_bench: bool,
}
#[derive(Deserialize)]
pub struct ReplaceReq {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub formation: Option<String>,
pub slots: Vec<SlotReq>,
#[serde(default)]
pub client_reported: ClientReportedEvaluation,
pub extension: OpaqueExtensionWrite,
}
/// `PUT /squad/replace` — full-replacement of the active squad's canonical slots
/// plus its opaque game extension, in ONE Core transaction. Resolves the active
/// squad in place (creates one if none exists). Ownership, duplicate, and size
/// validation happen inside the service before any write.
pub async fn put_squad_replace(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ReplaceReq>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Replace the club's active squad in place; if there is none yet, create it.
let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await {
Ok((s, _)) => Some(s.id),
Err(AppError::NotFound(_)) => None,
Err(e) => return Err(e),
};
let replacement = SquadReplacement {
name: req.name,
formation: req.formation,
slots: req
.slots
.into_iter()
.map(|s| SlotAssignment {
owned_card_id: s.owned_card_id,
slot: s.slot,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
})
.collect(),
};
let out = squad_svc::replace_squad_with_extension(
&state.pool,
&state.card_db,
&DefaultSquadRules,
game.as_str(),
&club.id,
squad_id.as_deref(),
&replacement,
&req.client_reported,
&req.extension,
)
.await?;
Ok(Json(json!({
"squad_id": out.squad.id,
"canonical_fingerprint": out.canonical_fingerprint,
"slots_written": out.slots_written,
})))
}