factorio_mod_api/
api.rs

1//! Data types used in the Mod Portal API.
2
3use std::{fmt::Display, str::FromStr};
4
5use chrono::{DateTime, Utc};
6use regex_macro::regex;
7use semver::Version;
8use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
9use strum_macros::{Display, EnumString};
10use url::Url;
11
12use crate::{FactorioModApiError, Result};
13
14/// A mod as returned from the `https://mods.factorio.com/api/mods` endpoint
15/// (not yet implemented).
16#[derive(Debug, Deserialize)]
17pub struct ModListing {
18    /// Metadata shared between the three different API invocations.
19    #[serde(flatten)]
20    pub metadata: ModMetadata,
21
22    /// The latest version of the mod available for download.
23    pub latest_release: ModRelease,
24}
25
26/// A mod as returned from the `https://mods.factorio.com/api/mods/{name}/full`
27/// endpoint.
28#[derive(Debug, Deserialize)]
29pub struct FullModSpec {
30    /// Spec data shared with short spec request.
31    #[serde(flatten)]
32    pub short_spec: ModSpec,
33
34    /// A string describing the recent changes to a mod
35    pub changelog: String,
36
37    /// ISO 6501 for when the mod was created.
38    pub created_at: DateTime<Utc>,
39
40    /// Usually a URL to the mod's main project page, but can be any string.
41    pub homepage: String,
42
43    // Undocumented
44    pub images: Vec<ModImage>,
45    pub license: ModLicense,
46    pub updated_at: DateTime<Utc>,
47    pub source_url: Option<Url>,
48    pub faq: Option<String>,
49}
50
51/// Mod metadata shared between the three different API invocations.
52#[derive(Debug, Deserialize)]
53pub struct ModMetadata {
54    /// The mod's machine-readable ID string.
55    pub name: String,
56
57    /// The Factorio username of the mod's author.
58    pub owner: String,
59
60    /// A shorter mod description.
61    pub summary: String,
62
63    /// The mod's human-readable name.
64    pub title: String,
65
66    /// A single tag describing the mod. Warning: Seems to be absent sometimes.
67    pub category: Option<String>,
68
69    /// Number of downloads.
70    pub downloads_count: u64,
71}
72/// A mod as returned from the `https://mods.factorio.com/api/mods/{name}`
73/// endpoint (not yet implemented). Also returned as part of the full request.
74#[derive(Debug, Deserialize)]
75pub struct ModSpec {
76    /// Metadata shared between the three different API invocations.
77    #[serde(flatten)]
78    pub metadata: ModMetadata,
79
80    /// A list of different versions of the mod available for download.
81    pub releases: Vec<ModRelease>,
82
83    /// A longer description of the mod, in text only format.
84    pub description: Option<String>,
85
86    /// A link to the mod's github project page, just prepend "github.com/". Can
87    /// be blank ("").
88    pub github_path: Option<String>,
89
90    /// A list of tag objects that categorize the mod.
91    pub tag: Option<ModTag>,
92
93    // Undocumented
94    pub score: f64,
95    pub thumbnail: Option<String>,
96}
97
98/// A tag object that categorizes a mod.
99#[derive(Debug, Deserialize)]
100pub struct ModTag {
101    /// An all lower-case string used to identify this tag internally.
102    pub name: String,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct ModImage {
107    pub id: String,
108    pub thumbnail: String,
109    pub url: Url,
110}
111
112#[derive(Debug, Deserialize)]
113pub struct ModLicense {
114    pub id: String,
115    pub name: String,
116    pub title: String,
117    pub description: String,
118    pub url: Url,
119}
120
121#[derive(Clone, Debug, Deserialize)]
122pub struct ModRelease {
123    /// Path to download for a mod. starts with "/download" and does not include
124    /// a full url.
125    pub download_url: String,
126
127    /// The file name of the release. Always seems to follow the pattern
128    /// "{name}_{version}.zip"
129    pub file_name: String,
130
131    /// A copy of the mod's info.json file, only contains factorio_version in
132    /// short version, also contains an array of dependencies in full version
133    pub info_json: ModManifest,
134
135    /// ISO 6501 for when the mod was released.
136    pub released_at: DateTime<Utc>,
137
138    /// The version string of this mod release. Used to determine dependencies.
139    #[serde(deserialize_with = "parse_version")]
140    pub version: Version,
141
142    /// The sha1 key for the file
143    pub sha1: String,
144}
145
146/// Deserializing visitor for `Version` fields.
147struct VersionVisitor;
148
149impl<'de> Visitor<'de> for VersionVisitor {
150    type Value = Version;
151
152    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
153        write!(formatter, "a semver version string (potentially with leading zeros)")
154    }
155
156    fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
157        Version::parse(&regex!(r#"\.0+([1-9])"#).replace_all(v, ".$1"))
158            .map_err(|e| E::custom(e.to_string()))
159    }
160}
161
162fn parse_version<'de, D>(d: D) -> std::result::Result<Version, D::Error>
163where
164    D: Deserializer<'de>,
165{
166    d.deserialize_str(VersionVisitor)
167}
168
169/// Partial contents of the `info.json` file that describes a mod.
170/// <https://wiki.factorio.com/Tutorial:Mod_structure#info.json>
171#[derive(Clone, Debug, Deserialize)]
172pub struct ModManifest {
173    pub factorio_version: String, // Doesn't parse as semver::Version (no patch level).
174
175    /// The mod's dependencies. Only available in "full" API calls.
176    pub dependencies: Option<Vec<ModDependency>>,
177}
178
179/// A dependency specification between mods.
180#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
181#[serde(try_from = "&str")]
182pub struct ModDependency {
183    pub flavor: ModDependencyFlavor,
184    pub name: String,
185    pub comparator: Option<semver::Comparator>,
186}
187
188impl ModDependency {
189    pub fn unversioned(name: String) -> ModDependency {
190        ModDependency { flavor: ModDependencyFlavor::Normal, name, comparator: None }
191    }
192}
193
194#[derive(Clone, Debug, Display, EnumString, Eq, PartialEq)]
195pub enum ModDependencyFlavor {
196    #[strum(serialize = "")]
197    Normal,
198    #[strum(serialize = "!")]
199    Incompatibility,
200    #[strum(serialize = "?")]
201    Optional,
202    #[strum(serialize = "(?)")]
203    Hidden,
204    #[strum(serialize = "~")]
205    NoEffectOnLoadOrder,
206}
207
208impl TryFrom<&str> for ModDependency {
209    type Error = FactorioModApiError;
210
211    fn try_from(value: &str) -> Result<Self> {
212        let re = regex!(
213            r#"(?x)^
214            (?: (?P<prefix> ! | \? | \(\?\) | ~ ) \s*)?
215            (?P<name> [[[:alnum:]]-_][[[:alnum:]]-_\ ]{1, 48}[[[:alnum:]]-_])
216            (?P<comparator>
217                \s* (?: < | <= | = | >= | > )
218                \s* \d{1,5}\.\d{1,5}(\.\d{1,5})?
219            )?
220            $"#,
221        );
222
223        let caps = re
224            .captures(value)
225            .ok_or_else(|| FactorioModApiError::InvalidModDependency { dep: value.into() })?;
226
227        Ok(ModDependency {
228            flavor: caps
229                .name("prefix")
230                .map(|prefix| ModDependencyFlavor::from_str(prefix.as_str()).unwrap())
231                .unwrap_or(ModDependencyFlavor::Normal),
232            name: caps["name"].into(),
233            comparator: caps
234                .name("comparator")
235                .map(|comparator| semver::Comparator::parse(comparator.as_str()))
236                .transpose()?,
237        })
238    }
239}
240
241impl Display for ModDependency {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        write!(f, "{}", self.flavor)?;
244        if self.flavor != ModDependencyFlavor::Normal {
245            write!(f, " ")?;
246        }
247
248        write!(f, "{}", self.name)?;
249
250        if let Some(comparator) = &self.comparator {
251            use semver::Op::*;
252            let op = match comparator.op {
253                Exact => "=",
254                Greater => ">",
255                GreaterEq => ">=",
256                Less => "<",
257                LessEq => "<=",
258                _ => unimplemented!(),
259            };
260            write!(
261                f,
262                " {op} {}.{}.{}",
263                comparator.major,
264                comparator.minor.unwrap(),
265                comparator.patch.unwrap()
266            )?;
267        }
268
269        Ok(())
270    }
271}
272
273impl ModDependency {
274    pub fn is_required(&self) -> bool {
275        use ModDependencyFlavor::*;
276        [Normal, NoEffectOnLoadOrder].contains(&self.flavor)
277    }
278}
279
280/// A token that identifies a logged in user that needs to be passed to API
281/// calls that require login.
282///
283/// Use [`ModPortalClient::login`] to obtain.
284#[derive(Debug, Deserialize, Serialize)]
285pub struct ApiToken {
286    pub token: String,
287    pub username: String,
288}
289
290/// Response from the `login` endpoint.
291#[derive(Debug, Deserialize)]
292#[serde(untagged)]
293pub enum LoginResponse {
294    Success {
295        #[serde(flatten)]
296        token: ApiToken,
297    },
298    Error {
299        error: String,
300        message: String,
301    },
302}
303
304#[cfg(test)]
305mod test {
306    use super::*;
307    use crate::api::{ModDependency, ModDependencyFlavor};
308    use semver::Comparator;
309
310    #[test]
311    fn basic() -> Result<()> {
312        let d: ModDependency = serde_json::from_str(r#""? some-mod-everyone-loves >= 4.2.0""#)?;
313        assert!(
314            d == ModDependency {
315                flavor: ModDependencyFlavor::Optional,
316                name: "some-mod-everyone-loves".into(),
317                comparator: Some(Comparator::parse(">= 4.2.0")?),
318            }
319        );
320        Ok(())
321    }
322}