factorio_mod_api/
lib.rs

1//! A client for the [Factorio](http://www.factorio.com) [mod portal
2//! API](https://wiki.factorio.com/Mod_portal_API).
3
4pub mod api;
5
6use api::{ApiToken, FullModSpec};
7use bytes::Bytes;
8use elsa::FrozenMap;
9use semver::Version;
10use thiserror::Error;
11use tracing::info;
12
13use crate::api::LoginResponse;
14
15/// A simple caching client for the [Factorio mod portal
16/// API](https://wiki.factorio.com/Mod_portal_API).
17pub struct ModPortalClient {
18    client: reqwest::Client,
19    specs: FrozenMap<String, Box<FullModSpec>>,
20}
21
22impl ModPortalClient {
23    /// Creates a new client with default configuration.
24    pub fn new() -> Result<ModPortalClient> {
25        ModPortalClient::with_client(reqwest::Client::builder().build()?)
26    }
27
28    /// Creates a new client with a pre-configured `reqwest::Client`.
29    pub fn with_client(client: reqwest::Client) -> Result<ModPortalClient> {
30        Ok(ModPortalClient { client, specs: FrozenMap::new() })
31    }
32
33    /// Get the full spec of a Factorio mod. Request results are cached in memory.
34    ///
35    /// # Example
36    ///
37    /// ```no_run
38    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
39    /// use factorio_mod_api::ModPortalClient;
40    ///
41    /// let client = ModPortalClient::new()?;
42    /// let spec = client.get_mod_spec("my_mod").await?;
43    /// println!("{}", spec.created_at);
44    /// # Ok(())
45    /// # }
46    /// ```
47    pub async fn get_mod_spec(&self, name: &str) -> Result<&FullModSpec> {
48        Ok(if let Some(spec) = self.specs.get(name) {
49            info!("returning mod spec for '{name}' from cache");
50            spec
51        } else {
52            info!("requesting mod spec for '{name}'");
53            let url = format!("https://mods.factorio.com/api/mods/{name}/full");
54            let response = self.client.get(url).send().await?.json().await?;
55            self.specs.insert(name.into(), Box::new(response))
56        })
57    }
58
59    /// Get a login token needed to invoke authenticated APIs.
60    ///
61    /// # Example
62    ///
63    /// ```no_run
64    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
65    /// use factorio_mod_api::ModPortalClient;
66    /// use semver::Version;
67    ///
68    /// let client = ModPortalClient::new()?;
69    /// let token = client.login("my_user", "my_password").await?;
70    /// client.download_mod("my_mod", &Version::parse("1.0.0")?, &token);
71    /// # Ok(())
72    /// # }
73    //
74    /// ```
75    pub async fn login(&self, user_name: &str, password: &str) -> Result<ApiToken> {
76        info!("logging in with user name '{user_name}'");
77
78        let url = "https://auth.factorio.com/api-login";
79        let query = [("api_version", "4"), ("username", user_name), ("password", password)];
80
81        let request = self.client.post(url).query(&query);
82        let response = request.send().await?.json().await?;
83
84        match response {
85            LoginResponse::Success { token } => Ok(token),
86            LoginResponse::Error { error, message } => {
87                Err(FactorioModApiError::LoginError { error, message })
88            }
89        }
90    }
91
92    /// Download a mod from the mod portal.
93    ///
94    /// This is an authenticated endpoint that needs a login token to be
95    /// obtained with [`ModPortalClient::login`] first.
96    ///
97    /// # Example
98    /// ```no_run
99    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
100    /// use factorio_mod_api::ModPortalClient;
101    /// use semver::Version;
102    ///
103    /// let client = ModPortalClient::new()?;
104    /// let token = client.login("my_user", "my_password").await?;
105    /// let bytes = client.download_mod("my_mod", &Version::parse("1.0.0")?, &token).await?;
106    /// std::fs::write("my_mod_1.0.0.zip", bytes)?;
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub async fn download_mod(
111        &self,
112        mod_name: &str,
113        version: &Version,
114        api_token: &ApiToken,
115    ) -> Result<Bytes> {
116        info!("downloading version {version} of '{mod_name}' mod");
117
118        let releases = &self.get_mod_spec(mod_name).await?.short_spec.releases;
119        let Some(release) = releases.iter().find(|r| r.version == *version) else {
120            return Err(FactorioModApiError::InvalidModVersion { version: version.clone() })
121        };
122
123        let url = format!("https://mods.factorio.com/{}", release.download_url);
124        let query = [("username", &api_token.username), ("token", &api_token.token)];
125
126        Ok(self.client.get(url).query(&query).send().await?.bytes().await?)
127    }
128}
129
130/// Main result type used throughout factorio-mod-api
131pub type Result<T> = std::result::Result<T, FactorioModApiError>;
132
133/// Main error type used throughout factorio-mod-api
134#[derive(Error, Debug)]
135pub enum FactorioModApiError {
136    // Error that is raised if a mod dependency has an invalid format.
137    #[error("Invalid mod dependency: '{dep}'")]
138    InvalidModDependency { dep: String },
139
140    // Error that is raised if a mod version doesn't exist.
141    #[error("Invalid mod version: '{version}'")]
142    InvalidModVersion { version: Version },
143
144    /// Error that is raised if a request to the mod portal failed.
145    #[error("Error while talking to the API Server")]
146    RequestError(#[from] reqwest::Error),
147
148    /// Error that is raised if parsing of a SemVer version number failed.
149    #[error("Error while parsing a version number")]
150    VersionError(#[from] semver::Error),
151
152    /// Error that is raised if deserialization from JSON failed.
153    #[error("failed to parse JSON")]
154    JsonParsingError(#[from] serde_json::Error),
155
156    #[error("failed to log in: {error}, {message}")]
157    LoginError { error: String, message: String },
158}