Phase 7 (Bridge): response shaper, diff tool, expanded mapper
- shaper.rs: shape_response() dispatches on core_path to wrap Core JSON in FUT envelope format; shapes auth (/ut/auth → sid/pid/ phishingToken/persona envelope), profile, club, squad, market, packs; unknown paths pass through unchanged - proxy.rs: calls shape_response() on every successful Core response before returning to the FIFA 23 client - mapper.rs: expanded from 6 to 18 speculative FUT endpoint mappings covering auth, profile/settings, club/usermassinfo, item/collection, squad (GET+PUT), packs+purchase, transfer market+watchlist+bid, objectives, events, squad battles, and match result submission - admin.rs: GET /_bridge/captures/diff?a=<id>&b=<id> compares two captures; reports method/path/status changes, header diffs, and structured JSON body diffs (key-level for objects, length for others) - main.rs: registers /_bridge/captures/diff route - Unit tests: 4 shaper tests, 6 mapper tests (10 total, all passing) - All 13 integration tests still passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,4 +4,5 @@ pub mod error;
|
|||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod shaper;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ async fn main() -> Result<()> {
|
|||||||
get(routes::admin::get_unknown_endpoints),
|
get(routes::admin::get_unknown_endpoints),
|
||||||
)
|
)
|
||||||
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/diff",
|
||||||
|
get(routes::admin::get_capture_diff),
|
||||||
|
)
|
||||||
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|||||||
+169
-12
@@ -11,43 +11,81 @@ pub struct CoreMapping {
|
|||||||
|
|
||||||
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
|
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
|
||||||
/// Returns Some(mapping) if the route is known, None otherwise.
|
/// 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> {
|
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
||||||
let m = method.to_uppercase();
|
let m = method.to_uppercase();
|
||||||
let p = path.trim_end_matches('/');
|
let p = path.trim_end_matches('/');
|
||||||
|
|
||||||
// These are speculative mappings based on common FUT API patterns.
|
|
||||||
// They will be refined as reverse engineering progresses.
|
|
||||||
let known: &[(&str, &str, CoreMapping)] = &[
|
let known: &[(&str, &str, CoreMapping)] = &[
|
||||||
// Auth
|
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||||
(
|
(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/auth",
|
"/ut/auth",
|
||||||
CoreMapping {
|
CoreMapping {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
core_path: "/auth/local",
|
core_path: "/auth/local",
|
||||||
notes: "FUT auth → Core local auth",
|
notes: "FUT login → Core local auth (shaped into EA envelope format)",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// ── Profile / Settings ───────────────────────────────────────────────────
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
"/ut/game/fut/user/settings",
|
"/ut/game/fut/user/settings",
|
||||||
CoreMapping {
|
CoreMapping {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
core_path: "/profile",
|
core_path: "/profile",
|
||||||
notes: "FUT settings → Core profile",
|
notes: "FUT user settings → Core profile",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Club
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fut/user/accountinfo",
|
||||||
|
CoreMapping {
|
||||||
|
method: "GET",
|
||||||
|
core_path: "/profile",
|
||||||
|
notes: "FUT account info → Core profile",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// ── Club / Mass info ──────────────────────────────────────────────────────
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
"/ut/game/fut/usermassinfo",
|
"/ut/game/fut/usermassinfo",
|
||||||
CoreMapping {
|
CoreMapping {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
core_path: "/club",
|
core_path: "/club",
|
||||||
notes: "FUT mass info → Core club",
|
notes: "FUT mass info (club + coins) → Core club",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Squad
|
(
|
||||||
|
"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",
|
"GET",
|
||||||
"/ut/game/fut/squad/active",
|
"/ut/game/fut/squad/active",
|
||||||
@@ -57,7 +95,16 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
|||||||
notes: "FUT active squad → Core squad",
|
notes: "FUT active squad → Core squad",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Packs
|
(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fut/squad/active",
|
||||||
|
CoreMapping {
|
||||||
|
method: "POST",
|
||||||
|
core_path: "/squad",
|
||||||
|
notes: "FUT save squad → Core save squad",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// ── Packs ─────────────────────────────────────────────────────────────────
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
"/ut/game/fut/store/packdetails",
|
"/ut/game/fut/store/packdetails",
|
||||||
@@ -67,14 +114,80 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
|||||||
notes: "FUT pack store → Core pack list",
|
notes: "FUT pack store → Core pack list",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Transfer market (guessed)
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fut/store/purchase",
|
||||||
|
CoreMapping {
|
||||||
|
method: "POST",
|
||||||
|
core_path: "/packs/buy",
|
||||||
|
notes: "FUT pack purchase → Core pack buy (guess)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// ── Transfer Market ───────────────────────────────────────────────────────
|
||||||
(
|
(
|
||||||
"GET",
|
"GET",
|
||||||
"/ut/game/fut/transfermarket",
|
"/ut/game/fut/transfermarket",
|
||||||
CoreMapping {
|
CoreMapping {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
core_path: "/market",
|
core_path: "/market",
|
||||||
notes: "FUT transfer market → Core NPC 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",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -89,7 +202,7 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return a safe placeholder response for unknown endpoints.
|
/// Return a safe placeholder response for unknown endpoints.
|
||||||
/// This prevents the FIFA 23 client from crashing while we log traffic.
|
/// Prevents the FIFA 23 client from crashing while we log traffic.
|
||||||
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
|
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
|
||||||
@@ -103,3 +216,47 @@ pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
|||||||
"path": path,
|
"path": path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_auth_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/auth");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/auth/local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_market_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/transfermarket");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/market");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_events_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/events");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/events");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squad_put_maps() {
|
||||||
|
let m = map_to_core("PUT", "/ut/game/fut/squad/active");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().method, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_returns_none() {
|
||||||
|
assert!(map_to_core("GET", "/ut/game/fut/some/unknown/path").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trailing_slash_trimmed() {
|
||||||
|
let m = map_to_core("POST", "/ut/auth/");
|
||||||
|
assert!(m.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -18,6 +18,7 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
error::BridgeResult,
|
error::BridgeResult,
|
||||||
mapper::{map_to_core, placeholder_response},
|
mapper::{map_to_core, placeholder_response},
|
||||||
|
shaper::shape_response,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAPTURE_DEDUP_SECS: u64 = 1;
|
const CAPTURE_DEDUP_SECS: u64 = 1;
|
||||||
@@ -118,7 +119,7 @@ pub async fn catch_all_handler(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((body, status)) => (body, status),
|
Ok((body, status)) => (shape_response(mapping.core_path, body), status),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Core request failed: {e}");
|
tracing::error!("Core request failed: {e}");
|
||||||
(serde_json::json!({ "error": e.to_string() }), 502)
|
(serde_json::json!({ "error": e.to_string() }), 502)
|
||||||
|
|||||||
+136
-1
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Query, State},
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{
|
response::{
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
IntoResponse, Json, Response,
|
IntoResponse, Json, Response,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||||
@@ -136,6 +137,140 @@ pub async fn get_endpoint_status(State(state): State<ProxyState>) -> BridgeResul
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query params for the diff endpoint.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DiffQuery {
|
||||||
|
pub a: String,
|
||||||
|
pub b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff two captures by ID: shows what changed between them.
|
||||||
|
///
|
||||||
|
/// Usage: GET /_bridge/captures/diff?a=<uuid>&b=<uuid>
|
||||||
|
pub async fn get_capture_diff(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
Query(params): Query<DiffQuery>,
|
||||||
|
) -> BridgeResult<Json<Value>> {
|
||||||
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let find = |id: &str| -> Option<&crate::capture::CapturedRequest> {
|
||||||
|
captures.iter().find(|c| c.id == id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cap_a = find(¶ms.a)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
|
||||||
|
let cap_b = find(¶ms.b)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
|
||||||
|
|
||||||
|
// Structural JSON diff of request bodies
|
||||||
|
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
|
||||||
|
let resp_diff = diff_json_strings(
|
||||||
|
cap_a.response_body.as_deref(),
|
||||||
|
cap_b.response_body.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Header diff: keys present in one but not the other
|
||||||
|
let headers_a: std::collections::HashSet<String> =
|
||||||
|
cap_a.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
let headers_b: std::collections::HashSet<String> =
|
||||||
|
cap_b.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
|
||||||
|
let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect();
|
||||||
|
let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect();
|
||||||
|
|
||||||
|
// Headers present in both but with different values
|
||||||
|
let headers_changed: Vec<Value> = cap_a
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, va)| {
|
||||||
|
cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| {
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "header": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path },
|
||||||
|
"b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path },
|
||||||
|
"method_changed": cap_a.method != cap_b.method,
|
||||||
|
"path_changed": cap_a.path != cap_b.path,
|
||||||
|
"status_a": cap_a.response_status,
|
||||||
|
"status_b": cap_b.response_status,
|
||||||
|
"status_changed": cap_a.response_status != cap_b.response_status,
|
||||||
|
"headers": {
|
||||||
|
"only_in_a": headers_only_in_a,
|
||||||
|
"only_in_b": headers_only_in_b,
|
||||||
|
"changed": headers_changed,
|
||||||
|
},
|
||||||
|
"body_diff": body_diff,
|
||||||
|
"response_diff": resp_diff,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shallow JSON diff of two optional JSON strings.
|
||||||
|
///
|
||||||
|
/// If both parse as JSON objects, lists keys that differ or are absent.
|
||||||
|
/// Falls back to string comparison if either is not valid JSON.
|
||||||
|
fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
|
||||||
|
match (a, b) {
|
||||||
|
(None, None) => json!({ "same": true, "note": "both empty" }),
|
||||||
|
(Some(_), None) => json!({ "same": false, "note": "b has no body" }),
|
||||||
|
(None, Some(_)) => json!({ "same": false, "note": "a has no body" }),
|
||||||
|
(Some(a_str), Some(b_str)) => {
|
||||||
|
if a_str == b_str {
|
||||||
|
return json!({ "same": true });
|
||||||
|
}
|
||||||
|
// Try to parse as JSON objects for a structured diff
|
||||||
|
let va: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(a_str).ok();
|
||||||
|
let vb: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(b_str).ok();
|
||||||
|
|
||||||
|
match (va, vb) {
|
||||||
|
(Some(ma), Some(mb)) => {
|
||||||
|
let keys_a: std::collections::HashSet<&str> =
|
||||||
|
ma.keys().map(|s| s.as_str()).collect();
|
||||||
|
let keys_b: std::collections::HashSet<&str> =
|
||||||
|
mb.keys().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect();
|
||||||
|
let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect();
|
||||||
|
let changed: Vec<Value> = keys_a
|
||||||
|
.intersection(&keys_b)
|
||||||
|
.filter_map(|k| {
|
||||||
|
let va = ma.get(*k)?;
|
||||||
|
let vb = mb.get(*k)?;
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "key": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "json_object_diff",
|
||||||
|
"keys_only_in_a": only_in_a,
|
||||||
|
"keys_only_in_b": only_in_b,
|
||||||
|
"keys_changed": changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "string_diff",
|
||||||
|
"a_len": a_str.len(),
|
||||||
|
"b_len": b_str.len(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple HTML admin dashboard.
|
/// Simple HTML admin dashboard.
|
||||||
pub async fn get_admin_dashboard() -> impl IntoResponse {
|
pub async fn get_admin_dashboard() -> impl IntoResponse {
|
||||||
let html = include_str!("../admin.html");
|
let html = include_str!("../admin.html");
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
//! Response shaper: transforms OpenFUT Core responses into the envelope format
|
||||||
|
//! FIFA 23 expects, based on the Core path that was called.
|
||||||
|
//!
|
||||||
|
//! These are best-guess wrappers derived from typical EA FUT API patterns.
|
||||||
|
//! They will be refined as real traffic captures come in.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core auth response in the FUT auth envelope format.
|
||||||
|
///
|
||||||
|
/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona.
|
||||||
|
/// The `sid` is used as a session token in subsequent `X-UT-SID` headers.
|
||||||
|
fn shape_auth_response(core: Value) -> Value {
|
||||||
|
let profile_id = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
let display_name = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("username"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("FUT Player");
|
||||||
|
|
||||||
|
let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple());
|
||||||
|
let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]);
|
||||||
|
let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"sid": fake_sid,
|
||||||
|
"pid": {
|
||||||
|
"pidId": fake_pid,
|
||||||
|
"email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")),
|
||||||
|
"emailStatus": "VERIFIED",
|
||||||
|
"strength": "STRONG",
|
||||||
|
"dob": "1990-01-01",
|
||||||
|
"country": "US",
|
||||||
|
},
|
||||||
|
"phishingToken": phishing_token,
|
||||||
|
"persona": {
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"isEmailVerified": true,
|
||||||
|
},
|
||||||
|
"userAccountInfo": {
|
||||||
|
"userAccountInfoSummary": [{
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"clubs": [{ "clubType": "FUT", "supported": true }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core profile response in the FUT user settings envelope.
|
||||||
|
fn shape_profile_response(core: Value) -> Value {
|
||||||
|
let profile = core.get("profile").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"settings": {
|
||||||
|
"personaId": profile.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")),
|
||||||
|
"level": profile.get("level").cloned().unwrap_or(json!(1)),
|
||||||
|
"xp": profile.get("xp").cloned().unwrap_or(json!(0)),
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core club response in the FUT usermassinfo envelope.
|
||||||
|
fn shape_club_response(core: Value) -> Value {
|
||||||
|
let club = core.get("club").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"pilluserinfo": {
|
||||||
|
"clubId": club.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"credits": club.get("coins").cloned().unwrap_or(json!(0)),
|
||||||
|
"purchased": 0,
|
||||||
|
"sold": 0,
|
||||||
|
},
|
||||||
|
"userInfo": {
|
||||||
|
"duplicationInfo": { "isDuplicate": false },
|
||||||
|
"purchasedPacksInfo": { "premium": 0, "standard": 0 },
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": club,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core squad response in the FUT squad envelope.
|
||||||
|
fn shape_squad_response(core: Value) -> Value {
|
||||||
|
let squad = core.get("squad").cloned().unwrap_or(core.clone());
|
||||||
|
let players = core.get("players").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"squad": {
|
||||||
|
"squadId": squad.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"name": squad.get("name").cloned().unwrap_or(json!("My Squad")),
|
||||||
|
"formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")),
|
||||||
|
"players": players,
|
||||||
|
"rating": 0,
|
||||||
|
"chemistry": 0,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core market response in the FUT transfer market envelope.
|
||||||
|
fn shape_market_response(core: Value) -> Value {
|
||||||
|
let listings = core.get("listings").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core
|
||||||
|
.get("total")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auctionInfo": listings,
|
||||||
|
"bidTokens": {},
|
||||||
|
"credits": 0,
|
||||||
|
"currencies": [
|
||||||
|
{ "name": "COINS", "funds": 0, "finalFunds": 0 },
|
||||||
|
],
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"itemData": [],
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core packs response in the FUT pack store envelope.
|
||||||
|
fn shape_packs_response(core: Value) -> Value {
|
||||||
|
let packs = core.get("packs").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"purchasedPacks": {
|
||||||
|
"packs": packs,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_auth_response_has_required_fields() {
|
||||||
|
let core = json!({
|
||||||
|
"profile": { "id": "prof_001", "username": "TestPlayer" }
|
||||||
|
});
|
||||||
|
let shaped = shape_auth_response(core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
assert!(shaped.get("phishingToken").is_some());
|
||||||
|
assert!(shaped.get("persona").is_some());
|
||||||
|
assert_eq!(shaped["persona"]["displayName"], "TestPlayer");
|
||||||
|
assert_eq!(shaped["openfut"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_dispatches_correctly() {
|
||||||
|
let core = json!({ "profile": { "id": "x", "username": "u" } });
|
||||||
|
let shaped = shape_response("/auth/local", core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_passthrough_for_unknown_path() {
|
||||||
|
let core = json!({ "foo": "bar" });
|
||||||
|
let shaped = shape_response("/some/unknown/path", core.clone());
|
||||||
|
assert_eq!(shaped, core);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_market_response_has_auction_info() {
|
||||||
|
let core = json!({ "listings": [{ "id": "l1" }], "total": 1 });
|
||||||
|
let shaped = shape_market_response(core);
|
||||||
|
assert!(shaped["auctionInfo"].as_array().is_some());
|
||||||
|
assert_eq!(shaped["total"], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user