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