use anyhow::{Context, Result, bail};
use modde_core::NexusModId;
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub async fn post<T: DeserializeOwned>(
client: &Client,
api_key: &str,
query: &str,
variables: Value,
) -> Result<T> {
let body = serde_json::json!({
"query": query,
"variables": variables,
});
let resp = client
.post(super::graphql_url())
.header("apikey", api_key)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.context("GraphQL POST failed")?
.error_for_status()
.context("GraphQL transport error")?;
let envelope: Value = resp
.json()
.await
.context("failed to decode GraphQL response")?;
if let Some(errors) = envelope.get("errors")
&& !errors.is_null()
{
bail!("Nexus GraphQL errors: {errors}");
}
let data = envelope
.get("data")
.cloned()
.ok_or_else(|| anyhow::anyhow!("GraphQL response missing `data` field"))?;
let decoded: T =
serde_json::from_value(data).context("failed to decode GraphQL `data` payload")?;
Ok(decoded)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GqlModTile {
#[serde(rename = "modId")]
pub mod_id: NexusModId,
pub name: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default, rename = "pictureUrl")]
pub picture_url: Option<String>,
#[serde(default, rename = "thumbnailUrl")]
pub thumbnail_url: Option<String>,
#[serde(default, rename = "endorsements")]
pub endorsements: Option<u64>,
#[serde(default, rename = "downloads")]
pub downloads: Option<u64>,
#[serde(default, rename = "uploadedAt")]
pub uploaded_at: Option<String>,
#[serde(default, rename = "gameDomain")]
pub game_domain: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum ModFeedKind {
Trending,
MonthlyTop,
}
const TRENDING_QUERY: &str = r"
query TrendingMods($gameDomain: String!) {
mods(
filter: { gameDomain: { value: $gameDomain, op: EQUALS } }
sort: { endorsements: { direction: DESC } }
count: 30
) {
nodes {
modId
name
summary
version
author { name }
pictureUrl
thumbnailUrl
endorsements
downloads
uploadedAt
gameDomain
}
}
}
";
const SEARCH_QUERY: &str = r"
query SearchMods($gameDomain: String!, $term: String!, $page: Int!) {
mods(
filter: {
gameDomain: { value: $gameDomain, op: EQUALS }
name: { value: $term, op: WILDCARD }
}
offset: $page
count: 30
) {
nodes {
modId
name
summary
version
author { name }
pictureUrl
thumbnailUrl
endorsements
downloads
uploadedAt
gameDomain
}
}
}
";
pub async fn browse_feed(
client: &Client,
api_key: &str,
game_domain: &str,
kind: ModFeedKind,
) -> Result<Vec<GqlModTile>> {
let query = match kind {
ModFeedKind::Trending => TRENDING_QUERY,
ModFeedKind::MonthlyTop => TRENDING_QUERY,
};
let vars = serde_json::json!({ "gameDomain": game_domain });
let data: serde_json::Value = post(client, api_key, query, vars).await?;
decode_mod_list(&data)
}
pub async fn search_mods(
client: &Client,
api_key: &str,
game_domain: &str,
term: &str,
page: u32,
) -> Result<Vec<GqlModTile>> {
let vars = serde_json::json!({
"gameDomain": game_domain,
"term": term,
"page": i64::from(page),
});
let data: serde_json::Value = post(client, api_key, SEARCH_QUERY, vars).await?;
decode_mod_list(&data)
}
fn decode_mod_list(data: &Value) -> Result<Vec<GqlModTile>> {
let nodes = data
.get("mods")
.and_then(|m| m.get("nodes"))
.cloned()
.ok_or_else(|| anyhow::anyhow!("GraphQL response missing mods.nodes"))?;
let mut out = Vec::new();
if let Some(array) = nodes.as_array() {
for raw in array {
let mut tile: GqlModTile = serde_json::from_value(raw.clone()).unwrap_or(GqlModTile {
mod_id: raw
.get("modId")
.and_then(serde_json::Value::as_u64)
.map(NexusModId::from)
.unwrap_or_else(|| NexusModId::from(0)),
name: raw
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
summary: None,
version: None,
author: None,
picture_url: None,
thumbnail_url: None,
endorsements: None,
downloads: None,
uploaded_at: None,
game_domain: None,
});
if tile.author.is_none() {
tile.author = raw
.get("author")
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string);
}
out.push(tile);
}
}
Ok(out)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GqlCollectionTile {
pub slug: String,
pub name: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default, rename = "tileImage")]
pub tile_image: Option<String>,
#[serde(default, rename = "gameDomain")]
pub game_domain: Option<String>,
#[serde(default, rename = "endorsements")]
pub endorsements: Option<u64>,
#[serde(default, rename = "downloads")]
pub downloads: Option<u64>,
}
const COLLECTIONS_QUERY: &str = r"
query CollectionsFeed($gameDomain: String!, $term: String) {
collections(
filter: {
gameDomain: { value: $gameDomain, op: EQUALS }
name: { value: $term, op: WILDCARD }
}
sort: { endorsements: { direction: DESC } }
count: 30
) {
nodes {
slug
name
summary
tileImage
gameDomain
endorsements
downloads
}
}
}
";
pub async fn collections_feed(
client: &Client,
api_key: &str,
game_domain: &str,
term: Option<&str>,
) -> Result<Vec<GqlCollectionTile>> {
let vars = serde_json::json!({
"gameDomain": game_domain,
"term": term.unwrap_or(""),
});
let data: serde_json::Value = post(client, api_key, COLLECTIONS_QUERY, vars).await?;
let nodes = data
.get("collections")
.and_then(|m| m.get("nodes"))
.cloned()
.ok_or_else(|| anyhow::anyhow!("GraphQL response missing collections.nodes"))?;
let tiles: Vec<GqlCollectionTile> = serde_json::from_value(nodes)?;
Ok(tiles)
}