1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
//! A client for the [Factorio](http://www.factorio.com) [mod portal
//! API](https://wiki.factorio.com/Mod_portal_API).

pub mod api;

use api::{ApiToken, FullModSpec};
use bytes::Bytes;
use elsa::FrozenMap;
use semver::Version;
use thiserror::Error;
use tracing::info;

use crate::api::LoginResponse;

/// A simple caching client for the [Factorio mod portal
/// API](https://wiki.factorio.com/Mod_portal_API).
pub struct ModPortalClient {
    client: reqwest::Client,
    specs: FrozenMap<String, Box<FullModSpec>>,
}

impl ModPortalClient {
    /// Creates a new client with default configuration.
    pub fn new() -> Result<ModPortalClient> {
        ModPortalClient::with_client(reqwest::Client::builder().build()?)
    }

    /// Creates a new client with a pre-configured `reqwest::Client`.
    pub fn with_client(client: reqwest::Client) -> Result<ModPortalClient> {
        Ok(ModPortalClient { client, specs: FrozenMap::new() })
    }

    /// Get the full spec of a Factorio mod. Request results are cached in memory.
    ///
    /// # Example
    ///
    /// ```no_run
    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
    /// use factorio_mod_api::ModPortalClient;
    ///
    /// let client = ModPortalClient::new()?;
    /// let spec = client.get_mod_spec("my_mod").await?;
    /// println!("{}", spec.created_at);
    /// # Ok(())
    /// # }
    /// ```
    pub async fn get_mod_spec(&self, name: &str) -> Result<&FullModSpec> {
        Ok(if let Some(spec) = self.specs.get(name) {
            info!("returning mod spec for '{name}' from cache");
            spec
        } else {
            info!("requesting mod spec for '{name}'");
            let url = format!("https://mods.factorio.com/api/mods/{name}/full");
            let response = self.client.get(url).send().await?.json().await?;
            self.specs.insert(name.into(), Box::new(response))
        })
    }

    /// Get a login token needed to invoke authenticated APIs.
    ///
    /// # Example
    ///
    /// ```no_run
    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
    /// use factorio_mod_api::ModPortalClient;
    /// use semver::Version;
    ///
    /// let client = ModPortalClient::new()?;
    /// let token = client.login("my_user", "my_password").await?;
    /// client.download_mod("my_mod", &Version::parse("1.0.0")?, &token);
    /// # Ok(())
    /// # }
    //
    /// ```
    pub async fn login(&self, user_name: &str, password: &str) -> Result<ApiToken> {
        info!("logging in with user name '{user_name}'");

        let url = "https://auth.factorio.com/api-login";
        let query = [("api_version", "4"), ("username", user_name), ("password", password)];

        let request = self.client.post(url).query(&query);
        let response = request.send().await?.json().await?;

        match response {
            LoginResponse::Success { token } => Ok(token),
            LoginResponse::Error { error, message } => {
                Err(FactorioModApiError::LoginError { error, message })
            }
        }
    }

    /// Download a mod from the mod portal.
    ///
    /// This is an authenticated endpoint that needs a login token to be
    /// obtained with [`ModPortalClient::login`] first.
    ///
    /// # Example
    /// ```no_run
    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
    /// use factorio_mod_api::ModPortalClient;
    /// use semver::Version;
    ///
    /// let client = ModPortalClient::new()?;
    /// let token = client.login("my_user", "my_password").await?;
    /// let bytes = client.download_mod("my_mod", &Version::parse("1.0.0")?, &token).await?;
    /// std::fs::write("my_mod_1.0.0.zip", bytes)?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn download_mod(
        &self,
        mod_name: &str,
        version: &Version,
        api_token: &ApiToken,
    ) -> Result<Bytes> {
        info!("downloading version {version} of '{mod_name}' mod");

        let releases = &self.get_mod_spec(mod_name).await?.short_spec.releases;
        let Some(release) = releases.iter().find(|r| r.version == *version) else {
            return Err(FactorioModApiError::InvalidModVersion { version: version.clone() })
        };

        let url = format!("https://mods.factorio.com/{}", release.download_url);
        let query = [("username", &api_token.username), ("token", &api_token.token)];

        Ok(self.client.get(url).query(&query).send().await?.bytes().await?)
    }
}

/// Main result type used throughout factorio-mod-api
pub type Result<T> = std::result::Result<T, FactorioModApiError>;

/// Main error type used throughout factorio-mod-api
#[derive(Error, Debug)]
pub enum FactorioModApiError {
    // Error that is raised if a mod dependency has an invalid format.
    #[error("Invalid mod dependency: '{dep}'")]
    InvalidModDependency { dep: String },

    // Error that is raised if a mod version doesn't exist.
    #[error("Invalid mod version: '{version}'")]
    InvalidModVersion { version: Version },

    /// Error that is raised if a request to the mod portal failed.
    #[error("Error while talking to the API Server")]
    RequestError(#[from] reqwest::Error),

    /// Error that is raised if parsing of a SemVer version number failed.
    #[error("Error while parsing a version number")]
    VersionError(#[from] semver::Error),

    /// Error that is raised if deserialization from JSON failed.
    #[error("failed to parse JSON")]
    JsonParsingError(#[from] serde_json::Error),

    #[error("failed to log in: {error}, {message}")]
    LoginError { error: String, message: String },
}