use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[schemars(
description = "Standard X API v2 response envelope. `data` holds one object for single-item endpoints and an array for list endpoints."
)]
pub struct ApiResponse<T: Default> {
#[schemars(description = "Primary payload: one object, or an array for list endpoints.")]
pub data: T,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub includes: Option<Includes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<ResponseMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub errors: Option<Vec<ApiError>>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct Includes {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub users: Option<Vec<User>>,
#[serde(default, alias = "tweets", skip_serializing_if = "Option::is_none")]
#[doc(alias = "tweets")]
#[schemars(
description = "Post objects referenced by `referenced_posts`. X may send this under its legacy name `tweets`; either name fills this field."
)]
pub posts: Option<Vec<Post>>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ResponseMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_token: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ApiError {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct Post {
pub id: String,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_reply_to_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_metrics: Option<PostPublicMetrics>,
#[serde(
default,
alias = "referenced_tweets",
skip_serializing_if = "Option::is_none"
)]
#[doc(alias = "referenced_tweets")]
#[schemars(
description = "Posts this one references (reply, quote, repost). X may send this under its legacy name `referenced_tweets`; either name fills this field."
)]
pub referenced_posts: Option<Vec<ReferencedPost>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entities: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachments: Option<Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct PostPublicMetrics {
#[serde(default, alias = "retweet_count")]
#[doc(alias = "retweet_count")]
#[schemars(
description = "Repost count. X may send this under its legacy name `retweet_count`; either name fills this field."
)]
pub repost_count: u64,
#[serde(default)]
pub reply_count: u64,
#[serde(default)]
pub like_count: u64,
#[serde(default)]
pub quote_count: u64,
#[serde(default)]
pub bookmark_count: u64,
#[serde(default)]
pub impression_count: u64,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ReferencedPost {
pub id: String,
pub r#type: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct User {
pub id: String,
pub name: String,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verified: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_image_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_metrics: Option<UserPublicMetrics>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct UserPublicMetrics {
#[serde(default)]
pub followers_count: u64,
#[serde(default)]
pub following_count: u64,
#[serde(default, alias = "tweet_count")]
#[doc(alias = "tweet_count")]
#[schemars(
description = "Post count for the user. X sends this under its legacy name `tweet_count`; either name fills this field."
)]
pub post_count: u64,
#[serde(default)]
pub listed_count: u64,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct DmEvent {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dm_conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender_id: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct LikedResult {
pub liked: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct FollowingResult {
pub following: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct DeletedResult {
pub deleted: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct RepostedResult {
pub retweeted: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct BookmarkedResult {
pub bookmarked: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct BlockingResult {
pub blocking: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct MutingResult {
pub muting: bool,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct DmSentResult {
pub dm_conversation_id: String,
pub dm_event_id: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ChatModeratorsResult {
pub moderator_user_ids: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct MediaUploadResponse {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_after_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub processing_info: Option<MediaProcessingInfo>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct MediaProcessingInfo {
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_after_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress_percent: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct UsageData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_cap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_usage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cap_reset_day: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daily_project_usage: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daily_client_app_usage: Option<Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct UsageCreditsData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub free_balance: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub free_grants: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepaid_balance: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_balance: Option<f64>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
pub fn deserialize_response<T: Default + serde::de::DeserializeOwned>(
value: Value,
) -> crate::error::Result<ApiResponse<T>> {
decode(value)
}
pub(crate) fn decode<T: serde::de::DeserializeOwned>(mut value: Value) -> crate::error::Result<T> {
if value.as_object().is_some_and(|m| m.is_empty()) {
return Err(crate::error::Error::Json(
"empty response body — expected JSON with a \"data\" field".to_string(),
));
}
if let Some(obj) = value.as_object()
&& !obj.contains_key("data")
&& obj.contains_key("errors")
{
return Err(crate::error::Error::validation(value.to_string()));
}
super::vocabulary::normalize(&mut value);
Ok(serde_json::from_value(value)?)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn deserialize_single_post() {
let json = json!({
"data": {
"id": "123",
"text": "Hello world",
"created_at": "2026-01-01T00:00:00.000Z",
"public_metrics": {
"repost_count": 5,
"reply_count": 2,
"like_count": 10,
"quote_count": 1,
"bookmark_count": 0,
"impression_count": 100
}
}
});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("Post response must deserialize");
assert_eq!(resp.data.id, "123");
assert_eq!(resp.data.text, "Hello world");
assert_eq!(
resp.data.created_at.as_deref(),
Some("2026-01-01T00:00:00.000Z")
);
let metrics = resp
.data
.public_metrics
.expect("public_metrics must be present");
assert_eq!(metrics.like_count, 10);
assert_eq!(metrics.impression_count, 100);
}
#[test]
fn deserialize_post_list() {
let json = json!({
"data": [
{"id": "1", "text": "first"},
{"id": "2", "text": "second"}
],
"meta": {"result_count": 2}
});
let resp: ApiResponse<Vec<Post>> =
serde_json::from_value(json).expect("Post list response must deserialize");
assert_eq!(resp.data.len(), 2);
assert_eq!(resp.data[0].id, "1");
assert_eq!(resp.data[1].text, "second");
assert_eq!(
resp.meta.expect("meta must be present").result_count,
Some(2)
);
}
#[test]
fn deserialize_action_liked() {
let json = json!({"data": {"liked": true}});
let resp: ApiResponse<LikedResult> =
serde_json::from_value(json).expect("LikedResult response must deserialize");
assert!(resp.data.liked);
}
#[test]
fn deserialize_with_includes_and_meta() {
let json = json!({
"data": [{"id": "1", "text": "post"}],
"includes": {
"users": [{"id": "42", "name": "Bot", "username": "bot"}]
},
"meta": {"result_count": 1, "next_token": "abc123"}
});
let resp: ApiResponse<Vec<Post>> =
serde_json::from_value(json).expect("Post list with includes/meta must deserialize");
let includes = resp.includes.expect("includes must be present");
let users = includes.users.expect("includes.users must be present");
assert_eq!(users[0].id, "42");
assert_eq!(
resp.meta
.expect("meta must be present")
.next_token
.as_deref(),
Some("abc123")
);
}
#[test]
fn deserialize_user() {
let json = json!({
"data": {
"id": "42",
"name": "Test User",
"username": "testuser",
"verified": true,
"public_metrics": {
"followers_count": 100,
"following_count": 50,
"post_count": 1000,
"listed_count": 5
}
}
});
let resp: ApiResponse<User> =
serde_json::from_value(json).expect("User response must deserialize");
assert_eq!(resp.data.id, "42");
assert_eq!(resp.data.username, "testuser");
assert_eq!(resp.data.verified, Some(true));
let metrics = resp
.data
.public_metrics
.expect("user.public_metrics must be present");
assert_eq!(metrics.followers_count, 100);
}
#[test]
fn deserialize_dm_sent_result() {
let json = json!({
"data": {"dm_conversation_id": "222-111", "dm_event_id": "1580705921830768647"}
});
let resp: ApiResponse<DmSentResult> =
serde_json::from_value(json).expect("the send confirmation must deserialize");
assert_eq!(resp.data.dm_conversation_id, "222-111");
assert_eq!(resp.data.dm_event_id, "1580705921830768647");
}
#[test]
fn deserialize_dm_event() {
let json = json!({
"data": {
"id": "dm1",
"text": "hello",
"event_type": "MessageCreate",
"dm_conversation_id": "conv1",
"sender_id": "42"
}
});
let resp: ApiResponse<DmEvent> =
serde_json::from_value(json).expect("DmEvent response must deserialize");
assert_eq!(resp.data.id, "dm1");
assert_eq!(resp.data.text.as_deref(), Some("hello"));
assert_eq!(resp.data.sender_id.as_deref(), Some("42"));
}
#[test]
fn deserialize_usage_data() {
let json = json!({
"data": {
"project_cap": "2000000",
"project_id": "123",
"project_usage": "399",
"cap_reset_day": 19
}
});
let resp: ApiResponse<UsageData> =
serde_json::from_value(json).expect("UsageData response must deserialize");
assert_eq!(resp.data.project_cap.as_deref(), Some("2000000"));
assert_eq!(resp.data.cap_reset_day, Some(19));
}
#[test]
fn deserialize_media_upload_response() {
let json = json!({
"data": {
"id": "media_123",
"media_key": "key_456",
"expires_after_secs": 3600
}
});
let resp: ApiResponse<MediaUploadResponse> =
serde_json::from_value(json).expect("MediaUploadResponse must deserialize");
assert_eq!(resp.data.id, "media_123");
assert_eq!(resp.data.media_key.as_deref(), Some("key_456"));
assert_eq!(resp.data.expires_after_secs, Some(3600));
}
#[test]
fn deserialize_media_with_processing_info() {
let json = json!({
"data": {
"id": "media_123",
"processing_info": {
"state": "in_progress",
"check_after_secs": 5,
"progress_percent": 45
}
}
});
let resp: ApiResponse<MediaUploadResponse> = serde_json::from_value(json)
.expect("MediaUploadResponse with processing_info must deserialize");
let info = resp
.data
.processing_info
.expect("processing_info must be present");
assert_eq!(info.state, "in_progress");
assert_eq!(info.check_after_secs, Some(5));
assert_eq!(info.progress_percent, Some(45));
}
#[test]
fn unknown_fields_captured_in_extra() {
let json = json!({
"data": {
"id": "123",
"text": "hello",
"brand_new_field": "surprise"
},
"top_level_extra": 42
});
let resp: ApiResponse<Post> = serde_json::from_value(json)
.expect("Post response with unknown fields must deserialize");
assert_eq!(resp.data.extra["brand_new_field"], "surprise");
assert_eq!(resp.extra["top_level_extra"], 42);
}
#[test]
fn unknown_fields_round_trip() {
let json = json!({
"data": {
"id": "123",
"text": "hello",
"new_field": 42
},
"top_extra": "value"
});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("Post round-trip fixture must deserialize");
let serialized = serde_json::to_value(&resp).expect("Post response must serialize");
assert_eq!(serialized["data"]["new_field"], 42);
assert_eq!(serialized["top_extra"], "value");
}
#[test]
fn nested_unknown_fields_both_captured() {
let json = json!({
"data": {
"id": "123",
"text": "hello",
"post_extra": "a",
"public_metrics": {
"repost_count": 0,
"reply_count": 0,
"like_count": 0,
"quote_count": 0,
"bookmark_count": 0,
"impression_count": 0,
"metrics_extra": "b"
}
}
});
let resp: ApiResponse<Post> = serde_json::from_value(json)
.expect("Post response with nested unknown fields must deserialize");
assert_eq!(resp.data.extra["post_extra"], "a");
let metrics = resp
.data
.public_metrics
.expect("public_metrics must be present");
assert_eq!(metrics.extra["metrics_extra"], "b");
}
#[test]
fn extra_is_empty_when_no_unknown_fields() {
let json = json!({"data": {"id": "1", "text": "hi"}});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("minimal Post response must deserialize");
assert!(resp.extra.is_empty());
assert!(resp.data.extra.is_empty());
let out = serde_json::to_value(&resp).expect("Post response must serialize");
let data = out["data"]
.as_object()
.expect("serialized data must be a JSON object");
assert!(!data.contains_key("extra"));
}
#[test]
fn missing_optional_fields_are_none() {
let json = json!({"data": {"id": "1", "text": "minimal"}});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("minimal Post must deserialize");
assert!(resp.data.created_at.is_none());
assert!(resp.data.public_metrics.is_none());
assert!(resp.data.author_id.is_none());
assert!(resp.includes.is_none());
assert!(resp.meta.is_none());
}
#[test]
fn default_produces_valid_structs() {
let post = Post {
id: "test".into(),
text: "hello".into(),
..Default::default()
};
assert_eq!(post.id, "test");
assert!(post.created_at.is_none());
let user = User {
id: "42".into(),
name: "Bot".into(),
username: "bot".into(),
..Default::default()
};
assert_eq!(user.username, "bot");
let _resp: ApiResponse<Post> = ApiResponse {
data: post,
..Default::default()
};
}
#[test]
fn chat_moderators_result_reads_the_moderator_ids() {
let json = json!({"data": {"moderator_user_ids": ["1", "2"]}});
let r: ApiResponse<ChatModeratorsResult> =
serde_json::from_value(json).expect("moderator set deserializes");
assert_eq!(r.data.moderator_user_ids, vec!["1", "2"]);
}
#[test]
fn all_action_types_default_and_deserialize() {
let _: LikedResult = Default::default();
let _: FollowingResult = Default::default();
let _: DeletedResult = Default::default();
let _: RepostedResult = Default::default();
let _: BookmarkedResult = Default::default();
let _: BlockingResult = Default::default();
let _: MutingResult = Default::default();
for (field, ty) in [
("liked", "LikedResult"),
("following", "FollowingResult"),
("deleted", "DeletedResult"),
("retweeted", "RepostedResult"),
("bookmarked", "BookmarkedResult"),
("blocking", "BlockingResult"),
("muting", "MutingResult"),
] {
let json = json!({"data": {field: true}});
match ty {
"LikedResult" => {
let r: ApiResponse<LikedResult> = serde_json::from_value(json)
.expect("LikedResult action response must deserialize");
assert!(r.data.liked);
}
"FollowingResult" => {
let r: ApiResponse<FollowingResult> = serde_json::from_value(json)
.expect("FollowingResult action response must deserialize");
assert!(r.data.following);
}
"DeletedResult" => {
let r: ApiResponse<DeletedResult> = serde_json::from_value(json)
.expect("DeletedResult action response must deserialize");
assert!(r.data.deleted);
}
"RepostedResult" => {
let r: ApiResponse<RepostedResult> = serde_json::from_value(json)
.expect("RepostedResult action response must deserialize");
assert!(r.data.retweeted);
}
"BookmarkedResult" => {
let r: ApiResponse<BookmarkedResult> = serde_json::from_value(json)
.expect("BookmarkedResult action response must deserialize");
assert!(r.data.bookmarked);
}
"BlockingResult" => {
let r: ApiResponse<BlockingResult> = serde_json::from_value(json)
.expect("BlockingResult action response must deserialize");
assert!(r.data.blocking);
}
"MutingResult" => {
let r: ApiResponse<MutingResult> = serde_json::from_value(json)
.expect("MutingResult action response must deserialize");
assert!(r.data.muting);
}
_ => unreachable!(),
}
}
}
#[test]
fn invalid_json_missing_required_field() {
let json = json!({"data": {"text": "no id"}});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err());
}
#[test]
fn round_trip_serialize_deserialize() {
let post = Post {
id: "456".into(),
text: "round trip".into(),
created_at: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
};
let resp = ApiResponse {
data: post,
..Default::default()
};
let value = serde_json::to_value(&resp).expect("Post must serialize");
let back: ApiResponse<Post> =
serde_json::from_value(value).expect("round-tripped Post must deserialize");
assert_eq!(back.data.id, "456");
assert_eq!(back.data.text, "round trip");
}
#[test]
fn deserialize_helper_rejects_empty_object() {
let value = json!({});
let result = deserialize_response::<Post>(value);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("empty response body"), "Got: {err}");
}
fn read_alike(legacy: Value, current: Value) -> ApiResponse<Post> {
let from_legacy: ApiResponse<Post> =
serde_json::from_value(legacy).expect("the legacy spelling deserializes");
let from_current: ApiResponse<Post> =
serde_json::from_value(current).expect("the current spelling deserializes");
assert_eq!(
serde_json::to_value(&from_legacy).expect("serializes"),
serde_json::to_value(&from_current).expect("serializes"),
"both spellings must read identically"
);
from_legacy
}
#[test]
fn retweet_count_fills_repost_count() {
let post = read_alike(
json!({"data": {"id": "1", "text": "t", "public_metrics": {"retweet_count": 4}}}),
json!({"data": {"id": "1", "text": "t", "public_metrics": {"repost_count": 4}}}),
);
let metrics = post.data.public_metrics.expect("public_metrics present");
assert_eq!(metrics.repost_count, 4);
assert!(
metrics.extra.is_empty(),
"left in extra: {:?}",
metrics.extra
);
}
#[test]
fn referenced_tweets_fills_referenced_posts() {
let refs = json!([{"id": "2", "type": "quoted"}]);
let post = read_alike(
json!({"data": {"id": "1", "text": "t", "referenced_tweets": refs}}),
json!({"data": {"id": "1", "text": "t", "referenced_posts": refs}}),
);
assert_eq!(post.data.referenced_posts.map(|r| r.len()), Some(1));
assert!(
post.data.extra.is_empty(),
"left in extra: {:?}",
post.data.extra
);
}
#[test]
fn tweets_under_includes_fills_posts() {
let included = json!([{"id": "2", "text": "q"}]);
let post = read_alike(
json!({"data": {"id": "1", "text": "t"}, "includes": {"tweets": included}}),
json!({"data": {"id": "1", "text": "t"}, "includes": {"posts": included}}),
);
let includes = post.includes.expect("includes present");
assert_eq!(includes.posts.map(|p| p.len()), Some(1));
assert!(
includes.extra.is_empty(),
"left in extra: {:?}",
includes.extra
);
}
#[test]
fn adversarial_array_where_object_expected() {
let json = json!({"data": [{"id": "1", "text": "oops"}]});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err(), "Should fail: array where object expected");
}
#[test]
fn adversarial_string_where_object_expected() {
let json = json!({"data": "not an object"});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err(), "Should fail: string where object expected");
}
#[test]
fn adversarial_null_data_field() {
let json = json!({"data": null});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err(), "Should fail: null data");
}
#[test]
fn adversarial_numeric_overflow_u64() {
let json = json!({
"data": {
"id": "123",
"text": "hi",
"public_metrics": {
"like_count": 99_999_999_999_999u64,
"repost_count": 0,
"reply_count": 0,
"quote_count": 0,
"bookmark_count": 0,
"impression_count": 0
}
}
});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("Post with large u64 must deserialize");
assert_eq!(
resp.data
.public_metrics
.expect("public_metrics must be present")
.like_count,
99_999_999_999_999
);
}
#[test]
fn adversarial_negative_count() {
let json = json!({
"data": {
"id": "123",
"text": "hi",
"public_metrics": {
"like_count": -1,
"repost_count": 0,
"reply_count": 0,
"quote_count": 0,
"bookmark_count": 0,
"impression_count": 0
}
}
});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err(), "Should fail: negative u64");
}
#[test]
fn adversarial_deeply_nested_unknown_fields() {
let json = json!({
"data": {"id": "123", "text": "hi"},
"extra_field": {
"a": {"b": {"c": [1, 2, 3]}}
}
});
let resp: ApiResponse<Post> =
serde_json::from_value(json).expect("Post with deep unknown fields must deserialize");
assert!(resp.extra.contains_key("extra_field"));
}
#[test]
fn adversarial_empty_string_required_fields() {
let json = json!({"data": {"id": "", "text": ""}});
let resp: ApiResponse<Post> = serde_json::from_value(json)
.expect("Post with empty string required fields must deserialize");
assert_eq!(resp.data.id, "");
assert_eq!(resp.data.text, "");
}
#[test]
fn adversarial_errors_field_no_data_raw_serde() {
let json = json!({"errors": [{"message": "forbidden"}]});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err(), "Should fail: no data field");
}
#[test]
fn errors_only_response_returns_validation_error() {
let json = json!({
"errors": [{
"detail": "Could not find tweet with id: [123].",
"title": "Not Found Error",
"resource_type": "tweet",
"type": "https://api.twitter.com/2/problems/resource-not-found"
}]
});
let result = deserialize_response::<Post>(json);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.is_validation(), "Expected Validation error, got: {err}");
let msg = err.to_string();
assert!(
msg.contains("Not Found Error"),
"Error should contain API message: {msg}"
);
}
#[test]
fn adversarial_extra_top_level_on_action() {
let json = json!({
"data": {"liked": true},
"extra_top_level": "ignored_by_consumer"
});
let resp: ApiResponse<LikedResult> = serde_json::from_value(json)
.expect("LikedResult with extra top-level fields must deserialize");
assert!(resp.data.liked);
assert_eq!(resp.extra["extra_top_level"], "ignored_by_consumer");
}
#[test]
fn adversarial_wrong_bool_type_for_action() {
let json = json!({"data": {"liked": "true"}});
let result = serde_json::from_value::<ApiResponse<LikedResult>>(json);
assert!(result.is_err(), "Should fail: string instead of bool");
}
#[test]
fn adversarial_partial_errors_with_valid_data() {
let json = json!({
"data": {"id": "123", "text": "partial"},
"errors": [{"message": "some field unavailable", "title": "Partial Error"}]
});
let resp: ApiResponse<Post> = serde_json::from_value(json)
.expect("Post with partial errors payload must deserialize");
assert_eq!(resp.data.id, "123");
let errors = resp.errors.expect("errors field must be present");
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].message.as_deref(), Some("some field unavailable"));
}
#[test]
fn adversarial_huge_array_in_data() {
let posts: Vec<Value> = (0..1000)
.map(|i| json!({"id": i.to_string(), "text": format!("post {i}")}))
.collect();
let json = json!({"data": posts});
let resp: ApiResponse<Vec<Post>> =
serde_json::from_value(json).expect("1000-element Post list must deserialize");
assert_eq!(resp.data.len(), 1000);
}
#[test]
fn adversarial_completely_wrong_shape() {
let json = json!({"data": 42});
let result = serde_json::from_value::<ApiResponse<Post>>(json);
assert!(result.is_err());
}
#[test]
fn adversarial_api_error_with_extra_fields() {
let json = json!({
"data": {"id": "1", "text": "ok"},
"errors": [{
"message": "oops",
"new_error_field": "surprise"
}]
});
let resp: ApiResponse<Post> = serde_json::from_value(json)
.expect("Post with errors containing extra fields must deserialize");
let error = &resp.errors.expect("errors field must be present")[0];
assert_eq!(error.extra["new_error_field"], "surprise");
}
}