Skip to main content

xurl/api/response/
types.rs

1//! Typed API response structs for X API v2 endpoints.
2//!
3//! Every response struct includes `#[serde(flatten)] extra: BTreeMap<String, Value>`
4//! for forward compatibility — unknown API fields are captured during deserialization
5//! and re-emitted during serialization.
6
7use std::collections::BTreeMap;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13// ── Generic wrapper ─────────────────────────────────────────────────
14
15/// Standard X API v2 response envelope.
16///
17/// Single-item endpoints use `ApiResponse<Tweet>`, list endpoints use
18/// `ApiResponse<Vec<Tweet>>`. Serde handles both shapes transparently.
19#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
20pub struct ApiResponse<T: Default> {
21    /// Primary payload — a single object or a `Vec<T>` for list endpoints.
22    pub data: T,
23    /// Expanded objects referenced by `data` when the caller requested
24    /// `expansions=...`.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub includes: Option<Includes>,
27    /// Pagination and result-count metadata.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub meta: Option<ResponseMeta>,
30    /// Partial errors returned alongside a 200 response.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub errors: Option<Vec<ApiError>>,
33    /// Forward-compatibility bucket — captures unknown top-level fields so a
34    /// new spec field round-trips through serialize/deserialize unchanged.
35    #[serde(flatten)]
36    pub extra: BTreeMap<String, Value>,
37}
38
39/// Expanded objects included alongside the primary data.
40#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
41pub struct Includes {
42    /// User objects referenced by `author_id`, `sender_id`, etc.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub users: Option<Vec<User>>,
45    /// Tweet objects referenced by `referenced_tweets`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub tweets: Option<Vec<Tweet>>,
48    /// Forward-compatibility bucket — captures unknown include keys.
49    #[serde(flatten)]
50    pub extra: BTreeMap<String, Value>,
51}
52
53/// Pagination and result count metadata.
54#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
55pub struct ResponseMeta {
56    /// Total items returned in `data` for list endpoints.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub result_count: Option<u64>,
59    /// Opaque cursor for the next page; pass to a follow-up call as
60    /// `pagination_token`.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub next_token: Option<String>,
63    /// Opaque cursor for the previous page.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub previous_token: Option<String>,
66    /// Forward-compatibility bucket — captures unknown meta fields.
67    #[serde(flatten)]
68    pub extra: BTreeMap<String, Value>,
69}
70
71/// Partial error returned alongside valid data in 200 responses.
72#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
73pub struct ApiError {
74    /// Short human-readable error description.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub message: Option<String>,
77    /// Error category title (e.g. `"Not Found Error"`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub title: Option<String>,
80    /// Longer human-readable detail.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub detail: Option<String>,
83    /// URI identifying the error type (problem-details style).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub r#type: Option<String>,
86    /// Forward-compatibility bucket — captures unknown error fields.
87    #[serde(flatten)]
88    pub extra: BTreeMap<String, Value>,
89}
90
91// ── Tweet ───────────────────────────────────────────────────────────
92
93/// A tweet object from the X API v2.
94///
95/// Required fields: `id`, `text` (always present in API responses).
96/// Optional fields depend on which `tweet.fields` the caller requests.
97#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
98pub struct Tweet {
99    /// Tweet identifier (X API snowflake string).
100    pub id: String,
101    /// Tweet body text.
102    pub text: String,
103    /// ISO-8601 timestamp the tweet was created.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub created_at: Option<String>,
106    /// Author's user ID; resolve via `includes.users` when expanded.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub author_id: Option<String>,
109    /// Root tweet ID of the conversation thread.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub conversation_id: Option<String>,
112    /// User ID this tweet replies to, when applicable.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub in_reply_to_user_id: Option<String>,
115    /// Engagement counts (likes, replies, retweets, etc).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub public_metrics: Option<TweetPublicMetrics>,
118    /// Tweets this one references (reply, quote, retweet).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub referenced_tweets: Option<Vec<ReferencedTweet>>,
121    /// Parsed entities (URLs, mentions, hashtags) — opaque JSON.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub entities: Option<Value>,
124    /// Media / poll attachment references — opaque JSON.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub attachments: Option<Value>,
127    /// Forward-compatibility bucket — captures unknown tweet fields.
128    #[serde(flatten)]
129    pub extra: BTreeMap<String, Value>,
130}
131
132/// Public engagement metrics for a tweet.
133#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
134pub struct TweetPublicMetrics {
135    /// Retweet count.
136    #[serde(default)]
137    pub retweet_count: u64,
138    /// Reply count.
139    #[serde(default)]
140    pub reply_count: u64,
141    /// Like count.
142    #[serde(default)]
143    pub like_count: u64,
144    /// Quote-tweet count.
145    #[serde(default)]
146    pub quote_count: u64,
147    /// Bookmark count.
148    #[serde(default)]
149    pub bookmark_count: u64,
150    /// View count.
151    #[serde(default)]
152    pub impression_count: u64,
153    /// Forward-compatibility bucket — captures unknown metric fields.
154    #[serde(flatten)]
155    pub extra: BTreeMap<String, Value>,
156}
157
158/// A referenced tweet (reply-to, quote, retweet).
159#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
160pub struct ReferencedTweet {
161    /// Referenced tweet identifier.
162    pub id: String,
163    /// Reference kind — `"replied_to"`, `"quoted"`, or `"retweeted"`.
164    pub r#type: String,
165    /// Forward-compatibility bucket — captures unknown reference fields.
166    #[serde(flatten)]
167    pub extra: BTreeMap<String, Value>,
168}
169
170// ── User ────────────────────────────────────────────────────────────
171
172/// A user object from the X API v2.
173///
174/// Required fields: `id`, `name`, `username`.
175/// Optional fields depend on which `user.fields` the caller requests.
176#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
177pub struct User {
178    /// User identifier (X API snowflake string).
179    pub id: String,
180    /// Display name.
181    pub name: String,
182    /// Handle without the leading `@`.
183    pub username: String,
184    /// ISO-8601 account creation timestamp.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub created_at: Option<String>,
187    /// Bio / profile description.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub description: Option<String>,
190    /// Whether the account carries a verified badge.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub verified: Option<bool>,
193    /// Profile image URL.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub profile_image_url: Option<String>,
196    /// Engagement counts (followers, following, tweets).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub public_metrics: Option<UserPublicMetrics>,
199    /// Forward-compatibility bucket — captures unknown user fields.
200    #[serde(flatten)]
201    pub extra: BTreeMap<String, Value>,
202}
203
204/// Public engagement metrics for a user.
205#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
206pub struct UserPublicMetrics {
207    /// Follower count.
208    #[serde(default)]
209    pub followers_count: u64,
210    /// Following count.
211    #[serde(default)]
212    pub following_count: u64,
213    /// Tweet count for the user.
214    #[serde(default)]
215    pub tweet_count: u64,
216    /// Number of public lists the user is on.
217    #[serde(default)]
218    pub listed_count: u64,
219    /// Forward-compatibility bucket — captures unknown metric fields.
220    #[serde(flatten)]
221    pub extra: BTreeMap<String, Value>,
222}
223
224// ── DM ──────────────────────────────────────────────────────────────
225
226/// A direct message event from the X API v2.
227#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
228pub struct DmEvent {
229    /// Event identifier.
230    pub id: String,
231    /// Message body, for text events.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub text: Option<String>,
234    /// Event kind (e.g. `"MessageCreate"`, `"ParticipantsJoin"`).
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub event_type: Option<String>,
237    /// ISO-8601 timestamp the event occurred.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub created_at: Option<String>,
240    /// Conversation the event belongs to.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub dm_conversation_id: Option<String>,
243    /// Sender's user ID.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub sender_id: Option<String>,
246    /// Forward-compatibility bucket — captures unknown event fields.
247    #[serde(flatten)]
248    pub extra: BTreeMap<String, Value>,
249}
250
251// ── Action confirmations ────────────────────────────────────────────
252
253/// Confirmation for like/unlike actions.
254#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
255pub struct LikedResult {
256    /// Whether the target is now liked.
257    pub liked: bool,
258    /// Forward-compatibility bucket — captures unknown response fields.
259    #[serde(flatten)]
260    pub extra: BTreeMap<String, Value>,
261}
262
263/// Confirmation for follow/unfollow actions.
264#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
265pub struct FollowingResult {
266    /// Whether the target is now followed.
267    pub following: bool,
268    /// Forward-compatibility bucket — captures unknown response fields.
269    #[serde(flatten)]
270    pub extra: BTreeMap<String, Value>,
271}
272
273/// Confirmation for delete actions.
274#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
275pub struct DeletedResult {
276    /// Whether the resource was deleted.
277    pub deleted: bool,
278    /// Forward-compatibility bucket — captures unknown response fields.
279    #[serde(flatten)]
280    pub extra: BTreeMap<String, Value>,
281}
282
283/// Confirmation for repost/unrepost actions.
284#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
285pub struct RetweetedResult {
286    /// Whether the target is now reposted.
287    pub retweeted: bool,
288    /// Forward-compatibility bucket — captures unknown response fields.
289    #[serde(flatten)]
290    pub extra: BTreeMap<String, Value>,
291}
292
293/// Confirmation for bookmark/unbookmark actions.
294#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
295pub struct BookmarkedResult {
296    /// Whether the target is now bookmarked.
297    pub bookmarked: bool,
298    /// Forward-compatibility bucket — captures unknown response fields.
299    #[serde(flatten)]
300    pub extra: BTreeMap<String, Value>,
301}
302
303/// Confirmation for block/unblock actions.
304#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
305pub struct BlockingResult {
306    /// Whether the target is now blocked.
307    pub blocking: bool,
308    /// Forward-compatibility bucket — captures unknown response fields.
309    #[serde(flatten)]
310    pub extra: BTreeMap<String, Value>,
311}
312
313/// Confirmation for mute/unmute actions.
314#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
315pub struct MutingResult {
316    /// Whether the target is now muted.
317    pub muting: bool,
318    /// Forward-compatibility bucket — captures unknown response fields.
319    #[serde(flatten)]
320    pub extra: BTreeMap<String, Value>,
321}
322
323// ── Media ───────────────────────────────────────────────────────────
324
325/// Response from media upload INIT and FINALIZE steps.
326#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
327pub struct MediaUploadResponse {
328    /// Media identifier — thread this into APPEND, FINALIZE, and STATUS calls.
329    pub id: String,
330    /// Stable media key surfaced once FINALIZE succeeds.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub media_key: Option<String>,
333    /// Seconds until the upload session expires.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub expires_after_secs: Option<u64>,
336    /// Server-side processing status for async media (video, GIF).
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub processing_info: Option<MediaProcessingInfo>,
339    /// Forward-compatibility bucket — captures unknown upload fields.
340    #[serde(flatten)]
341    pub extra: BTreeMap<String, Value>,
342}
343
344/// Media processing status returned during upload polling.
345#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
346pub struct MediaProcessingInfo {
347    /// Processing state — `"pending"`, `"in_progress"`, `"succeeded"`, `"failed"`.
348    pub state: String,
349    /// Recommended wait before polling again.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub check_after_secs: Option<u64>,
352    /// Server-reported processing progress (0..=100).
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub progress_percent: Option<u64>,
355    /// Error details when `state` is `"failed"` — opaque JSON.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub error: Option<Value>,
358    /// Forward-compatibility bucket — captures unknown status fields.
359    #[serde(flatten)]
360    pub extra: BTreeMap<String, Value>,
361}
362
363// ── Usage ───────────────────────────────────────────────────────────
364
365/// API usage data from the /2/usage/tweets endpoint.
366///
367/// All fields are optional because the shape varies based on query params
368/// and the data is deeply nested with mixed types (strings for numbers).
369#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
370pub struct UsageData {
371    /// Project tweet cap (string-encoded integer).
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub project_cap: Option<String>,
374    /// Project identifier.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub project_id: Option<String>,
377    /// Project tweet usage so far this period (string-encoded integer).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub project_usage: Option<String>,
380    /// Day of month the cap resets.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub cap_reset_day: Option<u64>,
383    /// Per-day project usage breakdown — opaque JSON.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub daily_project_usage: Option<Value>,
386    /// Per-day client-app usage breakdown — opaque JSON.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub daily_client_app_usage: Option<Value>,
389    /// Forward-compatibility bucket — captures unknown usage fields.
390    #[serde(flatten)]
391    pub extra: BTreeMap<String, Value>,
392}
393
394// ── Deserialization helper ──────────────────────────────────────────
395
396/// Deserializes a `serde_json::Value` into `ApiResponse<T>`.
397///
398/// Guards against empty `{}` responses (the `send_request()` fallback for
399/// non-JSON 2xx bodies) with a descriptive error instead of a cryptic
400/// serde deserialization failure.
401///
402/// # Errors
403///
404/// Returns `XurlError::Json` if the Value is an empty object or cannot
405/// be deserialized into the target type.
406pub fn deserialize_response<T: Default + serde::de::DeserializeOwned>(
407    value: Value,
408) -> crate::error::Result<ApiResponse<T>> {
409    if value.as_object().is_some_and(|m| m.is_empty()) {
410        return Err(crate::error::XurlError::Json(
411            "empty response body — expected JSON with a \"data\" field".to_string(),
412        ));
413    }
414    // X API v2 returns errors-only 200 responses with no `data` field
415    // (e.g., {"errors": [{"title": "Not Found Error", ...}]}). Surface
416    // the raw JSON as a validation error — these are not HTTP errors
417    // (status was 200) but semantic failures from the API.
418    if let Some(obj) = value.as_object()
419        && !obj.contains_key("data")
420        && obj.contains_key("errors")
421    {
422        return Err(crate::error::XurlError::validation(value.to_string()));
423    }
424    Ok(serde_json::from_value(value)?)
425}
426
427#[cfg(test)]
428mod tests {
429    use serde_json::json;
430
431    use super::*;
432
433    // ── Happy path ──────────────────────────────────────────────────
434
435    #[test]
436    fn deserialize_single_tweet() {
437        let json = json!({
438            "data": {
439                "id": "123",
440                "text": "Hello world",
441                "created_at": "2026-01-01T00:00:00.000Z",
442                "public_metrics": {
443                    "retweet_count": 5,
444                    "reply_count": 2,
445                    "like_count": 10,
446                    "quote_count": 1,
447                    "bookmark_count": 0,
448                    "impression_count": 100
449                }
450            }
451        });
452        let resp: ApiResponse<Tweet> =
453            serde_json::from_value(json).expect("Tweet response must deserialize");
454        assert_eq!(resp.data.id, "123");
455        assert_eq!(resp.data.text, "Hello world");
456        assert_eq!(
457            resp.data.created_at.as_deref(),
458            Some("2026-01-01T00:00:00.000Z")
459        );
460        let metrics = resp
461            .data
462            .public_metrics
463            .expect("public_metrics must be present");
464        assert_eq!(metrics.like_count, 10);
465        assert_eq!(metrics.impression_count, 100);
466    }
467
468    #[test]
469    fn deserialize_tweet_list() {
470        let json = json!({
471            "data": [
472                {"id": "1", "text": "first"},
473                {"id": "2", "text": "second"}
474            ],
475            "meta": {"result_count": 2}
476        });
477        let resp: ApiResponse<Vec<Tweet>> =
478            serde_json::from_value(json).expect("Tweet list response must deserialize");
479        assert_eq!(resp.data.len(), 2);
480        assert_eq!(resp.data[0].id, "1");
481        assert_eq!(resp.data[1].text, "second");
482        assert_eq!(
483            resp.meta.expect("meta must be present").result_count,
484            Some(2)
485        );
486    }
487
488    #[test]
489    fn deserialize_action_liked() {
490        let json = json!({"data": {"liked": true}});
491        let resp: ApiResponse<LikedResult> =
492            serde_json::from_value(json).expect("LikedResult response must deserialize");
493        assert!(resp.data.liked);
494    }
495
496    #[test]
497    fn deserialize_with_includes_and_meta() {
498        let json = json!({
499            "data": [{"id": "1", "text": "tweet"}],
500            "includes": {
501                "users": [{"id": "42", "name": "Bot", "username": "bot"}]
502            },
503            "meta": {"result_count": 1, "next_token": "abc123"}
504        });
505        let resp: ApiResponse<Vec<Tweet>> =
506            serde_json::from_value(json).expect("Tweet list with includes/meta must deserialize");
507        let includes = resp.includes.expect("includes must be present");
508        let users = includes.users.expect("includes.users must be present");
509        assert_eq!(users[0].id, "42");
510        assert_eq!(
511            resp.meta
512                .expect("meta must be present")
513                .next_token
514                .as_deref(),
515            Some("abc123")
516        );
517    }
518
519    #[test]
520    fn deserialize_user() {
521        let json = json!({
522            "data": {
523                "id": "42",
524                "name": "Test User",
525                "username": "testuser",
526                "verified": true,
527                "public_metrics": {
528                    "followers_count": 100,
529                    "following_count": 50,
530                    "tweet_count": 1000,
531                    "listed_count": 5
532                }
533            }
534        });
535        let resp: ApiResponse<User> =
536            serde_json::from_value(json).expect("User response must deserialize");
537        assert_eq!(resp.data.id, "42");
538        assert_eq!(resp.data.username, "testuser");
539        assert_eq!(resp.data.verified, Some(true));
540        let metrics = resp
541            .data
542            .public_metrics
543            .expect("user.public_metrics must be present");
544        assert_eq!(metrics.followers_count, 100);
545    }
546
547    #[test]
548    fn deserialize_dm_event() {
549        let json = json!({
550            "data": {
551                "id": "dm1",
552                "text": "hello",
553                "event_type": "MessageCreate",
554                "dm_conversation_id": "conv1",
555                "sender_id": "42"
556            }
557        });
558        let resp: ApiResponse<DmEvent> =
559            serde_json::from_value(json).expect("DmEvent response must deserialize");
560        assert_eq!(resp.data.id, "dm1");
561        assert_eq!(resp.data.text.as_deref(), Some("hello"));
562        assert_eq!(resp.data.sender_id.as_deref(), Some("42"));
563    }
564
565    #[test]
566    fn deserialize_usage_data() {
567        let json = json!({
568            "data": {
569                "project_cap": "2000000",
570                "project_id": "123",
571                "project_usage": "399",
572                "cap_reset_day": 19
573            }
574        });
575        let resp: ApiResponse<UsageData> =
576            serde_json::from_value(json).expect("UsageData response must deserialize");
577        assert_eq!(resp.data.project_cap.as_deref(), Some("2000000"));
578        assert_eq!(resp.data.cap_reset_day, Some(19));
579    }
580
581    #[test]
582    fn deserialize_media_upload_response() {
583        let json = json!({
584            "data": {
585                "id": "media_123",
586                "media_key": "key_456",
587                "expires_after_secs": 3600
588            }
589        });
590        let resp: ApiResponse<MediaUploadResponse> =
591            serde_json::from_value(json).expect("MediaUploadResponse must deserialize");
592        assert_eq!(resp.data.id, "media_123");
593        assert_eq!(resp.data.media_key.as_deref(), Some("key_456"));
594        assert_eq!(resp.data.expires_after_secs, Some(3600));
595    }
596
597    #[test]
598    fn deserialize_media_with_processing_info() {
599        let json = json!({
600            "data": {
601                "id": "media_123",
602                "processing_info": {
603                    "state": "in_progress",
604                    "check_after_secs": 5,
605                    "progress_percent": 45
606                }
607            }
608        });
609        let resp: ApiResponse<MediaUploadResponse> = serde_json::from_value(json)
610            .expect("MediaUploadResponse with processing_info must deserialize");
611        let info = resp
612            .data
613            .processing_info
614            .expect("processing_info must be present");
615        assert_eq!(info.state, "in_progress");
616        assert_eq!(info.check_after_secs, Some(5));
617        assert_eq!(info.progress_percent, Some(45));
618    }
619
620    // ── Edge cases ──────────────────────────────────────────────────
621
622    #[test]
623    fn unknown_fields_captured_in_extra() {
624        let json = json!({
625            "data": {
626                "id": "123",
627                "text": "hello",
628                "brand_new_field": "surprise"
629            },
630            "top_level_extra": 42
631        });
632        let resp: ApiResponse<Tweet> = serde_json::from_value(json)
633            .expect("Tweet response with unknown fields must deserialize");
634        assert_eq!(resp.data.extra["brand_new_field"], "surprise");
635        assert_eq!(resp.extra["top_level_extra"], 42);
636    }
637
638    #[test]
639    fn unknown_fields_round_trip() {
640        let json = json!({
641            "data": {
642                "id": "123",
643                "text": "hello",
644                "new_field": 42
645            },
646            "top_extra": "value"
647        });
648        let resp: ApiResponse<Tweet> =
649            serde_json::from_value(json).expect("Tweet round-trip fixture must deserialize");
650        let serialized = serde_json::to_value(&resp).expect("Tweet response must serialize");
651        assert_eq!(serialized["data"]["new_field"], 42);
652        assert_eq!(serialized["top_extra"], "value");
653    }
654
655    #[test]
656    fn nested_unknown_fields_both_captured() {
657        let json = json!({
658            "data": {
659                "id": "123",
660                "text": "hello",
661                "tweet_extra": "a",
662                "public_metrics": {
663                    "retweet_count": 0,
664                    "reply_count": 0,
665                    "like_count": 0,
666                    "quote_count": 0,
667                    "bookmark_count": 0,
668                    "impression_count": 0,
669                    "metrics_extra": "b"
670                }
671            }
672        });
673        let resp: ApiResponse<Tweet> = serde_json::from_value(json)
674            .expect("Tweet response with nested unknown fields must deserialize");
675        assert_eq!(resp.data.extra["tweet_extra"], "a");
676        let metrics = resp
677            .data
678            .public_metrics
679            .expect("public_metrics must be present");
680        assert_eq!(metrics.extra["metrics_extra"], "b");
681    }
682
683    #[test]
684    fn extra_is_empty_when_no_unknown_fields() {
685        let json = json!({"data": {"id": "1", "text": "hi"}});
686        let resp: ApiResponse<Tweet> =
687            serde_json::from_value(json).expect("minimal Tweet response must deserialize");
688        assert!(resp.extra.is_empty());
689        assert!(resp.data.extra.is_empty());
690        // Verify serialization produces no extra keys
691        let out = serde_json::to_value(&resp).expect("Tweet response must serialize");
692        let data = out["data"]
693            .as_object()
694            .expect("serialized data must be a JSON object");
695        assert!(!data.contains_key("extra"));
696    }
697
698    #[test]
699    fn missing_optional_fields_are_none() {
700        let json = json!({"data": {"id": "1", "text": "minimal"}});
701        let resp: ApiResponse<Tweet> =
702            serde_json::from_value(json).expect("minimal Tweet must deserialize");
703        assert!(resp.data.created_at.is_none());
704        assert!(resp.data.public_metrics.is_none());
705        assert!(resp.data.author_id.is_none());
706        assert!(resp.includes.is_none());
707        assert!(resp.meta.is_none());
708    }
709
710    #[test]
711    fn default_produces_valid_structs() {
712        let tweet = Tweet {
713            id: "test".into(),
714            text: "hello".into(),
715            ..Default::default()
716        };
717        assert_eq!(tweet.id, "test");
718        assert!(tweet.created_at.is_none());
719
720        let user = User {
721            id: "42".into(),
722            name: "Bot".into(),
723            username: "bot".into(),
724            ..Default::default()
725        };
726        assert_eq!(user.username, "bot");
727
728        let _resp: ApiResponse<Tweet> = ApiResponse {
729            data: tweet,
730            ..Default::default()
731        };
732    }
733
734    #[test]
735    fn all_action_types_default_and_deserialize() {
736        // Verify all 7 action types work
737        let _: LikedResult = Default::default();
738        let _: FollowingResult = Default::default();
739        let _: DeletedResult = Default::default();
740        let _: RetweetedResult = Default::default();
741        let _: BookmarkedResult = Default::default();
742        let _: BlockingResult = Default::default();
743        let _: MutingResult = Default::default();
744
745        for (field, ty) in [
746            ("liked", "LikedResult"),
747            ("following", "FollowingResult"),
748            ("deleted", "DeletedResult"),
749            ("retweeted", "RetweetedResult"),
750            ("bookmarked", "BookmarkedResult"),
751            ("blocking", "BlockingResult"),
752            ("muting", "MutingResult"),
753        ] {
754            let json = json!({"data": {field: true}});
755            // Verify they all parse — use a match to dispatch
756            match ty {
757                "LikedResult" => {
758                    let r: ApiResponse<LikedResult> = serde_json::from_value(json)
759                        .expect("LikedResult action response must deserialize");
760                    assert!(r.data.liked);
761                }
762                "FollowingResult" => {
763                    let r: ApiResponse<FollowingResult> = serde_json::from_value(json)
764                        .expect("FollowingResult action response must deserialize");
765                    assert!(r.data.following);
766                }
767                "DeletedResult" => {
768                    let r: ApiResponse<DeletedResult> = serde_json::from_value(json)
769                        .expect("DeletedResult action response must deserialize");
770                    assert!(r.data.deleted);
771                }
772                "RetweetedResult" => {
773                    let r: ApiResponse<RetweetedResult> = serde_json::from_value(json)
774                        .expect("RetweetedResult action response must deserialize");
775                    assert!(r.data.retweeted);
776                }
777                "BookmarkedResult" => {
778                    let r: ApiResponse<BookmarkedResult> = serde_json::from_value(json)
779                        .expect("BookmarkedResult action response must deserialize");
780                    assert!(r.data.bookmarked);
781                }
782                "BlockingResult" => {
783                    let r: ApiResponse<BlockingResult> = serde_json::from_value(json)
784                        .expect("BlockingResult action response must deserialize");
785                    assert!(r.data.blocking);
786                }
787                "MutingResult" => {
788                    let r: ApiResponse<MutingResult> = serde_json::from_value(json)
789                        .expect("MutingResult action response must deserialize");
790                    assert!(r.data.muting);
791                }
792                _ => unreachable!(),
793            }
794        }
795    }
796
797    // ── Error paths ─────────────────────────────────────────────────
798
799    #[test]
800    fn invalid_json_missing_required_field() {
801        // Missing required `id` field
802        let json = json!({"data": {"text": "no id"}});
803        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
804        assert!(result.is_err());
805    }
806
807    #[test]
808    fn round_trip_serialize_deserialize() {
809        let tweet = Tweet {
810            id: "456".into(),
811            text: "round trip".into(),
812            created_at: Some("2026-01-01T00:00:00Z".into()),
813            ..Default::default()
814        };
815        let resp = ApiResponse {
816            data: tweet,
817            ..Default::default()
818        };
819        let value = serde_json::to_value(&resp).expect("Tweet must serialize");
820        let back: ApiResponse<Tweet> =
821            serde_json::from_value(value).expect("round-tripped Tweet must deserialize");
822        assert_eq!(back.data.id, "456");
823        assert_eq!(back.data.text, "round trip");
824    }
825
826    #[test]
827    fn deserialize_helper_rejects_empty_object() {
828        let value = json!({});
829        let result = deserialize_response::<Tweet>(value);
830        assert!(result.is_err());
831        let err = result.unwrap_err().to_string();
832        assert!(err.contains("empty response body"), "Got: {err}");
833    }
834
835    // ── Red team / adversarial ──────────────────────────────────────
836
837    #[test]
838    fn adversarial_array_where_object_expected() {
839        // data is an array but we expect a single Tweet
840        let json = json!({"data": [{"id": "1", "text": "oops"}]});
841        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
842        assert!(result.is_err(), "Should fail: array where object expected");
843    }
844
845    #[test]
846    fn adversarial_string_where_object_expected() {
847        let json = json!({"data": "not an object"});
848        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
849        assert!(result.is_err(), "Should fail: string where object expected");
850    }
851
852    #[test]
853    fn adversarial_null_data_field() {
854        let json = json!({"data": null});
855        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
856        assert!(result.is_err(), "Should fail: null data");
857    }
858
859    #[test]
860    fn adversarial_numeric_overflow_u64() {
861        // u64::MAX + 1 would overflow — serde should error, not panic
862        let json = json!({
863            "data": {
864                "id": "123",
865                "text": "hi",
866                "public_metrics": {
867                    "like_count": 99_999_999_999_999u64,
868                    "retweet_count": 0,
869                    "reply_count": 0,
870                    "quote_count": 0,
871                    "bookmark_count": 0,
872                    "impression_count": 0
873                }
874            }
875        });
876        // This should succeed — 99_999_999_999_999 fits in u64
877        let resp: ApiResponse<Tweet> =
878            serde_json::from_value(json).expect("Tweet with large u64 must deserialize");
879        assert_eq!(
880            resp.data
881                .public_metrics
882                .expect("public_metrics must be present")
883                .like_count,
884            99_999_999_999_999
885        );
886    }
887
888    #[test]
889    fn adversarial_negative_count() {
890        // Negative number in a u64 field — should error
891        let json = json!({
892            "data": {
893                "id": "123",
894                "text": "hi",
895                "public_metrics": {
896                    "like_count": -1,
897                    "retweet_count": 0,
898                    "reply_count": 0,
899                    "quote_count": 0,
900                    "bookmark_count": 0,
901                    "impression_count": 0
902                }
903            }
904        });
905        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
906        assert!(result.is_err(), "Should fail: negative u64");
907    }
908
909    #[test]
910    fn adversarial_deeply_nested_unknown_fields() {
911        let json = json!({
912            "data": {"id": "123", "text": "hi"},
913            "extra_field": {
914                "a": {"b": {"c": [1, 2, 3]}}
915            }
916        });
917        let resp: ApiResponse<Tweet> =
918            serde_json::from_value(json).expect("Tweet with deep unknown fields must deserialize");
919        assert!(resp.extra.contains_key("extra_field"));
920    }
921
922    #[test]
923    fn adversarial_empty_string_required_fields() {
924        // Empty strings are valid String values — consumer must validate semantics
925        let json = json!({"data": {"id": "", "text": ""}});
926        let resp: ApiResponse<Tweet> = serde_json::from_value(json)
927            .expect("Tweet with empty string required fields must deserialize");
928        assert_eq!(resp.data.id, "");
929        assert_eq!(resp.data.text, "");
930    }
931
932    #[test]
933    fn adversarial_errors_field_no_data_raw_serde() {
934        // Raw serde (not deserialize_response) — should fail on missing data
935        let json = json!({"errors": [{"message": "forbidden"}]});
936        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
937        assert!(result.is_err(), "Should fail: no data field");
938    }
939
940    #[test]
941    fn errors_only_response_returns_validation_error() {
942        // X API v2 returns {"errors": [...]} with no "data" on 200 for not-found resources.
943        // deserialize_response should return XurlError::Validation with the raw JSON.
944        let json = json!({
945            "errors": [{
946                "detail": "Could not find tweet with id: [123].",
947                "title": "Not Found Error",
948                "resource_type": "tweet",
949                "type": "https://api.twitter.com/2/problems/resource-not-found"
950            }]
951        });
952        let result = deserialize_response::<Tweet>(json);
953        assert!(result.is_err());
954        let err = result.unwrap_err();
955        assert!(err.is_validation(), "Expected Validation error, got: {err}");
956        let msg = err.to_string();
957        assert!(
958            msg.contains("Not Found Error"),
959            "Error should contain API message: {msg}"
960        );
961    }
962
963    #[test]
964    fn adversarial_extra_top_level_on_action() {
965        let json = json!({
966            "data": {"liked": true},
967            "extra_top_level": "ignored_by_consumer"
968        });
969        let resp: ApiResponse<LikedResult> = serde_json::from_value(json)
970            .expect("LikedResult with extra top-level fields must deserialize");
971        assert!(resp.data.liked);
972        assert_eq!(resp.extra["extra_top_level"], "ignored_by_consumer");
973    }
974
975    #[test]
976    fn adversarial_wrong_bool_type_for_action() {
977        // String "true" instead of boolean true
978        let json = json!({"data": {"liked": "true"}});
979        let result = serde_json::from_value::<ApiResponse<LikedResult>>(json);
980        assert!(result.is_err(), "Should fail: string instead of bool");
981    }
982
983    #[test]
984    fn adversarial_partial_errors_with_valid_data() {
985        // 200 response with both data and errors (partial failure)
986        let json = json!({
987            "data": {"id": "123", "text": "partial"},
988            "errors": [{"message": "some field unavailable", "title": "Partial Error"}]
989        });
990        let resp: ApiResponse<Tweet> = serde_json::from_value(json)
991            .expect("Tweet with partial errors payload must deserialize");
992        assert_eq!(resp.data.id, "123");
993        let errors = resp.errors.expect("errors field must be present");
994        assert_eq!(errors.len(), 1);
995        assert_eq!(errors[0].message.as_deref(), Some("some field unavailable"));
996    }
997
998    #[test]
999    fn adversarial_huge_array_in_data() {
1000        // Large but valid list
1001        let tweets: Vec<Value> = (0..1000)
1002            .map(|i| json!({"id": i.to_string(), "text": format!("tweet {i}")}))
1003            .collect();
1004        let json = json!({"data": tweets});
1005        let resp: ApiResponse<Vec<Tweet>> =
1006            serde_json::from_value(json).expect("1000-element Tweet list must deserialize");
1007        assert_eq!(resp.data.len(), 1000);
1008    }
1009
1010    #[test]
1011    fn adversarial_completely_wrong_shape() {
1012        // Total garbage for data
1013        let json = json!({"data": 42});
1014        let result = serde_json::from_value::<ApiResponse<Tweet>>(json);
1015        assert!(result.is_err());
1016    }
1017
1018    #[test]
1019    fn adversarial_api_error_with_extra_fields() {
1020        let json = json!({
1021            "data": {"id": "1", "text": "ok"},
1022            "errors": [{
1023                "message": "oops",
1024                "new_error_field": "surprise"
1025            }]
1026        });
1027        let resp: ApiResponse<Tweet> = serde_json::from_value(json)
1028            .expect("Tweet with errors containing extra fields must deserialize");
1029        let error = &resp.errors.expect("errors field must be present")[0];
1030        assert_eq!(error.extra["new_error_field"], "surprise");
1031    }
1032}