Skip to main content

ocpi_kit/transport/
envelope.rs

1//! The OCPI response envelope, and the error type that maps onto it.
2
3use core::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::types::{DateTime, Validate, Validator, Violations};
8
9use super::status::{StatusClass, StatusCode};
10
11/// The JSON object every OCPI response body is.
12///
13/// > *The content that is sent with all the response messages is an 'application/json' type and
14/// > contains a JSON object with the following properties: `data`, `status_code`,
15/// > `status_message`, `timestamp`.*
16///
17/// # `data` absent, `data: null`, and `data` present
18///
19/// The specification is deliberately relaxed here:
20///
21/// > *We advise that in cases where the specification does not explicitly specify what to put in
22/// > the `data` field for the response to a certain request, the platform receiving the response
23/// > accept both the `data` field being absent and the data field being present with any possible
24/// > value. We also advise that in such cases, platform sending the response leave the `data`
25/// > field unset.*
26///
27/// So `data` absent and `data: null` both deserialise to `None`, and a `None` is serialised by
28/// leaving the key out.
29///
30/// ```
31/// use ocpi_kit::transport::{OcpiResponse, StatusCode};
32///
33/// let json = r#"{"status_code":2001,"status_message":"Missing required field: type","timestamp":"2015-06-30T21:59:59Z"}"#;
34/// let response: OcpiResponse<String> = serde_json::from_str(json).unwrap();
35/// assert_eq!(response.status_code, StatusCode::INVALID_PARAMETERS);
36/// assert!(response.data.is_none());
37/// assert_eq!(serde_json::to_string(&response).unwrap(), json);
38/// ```
39///
40/// Spec: 2.3.0 §transport_and_format_response_format
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43// `#[serde(default)]` on an `Option<T>` field would otherwise make serde demand `T: Default`,
44// which no OCPI object implements — the point of `data` being absent is that there is nothing to
45// default to.
46#[serde(bound(deserialize = "T: Deserialize<'de>"))]
47pub struct OcpiResponse<T> {
48    /// The response data, when the request succeeded and the endpoint documents a payload.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub data: Option<T>,
51    /// How the request was handled.
52    pub status_code: StatusCode,
53    /// An optional status message which may help when debugging.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub status_message: Option<String>,
56    /// The time this message was generated.
57    pub timestamp: DateTime,
58}
59
60impl<T> OcpiResponse<T> {
61    /// A `1000 Generic success` response carrying `data`, timestamped now.
62    #[must_use]
63    pub fn success(data: T) -> Self {
64        Self {
65            data: Some(data),
66            status_code: StatusCode::SUCCESS,
67            status_message: None,
68            timestamp: DateTime::now(),
69        }
70    }
71
72    /// A `1000 Generic success` response with no payload, timestamped now.
73    ///
74    /// This is the shape the spec advises for a PUT or PATCH whose response body is not
75    /// specified.
76    #[must_use]
77    pub fn success_empty() -> Self {
78        Self {
79            data: None,
80            status_code: StatusCode::SUCCESS,
81            status_message: None,
82            timestamp: DateTime::now(),
83        }
84    }
85
86    /// An error response with the given code and message, timestamped now.
87    #[must_use]
88    pub fn error(status_code: StatusCode, status_message: impl Into<String>) -> Self {
89        Self {
90            data: None,
91            status_code,
92            status_message: Some(status_message.into()),
93            timestamp: DateTime::now(),
94        }
95    }
96
97    /// Whether `status_code` is in the success range.
98    #[must_use]
99    pub const fn is_success(&self) -> bool {
100        self.status_code.is_success()
101    }
102
103    /// The payload, or the error the peer reported.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`OcpiError::Remote`] when the status code is not in the `1xxx` range, and
108    /// [`OcpiError::MissingData`] when a successful response carried no payload.
109    pub fn into_result(self) -> Result<T, OcpiError> {
110        if !self.is_success() {
111            return Err(OcpiError::Remote {
112                status_code: self.status_code,
113                status_message: self.status_message,
114            });
115        }
116        self.data.ok_or(OcpiError::MissingData { status_code: self.status_code })
117    }
118
119    /// Applies `f` to the payload, keeping the envelope.
120    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> OcpiResponse<U> {
121        OcpiResponse {
122            data: self.data.map(f),
123            status_code: self.status_code,
124            status_message: self.status_message,
125            timestamp: self.timestamp,
126        }
127    }
128}
129
130impl<T> OcpiResponse<Vec<T>> {
131    /// The payload of a list endpoint, treating an absent `data` as an empty list.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`OcpiError::Remote`] when the status code is not in the `1xxx` range.
136    pub fn into_list(self) -> Result<Vec<T>, OcpiError> {
137        if !self.is_success() {
138            return Err(OcpiError::Remote {
139                status_code: self.status_code,
140                status_message: self.status_message,
141            });
142        }
143        Ok(self.data.unwrap_or_default())
144    }
145}
146
147impl<T: Validate> Validate for OcpiResponse<T> {
148    fn validate_in(&self, v: &mut Validator) {
149        v.field("data", &self.data);
150        v.field("timestamp", &self.timestamp);
151    }
152}
153
154/// Everything that can go wrong on an OCPI request, from either side of the wire.
155#[derive(Debug, thiserror::Error)]
156#[non_exhaustive]
157pub enum OcpiError {
158    /// The peer answered with a non-success OCPI status code.
159    #[error("peer returned OCPI status {status_code}{}", format_message(.status_message.as_ref()))]
160    Remote {
161        /// The code the peer sent.
162        status_code: StatusCode,
163        /// The message the peer sent, if any.
164        status_message: Option<String>,
165    },
166
167    /// A successful response did not carry the payload the endpoint documents.
168    #[error("peer returned OCPI status {status_code} but no data")]
169    MissingData {
170        /// The code the peer sent.
171        status_code: StatusCode,
172    },
173
174    /// The request body was not valid JSON, so it never reached the OCPI layer.
175    ///
176    /// > *When a message does not contain a valid JSON string, the HTTP error `400 - Bad request`
177    /// > MUST be returned.*
178    #[error("malformed JSON: {0}")]
179    MalformedJson(String),
180
181    /// The body was valid JSON but did not fit the OCPI object it was supposed to be.
182    ///
183    /// `path` is the JSON path to the offending value, which is what turns a support ticket into
184    /// a one-line fix.
185    #[error("cannot decode {path}: {message}")]
186    Decode {
187        /// JSON path to the value that did not decode.
188        path: String,
189        /// What went wrong there.
190        message: String,
191    },
192
193    /// A decoded object broke rules of the specification.
194    #[error("object does not conform to the specification: {0}")]
195    Invalid(#[from] Violations),
196
197    /// No credentials token, or one that matches no known party.
198    ///
199    /// > *If the header is missing or the credentials token doesn't match any known party then
200    /// > the server SHALL respond with an HTTP `401 - Unauthorized` status code.*
201    #[error("unauthorized: {0}")]
202    Unauthorized(String),
203
204    /// `CREDENTIALS_TOKEN_A` was used on a module other than `credentials` or `versions`.
205    ///
206    /// > *the server SHALL respond with an HTTP `401 - Unauthorized` status code.*
207    #[error("CREDENTIALS_TOKEN_A may only be used on the credentials and versions modules")]
208    TokenAOutOfScope,
209
210    /// A GET addressed a resource that does not exist.
211    ///
212    /// > *In case of a GET request, when the resource does NOT exist, the server SHOULD return a
213    /// > HTTP `404 - Not Found`.*
214    #[error("not found: {0}")]
215    NotFound(String),
216
217    /// The HTTP method is not allowed in the current registration state.
218    ///
219    /// The credentials module uses this: POST when already registered, PUT or DELETE when not.
220    #[error("method not allowed: {0}")]
221    MethodNotAllowed(String),
222
223    /// The transport failed: connection refused, TLS failure, timeout, non-JSON body.
224    #[error("transport error: {0}")]
225    Transport(String),
226
227    /// A hub was asked to route a request whose headers and method do not describe any of the
228    /// scenarios the specification defines.
229    ///
230    /// The clearest example is a `GET` addressed to the hub's own party on a Receiver interface:
231    /// the `OCPI-to-` headers say Broadcast Push, but *"GET SHALL NOT be used in combination
232    /// with Broadcast Push"*, and the sender is told to use an Open Routing Request instead.
233    ///
234    /// Spec: 2.3.0 §transport_and_format_message_routing
235    #[error("cannot route this request: {0}")]
236    NotRoutable(String),
237
238    /// This build cannot carry a document between the two OCPI versions involved.
239    ///
240    /// A client whose peer speaks a version this crate has no conversions for, or a merge patch
241    /// that writes a field the two versions disagree about. It is a `3000` rather than a `2001`
242    /// because nothing about the request is wrong: the software simply cannot do it.
243    #[error("not supported by this build: {0}")]
244    Unsupported(String),
245
246    /// A URL was refused by the configured [`UrlPolicy`](crate::types::UrlPolicy).
247    #[error("refused to call {url}: {reason}")]
248    UrlRefused {
249        /// The URL that was refused.
250        url: String,
251        /// Why it was refused.
252        reason: String,
253    },
254}
255
256fn format_message(message: Option<&String>) -> String {
257    message.map_or_else(String::new, |m| format!(": {m}"))
258}
259
260impl OcpiError {
261    /// The OCPI status code this error should be reported as.
262    #[must_use]
263    pub fn status_code(&self) -> StatusCode {
264        match self {
265            Self::Remote { status_code, .. } | Self::MissingData { status_code } => *status_code,
266            Self::MalformedJson(_) | Self::Decode { .. } | Self::Invalid(_) | Self::NotRoutable(_) => {
267                StatusCode::INVALID_PARAMETERS
268            }
269            Self::Unauthorized(_)
270            | Self::TokenAOutOfScope
271            | Self::NotFound(_)
272            | Self::MethodNotAllowed(_) => StatusCode::CLIENT_ERROR,
273            Self::Transport(_) | Self::UrlRefused { .. } | Self::Unsupported(_) => StatusCode::SERVER_ERROR,
274        }
275    }
276
277    /// The HTTP status code this error should be answered with.
278    ///
279    /// This encodes the whole of §status_codes: the only cases that get an HTTP error are the
280    /// ones the spec explicitly names. **Everything that reached the OCPI layer is HTTP 200 with
281    /// a `2xxx`/`3xxx`/`4xxx` `status_code` in the body.**
282    ///
283    /// | Situation | HTTP |
284    /// |---|---|
285    /// | body is not valid JSON | `400` |
286    /// | missing or unknown credentials token | `401` |
287    /// | `CREDENTIALS_TOKEN_A` outside `credentials`/`versions` | `401` |
288    /// | GET of a resource that does not exist | `404` |
289    /// | credentials POST when registered, PUT/DELETE when not | `405` |
290    /// | anything else | `200` |
291    ///
292    /// Spec: 2.3.0 §status_codes_status_codes
293    #[must_use]
294    pub const fn http_status(&self) -> u16 {
295        match self {
296            Self::MalformedJson(_) => 400,
297            Self::Unauthorized(_) | Self::TokenAOutOfScope => 401,
298            Self::NotFound(_) => 404,
299            Self::MethodNotAllowed(_) => 405,
300            _ => 200,
301        }
302    }
303
304    /// Whether retrying the same request could plausibly succeed.
305    ///
306    /// The spec forbids automatically retrying a write:
307    ///
308    /// > *OCPI messages SHOULD NOT be queued. When a client does a POST, PUT or PATCH request and
309    /// > that request fails or times out, the client should not queue the message and retry the
310    /// > same message again later.*
311    ///
312    /// So this is only ever consulted for GETs; see
313    /// [`RetryPolicy`](crate::client::RetryPolicy).
314    #[must_use]
315    pub fn is_transient(&self) -> bool {
316        match self {
317            Self::Transport(_) => true,
318            Self::Remote { status_code, .. } => {
319                matches!(status_code.class(), StatusClass::ServerError | StatusClass::HubError)
320            }
321            _ => false,
322        }
323    }
324
325    /// Renders this error as the envelope a server should send back.
326    #[must_use]
327    pub fn to_response<T>(&self) -> OcpiResponse<T> {
328        OcpiResponse::error(self.status_code(), self.to_string())
329    }
330}
331
332impl fmt::Display for OcpiResponse<()> {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        write!(f, "{}", self.status_code)?;
335        if let Some(m) = &self.status_message {
336            write!(f, ": {m}")?;
337        }
338        Ok(())
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn null_and_absent_data_both_mean_none() {
348        let absent: OcpiResponse<String> =
349            serde_json::from_str(r#"{"status_code":1000,"timestamp":"2015-06-30T21:59:59Z"}"#).unwrap();
350        let null: OcpiResponse<String> =
351            serde_json::from_str(r#"{"data":null,"status_code":1000,"timestamp":"2015-06-30T21:59:59Z"}"#)
352                .unwrap();
353        assert_eq!(absent.data, None);
354        assert_eq!(null.data, None);
355        // Round-tripping a `null` normalises it to an absent key, as the spec advises.
356        assert_eq!(
357            serde_json::to_string(&null).unwrap(),
358            r#"{"status_code":1000,"timestamp":"2015-06-30T21:59:59Z"}"#
359        );
360    }
361
362    #[test]
363    fn a_list_endpoint_treats_absent_data_as_empty() {
364        let r: OcpiResponse<Vec<String>> =
365            serde_json::from_str(r#"{"status_code":1000,"timestamp":"2015-06-30T21:59:59Z"}"#).unwrap();
366        assert_eq!(r.into_list().unwrap(), Vec::<String>::new());
367    }
368
369    #[test]
370    fn into_result_surfaces_the_peers_error() {
371        let r: OcpiResponse<String> = serde_json::from_str(
372            r#"{"status_code":2001,"status_message":"Missing required field: type","timestamp":"2015-06-30T21:59:59Z"}"#,
373        )
374        .unwrap();
375        let err = r.into_result().unwrap_err();
376        assert_eq!(err.status_code(), StatusCode::INVALID_PARAMETERS);
377        assert!(err.to_string().contains("Missing required field"), "{err}");
378        assert!(!err.is_transient());
379    }
380
381    #[test]
382    fn http_status_mapping_matches_the_spec_table() {
383        assert_eq!(OcpiError::MalformedJson("x".into()).http_status(), 400);
384        assert_eq!(OcpiError::Unauthorized("x".into()).http_status(), 401);
385        assert_eq!(OcpiError::TokenAOutOfScope.http_status(), 401);
386        assert_eq!(OcpiError::NotFound("x".into()).http_status(), 404);
387        assert_eq!(OcpiError::MethodNotAllowed("x".into()).http_status(), 405);
388        // Everything that reached the OCPI layer is a 200 with an OCPI status code in the body.
389        assert_eq!(OcpiError::Decode { path: "/evses/0".into(), message: "nope".into() }.http_status(), 200);
390        let unroutable = OcpiError::NotRoutable("GET is not a Broadcast Push".into());
391        assert_eq!(unroutable.http_status(), 200);
392        assert_eq!(unroutable.status_code(), StatusCode::INVALID_PARAMETERS);
393        assert!(!unroutable.is_transient());
394        assert_eq!(OcpiError::Transport("timeout".into()).http_status(), 200);
395        assert_eq!(
396            OcpiError::Remote { status_code: StatusCode::HUB_ERROR, status_message: None }.http_status(),
397            200
398        );
399    }
400
401    #[test]
402    fn server_and_hub_errors_are_transient_client_errors_are_not() {
403        let transient =
404            OcpiError::Remote { status_code: StatusCode::CONNECTION_PROBLEM, status_message: None };
405        assert!(transient.is_transient());
406        let permanent = OcpiError::Remote { status_code: StatusCode::UNKNOWN_TOKEN, status_message: None };
407        assert!(!permanent.is_transient());
408        assert!(OcpiError::Transport("reset".into()).is_transient());
409    }
410}