cloudflare_but_works/framework/client/
async_api.rs

1use crate::framework::client::ClientConfig;
2use crate::framework::endpoint::{EndpointSpec, MultipartPart, RequestBody};
3use crate::framework::response::ResponseConverter;
4use crate::framework::{
5    auth::{AuthClient, Credentials},
6    response::ApiResponse,
7    response::{ApiErrors, ApiFailure, ApiSuccess},
8    Environment,
9};
10use std::borrow::Cow;
11use std::net::SocketAddr;
12
13/// A Cloudflare API client that makes requests asynchronously.
14// TODO: Rename to AsyncClient?
15pub struct Client {
16    environment: Environment,
17    credentials: Credentials,
18    http_client: reqwest::Client,
19}
20
21impl AuthClient for reqwest::RequestBuilder {
22    fn auth(mut self, credentials: &Credentials) -> Self {
23        for (k, v) in credentials.headers() {
24            self = self.header(k, v);
25        }
26        self
27    }
28}
29
30impl Client {
31    pub fn new(
32        credentials: Credentials,
33        config: ClientConfig,
34        environment: Environment,
35    ) -> Result<Client, crate::framework::Error> {
36        let mut builder = reqwest::Client::builder().default_headers(config.default_headers);
37
38        #[cfg(not(target_arch = "wasm32"))]
39        {
40            // There is no resolve method in wasm.
41            if let Some(address) = config.resolve_ip {
42                let url = url::Url::from(&environment);
43                builder = builder.resolve(
44                    url.host_str()
45                        .expect("Environment url should have a hostname"),
46                    SocketAddr::new(address, 443),
47                );
48            }
49
50            // There are no timeouts in wasm. The property is documented as no-op in wasm32.
51            builder = builder.timeout(config.http_timeout);
52        }
53
54        let http_client = builder.build()?;
55
56        Ok(Client {
57            environment,
58            credentials,
59            http_client,
60        })
61    }
62
63    //noinspection RsConstantConditionIf
64    /// Issue an API request of the given type.
65    pub async fn request<Endpoint>(
66        &self,
67        endpoint: &Endpoint,
68    ) -> ApiResponse<Endpoint::ResponseType>
69    where
70        Endpoint: EndpointSpec + Send + Sync,
71        Endpoint::ResponseType: ResponseConverter<Endpoint::JsonResponse>,
72    {
73        // Build the request
74        let mut request = self
75            .http_client
76            .request(endpoint.method(), endpoint.url(&self.environment));
77
78        if let Some(body) = endpoint.body() {
79            match body {
80                RequestBody::Json(json) => {
81                    request = request.body(json);
82                }
83                RequestBody::Raw(bytes) => {
84                    request = request.body(bytes);
85                }
86                RequestBody::MultiPart(multipart) => {
87                    let mut form = reqwest::multipart::Form::new();
88                    for (name, part) in multipart.parts() {
89                        match part {
90                            MultipartPart::Text(text) => {
91                                form = form.text(name, text);
92                            }
93                            MultipartPart::Bytes(bytes) => {
94                                form = form.part(name, reqwest::multipart::Part::bytes(bytes));
95                            }
96                        }
97                    }
98                    request = request.multipart(form);
99                }
100            }
101            // Reqwest::RequestBuilder::multipart sets the content type for us.
102            match endpoint.content_type() {
103                None | Some(Cow::Borrowed("multipart/form-data")) => {}
104                Some(content_type) => {
105                    request = request.header(reqwest::header::CONTENT_TYPE, content_type.as_ref());
106                }
107            }
108        }
109
110        request = request.auth(&self.credentials);
111        let response = request.send().await?;
112
113        // The condition is necessary, even if a warning is present.
114        // The constant is overridden in some cases.
115        if Endpoint::IS_RAW_BODY {
116            map_api_response_raw::<Endpoint>(response).await
117        } else {
118            map_api_response_json::<Endpoint>(response).await
119        }
120    }
121}
122
123// If the response is 2XX and parses, return Success.
124// If the response is 2XX and doesn't parse, return Invalid.
125// If the response isn't 2XX, return Failure, with API errors if they were included.
126async fn map_api_response_raw<Endpoint>(
127    resp: reqwest::Response,
128) -> Result<Endpoint::ResponseType, ApiFailure>
129where
130    Endpoint: EndpointSpec,
131    Endpoint::ResponseType: ResponseConverter<Endpoint::JsonResponse>,
132{
133    let status = resp.status();
134    if status.is_success() {
135        let bytes = resp.bytes().await.map_err(ApiFailure::Invalid)?.to_vec();
136        Ok(Endpoint::ResponseType::from_raw(bytes))
137    } else {
138        let parsed: Result<ApiErrors, reqwest::Error> = resp.json().await;
139        let errors = parsed.unwrap_or_default();
140        Err(ApiFailure::Error(status, errors))
141    }
142}
143
144async fn map_api_response_json<Endpoint>(
145    resp: reqwest::Response,
146) -> Result<Endpoint::ResponseType, ApiFailure>
147where
148    Endpoint: EndpointSpec,
149    Endpoint::ResponseType: ResponseConverter<Endpoint::JsonResponse>,
150{
151    let status = resp.status();
152    if status.is_success() {
153        let parsed: Result<ApiSuccess<Endpoint::JsonResponse>, reqwest::Error> = resp.json().await;
154        match parsed {
155            Ok(success) => Ok(Endpoint::ResponseType::from_json(success)),
156            Err(e) => Err(ApiFailure::Invalid(e)),
157        }
158    } else {
159        let parsed: Result<ApiErrors, reqwest::Error> = resp.json().await;
160        let errors = parsed.unwrap_or_default();
161        Err(ApiFailure::Error(status, errors))
162    }
163}
164
165// TODO: Refactor this to test the blocking_api as well
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::framework::auth::Credentials;
170    use crate::framework::client::ClientConfig;
171    use crate::framework::endpoint::RequestBody;
172    use crate::framework::endpoint::{serialize_query, EndpointSpec};
173    use crate::framework::response::{ApiFailure, ApiResult, ApiSuccess};
174    use crate::framework::Environment;
175    use mockito::{Matcher, Server};
176    use regex;
177    use regex::Regex;
178    use serde::{Deserialize, Serialize};
179    use serde_json::json;
180    use tokio;
181
182    //region Endpoint that returns JSON (ApiSuccess).
183    #[derive(Debug)]
184    struct DummyJsonEndpoint;
185
186    #[derive(Debug, Deserialize)]
187    struct DummyJsonResponse {
188        message: String,
189    }
190
191    impl ApiResult for DummyJsonResponse {}
192
193    impl EndpointSpec for DummyJsonEndpoint {
194        type JsonResponse = DummyJsonResponse;
195        type ResponseType = ApiSuccess<Self::JsonResponse>;
196
197        fn method(&self) -> reqwest::Method {
198            reqwest::Method::GET
199        }
200
201        fn path(&self) -> String {
202            "/dummy/json".into()
203        }
204    }
205    //endregion
206
207    //region Endpoint that returns raw bytes.
208    #[derive(Debug)]
209    struct DummyRawEndpoint;
210
211    impl EndpointSpec for DummyRawEndpoint {
212        const IS_RAW_BODY: bool = true;
213        type JsonResponse = ();
214        type ResponseType = Vec<u8>;
215
216        fn method(&self) -> reqwest::Method {
217            reqwest::Method::GET
218        }
219
220        fn path(&self) -> String {
221            "/dummy/raw".into()
222        }
223    }
224    //endregion
225
226    //region Endpoint that returns nothing.
227    #[derive(Debug)]
228    struct DummyNothingEndpoint;
229
230    impl EndpointSpec for DummyNothingEndpoint {
231        type JsonResponse = ();
232        type ResponseType = ApiSuccess<Self::JsonResponse>;
233
234        fn method(&self) -> reqwest::Method {
235            reqwest::Method::GET
236        }
237
238        fn path(&self) -> String {
239            "/dummy/nothing".into()
240        }
241    }
242    //endregion
243
244    //region Endpoint that sends a JSON request.
245    #[derive(Debug)]
246    struct DummyJsonRequestEndpoint;
247
248    impl EndpointSpec for DummyJsonRequestEndpoint {
249        type JsonResponse = ();
250        type ResponseType = ApiSuccess<Self::JsonResponse>;
251
252        fn method(&self) -> reqwest::Method {
253            reqwest::Method::POST
254        }
255
256        fn path(&self) -> String {
257            "/dummy/json".into()
258        }
259
260        fn body(&self) -> Option<RequestBody> {
261            Some(RequestBody::Json(json!({"key": "value"}).to_string()))
262        }
263    }
264    //endregion
265
266    //region Endpoint that sends raw bytes.
267    #[derive(Debug)]
268    struct DummyRawRequestEndpoint;
269
270    impl EndpointSpec for DummyRawRequestEndpoint {
271        const IS_RAW_BODY: bool = true;
272        type JsonResponse = ();
273        type ResponseType = Vec<u8>;
274
275        fn method(&self) -> reqwest::Method {
276            reqwest::Method::POST
277        }
278
279        fn path(&self) -> String {
280            "/dummy/raw".into()
281        }
282
283        fn body(&self) -> Option<RequestBody> {
284            Some(RequestBody::Raw(b"raw content".to_vec()))
285        }
286    }
287    //endregion
288
289    //region Endpoint that sends a multipart request.
290    #[derive(Debug)]
291    struct DummyMultipartEndpoint;
292
293    impl EndpointSpec for DummyMultipartEndpoint {
294        type JsonResponse = ();
295        type ResponseType = ApiSuccess<Self::JsonResponse>;
296
297        fn method(&self) -> reqwest::Method {
298            reqwest::Method::POST
299        }
300
301        fn path(&self) -> String {
302            "/dummy/multipart".into()
303        }
304
305        fn body(&self) -> Option<RequestBody> {
306            Some(RequestBody::MultiPart(&DummyMultipart))
307        }
308    }
309
310    struct DummyMultipart;
311
312    impl crate::framework::endpoint::MultipartBody for DummyMultipart {
313        fn parts(&self) -> Vec<(String, MultipartPart)> {
314            vec![("key".into(), MultipartPart::Text("value".into()))]
315        }
316    }
317    //endregion
318
319    //region Endpoint that sends a request with query parameters.
320    #[derive(Debug)]
321    struct DummyJsonRequestWithQueryEndpoint;
322
323    #[derive(Debug, Serialize)]
324    struct DummyJsonRequestWithQueryParams {
325        key: String,
326    }
327
328    impl EndpointSpec for DummyJsonRequestWithQueryEndpoint {
329        type JsonResponse = ();
330        type ResponseType = ApiSuccess<Self::JsonResponse>;
331
332        fn method(&self) -> reqwest::Method {
333            reqwest::Method::POST
334        }
335
336        fn path(&self) -> String {
337            "/dummy/json".into()
338        }
339
340        fn query(&self) -> Option<String> {
341            serialize_query(&DummyJsonRequestWithQueryParams {
342                key: "value".into(),
343            })
344        }
345    }
346    //endregion
347
348    fn create_test_client(url: String) -> Client {
349        let environment = Environment::Custom(url);
350        let credentials = Credentials::UserAuthToken {
351            token: "dummy".into(),
352        };
353        let config = ClientConfig::default();
354        Client::new(credentials, config, environment).unwrap()
355    }
356
357    /// Test that the client can successfully request a JSON endpoint.
358    #[tokio::test]
359    async fn test_json_endpoint_success() {
360        let body = json!({
361            "result": {"message": "Hello, World!"},
362            "result_info": null,
363            "messages": [],
364            "errors": [],
365            "success": true
366        });
367
368        let mut server = Server::new_async().await;
369        let mock = server
370            .mock("GET", "/dummy/json")
371            .with_status(200)
372            .with_header("content-type", "application/json")
373            .with_body(body.to_string())
374            .match_header("content-type", Matcher::Missing)
375            .match_query(Matcher::Missing)
376            .match_body(Matcher::Missing)
377            .create();
378
379        let client = create_test_client(server.url());
380        let response = client.request(&DummyJsonEndpoint).await;
381
382        mock.assert();
383        let response = response.unwrap();
384        assert_eq!(response.result.message, "Hello, World!");
385        assert_eq!(response.result_info, None);
386        assert!(response.messages.is_empty());
387        assert!(response.errors.is_empty());
388    }
389
390    /// Test that the client can successfully request a raw endpoint.
391    #[tokio::test]
392    async fn test_raw_endpoint_success() {
393        let raw_body = b"raw content".to_vec();
394
395        let mut server = Server::new_async().await;
396        let mock = server
397            .mock("GET", "/dummy/raw")
398            .with_status(200)
399            .with_header("content-type", "application/octet-stream")
400            .with_body(raw_body.clone())
401            .match_header("content-type", Matcher::Missing)
402            .match_query(Matcher::Missing)
403            .match_body(Matcher::Missing)
404            .create();
405
406        let client = create_test_client(server.url());
407        let response = client.request(&DummyRawEndpoint).await.unwrap();
408
409        mock.assert();
410        assert_eq!(response, raw_body);
411    }
412
413    /// Test that the client can handle an endpoint that returns an error.
414    #[tokio::test]
415    async fn test_endpoint_failure() {
416        let body = json!({
417            "errors": [{"code": 123, "message": "Something went wrong", "other": {}}],
418            "other": {}
419        });
420
421        let mut server = Server::new_async().await;
422        let mock = server
423            .mock("GET", "/dummy/json")
424            .with_status(400)
425            .with_header("content-type", "application/json")
426            .with_body(body.to_string())
427            .match_header("content-type", Matcher::Missing)
428            .match_query(Matcher::Missing)
429            .match_body(Matcher::Missing)
430            .create();
431
432        let client = create_test_client(server.url());
433        let result = client.request(&DummyJsonEndpoint).await;
434
435        mock.assert();
436        assert!(result.is_err());
437        if let Err(ApiFailure::Error(status, errors)) = result {
438            assert_eq!(status.as_u16(), 400);
439            assert!(!errors.errors.is_empty());
440            assert_eq!(errors.errors[0].code, 123);
441        } else {
442            panic!("Expected error result");
443        }
444    }
445
446    /// Test that the client can handle an endpoint that returns nothing.
447    #[tokio::test]
448    async fn test_nothing_endpoint_success() {
449        let body = json!({
450            "result": null,
451            "result_info": null,
452            "messages": [],
453            "errors": [],
454            "success": true
455        });
456
457        let mut server = Server::new_async().await;
458        let mock = server
459            .mock("GET", "/dummy/nothing")
460            .with_status(200)
461            .with_header("content-type", "application/json")
462            .with_body(body.to_string())
463            .match_header("content-type", Matcher::Missing)
464            .match_query(Matcher::Missing)
465            .match_body(Matcher::Missing)
466            .create();
467
468        let client = create_test_client(server.url());
469        let response = client.request(&DummyNothingEndpoint).await;
470
471        mock.assert();
472        let response = response.unwrap();
473        assert!(matches!(response.result, ()));
474        assert_eq!(response.result_info, None);
475        assert!(response.messages.is_empty());
476        assert!(response.errors.is_empty());
477    }
478
479    /// Test that the client can successfully send a JSON request.
480    #[tokio::test]
481    async fn test_json_body_success() {
482        let body = json!({
483            "result": null,
484            "result_info": null,
485            "messages": [],
486            "errors": [],
487            "success": true
488        });
489
490        let mut server = Server::new_async().await;
491        let mock = server
492            .mock("POST", "/dummy/json")
493            .with_status(200)
494            .with_header("content-type", "application/json")
495            .with_body(body.to_string())
496            .match_header("content-type", "application/json")
497            .match_query(Matcher::Missing)
498            .match_body(Matcher::Json(json!({"key": "value"})))
499            .create();
500
501        let client = create_test_client(server.url());
502        let _ = client.request(&DummyJsonRequestEndpoint).await;
503
504        mock.assert();
505    }
506
507    /// Test that the client can successfully send a raw request.
508    #[tokio::test]
509    async fn test_raw_body_success() {
510        let raw_body = b"raw content".to_vec();
511
512        let mut server = Server::new_async().await;
513        let mock = server
514            .mock("POST", "/dummy/raw")
515            .with_status(200)
516            .with_header("content-type", "application/octet-stream")
517            .with_body(raw_body.clone())
518            .match_header("content-type", "application/octet-stream")
519            .match_query(Matcher::Missing)
520            .match_body(raw_body)
521            .create();
522
523        let client = create_test_client(server.url());
524        let _ = client.request(&DummyRawRequestEndpoint).await;
525
526        mock.assert();
527    }
528
529    /// Test that the client can successfully send a multipart request.
530    #[tokio::test]
531    async fn test_multipart_body_success() {
532        let body = json!({
533            "result": null,
534            "result_info": null,
535            "messages": [],
536            "errors": [],
537            "success": true
538        });
539
540        let mut server = Server::new_async().await;
541
542        let mock = server
543            .mock("POST", "/dummy/multipart")
544            .with_status(200)
545            .with_header("content-type", "application/json")
546            .with_body(body.to_string())
547            .match_header(
548                "content-type",
549                Matcher::Regex("multipart/form-data; boundary=.*".into()),
550            )
551            .match_query(Matcher::Missing)
552            .match_request(|req| {
553                let body = req.body().unwrap().to_vec();
554                let body = String::from_utf8_lossy(&body);
555
556                let re = Regex::new(
557                    r#"^--.*\s+Content-Disposition: form-data; name="key"\s+\s+value\s+--.*\s*$"#,
558                )
559                .unwrap();
560                re.is_match(&body)
561            })
562            .create();
563
564        let client = create_test_client(server.url());
565        let _ = client.request(&DummyMultipartEndpoint).await;
566
567        mock.assert();
568    }
569
570    /// Test that the client can successfully send a request with query parameters.
571    #[tokio::test]
572    async fn test_query_parameters_success() {
573        let body = json!({
574            "result": null,
575            "result_info": null,
576            "messages": [],
577            "errors": [],
578            "success": true
579        });
580
581        let mut server = Server::new_async().await;
582        let mock = server
583            .mock("POST", "/dummy/json")
584            .with_status(200)
585            .with_header("content-type", "application/json")
586            .with_body(body.to_string())
587            .match_header("content-type", Matcher::Missing)
588            .match_query(Matcher::UrlEncoded("key".into(), "value".into()))
589            .match_body(Matcher::Missing)
590            .create();
591
592        let client = create_test_client(server.url());
593        let _ = client.request(&DummyJsonRequestWithQueryEndpoint).await;
594
595        mock.assert();
596    }
597}