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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! Starting point for interacting with a lotide API

use reqwest::{Method, RequestBuilder};
use secrecy::Secret;
use serde::de::DeserializeOwned;

use crate::{
    api_models::{CommunityInfo, InstanceInfo, JustId, List, LoginInfo, PostListPost},
    prelude::{PostId, ReqCommunities, ReqNewPost, ReqPosts, ReqRegister},
};

/// Starting point for interacting with a lotide API
pub struct Client {
    client: reqwest::Client,
    instance_url: String,
    lang: Option<String>,
    token: Option<Secret<String>>,
}

impl PartialEq for Client {
    fn eq(&self, other: &Self) -> bool {
        // PartialEq is useful for things like Yew
        let tokens_are_equal = if let (Some(a), Some(b)) = (&self.token, &other.token) {
            use secrecy::ExposeSecret;
            a.expose_secret() == b.expose_secret()
        } else {
            // TODO: Address the potential security concern with comparing tokens like this
            self.token.is_none() && other.token.is_none()
        };
        self.instance_url == other.instance_url && tokens_are_equal
    }
}

impl Client {
    /// Create a new [`Client`]
    ///
    /// `instance_url` should be the full base url to the instance's API,
    /// e.g. `https://lotide.example.com/api/unstable`
    pub fn new(instance_url: impl ToString) -> Self {
        Self {
            client: reqwest::Client::new(),
            instance_url: instance_url.to_string(),
            lang: None,
            token: None,
        }
    }

    /// Set the language
    pub fn set_lang(&mut self, lang: impl ToString) {
        self.lang = Some(lang.to_string());
    }

    /// Set the language
    pub fn with_lang(mut self, lang: impl ToString) -> Self {
        self.set_lang(lang);
        self
    }

    /// Get the language
    pub fn lang(&self) -> Option<&str> {
        self.lang.as_deref()
    }

    /// Get the internal [`reqwest::Client`] instance
    pub fn reqwest_client(&self) -> &reqwest::Client {
        &self.client
    }

    /// Get the internal [`reqwest::Client`] instance mutably
    pub fn reqwest_client_mut(&mut self) -> &mut reqwest::Client {
        &mut self.client
    }

    /// Get the stored instance URL
    ///
    /// This is the URL prepended to every request
    pub fn instance_url(&self) -> &str {
        &self.instance_url
    }

    /// Set the bearer token to be used with requests
    pub fn set_token(&mut self, token: impl ToString) {
        self.token = Some(Secret::new(token.to_string()));
    }

    /// Does the [`Client`] have a token set?
    pub fn has_token(&self) -> bool {
        self.token.is_some()
    }

    /// Get the stored token
    pub fn get_token(&self) -> Option<Secret<String>> {
        self.token.clone()
    }

    /// Make a request to the instance for information about itself
    pub async fn instance_info(&self) -> reqwest::Result<InstanceInfo> {
        self.request(Method::GET, "instance").await
    }

    /// Log in to the service
    pub async fn login(
        &self,
        username: impl ToString,
        password: impl ToString,
    ) -> reqwest::Result<(Client, LoginInfo)> {
        #[derive(serde::Deserialize)]
        struct LoginResponse {
            token: Secret<String>,
            #[serde(flatten)]
            user: LoginInfo,
        }

        let LoginResponse { token, user } = self
            .request_with(Method::POST, "logins", |b| {
                b.json(&serde_json::json!({
                    "username": username.to_string(),
                    "password": password.to_string(),
                }))
            })
            .await?;

        let new_self = Self {
            client: self.client.clone(),
            instance_url: self.instance_url.clone(),
            lang: self.lang.clone(),
            token: Some(token),
        };

        Ok((new_self, user))
    }

    /// Fetch current login state
    pub async fn current_login(&self) -> reqwest::Result<LoginInfo> {
        self.request(Method::GET, "logins/~current").await
    }

    /// Log out
    pub async fn log_out(&self) -> reqwest::Result<()> {
        self.request(Method::DELETE, "logins/~current").await
    }

    /// Register a new account
    pub async fn register<'a>(
        &self,
        req: &ReqRegister<'a>,
    ) -> reqwest::Result<(Option<Client>, LoginInfo)> {
        #[derive(serde::Deserialize)]
        struct RegisterResponse {
            token: Option<Secret<String>>,
            #[serde(flatten)]
            user: LoginInfo,
        }

        let RegisterResponse { token, user } = self
            .request_with(Method::POST, "users", |b| b.json(&req))
            .await?;

        let new_self = token.map(|t| Self {
            client: self.client.clone(),
            instance_url: self.instance_url.clone(),
            lang: self.lang.clone(),
            token: Some(t),
        });

        Ok((new_self, user))
    }

    /// List communities on the instance
    pub async fn communities<'a>(
        &self,
        req: &ReqCommunities<'a>,
    ) -> reqwest::Result<List<CommunityInfo>> {
        self.request_with(Method::GET, "communities", |b| b.query(req))
            .await
    }

    /// List posts on the instance
    pub async fn posts<'a>(&self, req: &ReqPosts<'a>) -> reqwest::Result<List<PostListPost>> {
        self.request_with(Method::GET, "posts", |b| b.query(req))
            .await
    }

    /// Create a new post
    pub async fn new_post<'a>(&self, req: &ReqNewPost<'a>) -> reqwest::Result<PostId> {
        let res: JustId<PostId> = self
            .request_with(Method::POST, "posts", |b| b.json(&req))
            .await?;
        Ok(res.id)
    }

    /// Updload an image to the instance
    pub async fn media(&self, mime: &str, data: Vec<u8>) -> reqwest::Result<String> {
        let res: JustId<String> = self
            .request_with(Method::POST, "media", move |b| {
                b.header("Content-Type", mime).body(data)
            })
            .await?;

        Ok(res.id)
    }

    /// Make a request to the instance
    pub async fn request<T: DeserializeOwned>(
        &self,
        method: Method,
        subpath: &str,
    ) -> Result<T, reqwest::Error> {
        self.request_with(method, subpath, |b| b).await
    }

    /// Like [`Ctx::request`](`#method.request`), but allows you to modify the [`RequestBuilder`] before sending
    pub async fn request_with<T: DeserializeOwned>(
        &self,
        method: Method,
        subpath: &str,
        f: impl FnOnce(RequestBuilder) -> RequestBuilder,
    ) -> Result<T, reqwest::Error> {
        use secrecy::ExposeSecret;

        let mut builder = self
            .client
            .request(method, format!("{}/{}", self.instance_url, subpath));

        if let Some(lang) = &self.lang {
            builder = builder.header("Accept-Language", lang);
        };

        let mut builder = f(builder);

        if let Some(token) = &self.token {
            builder = builder.bearer_auth(token.expose_secret())
        };

        builder.send().await?.error_for_status()?.json().await
    }
}