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 modde_core::NexusModId;
24use reqwest::Client;
25use serde::de::DeserializeOwned;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29/// POST a query to the Nexus v2 GraphQL endpoint and decode the `data`
30/// field into `T`. On any shape mismatch or transport error, returns
31/// an `anyhow::Error` — callers that have a REST fallback should
32/// `.or_else(|_| rest_call())`.
33///
34/// We deserialize into a `serde_json::Value` first and then pull out
35/// `data` / `errors` manually. Going directly into `GqlResponse<T>`
36/// would impose a `Default` bound on every caller's response type
37/// (because of how `#[serde(default)]` interacts with generic enum
38/// deserialization in this version of serde).
39pub 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// ── Typed queries ────────────────────────────────────────────────
81
82/// Response tile for the browse / search feeds. A slimmer view of a mod
83/// than the REST `NexusMod` struct — this is what the UI card grid
84/// renders, so it carries only what's visible at a glance.
85#[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    /// Nexus game domain (returned by the v2 schema). Optional because
107    /// some feed variants don't include it; callers fall back to the
108    /// `gameDomain` they issued the query with.
109    #[serde(default, rename = "gameDomain")]
110    pub game_domain: Option<String>,
111}
112
113/// Browse feed kind. Matches the UI tab enum.
114#[derive(Debug, Clone, Copy)]
115pub enum ModFeedKind {
116    /// All-time trending mods for a game.
117    Trending,
118    /// Mods updated in the last month — equivalent to the REST
119    /// `updated_mods(period="1m")` feed.
120    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
174/// Fetch a trending / monthly-top browse feed. The `kind` drives which
175/// server-side sort is used.
176///
177/// The server-side schema is in flux, so parsing is deliberately
178/// tolerant: any missing field becomes `None`. On a total parse failure
179/// the caller should fall back to the REST feed — use
180/// [`super::api::NexusApi::trending_mods`] or `updated_mods("1m")`.
181pub 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        // For monthly-top we reuse the trending sort — the v2 schema
190        // doesn't expose a clean "updated in last month" filter without
191        // date math in the query, and trending approximates what users
192        // expect from "mods of the month". The REST path remains
193        // available for a strict month filter.
194        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
201/// Full-text search across a game's mods. `term` is passed as a
202/// wildcard filter — callers should trim whitespace but not escape;
203/// the server handles tokenization.
204pub 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
220/// Extract a `mods { nodes { ... } }` list from a GraphQL response,
221/// tolerating missing fields. Used by both browse and search because
222/// they share the same wrapper shape.
223fn 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    // The `author` field comes back as `{ name: "..." }` in the v2
230    // schema; flatten it into the `GqlModTile::author` string field.
231    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// ── Collections feed ─────────────────────────────────────────────
269
270/// A collection as it appears in the GraphQL collections feed (tile view).
271#[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
310/// Fetch the collections browse/search feed. `term` is `None` for the
311/// default "top collections" listing and `Some(q)` for a user search.
312pub 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}