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}