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:
@@ -172,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||||
.route("/squad", get(routes::squad::get_squad))
|
.route("/squad", get(routes::squad::get_squad))
|
||||||
.route("/squad", post(routes::squad::post_squad))
|
.route("/squad", post(routes::squad::post_squad))
|
||||||
|
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
||||||
|
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
||||||
.route("/squads", get(routes::squad::get_squads))
|
.route("/squads", get(routes::squad::get_squads))
|
||||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
|
|||||||
+138
-4
@@ -1,15 +1,20 @@
|
|||||||
use crate::extractors::GameId;
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::squad::SaveSquadRequest,
|
models::game_ext::OpaqueExtensionWrite,
|
||||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
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>> {
|
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
@@ -98,3 +103,132 @@ fn squad_response(
|
|||||||
"chemistry": chemistry,
|
"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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -675,7 +675,7 @@ async fn test_draft_pick_advances_session() {
|
|||||||
assert_eq!(pick1["status"], "active");
|
assert_eq!(pick1["status"], "active");
|
||||||
assert_eq!(pick1["progress"]["filled"], 1);
|
assert_eq!(pick1["progress"]["filled"], 1);
|
||||||
assert_eq!(pick1["current_position"], "RB");
|
assert_eq!(pick1["current_position"], "RB");
|
||||||
assert!(pick1["candidates"].as_array().unwrap().len() >= 1);
|
assert!(!pick1["candidates"].as_array().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2279,3 +2279,84 @@ async fn test_owned_query_parameter_order_invariance() {
|
|||||||
);
|
);
|
||||||
assert_eq!(a["total"], b["total"]);
|
assert_eq!(a["total"], b["total"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(payload.to_string()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = resp.status();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(status, serde_json::from_slice(&body).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "SquadExtUser").await;
|
||||||
|
|
||||||
|
// Owned cards from the starter pack.
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(ids.len() >= 2, "starter pack should yield >=2 owned cards");
|
||||||
|
|
||||||
|
let payload = "{\"custom\":\"[1,2,3]\",\"kit_numbers\":{}}";
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [
|
||||||
|
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||||
|
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||||
|
],
|
||||||
|
"client_reported": {
|
||||||
|
"client_reported_chemistry": 52,
|
||||||
|
"client_reported_rating": 90,
|
||||||
|
"client_reported_star_rating": 90
|
||||||
|
},
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": payload},
|
||||||
|
});
|
||||||
|
|
||||||
|
let (s, put) = json_put(&app, "/squad/replace", body.clone()).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{put}");
|
||||||
|
assert_eq!(put["slots_written"], 2);
|
||||||
|
let fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Read the canonical squad + opaque extension back: Fresh, payload verbatim.
|
||||||
|
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{ext}");
|
||||||
|
assert_eq!(ext["extension"]["state"], "fresh");
|
||||||
|
assert_eq!(ext["extension"]["payload"], payload, "opaque payload round-trips verbatim");
|
||||||
|
assert_eq!(ext["extension"]["schema_version"], 1);
|
||||||
|
assert_eq!(ext["extension"]["stored_fingerprint"], fp);
|
||||||
|
assert_eq!(ext["squad"]["formation"], "f442");
|
||||||
|
assert_eq!(ext["players"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
// Idempotent: an identical replacement converges to the same fingerprint.
|
||||||
|
let (s2, put2) = json_put(&app, "/squad/replace", body).await;
|
||||||
|
assert_eq!(s2, StatusCode::OK);
|
||||||
|
assert_eq!(put2["canonical_fingerprint"], fp, "identical PUT is idempotent");
|
||||||
|
|
||||||
|
// A different namespace has no stored extension: Missing, never fabricated.
|
||||||
|
let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await;
|
||||||
|
assert_eq!(other["extension"]["state"], "missing");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user