fundamentum_edge_mcu_http_client/
http_client.rs

1use core::{marker::PhantomData, str};
2
3use httparse::{Response, Status};
4use serde::{Deserialize, Serialize};
5use serde_json_core::ser;
6
7use crate::{
8    api_version::{ApiVersion, V3},
9    http_client_config::HttpClientConfig,
10    models::{
11        ApiResponse, ApiResponseFailedData, ApiResponseFailedEmpty, ApiStatus, HttpBody,
12        HttpMethod, HttpRequest, StatusCode,
13    },
14    HttpClientError, HttpClientErrorWrapper, HttpHandler,
15};
16
17/// The common successful Fundamentum API's response is 300 bytes long. Thus, 2K should be fine.
18const MAX_BODY_LENGTH: usize = 2048;
19/// The usage [example][httparse-github] of the `httparse` crate recommends 64 headers.
20///
21/// [httparse-github]: https://github.com/seanmonstar/httparse?tab=readme-ov-file#usage
22const MAX_NUMBER_OF_HEADERS: usize = 64;
23const DEFAULT_API_PATH: &str = "/api";
24const PATH_SEPARATOR: &str = "/";
25const CONTENT_LENGTH_HEADER: &str = "Content-Length";
26
27/// Client wrapping a [`HttpHandler`] structure to to perform requests with
28/// the Fundamentum API for a specific version.
29//
30// TODO: ASP00009-1809: The HttpClient should not have a version, and this be handled by the FundamentumApi
31pub struct HttpClient<'a, V: ApiVersion, H: HttpHandler> {
32    config: HttpClientConfig<'a>,
33    http_handler: H,
34    // We use PhantomData to make the compiler think that we are using the V type.
35    // Read more here: <https://doc.rust-lang.org/nomicon/phantom-data.html#table-of-phantomdata-patterns>
36    _marker: PhantomData<fn() -> V>,
37}
38
39impl<'a, H: HttpHandler> HttpClient<'a, V3, H> {
40    /// Create a new HTTP client.
41    #[must_use]
42    pub fn new(config: HttpClientConfig<'a>, http_handler: H) -> Self {
43        Self {
44            config,
45            http_handler,
46            _marker: PhantomData,
47        }
48    }
49
50    /// Get the current configuration of this client.
51    #[must_use]
52    pub const fn config(&self) -> &HttpClientConfig {
53        &self.config
54    }
55
56    /// Send JSON request with the configuration and the handler.
57    ///
58    /// # Errors
59    ///
60    /// See [`HttpClientError`] for the error type.
61    pub async fn send<'d, B, T>(
62        &'a self,
63        method: HttpMethod,
64        base_path: Option<&'a str>,
65        path: &'a str,
66        body: B,
67        // TODO: ASP00009-1811: The HTTP library's API should expose a structure to manage all buffer usages
68        out_buffer: &'d mut [u8],
69    ) -> Result<T, HttpClientErrorWrapper<'d, H>>
70    where
71        B: Serialize,
72        T: Deserialize<'d>,
73    {
74        // Serialize body
75        let mut serialized: [u8; MAX_BODY_LENGTH] = [0; MAX_BODY_LENGTH];
76        let size = serde_json_core::to_slice(&body, &mut serialized).map_err(|e| match e {
77            ser::Error::BufferFull => HttpClientError::SerializationBufferFull(MAX_BODY_LENGTH),
78            _ => HttpClientError::Serialization,
79        })?;
80        #[allow(clippy::shadow_reuse)]
81        let serialized = &serialized[0..size];
82
83        let request = HttpRequest {
84            method,
85            domain_name: self.config.domain_name,
86            port: self.config.port,
87            user_agent: self.config.user_agent,
88            base_path: base_path.or(Some(const_format::concatcp!(
89                DEFAULT_API_PATH,
90                PATH_SEPARATOR,
91                V3::VERSION
92            ))),
93            path,
94            accept: "application/json",
95            body: Some(HttpBody {
96                content_type: "application/json",
97                body: serialized,
98            }),
99        };
100
101        // Send HTTPS request
102        self.http_handler
103            .http_call(request, out_buffer)
104            .await
105            .map_err(HttpClientError::SendRequest)?;
106
107        // Extract body
108        let (body, status_code) = Self::extract_body(out_buffer)?;
109
110        // TODO: See related GitHub Issue : https://github.com/rust-embedded-community/serde-json-core/issues/87
111        //
112        // TODO: ASP00009-1808: The Fundamentum API's response should be handled without specific errors
113        //
114        // Should be this :
115        //
116        // ```
117        // let (api_response, _size): (ApiResponseFailedData<'d>, usize) =
118        //     serde_json_core::from_slice(body).map_err(HttpClientError::DeserializationFailed)?;
119        // ```
120        let (api_response, _bytes): (ApiResponse<'d, T>, usize) =
121            match serde_json_core::from_slice(body) {
122                Ok(response) => response,
123                Err(_) => match serde_json_core::from_slice::<ApiResponseFailedData<'d>>(body) {
124                    Ok((response_failed, size)) => (ApiResponse::from(response_failed), size),
125
126                    Err(_) => match serde_json_core::from_slice::<ApiResponseFailedEmpty<'d>>(body)
127                    {
128                        Ok((response_failed, size)) => (ApiResponse::from(response_failed), size),
129
130                        Err(e) => return Err(HttpClientError::DeserializationFailed(e).into()),
131                    },
132                },
133            };
134
135        // Handle errors
136        match api_response.status {
137            Some(ApiStatus::Success) => {
138                Ok(api_response.data.ok_or(HttpClientError::MissingDataField)?)
139            }
140
141            Some(ApiStatus::Error) => api_response.message.map_or_else(
142                || Err(HttpClientError::FailedAndMissingMessageField.into()),
143                |message: &'d str| {
144                    Err(HttpClientErrorWrapper::GenericApiError {
145                        message,
146                        status_code,
147                    })
148                },
149            ),
150
151            None => Err(HttpClientError::MissingStatusField.into()),
152        }
153    }
154}
155
156impl<'a, V: ApiVersion, H: HttpHandler> HttpClient<'a, V, H> {
157    /// Extracts the body from a HTTP response buffer.
158    ///
159    /// # Errors
160    ///
161    /// See [`HttpClientError`] for the error type.
162    fn extract_body(buffer: &[u8]) -> Result<(&[u8], StatusCode), HttpClientError<H>> {
163        // Create response
164        let mut headers = [httparse::EMPTY_HEADER; MAX_NUMBER_OF_HEADERS];
165        let mut response = Response::new(&mut headers);
166
167        let body_index = match response.parse(buffer).map_err(HttpClientError::ParseHttp)? {
168            Status::Complete(body_start_index) => body_start_index,
169            Status::Partial => {
170                return Err(HttpClientError::PartialHttpRequest);
171            }
172        };
173        let status_code = response.code.ok_or(HttpClientError::NoStatusCode)?;
174
175        // Extract content length
176        let content_length_header = response
177            .headers
178            .iter()
179            .find(|header| header.name == CONTENT_LENGTH_HEADER)
180            .ok_or(HttpClientError::NoContentHeader)?;
181
182        let content_length = str::from_utf8(content_length_header.value)
183            .map_err(|e| HttpClientError::Utf8Conversion(e.valid_up_to()))?
184            .parse::<usize>()
185            .map_err(HttpClientError::ContentLengthConversion)?;
186
187        let body = &buffer[body_index..(body_index + content_length)];
188
189        Ok((body, StatusCode(status_code)))
190    }
191}
192
193// MARK: tests
194
195#[cfg(test)]
196mod tests {
197    use httparse::Error as HttpParseError;
198    use indoc::indoc;
199
200    use super::*;
201
202    #[derive(Debug, PartialEq, Eq)]
203    struct TestHttpHandler<'a> {
204        response: &'a [u8],
205    }
206
207    #[derive(Debug, PartialEq, Eq)]
208    #[cfg_attr(feature = "log", derive(defmt::Format))]
209    enum TestHttpHandlerError {
210        InvalidPath,
211    }
212
213    #[derive(Debug, PartialEq, Eq, Deserialize)]
214    #[cfg_attr(feature = "log", derive(defmt::Format))]
215    struct TestDataStructure {
216        test: u8,
217    }
218
219    impl<'b> HttpHandler for TestHttpHandler<'b> {
220        type Error = TestHttpHandlerError;
221
222        async fn http_call<'a, 'd>(
223            &'a self,
224            request: HttpRequest<'_>,
225            out_buffer: &'d mut [u8],
226        ) -> Result<(), Self::Error> {
227            if request.path == INVALID_PATH {
228                return Err(TestHttpHandlerError::InvalidPath);
229            }
230
231            out_buffer[0..self.response.len()].copy_from_slice(self.response);
232
233            Ok(())
234        }
235    }
236
237    const VALID_TEST_DATA_STRUCTURE_RESPONSE: &str = indoc! {r#"
238        HTTP/1.1 200 OK
239        Content-Type: application/json; charset=utf-8
240        Content-Length: 39
241        Connection: close
242
243        {"status":"success","data":{"test":42}}
244    "#};
245
246    const ANY_METHOD: HttpMethod = HttpMethod::GET;
247    const ANY_PATH: &str = "any";
248    const ANY_BODY: &str = "any";
249    const INVALID_PATH: &str = "invalid_path";
250
251    fn given_http_client(response: &[u8]) -> (HttpClient<V3, TestHttpHandler<'_>>, [u8; 2048]) {
252        let http_handler = TestHttpHandler { response };
253        let http_client =
254            HttpClient::<'_, V3, TestHttpHandler>::new(HttpClientConfig::default(), http_handler);
255        let response_buffer: [u8; 2048] = [0; 2048];
256
257        (http_client, response_buffer)
258    }
259
260    #[tokio::test]
261    async fn given_valid_parameters_and_response_when_send_http_then_ok_t() {
262        let (http_client, mut response_buffer) =
263            given_http_client(VALID_TEST_DATA_STRUCTURE_RESPONSE.as_bytes());
264
265        let response: TestDataStructure = http_client
266            .send(ANY_METHOD, None, ANY_PATH, ANY_BODY, &mut response_buffer)
267            .await
268            .unwrap();
269
270        assert_eq!(42, response.test);
271    }
272
273    #[tokio::test]
274    async fn given_serialization_buffer_too_small_when_send_http_then_serialization_err() {
275        let (http_client, mut response_buffer) =
276            given_http_client(VALID_TEST_DATA_STRUCTURE_RESPONSE.as_bytes());
277        let body_buffer: [u8; MAX_BODY_LENGTH + 1] = [0; MAX_BODY_LENGTH + 1];
278        let body = str::from_utf8(&body_buffer).unwrap();
279
280        let response = http_client
281            .send::<_, ()>(ANY_METHOD, None, ANY_PATH, body, &mut response_buffer)
282            .await;
283
284        assert_eq!(
285            HttpClientErrorWrapper::OtherErrors(HttpClientError::SerializationBufferFull(2048)),
286            response.unwrap_err()
287        );
288    }
289
290    #[tokio::test]
291    async fn given_http_call_error_when_send_http_then_request_err() {
292        let (http_client, mut response_buffer) =
293            given_http_client(VALID_TEST_DATA_STRUCTURE_RESPONSE.as_bytes());
294
295        let response = http_client
296            .send::<_, ()>(
297                ANY_METHOD,
298                Some("/"),
299                INVALID_PATH,
300                ANY_BODY,
301                &mut response_buffer,
302            )
303            .await;
304
305        assert_eq!(
306            HttpClientErrorWrapper::OtherErrors(HttpClientError::SendRequest(
307                TestHttpHandlerError::InvalidPath
308            )),
309            response.unwrap_err()
310        );
311    }
312
313    #[tokio::test]
314    async fn given_no_status_field_when_send_http_then_missing_status_field() {
315        let response = indoc! {r#"
316            HTTP/1.1 200 OK
317            Content-Type: application/json; charset=utf-8
318            Content-Length: 91
319            Connection: close
320
321            {"data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}
322        "#};
323
324        let (http_client, mut response_buffer) = given_http_client(response.as_bytes());
325
326        let response = http_client
327            .send::<_, ()>(ANY_METHOD, None, ANY_PATH, ANY_BODY, &mut response_buffer)
328            .await;
329
330        assert_eq!(
331            HttpClientErrorWrapper::OtherErrors(HttpClientError::MissingStatusField),
332            response.unwrap_err()
333        );
334    }
335
336    #[tokio::test]
337    async fn given_no_data_field_when_send_http_then_missing_data_field() {
338        let response = indoc! {r#"
339            HTTP/1.1 200 OK
340            Content-Type: application/json; charset=utf-8
341            Content-Length: 20
342            Connection: close
343
344            {"status":"success"}
345        "#};
346        let (http_client, mut response_buffer) = given_http_client(response.as_bytes());
347
348        let response = http_client
349            .send::<_, ()>(ANY_METHOD, None, ANY_PATH, ANY_BODY, &mut response_buffer)
350            .await;
351
352        assert_eq!(
353            HttpClientErrorWrapper::OtherErrors(HttpClientError::MissingDataField),
354            response.unwrap_err()
355        );
356    }
357
358    #[tokio::test]
359    async fn given_no_message_field_when_send_http_then_missing_message_field() {
360        let response = indoc! {r#"
361            HTTP/1.1 200 OK
362            Content-Type: application/json; charset=utf-8
363            Content-Length: 28
364            Connection: close
365
366            {"status":"error","data":{}}
367        "#};
368        let (http_client, mut response_buffer) = given_http_client(response.as_bytes());
369
370        let response = http_client
371            .send::<_, ()>(ANY_METHOD, None, ANY_PATH, ANY_BODY, &mut response_buffer)
372            .await;
373
374        assert_eq!(
375            HttpClientErrorWrapper::OtherErrors(HttpClientError::FailedAndMissingMessageField),
376            response.unwrap_err()
377        );
378    }
379
380    // MARK: extract_body()
381
382    #[test]
383    fn given_valid_response_when_extract_body_then_body_and_status_code() {
384        let buffer = indoc! {r#"
385            HTTP/1.1 200 OK
386            Content-Type: application/json; charset=utf-8
387            Content-Length: 110
388            Connection: close
389
390            {"status":"success","data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}
391        "#};
392        let expected_body = r#"{"status":"success","data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}"#;
393
394        let (body, status_code) =
395            HttpClient::<'_, V3, TestHttpHandler>::extract_body(buffer.as_bytes()).unwrap();
396
397        assert_eq!(expected_body, str::from_utf8(body).unwrap());
398        assert_eq!(StatusCode(200), status_code);
399    }
400
401    #[test]
402    fn given_bad_http_response_when_extract_body_then_parse_http_err() {
403        let buffer_missing_space = indoc! {r#"
404            HTTP/1.1 200 OK
405            Content-Type: application/json; charset=utf-8
406            Content-Length: 110
407            Connection: close
408            {"status":"success","data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}
409        "#};
410
411        let result =
412            HttpClient::<'_, V3, TestHttpHandler>::extract_body(buffer_missing_space.as_bytes());
413
414        assert_eq!(
415            HttpClientError::ParseHttp(HttpParseError::HeaderName),
416            result.unwrap_err()
417        );
418    }
419
420    #[test]
421    fn given_partial_http_response_when_extract_body_then_partial_http_err() {
422        let buffer_missing_headers_and_body = indoc! {"
423            HTTP/1.1 200 OK
424            Content-Type: application/json; charset=utf-8
425            Content-Length: 110"
426        };
427
428        let result = HttpClient::<'_, V3, TestHttpHandler>::extract_body(
429            buffer_missing_headers_and_body.as_bytes(),
430        );
431
432        assert_eq!(HttpClientError::PartialHttpRequest, result.unwrap_err());
433    }
434
435    #[test]
436    fn given_missing_content_length_header_when_extract_body_then_no_content_header_err() {
437        let buffer_missing_content_length = indoc! {r#"
438            HTTP/1.1 200 OK
439            Content-Type: application/json; charset=utf-8
440            Connection: close
441
442            {"status":"success","data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}
443        "#};
444
445        let result = HttpClient::<'_, V3, TestHttpHandler>::extract_body(
446            buffer_missing_content_length.as_bytes(),
447        );
448
449        assert_eq!(HttpClientError::NoContentHeader, result.unwrap_err());
450    }
451
452    #[test]
453    fn given_invalid_utf8_string_when_extract_body_then_utf8_err() {
454        let buffer_with_invalid_utf8 = [
455            0x48, 0x54, 0x54, 0x50, 0x2f, 0x31, 0x2e, 0x31, 0x20, 0x32, 0x30, 0x30, 0x20, 0x4f,
456            0x4b, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x54, 0x79, 0x70, 0x65,
457            0x3a, 0x20, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f,
458            0x6a, 0x73, 0x6f, 0x6e, 0x3b, 0x20, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x3d,
459            0x75, 0x74, 0x66, 0x2d, 0x38, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d,
460            0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3a, 0x20, 0x31, 0xff, // "0x31 => 0xFF"
461            0x30, 0x0a, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x20,
462            0x63, 0x6c, 0x6f, 0x73, 0x65, 0x0a, 0x0a, 0x7b, 0x22, 0x73, 0x74, 0x61, 0x74, 0x75,
463            0x73, 0x22, 0x3a, 0x22, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0x2c, 0x22,
464            0x64, 0x61, 0x74, 0x61, 0x22, 0x3a, 0x7b, 0x22, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
465            0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3a, 0x6e, 0x75, 0x6c, 0x6c, 0x2c,
466            0x22, 0x6d, 0x71, 0x74, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x68, 0x6f, 0x73, 0x74, 0x22,
467            0x3a, 0x22, 0x6d, 0x71, 0x74, 0x74, 0x73, 0x2e, 0x66, 0x75, 0x6e, 0x64, 0x61, 0x6d,
468            0x65, 0x6e, 0x74, 0x75, 0x6d, 0x2d, 0x69, 0x6f, 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2e,
469            0x63, 0x6f, 0x6d, 0x22, 0x2c, 0x22, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x3a, 0x38, 0x38,
470            0x38, 0x33, 0x7d, 0x7d, 0x7d,
471        ];
472        // same as:
473        //                 let buffer_with_invalid_utf8 = r#"HTTP/1.1 200 OK
474        // Content-Type: application/json; charset=utf-8
475        // Content-Length: 1?0
476        // Connection: close
477
478        // {"status":"success","data":{"configuration":null,"mqtt":{"host":"mqtts.fundamentum-iot-dev.com","port":8883}}}"#;
479
480        let result = HttpClient::<'_, V3, TestHttpHandler>::extract_body(&buffer_with_invalid_utf8);
481
482        assert_eq!(HttpClientError::Utf8Conversion(1), result.unwrap_err());
483    }
484}