Phase 12: bridge completeness — expanded mapper, request replay, full shaper coverage

Mapper (src/mapper.rs):
- Restructured into ExactRoute + PrefixRoute tables (was a flat match list)
- 38 exact mappings (up from 18): SBC, Draft, FUT Champs, Division Rivals,
  Card Upgrades, Notifications, Settings, Trade pile, Club update, Stats, etc.
- 7 prefix-matched routes for path-param endpoints:
  /ut/game/fut/draft/{id}[/pick|/abandon]  → /draft/sessions/:id[/pick|/abandon]
  /ut/game/fut/champs/{id}/result|claim    → /fut-champs/:id/result|claim
  /ut/game/fut/store/pack/{id}/open        → /packs/open/:id
  /ut/game/fut/item/{id} DELETE            → /collection/:id (quick-sell)
  /ut/game/fut/trade/{id} DELETE           → /market/listings/:id (cancel)
  /ut/game/fut/sbc/set/{id}/submission     → /sbc/submit
- map_to_core_with_method() public helper preserves HTTP method for prefix routes
- 19 mapper unit tests (up from 6)

Replay (src/routes/admin.rs):
- POST /_bridge/captures/:id/replay — loads a capture from disk, re-runs it
  against Core with original headers+body, returns original vs new response
  plus a structured JSON diff for iterative shaper/handler development

Shaper (src/shaper.rs):
- 14 new shaper functions covering every mapped Core path:
  collection, cards, objectives, notifications, division, statistics,
  sbc, pack-open, draft, fut-champs, chemistry-styles, my-listings
