misskey_http/
client.rs

1use std::convert::TryInto;
2use std::fmt::{self, Debug};
3
4use crate::error::{Error, Result};
5
6use common_multipart_rfc7578::client::{multipart, Error as MultipartError};
7use futures::future::BoxFuture;
8use futures::io::AsyncReadExt;
9use isahc::http;
10#[cfg(feature = "inspect-contents")]
11use log::debug;
12use mime::Mime;
13use misskey_core::model::ApiResult;
14use misskey_core::{Client, Request, UploadFileClient, UploadFileRequest};
15use serde::Serialize;
16use serde_json::value::{self, Value};
17use url::Url;
18
19pub mod builder;
20
21use builder::HttpClientBuilder;
22
23/// Asynchronous HTTP-based client for Misskey.
24///
25/// [`HttpClient`] can be constructed using [`HttpClient::new`], [`HttpClient::with_token`] or
26/// [`HttpClientBuilder`][`builder::HttpClientBuilder`].
27pub struct HttpClient {
28    url: Url,
29    token: Option<String>,
30    client: isahc::HttpClient,
31}
32
33impl Debug for HttpClient {
34    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
35        f.debug_struct("HttpClient")
36            .field("url", &self.url)
37            .finish()
38    }
39}
40
41impl HttpClient {
42    /// Creates a new HTTP-based client without a token.
43    pub fn new(url: Url) -> Result<Self> {
44        Ok(HttpClient {
45            url,
46            token: None,
47            client: isahc::HttpClient::new()?,
48        })
49    }
50
51    /// Creates a new HTTP-based client with a token.
52    pub fn with_token(url: Url, token: impl Into<String>) -> Result<Self> {
53        Ok(HttpClient {
54            url,
55            token: Some(token.into()),
56            client: isahc::HttpClient::new()?,
57        })
58    }
59
60    /// Creates a new builder instance with `url`.
61    /// All configurations are set to default.
62    ///
63    /// This function is identical to [`HttpClientBuilder::new`].
64    pub fn builder<T>(url: T) -> HttpClientBuilder
65    where
66        T: TryInto<Url>,
67        T::Error: Into<Error>,
68    {
69        HttpClientBuilder::new(url)
70    }
71
72    fn set_api_key<R: Request>(
73        &self,
74        request: R,
75    ) -> std::result::Result<impl Serialize, serde_json::Error> {
76        #[derive(Serialize)]
77        #[serde(untagged)]
78        enum ValueOrRequest<R> {
79            Value(Value),
80            Request(R),
81        }
82
83        if let Some(token) = &self.token {
84            let mut value = value::to_value(request)?;
85
86            let obj = value.as_object_mut().expect("Request must be an object");
87            if obj
88                .insert("i".to_string(), Value::String(token.to_owned()))
89                .is_some()
90            {
91                panic!("Request must not have 'i' key");
92            }
93
94            Ok(ValueOrRequest::Value(value))
95        } else {
96            Ok(ValueOrRequest::Request(request))
97        }
98    }
99
100    fn make_url<R: Request>(&self) -> Result<Url> {
101        let mut url = self.url.clone();
102        if let Ok(mut segments) = url.path_segments_mut() {
103            segments.pop_if_empty();
104            for segment in R::ENDPOINT.split('/') {
105                segments.push(segment);
106            }
107        } else {
108            return self.url.join(R::ENDPOINT).map_err(Into::into);
109        }
110        Ok(url)
111    }
112}
113
114impl Client for HttpClient {
115    type Error = Error;
116
117    fn request<R: Request>(&self, request: R) -> BoxFuture<Result<ApiResult<R::Response>>> {
118        let url = self.make_url::<R>();
119
120        // limit the use of `R` value to the outside of `async`
121        // in order not to require `Send` on `R`
122        let body = self
123            .set_api_key(request)
124            .and_then(|b| serde_json::to_vec(&b));
125
126        Box::pin(async move {
127            let url = url?;
128            let body = body?;
129
130            #[cfg(feature = "inspect-contents")]
131            debug!(
132                "sending request to {}: {}",
133                url,
134                String::from_utf8_lossy(&body)
135            );
136
137            use isahc::http::header::CONTENT_TYPE;
138            let response = self
139                .client
140                .send_async(
141                    // TODO: uncomfortable conversion from `Url` to `Uri`
142                    http::Request::post(url.to_string())
143                        .header(CONTENT_TYPE, "application/json")
144                        .body(body)
145                        .unwrap(),
146                )
147                .await?;
148
149            response_to_result::<R>(response).await
150        })
151    }
152}
153
154impl UploadFileClient for HttpClient {
155    fn request_with_file<R, T>(
156        &self,
157        request: R,
158        type_: Mime,
159        file_name: String,
160        read: T,
161    ) -> BoxFuture<Result<ApiResult<R::Response>>>
162    where
163        R: UploadFileRequest,
164        T: std::io::Read + Send + Sync + 'static,
165    {
166        let url = self.make_url::<R>();
167
168        // limit the use of `R` value to the outside of `async`
169        // in order not to require `Send` on `R`
170        let value = self.set_api_key(request).and_then(value::to_value);
171
172        Box::pin(async move {
173            let url = url?;
174            let value = value?;
175
176            #[cfg(feature = "inspect-contents")]
177            debug!(
178                "sending request to {} with {} content: {}",
179                url, type_, value
180            );
181
182            let mut form = multipart::Form::default();
183
184            form.add_reader_file_with_mime("file", read, file_name, type_);
185
186            let obj = value.as_object().expect("Request must be an object");
187            for (k, v) in obj {
188                let v = v
189                    .as_str()
190                    .expect("UploadFileRequest must be an object that all values are string");
191                form.add_text(k.to_owned(), v.to_owned());
192            }
193
194            let content_type = form.content_type();
195
196            use futures::stream::TryStreamExt;
197            let stream = multipart::Body::from(form).map_err(|e| match e {
198                MultipartError::HeaderWrite(e) => e,
199                MultipartError::BoundaryWrite(e) => e,
200                MultipartError::ContentRead(e) => e,
201            });
202            let body = isahc::Body::from_reader(async_dup::Mutex::new(stream.into_async_read()));
203
204            use isahc::http::header::CONTENT_TYPE;
205            let response = self
206                .client
207                .send_async(
208                    // TODO: uncomfortable conversion from `Url` to `Uri`
209                    http::Request::post(url.into_string())
210                        .header(CONTENT_TYPE, content_type)
211                        .body(body)
212                        .unwrap(),
213                )
214                .await?;
215
216            response_to_result::<R>(response).await
217        })
218    }
219}
220
221async fn response_to_result<R: Request>(
222    response: http::Response<isahc::Body>,
223) -> Result<ApiResult<R::Response>> {
224    let status = response.status();
225    let mut bytes = Vec::new();
226    response.into_body().read_to_end(&mut bytes).await?;
227
228    #[cfg(feature = "inspect-contents")]
229    debug!(
230        "got response ({}) from {}: {}",
231        status,
232        R::ENDPOINT,
233        String::from_utf8_lossy(&bytes)
234    );
235
236    let json_bytes = if bytes.is_empty() {
237        b"null".as_ref()
238    } else {
239        bytes.as_ref()
240    };
241
242    if status.is_success() {
243        // Limit response to `ApiResult::Ok` branch to get informative error message
244        // when our model does not match the response.
245        Ok(ApiResult::Ok(serde_json::from_slice(json_bytes)?))
246    } else {
247        Ok(serde_json::from_slice(json_bytes)?)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::HttpClient;
254
255    use misskey_core::{Client, UploadFileClient};
256    use misskey_test::{self, env};
257    use uuid::Uuid;
258
259    fn test_client() -> HttpClient {
260        misskey_test::init_logger();
261        HttpClient::with_token(env::api_url(), env::token()).unwrap()
262    }
263
264    #[test]
265    fn test_send() {
266        fn assert_send<T: Send>() {}
267        assert_send::<HttpClient>();
268    }
269
270    #[test]
271    fn test_sync() {
272        fn assert_send<T: Sync>() {}
273        assert_send::<HttpClient>();
274    }
275
276    #[tokio::test]
277    async fn test_url_without_trailing_slash() {
278        let mut url = env::api_url().to_string();
279        assert_eq!(url.pop(), Some('/'));
280        let client = HttpClient::with_token(url.parse().unwrap(), env::token()).unwrap();
281        client
282            .request(
283                misskey_api::endpoint::notes::create::Request::builder()
284                    .text("hi")
285                    .build(),
286            )
287            .await
288            .unwrap()
289            .unwrap();
290    }
291
292    #[tokio::test]
293    async fn tokio_request() {
294        let client = test_client();
295        client
296            .request(
297                misskey_api::endpoint::notes::create::Request::builder()
298                    .text("hi")
299                    .build(),
300            )
301            .await
302            .unwrap()
303            .unwrap();
304    }
305
306    #[async_std::test]
307    async fn async_std_request() {
308        let client = test_client();
309        client
310            .request(
311                misskey_api::endpoint::notes::create::Request::builder()
312                    .text("hi")
313                    .build(),
314            )
315            .await
316            .unwrap()
317            .unwrap();
318    }
319
320    fn write_to_temp_file(data: impl AsRef<[u8]>) -> std::path::PathBuf {
321        let tmp_name = Uuid::new_v4().to_simple().to_string();
322        let path = std::env::temp_dir().join(tmp_name);
323        {
324            use std::{fs::File, io::Write};
325            let mut file = File::create(&path).unwrap();
326            file.write_all(data.as_ref()).unwrap();
327            file.sync_all().unwrap();
328        }
329        path
330    }
331
332    #[tokio::test]
333    async fn tokio_request_with_file() {
334        let client = test_client();
335        let path = write_to_temp_file("test");
336        let file = std::fs::File::open(path).unwrap();
337
338        client
339            .request_with_file(
340                misskey_api::endpoint::drive::files::create::Request::default(),
341                mime::TEXT_PLAIN,
342                "test.txt".to_string(),
343                file,
344            )
345            .await
346            .unwrap()
347            .unwrap();
348    }
349
350    #[async_std::test]
351    async fn async_std_request_with_file() {
352        let client = test_client();
353        let path = write_to_temp_file("test");
354        let file = std::fs::File::open(path).unwrap();
355
356        client
357            .request_with_file(
358                misskey_api::endpoint::drive::files::create::Request::default(),
359                mime::TEXT_PLAIN,
360                "test.txt".to_string(),
361                file,
362            )
363            .await
364            .unwrap()
365            .unwrap();
366    }
367}