1use anyhow::{Context, Result, bail};
23use reqwest::Client;
24use serde::{Deserialize, Serialize};
25use serde::de::DeserializeOwned;
26use serde_json::Value;
27
28const GRAPHQL_URL: &str = "https://api.nexusmods.com/v2/graphql";
29
30pub async fn post<T: DeserializeOwned>(
41 client: &Client,
42 api_key: &str,
43 query: &str,
44 variables: Value,
45) -> Result<T> {
46 let body = serde_json::json!({
47 "query": query,
48 "variables": variables,
49 });
50
51 let resp = client
52 .post(GRAPHQL_URL)
53 .header("apikey", api_key)
54 .header("content-type", "application/json")
55 .json(&body)
56 .send()
57 .await
58 .context("GraphQL POST failed")?
59 .error_for_status()
60 .context("GraphQL transport error")?;
61
62 let envelope: Value = resp
63 .json()
64 .await
65 .context("failed to decode GraphQL response")?;
66
67 if let Some(errors) = envelope.get("errors") {
68 if !errors.is_null() {
69 bail!("Nexus GraphQL errors: {errors}");
70 }
71 }
72 let data = envelope
73 .get("data")
74 .cloned()
75 .ok_or_else(|| anyhow::anyhow!("GraphQL response missing `data` field"))?;
76 let decoded: T = serde_json::from_value(data)
77 .context("failed to decode GraphQL `data` payload")?;
78 Ok(decoded)
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct GqlModTile {
88 #[serde(rename = "modId")]
89 pub mod_id: u64,
90 pub name: String,
91 #[serde(default)]
92 pub summary: Option<String>,
93 #[serde(default)]
94 pub version: Option<String>,
95 #[serde(default)]
96 pub author: Option<String>,
97 #[serde(default, rename = "pictureUrl")]
98 pub picture_url: Option<String>,
99 #[serde(default, rename = "thumbnailUrl")]
100 pub thumbnail_url: Option<String>,
101 #[serde(default, rename = "endorsements")]
102 pub endorsements: Option<u64>,
103 #[serde(default, rename = "downloads")]
104 pub downloads: Option<u64>,
105 #[serde(default, rename = "uploadedAt")]
106 pub uploaded_at: Option<String>,
107 #[serde(default, rename = "gameDomain")]
111 pub game_domain: Option<String>,
112}
113
114#[derive(Debug, Clone, Copy)]
116pub enum ModFeedKind {
117 Trending,
119 MonthlyTop,
122}
123
124const TRENDING_QUERY: &str = r#"
125query TrendingMods($gameDomain: String!) {
126 mods(
127 filter: { gameDomain: { value: $gameDomain, op: EQUALS } }
128 sort: { endorsements: { direction: DESC } }
129 count: 30
130 ) {
131 nodes {
132 modId
133 name
134 summary
135 version
136 author { name }
137 pictureUrl
138 thumbnailUrl
139 endorsements
140 downloads
141 uploadedAt
142 gameDomain
143 }
144 }
145}
146"#;
147
148const SEARCH_QUERY: &str = r#"
149query SearchMods($gameDomain: String!, $term: String!, $page: Int!) {
150 mods(
151 filter: {
152 gameDomain: { value: $gameDomain, op: EQUALS }
153 name: { value: $term, op: WILDCARD }
154 }
155 offset: $page
156 count: 30
157 ) {
158 nodes {
159 modId
160 name
161 summary
162 version
163 author { name }
164 pictureUrl
165 thumbnailUrl
166 endorsements
167 downloads
168 uploadedAt
169 gameDomain
170 }
171 }
172}
173"#;
174
175pub async fn browse_feed(
183 client: &Client,
184 api_key: &str,
185 game_domain: &str,
186 kind: ModFeedKind,
187) -> Result<Vec<GqlModTile>> {
188 let query = match kind {
189 ModFeedKind::Trending => TRENDING_QUERY,
190 ModFeedKind::MonthlyTop => TRENDING_QUERY,
196 };
197 let vars = serde_json::json!({ "gameDomain": game_domain });
198 let data: serde_json::Value = post(client, api_key, query, vars).await?;
199 decode_mod_list(&data)
200}
201
202pub async fn search_mods(
206 client: &Client,
207 api_key: &str,
208 game_domain: &str,
209 term: &str,
210 page: u32,
211) -> Result<Vec<GqlModTile>> {
212 let vars = serde_json::json!({
213 "gameDomain": game_domain,
214 "term": term,
215 "page": page as i64,
216 });
217 let data: serde_json::Value = post(client, api_key, SEARCH_QUERY, vars).await?;
218 decode_mod_list(&data)
219}
220
221fn decode_mod_list(data: &Value) -> Result<Vec<GqlModTile>> {
225 let nodes = data
226 .get("mods")
227 .and_then(|m| m.get("nodes"))
228 .cloned()
229 .ok_or_else(|| anyhow::anyhow!("GraphQL response missing mods.nodes"))?;
230 let mut out = Vec::new();
233 if let Some(array) = nodes.as_array() {
234 for raw in array {
235 let mut tile: GqlModTile = serde_json::from_value(raw.clone()).unwrap_or(GqlModTile {
236 mod_id: raw
237 .get("modId")
238 .and_then(|v| v.as_u64())
239 .unwrap_or_default(),
240 name: raw
241 .get("name")
242 .and_then(|v| v.as_str())
243 .unwrap_or_default()
244 .to_string(),
245 summary: None,
246 version: None,
247 author: None,
248 picture_url: None,
249 thumbnail_url: None,
250 endorsements: None,
251 downloads: None,
252 uploaded_at: None,
253 game_domain: None,
254 });
255 if tile.author.is_none() {
256 tile.author = raw
257 .get("author")
258 .and_then(|a| a.get("name"))
259 .and_then(|n| n.as_str())
260 .map(str::to_string);
261 }
262 out.push(tile);
263 }
264 }
265 Ok(out)
266}
267
268#[derive(Debug, Clone, Deserialize, Serialize)]
271pub struct GqlCollectionTile {
272 pub slug: String,
273 pub name: String,
274 #[serde(default)]
275 pub summary: Option<String>,
276 #[serde(default, rename = "tileImage")]
277 pub tile_image: Option<String>,
278 #[serde(default, rename = "gameDomain")]
279 pub game_domain: Option<String>,
280 #[serde(default, rename = "endorsements")]
281 pub endorsements: Option<u64>,
282 #[serde(default, rename = "downloads")]
283 pub downloads: Option<u64>,
284}
285
286const COLLECTIONS_QUERY: &str = r#"
287query CollectionsFeed($gameDomain: String!, $term: String) {
288 collections(
289 filter: {
290 gameDomain: { value: $gameDomain, op: EQUALS }
291 name: { value: $term, op: WILDCARD }
292 }
293 sort: { endorsements: { direction: DESC } }
294 count: 30
295 ) {
296 nodes {
297 slug
298 name
299 summary
300 tileImage
301 gameDomain
302 endorsements
303 downloads
304 }
305 }
306}
307"#;
308
309pub async fn collections_feed(
312 client: &Client,
313 api_key: &str,
314 game_domain: &str,
315 term: Option<&str>,
316) -> Result<Vec<GqlCollectionTile>> {
317 let vars = serde_json::json!({
318 "gameDomain": game_domain,
319 "term": term.unwrap_or(""),
320 });
321 let data: serde_json::Value = post(client, api_key, COLLECTIONS_QUERY, vars).await?;
322 let nodes = data
323 .get("collections")
324 .and_then(|m| m.get("nodes"))
325 .cloned()
326 .ok_or_else(|| anyhow::anyhow!("GraphQL response missing collections.nodes"))?;
327 let tiles: Vec<GqlCollectionTile> = serde_json::from_value(nodes)?;
328 Ok(tiles)
329}