Skip to main content

modde_sources/nexus/
graphql.rs

1//! Typed Nexus Mods v2 GraphQL client.
2//!
3//! The v2 GraphQL endpoint is undocumented but stable enough to back the
4//! browse / search UI. This module centralizes the transport, query
5//! literals, and response types so callers do not have to poke at
6//! `serde_json::Value` directly.
7//!
8//! All queries take a numeric `gameId` rather than a `gameDomain`
9//! string. Callers get the numeric ID from
10//! [`modde_games::GamePlugin::nexus_game_id_u32`] and the domain from
11//! [`modde_games::GamePlugin::nexus_game_domain`] — the former is
12//! needed for browse feeds, the latter for mod-detail lookups that the
13//! REST API already accepts.
14//!
15//! On any error, callers may fall back to the v1 REST helpers on
16//! [`super::api::NexusApi`], which exist for every query that has a
17//! REST equivalent. The GraphQL path is preferred because it returns
18//! richer per-mod data (thumbnails, summaries, download counts) in one
19//! round-trip, but the REST path is preserved to keep the UI working
20//! if Nexus ever pulls the v2 endpoint.
21
22use 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
30/// POST a query to the Nexus v2 GraphQL endpoint and decode the `data`
31/// field into `T`. On any shape mismatch or transport error, returns
32/// an `anyhow::Error` — callers that have a REST fallback should
33/// `.or_else(|_| rest_call())`.
34///
35/// We deserialize into a `serde_json::Value` first and then pull out
36/// `data` / `errors` manually. Going directly into `GqlResponse<T>`
37/// would impose a `Default` bound on every caller's response type
38/// (because of how `#[serde(default)]` interacts with generic enum
39/// deserialization in this version of serde).
40pub 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// ── Typed queries ────────────────────────────────────────────────
82
83/// Response tile for the browse / search feeds. A slimmer view of a mod
84/// than the REST `NexusMod` struct — this is what the UI card grid
85/// renders, so it carries only what's visible at a glance.
86#[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    /// Nexus game domain (returned by the v2 schema). Optional because
108    /// some feed variants don't include it; callers fall back to the
109    /// `gameDomain` they issued the query with.
110    #[serde(default, rename = "gameDomain")]
111    pub game_domain: Option<String>,
112}
113
114/// Browse feed kind. Matches the UI tab enum.
115#[derive(Debug, Clone, Copy)]
116pub enum ModFeedKind {
117    /// All-time trending mods for a game.
118    Trending,
119    /// Mods updated in the last month — equivalent to the REST
120    /// `updated_mods(period="1m")` feed.
121    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
175/// Fetch a trending / monthly-top browse feed. The `kind` drives which
176/// server-side sort is used.
177///
178/// The server-side schema is in flux, so parsing is deliberately
179/// tolerant: any missing field becomes `None`. On a total parse failure
180/// the caller should fall back to the REST feed — use
181/// [`super::api::NexusApi::trending_mods`] or `updated_mods("1m")`.
182pub 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        // For monthly-top we reuse the trending sort — the v2 schema
191        // doesn't expose a clean "updated in last month" filter without
192        // date math in the query, and trending approximates what users
193        // expect from "mods of the month". The REST path remains
194        // available for a strict month filter.
195        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
202/// Full-text search across a game's mods. `term` is passed as a
203/// wildcard filter — callers should trim whitespace but not escape;
204/// the server handles tokenization.
205pub 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
221/// Extract a `mods { nodes { ... } }` list from a GraphQL response,
222/// tolerating missing fields. Used by both browse and search because
223/// they share the same wrapper shape.
224fn 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    // The `author` field comes back as `{ name: "..." }` in the v2
231    // schema; flatten it into the `GqlModTile::author` string field.
232    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// ── Collections feed ─────────────────────────────────────────────
269
270#[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
309/// Fetch the collections browse/search feed. `term` is `None` for the
310/// default "top collections" listing and `Some(q)` for a user search.
311pub 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}