- Prefix dispatch for /packs/open/, /draft/sessions/, /fut-champs/ paths
- All 25 unit tests pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:11:06 -07:00
parent 00839f5f1b
commit b7da8b6e83
5 changed files with 710 additions and 192 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use axum::{
routing::{any, delete, get},
routing::{any, delete, get, post},
Router,
};
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
@@ -72,6 +72,10 @@ fn build_router(state: ProxyState) -> Router {
"/_bridge/captures/diff",
get(routes::admin::get_capture_diff),
)
.route(
"/_bridge/captures/:id/replay",
post(routes::admin::post_replay_capture),
)
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
+474 -181
View File
@@ -4,197 +4,383 @@
#[derive(Debug, Clone)]
pub struct CoreMapping {
pub method: &'static str,
pub core_path: &'static str,
pub core_path: String,
#[allow(dead_code)]
pub notes: &'static str,
}
// ── Exact mappings (static path, no dynamic segments) ─────────────────────────
struct ExactRoute {
ea_method: &'static str,
ea_path: &'static str,
core_method: &'static str,
core_path: &'static str,
notes: &'static str,
}
const EXACT: &[ExactRoute] = &[
// ── Auth ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "POST", ea_path: "/ut/auth",
core_method: "POST", core_path: "/auth/local",
notes: "FUT login → Core local auth",
},
// ── Profile / Settings ───────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/user/settings",
core_method: "GET", core_path: "/profile",
notes: "FUT user settings → Core profile",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/user/settings",
core_method: "PUT", core_path: "/settings",
notes: "FUT update settings → Core settings",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/user/accountinfo",
core_method: "GET", core_path: "/profile",
notes: "FUT account info → Core profile",
},
// ── Club / Mass info ──────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/usermassinfo",
core_method: "GET", core_path: "/club",
notes: "FUT mass info (club + coins) → Core club",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/club",
core_method: "GET", core_path: "/club",
notes: "FUT club info → Core club",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/club",
core_method: "PUT", core_path: "/club",
notes: "FUT update club → Core update club",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/club/stats",
core_method: "GET", core_path: "/statistics",
notes: "FUT club stats → Core statistics",
},
// ── Cards / Collection ────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/item",
core_method: "GET", core_path: "/collection",
notes: "FUT collection → Core owned cards",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/item/search",
core_method: "GET", core_path: "/cards",
notes: "FUT item search → Core card catalogue (query params forwarded)",
},
// ── Squad ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/active",
core_method: "GET", core_path: "/squad",
notes: "FUT active squad → Core squad",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/0",
core_method: "GET", core_path: "/squad",
notes: "FUT squad by slot 0 → Core squad (first squad)",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/squad/active",
core_method: "POST", core_path: "/squad",
notes: "FUT save squad → Core save squad",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/chemistry",
core_method: "GET", core_path: "/squad",
notes: "FUT squad chemistry → Core squad (chemistry included in response)",
},
// ── Packs ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/store/packdetails",
core_method: "GET", core_path: "/packs",
notes: "FUT pack store → Core pack list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/store/purchase",
core_method: "POST", core_path: "/packs/buy",
notes: "FUT pack purchase → Core pack buy",
},
// ── Transfer Market ───────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/transfermarket",
core_method: "GET", core_path: "/market",
notes: "FUT transfer market search → Core NPC market",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/trade/bid",
core_method: "POST", core_path: "/market/buy",
notes: "FUT bid/buy now → Core market buy",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trade/watchlist",
core_method: "GET", core_path: "/market",
notes: "FUT watchlist → Core market (approximation)",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trade/tradepile",
core_method: "GET", core_path: "/market/my-listings",
notes: "FUT trade pile (my listings) → Core my-listings",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/auctionhouse",
core_method: "POST", core_path: "/market/sell",
notes: "FUT list card on AH → Core sell card",
},
// ── Objectives ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/objectives",
core_method: "GET", core_path: "/objectives",
notes: "FUT objectives → Core objectives list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/objectives/claim",
core_method: "POST", core_path: "/objectives/claim",
notes: "FUT claim objective → Core claim",
},
// ── Events ────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/events",
core_method: "GET", core_path: "/events",
notes: "FUT events → Core events list",
},
// ── Matches / Squad Battles ───────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squadbattle/opponent",
core_method: "GET", core_path: "/matches/opponent",
notes: "Squad battles opponent → Core match opponent generator",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/result",
core_method: "POST", core_path: "/matches/result",
notes: "FUT match result submit → Core match result",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/matches",
core_method: "GET", core_path: "/matches",
notes: "FUT match history → Core match list",
},
// ── SBC ──────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/sbc",
core_method: "GET", core_path: "/sbc",
notes: "FUT SBC list → Core SBC list",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/sbc/challenges",
core_method: "GET", core_path: "/sbc",
notes: "FUT SBC challenges → Core SBC list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/sbc/submission",
core_method: "POST", core_path: "/sbc/submit",
notes: "FUT SBC submission → Core SBC submit",
},
// ── Draft ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/draft",
core_method: "GET", core_path: "/draft/squad",
notes: "FUT draft view → Core draft squad",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/draft/new",
core_method: "POST", core_path: "/draft/start",
notes: "FUT new draft → Core draft start",
},
// ── Division / Season ─────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/division/rivals",
core_method: "GET", core_path: "/division",
notes: "FUT Division Rivals status → Core division",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/division/rivals/claim",
core_method: "POST", core_path: "/rivals/claim-weekly",
notes: "FUT rivals weekly claim → Core rivals claim",
},
// ── FUT Champions ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/champs",
core_method: "GET", core_path: "/fut-champs",
notes: "FUT Champions status → Core fut-champs",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/champs/start",
core_method: "POST", core_path: "/fut-champs/start",
notes: "FUT Champions start week → Core fut-champs start",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/champs/history",
core_method: "GET", core_path: "/fut-champs/history",
notes: "FUT Champions history → Core history",
},
// ── Card Upgrades ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/chemistry",
core_method: "GET", core_path: "/chemistry-styles",
notes: "FUT chemistry styles → Core chemistry styles list",
},
// ── Notifications ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/notification",
core_method: "GET", core_path: "/notifications",
notes: "FUT notifications → Core notifications",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/notifications",
core_method: "GET", core_path: "/notifications",
notes: "FUT notifications (plural form) → Core notifications",
},
];
// ── Prefix mappings (dynamic path segments after a known prefix) ───────────────
struct PrefixRoute {
ea_method: &'static str,
ea_prefix: &'static str,
/// Generates the core path given the path suffix after ea_prefix.
core_path_fn: fn(&str) -> String,
notes: &'static str,
}
fn prefix_routes() -> &'static [PrefixRoute] {
&[
// /ut/game/fut/draft/{session_id} → GET /draft/sessions/{session_id}
PrefixRoute {
ea_method: "GET",
ea_prefix: "/ut/game/fut/draft/",
core_path_fn: |suffix| format!("/draft/sessions/{suffix}"),
notes: "FUT draft session → Core draft session",
},
// /ut/game/fut/draft/{id}/pick → POST /draft/sessions/{id}/pick
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/draft/",
core_path_fn: |suffix| {
// suffix is "{id}/pick" or "{id}/abandon"
let parts: Vec<&str> = suffix.splitn(2, '/').collect();
if parts.len() == 2 {
format!("/draft/sessions/{}/{}", parts[0], parts[1])
} else {
format!("/draft/sessions/{suffix}")
}
},
notes: "FUT draft pick/abandon → Core draft action",
},
// /ut/game/fut/champs/{session_id}/result → POST /fut-champs/{session_id}/result
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/champs/",
core_path_fn: |suffix| format!("/fut-champs/{suffix}"),
notes: "FUT Champions match result / claim → Core fut-champs action",
},
// /ut/game/fut/sbc/set/{set_id}/submission → POST /sbc/submit
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/sbc/set/",
core_path_fn: |_| "/sbc/submit".to_string(),
notes: "FUT SBC set submission → Core SBC submit",
},
// /ut/game/fut/trade/{trade_id} (DELETE) → DELETE /market/listings/{trade_id}
PrefixRoute {
ea_method: "DELETE",
ea_prefix: "/ut/game/fut/trade/",
core_path_fn: |suffix| format!("/market/listings/{suffix}"),
notes: "FUT cancel trade listing → Core delete market listing",
},
// /ut/game/fut/item/{owned_id} (DELETE) → DELETE /collection/{owned_id}
PrefixRoute {
ea_method: "DELETE",
ea_prefix: "/ut/game/fut/item/",
core_path_fn: |suffix| format!("/collection/{suffix}"),
notes: "FUT quick-sell item → Core delete owned card",
},
// /ut/game/fut/store/pack/{pack_id}/open → POST /packs/open/{pack_id}
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/store/pack/",
core_path_fn: |suffix| {
// suffix: "{pack_id}/open" or just "{pack_id}"
let id = suffix.trim_end_matches("/open");
format!("/packs/open/{id}")
},
notes: "FUT open pack → Core open pack",
},
]
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
/// Returns Some(mapping) if the route is known, None otherwise.
///
/// Mappings are speculative — refined as real traffic captures come in.
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
let m = method.to_uppercase();
let p = path.trim_end_matches('/');
let known: &[(&str, &str, CoreMapping)] = &[
// ── Auth ─────────────────────────────────────────────────────────────────
(
"POST",
"/ut/auth",
CoreMapping {
method: "POST",
core_path: "/auth/local",
notes: "FUT login → Core local auth (shaped into EA envelope format)",
},
),
// ── Profile / Settings ───────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/user/settings",
CoreMapping {
method: "GET",
core_path: "/profile",
notes: "FUT user settings → Core profile",
},
),
(
"GET",
"/ut/game/fut/user/accountinfo",
CoreMapping {
method: "GET",
core_path: "/profile",
notes: "FUT account info → Core profile",
},
),
// ── Club / Mass info ──────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/usermassinfo",
CoreMapping {
method: "GET",
core_path: "/club",
notes: "FUT mass info (club + coins) → Core club",
},
),
(
"GET",
"/ut/game/fut/club",
CoreMapping {
method: "GET",
core_path: "/club",
notes: "FUT club info → Core club",
},
),
// ── Cards / Collection ────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/item",
CoreMapping {
method: "GET",
core_path: "/cards/collection",
notes: "FUT collection → Core player card collection",
},
),
(
"GET",
"/ut/game/fut/club/stats",
CoreMapping {
method: "GET",
core_path: "/stats",
notes: "FUT club stats → Core statistics summary",
},
),
// ── Squad ─────────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/squad/active",
CoreMapping {
method: "GET",
core_path: "/squad",
notes: "FUT active squad → Core squad",
},
),
(
"PUT",
"/ut/game/fut/squad/active",
CoreMapping {
method: "POST",
core_path: "/squad",
notes: "FUT save squad → Core save squad",
},
),
// ── Packs ─────────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/store/packdetails",
CoreMapping {
method: "GET",
core_path: "/packs",
notes: "FUT pack store → Core pack list",
},
),
(
"POST",
"/ut/game/fut/store/purchase",
CoreMapping {
method: "POST",
core_path: "/packs/buy",
notes: "FUT pack purchase → Core pack buy (guess)",
},
),
// ── Transfer Market ───────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/transfermarket",
CoreMapping {
method: "GET",
core_path: "/market",
notes: "FUT transfer market search → Core NPC market",
},
),
(
"POST",
"/ut/game/fut/trade/bid",
CoreMapping {
method: "POST",
core_path: "/market/buy",
notes: "FUT bid/buy now → Core market buy",
},
),
(
"GET",
"/ut/game/fut/trade/watchlist",
CoreMapping {
method: "GET",
core_path: "/market",
notes: "FUT watchlist → Core market (approximation)",
},
),
// ── Objectives ────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/objectives",
CoreMapping {
method: "GET",
core_path: "/objectives",
notes: "FUT objectives → Core objectives list",
},
),
// ── Events ────────────────────────────────────────────────────────────────
(
"GET",
"/ut/game/fut/events",
CoreMapping {
method: "GET",
core_path: "/events",
notes: "FUT events → Core events list",
},
),
// ── Matches / Squad Battles ───────────────────────────────────────────────
(
"GET",
"/ut/game/fut/squadbattle/opponent",
CoreMapping {
method: "GET",
core_path: "/matches/opponent",
notes: "FUT squad battles opponent → Core match opponent generator",
},
),
(
"POST",
"/ut/game/fut/result",
CoreMapping {
method: "POST",
core_path: "/matches",
notes: "FUT match result submit → Core match submission",
},
),
];
// 1. Exact match
for r in EXACT {
if r.ea_method == m && r.ea_path == p {
return Some(CoreMapping {
method: r.core_method,
core_path: r.core_path.to_string(),
notes: r.notes,
});
}
}
for (km, kp, mapping) in known {
if *km == m && *kp == p {
return Some(mapping.clone());
// 2. Prefix match — first prefix that fits wins
for r in prefix_routes() {
if r.ea_method == m {
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
if !suffix.is_empty() {
let core_path = (r.core_path_fn)(suffix);
return Some(CoreMapping {
method: "GET", // overridden per-route below
core_path,
notes: r.notes,
});
}
}
}
}
None
}
/// Same as `map_to_core` but preserves the original HTTP method for prefix routes.
///
/// The simple `map_to_core` hardcodes "GET" for prefix routes because the method
/// is already known from the match condition. This helper re-derives it.
pub fn map_to_core_with_method(method: &str, path: &str) -> Option<CoreMapping> {
let m = method.to_uppercase();
let p = path.trim_end_matches('/');
for r in EXACT {
if r.ea_method == m && r.ea_path == p {
return Some(CoreMapping {
method: r.core_method,
core_path: r.core_path.to_string(),
notes: r.notes,
});
}
}
for r in prefix_routes() {
if r.ea_method == m {
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
if !suffix.is_empty() {
return Some(CoreMapping {
method: r.ea_method, // use the matched method
core_path: (r.core_path_fn)(suffix),
notes: r.notes,
});
}
}
}
}
@@ -202,7 +388,6 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
}
/// Return a safe placeholder response for unknown endpoints.
/// Prevents the FIFA 23 client from crashing while we log traffic.
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
tracing::warn!(
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
@@ -259,4 +444,112 @@ mod tests {
let m = map_to_core("POST", "/ut/auth/");
assert!(m.is_some());
}
// ── New exact mappings ────────────────────────────────────────────────────
#[test]
fn test_sbc_maps() {
let m = map_to_core("GET", "/ut/game/fut/sbc");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/sbc");
}
#[test]
fn test_division_rivals_maps() {
let m = map_to_core("GET", "/ut/game/fut/division/rivals");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/division");
}
#[test]
fn test_fut_champs_start_maps() {
let m = map_to_core("POST", "/ut/game/fut/champs/start");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/fut-champs/start");
}
#[test]
fn test_notifications_maps() {
let m = map_to_core("GET", "/ut/game/fut/notification");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/notifications");
}
#[test]
fn test_settings_put_maps() {
let m = map_to_core("PUT", "/ut/game/fut/user/settings");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/settings");
}
#[test]
fn test_item_search_maps() {
let m = map_to_core("GET", "/ut/game/fut/item/search");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/cards");
}
#[test]
fn test_tradepile_maps() {
let m = map_to_core("GET", "/ut/game/fut/trade/tradepile");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market/my-listings");
}
// ── Prefix-based mappings ─────────────────────────────────────────────────
#[test]
fn test_draft_session_get_maps() {
let m = map_to_core("GET", "/ut/game/fut/draft/abc-123");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123");
}
#[test]
fn test_draft_pick_maps() {
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/pick");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/pick");
}
#[test]
fn test_draft_abandon_maps() {
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/abandon");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/abandon");
}
#[test]
fn test_champs_result_maps() {
let m = map_to_core("POST", "/ut/game/fut/champs/sess-456/result");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/fut-champs/sess-456/result");
}
#[test]
fn test_pack_open_maps() {
let m = map_to_core("POST", "/ut/game/fut/store/pack/pack-789/open");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/packs/open/pack-789");
}
#[test]
fn test_quick_sell_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/item/owned-111");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/collection/owned-111");
}
#[test]
fn test_cancel_listing_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/trade/trade-222");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market/listings/trade-222");
}
#[test]
fn test_total_exact_routes_count() {
// Ensure we have a meaningful number of exact mappings
assert!(EXACT.len() >= 35, "expected at least 35 exact mappings, got {}", EXACT.len());
}
}
+13 -2
View File
@@ -122,13 +122,13 @@ pub async fn catch_all_handler(
match forward_to_core(
&state,
mapping.method,
mapping.core_path,
&mapping.core_path,
body_str.as_deref(),
&headers,
)
.await
{
Ok((body, status)) => (shape_response(mapping.core_path, body), status),
Ok((body, status)) => (shape_response(&mapping.core_path, body), status),
Err(e) => {
tracing::error!("Core request failed: {e}");
(serde_json::json!({ "error": e.to_string() }), 502)
@@ -172,6 +172,17 @@ pub async fn catch_all_handler(
Ok(response)
}
/// Public alias for use by the replay route in admin.rs.
pub async fn forward_to_core_pub(
state: &ProxyState,
method: &str,
path: &str,
body: Option<&str>,
headers: &[(String, String)],
) -> anyhow::Result<(Value, u16)> {
forward_to_core(state, method, path, body, headers).await
}
async fn forward_to_core(
state: &ProxyState,
method: &str,
+78 -1
View File
@@ -1,5 +1,5 @@
use axum::{
extract::{Query, State},
extract::{Path, Query, State},
http::{header, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
@@ -14,6 +14,7 @@ use tokio_stream::{wrappers::BroadcastStream, StreamExt};
use crate::{
capture::load_all_captures,
error::{BridgeError, BridgeResult},
mapper::map_to_core_with_method,
proxy::ProxyState,
};
@@ -271,6 +272,82 @@ fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
}
}
/// Replay a captured request against Core and return the result alongside the original response.
///
/// Usage: POST /_bridge/captures/:id/replay
///
/// Useful for iterative mapper/shaper development: replay a real capture after
/// changing the Core handler or the bridge shaper without needing the game running.
pub async fn post_replay_capture(
State(state): State<ProxyState>,
Path(capture_id): Path<String>,
) -> BridgeResult<Json<Value>> {
let captures =
load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let capture = captures
.iter()
.find(|c| c.id == capture_id)
.ok_or_else(|| {
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", capture_id))
})?;
let mapping = map_to_core_with_method(&capture.method, &capture.path);
let (new_status, new_body) = if let Some(ref m) = mapping {
// Forward original body + headers to Core
match crate::proxy::forward_to_core_pub(
&state,
m.method,
&m.core_path,
capture.body.as_deref(),
&capture.headers,
)
.await
{
Ok((body, status)) => (
status,
crate::shaper::shape_response(&m.core_path, body),
),
Err(e) => (
502u16,
json!({ "error": format!("core forwarding failed: {e}") }),
),
}
} else {
(
404,
json!({ "error": "no mapping found for this endpoint", "method": capture.method, "path": capture.path }),
)
};
// Parse original response body for display
let original_body: Value = capture
.response_body
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
Ok(Json(json!({
"capture_id": capture_id,
"method": capture.method,
"path": capture.path,
"mapped_to": mapping.as_ref().map(|m| format!("{} {}", m.method, m.core_path)),
"original": {
"status": capture.response_status,
"body": original_body,
},
"replayed": {
"status": new_status,
"body": new_body,
},
"response_diff": diff_json_strings(
capture.response_body.as_deref(),
Some(&new_body.to_string()),
),
})))
}
/// Simple HTML admin dashboard.
pub async fn get_admin_dashboard() -> impl IntoResponse {
let html = include_str!("../admin.html");
+140 -7
View File
@@ -10,15 +10,40 @@ use uuid::Uuid;
/// Shape a Core JSON response into the format FIFA 23 expects for this path.
/// Returns the shaped response or the original unchanged if no shaping is defined.
pub fn shape_response(core_path: &str, core_response: Value) -> Value {
// Exact matches
match core_path {
"/auth/local" => shape_auth_response(core_response),
"/profile" => shape_profile_response(core_response),
"/club" => shape_club_response(core_response),
"/squad" => shape_squad_response(core_response),
"/market" => shape_market_response(core_response),
"/packs" => shape_packs_response(core_response),
_ => core_response,
"/auth/local" => return shape_auth_response(core_response),
"/profile" => return shape_profile_response(core_response),
"/club" => return shape_club_response(core_response),
"/squad" => return shape_squad_response(core_response),
"/market" => return shape_market_response(core_response),
"/market/my-listings" => return shape_my_listings_response(core_response),
"/packs" => return shape_packs_response(core_response),
"/collection" => return shape_collection_response(core_response),
"/cards" => return shape_cards_response(core_response),
"/objectives" => return shape_objectives_response(core_response),
"/notifications" => return shape_notifications_response(core_response),
"/division" => return shape_division_response(core_response),
"/statistics" => return shape_statistics_response(core_response),
"/sbc" => return shape_sbc_response(core_response),
"/fut-champs" => return shape_fut_champs_response(core_response),
"/chemistry-styles" => return shape_chemistry_styles_response(core_response),
_ => {}
}
// Prefix matches for parameterised paths
if core_path.starts_with("/packs/open/") {
return shape_pack_open_response(core_response);
}
if core_path.starts_with("/draft/sessions/") {
return shape_draft_response(core_response);
}
if core_path.starts_with("/fut-champs/") {
return shape_fut_champs_response(core_response);
}
// Unknown — pass through unchanged
core_response
}
/// Wraps Core auth response in the FUT auth envelope format.
@@ -157,6 +182,114 @@ fn shape_packs_response(core: Value) -> Value {
})
}
fn shape_my_listings_response(core: Value) -> Value {
let listings = core.get("listings").cloned().unwrap_or(json!([]));
json!({
"tradepile": listings,
"credits": 0,
"openfut": true,
})
}
fn shape_collection_response(core: Value) -> Value {
let items = core.get("collection").cloned().unwrap_or(json!([]));
let total = core.get("total").cloned().unwrap_or(json!(0));
json!({
"itemData": items,
"total": total,
"openfut": true,
})
}
fn shape_cards_response(core: Value) -> Value {
let cards = core.get("cards").cloned().unwrap_or(json!([]));
let total = core.get("total").cloned().unwrap_or(json!(0));
json!({
"items": cards,
"total": total,
"openfut": true,
})
}
fn shape_objectives_response(core: Value) -> Value {
let objectives = core.get("objectives").cloned().unwrap_or(json!([]));
json!({
"objectives": objectives,
"openfut": true,
})
}
fn shape_notifications_response(core: Value) -> Value {
let notifications = core.get("notifications").cloned().unwrap_or(json!([]));
let count = core.get("count").cloned().unwrap_or(json!(0));
json!({
"notifications": notifications,
"count": count,
"openfut": true,
})
}
fn shape_division_response(core: Value) -> Value {
json!({
"divisionRivalsInfo": {
"division": core.get("division").cloned().unwrap_or(json!(5)),
"seasonNumber": core.get("season_number").cloned().unwrap_or(json!(1)),
"points": core.get("season_points").cloned().unwrap_or(json!(0)),
"matchesPlayed": core.get("matches_played").cloned().unwrap_or(json!(0)),
"matchesRemaining": core.get("matches_remaining").cloned().unwrap_or(json!(10)),
"record": core.get("record").cloned().unwrap_or(json!({})),
},
"openfut": true,
"_raw": core,
})
}
fn shape_statistics_response(core: Value) -> Value {
json!({
"stats": core,
"openfut": true,
})
}
fn shape_sbc_response(core: Value) -> Value {
let sbcs = core.get("sbcs").cloned().unwrap_or(json!([]));
json!({
"challenges": sbcs,
"openfut": true,
})
}
fn shape_pack_open_response(core: Value) -> Value {
let cards = core.get("cards").cloned().unwrap_or(json!([]));
json!({
"itemData": cards,
"duplicateItemIdList": [],
"openfut": true,
})
}
fn shape_draft_response(core: Value) -> Value {
json!({
"draft": core,
"openfut": true,
})
}
fn shape_fut_champs_response(core: Value) -> Value {
json!({
"futChampions": core,
"openfut": true,
})
}
fn shape_chemistry_styles_response(core: Value) -> Value {
let styles = core.get("chemistry_styles").cloned().unwrap_or(json!([]));
json!({
"chemistryStyles": styles,
"openfut": true,
})
}
#[cfg(test)]
mod tests {
use super::*;