1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
3
4#[cfg(all(debug_assertions, feature = "openapi"))]
5mod schema;
6
7pub trait ApiResponseCode {
9 fn is_success(&self) -> bool;
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum ResponseEnvelopeError {
16 #[error("success response requires a success code")]
18 SuccessCodeRequired,
19 #[error("error response requires a failure code")]
21 FailureCodeRequired,
22 #[error("success response cannot contain error metadata")]
24 SuccessHasError,
25 #[error("error response cannot contain success data")]
27 FailureHasData,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
33pub struct ApiEmptyData {}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(bound(deserialize = "D: Deserialize<'de>"))]
39pub struct ApiErrorInfo<D = ()> {
40 pub retryable: bool,
42 #[serde(
44 default,
45 deserialize_with = "present",
46 skip_serializing_if = "Option::is_none"
47 )]
48 pub diagnostic: Option<D>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct ApiResponse<T, C, D = ()> {
77 code: C,
78 msg: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 data: Option<T>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 error: Option<ApiErrorInfo<D>>,
83}
84
85impl<T, C: ApiResponseCode, D> ApiResponse<T, C, D> {
86 pub fn ok(code: C, data: T) -> Result<Self, ResponseEnvelopeError> {
91 Self::ok_with_message(code, "", data)
92 }
93
94 pub fn ok_with_message(
99 code: C,
100 message: impl Into<String>,
101 data: T,
102 ) -> Result<Self, ResponseEnvelopeError> {
103 if !code.is_success() {
104 return Err(ResponseEnvelopeError::SuccessCodeRequired);
105 }
106 Ok(Self {
107 code,
108 msg: message.into(),
109 data: Some(data),
110 error: None,
111 })
112 }
113}
114
115impl<C: ApiResponseCode, D> ApiResponse<(), C, D> {
116 pub fn ok_empty(code: C) -> Result<Self, ResponseEnvelopeError> {
121 if !code.is_success() {
122 return Err(ResponseEnvelopeError::SuccessCodeRequired);
123 }
124 Ok(Self {
125 code,
126 msg: String::new(),
127 data: None,
128 error: None,
129 })
130 }
131
132 pub fn error(code: C, message: impl Into<String>) -> Result<Self, ResponseEnvelopeError> {
137 Self::error_with_details(
138 code,
139 message,
140 Some(ApiErrorInfo {
141 retryable: false,
142 diagnostic: None,
143 }),
144 )
145 }
146
147 pub fn error_with_details(
152 code: C,
153 message: impl Into<String>,
154 error: Option<ApiErrorInfo<D>>,
155 ) -> Result<Self, ResponseEnvelopeError> {
156 if code.is_success() {
157 return Err(ResponseEnvelopeError::FailureCodeRequired);
158 }
159 Ok(Self {
160 code,
161 msg: message.into(),
162 data: None,
163 error,
164 })
165 }
166}
167
168impl<C: ApiResponseCode, D> ApiResponse<ApiEmptyData, C, D> {
169 pub fn ok_empty_data(code: C) -> Result<Self, ResponseEnvelopeError> {
174 Self::ok(code, ApiEmptyData::default())
175 }
176}
177
178impl<T, C, D> ApiResponse<T, C, D> {
179 #[must_use]
181 pub const fn code(&self) -> &C {
182 &self.code
183 }
184 #[must_use]
186 pub fn message(&self) -> &str {
187 &self.msg
188 }
189 #[must_use]
191 pub const fn data(&self) -> Option<&T> {
192 self.data.as_ref()
193 }
194 #[must_use]
196 pub fn into_data(self) -> Option<T> {
197 self.data
198 }
199 #[must_use]
201 pub const fn error_info(&self) -> Option<&ApiErrorInfo<D>> {
202 self.error.as_ref()
203 }
204}
205
206fn present<'de, T: Deserialize<'de>, E: Deserializer<'de>>(
209 deserializer: E,
210) -> Result<Option<T>, E::Error> {
211 T::deserialize(deserializer).map(Some)
212}
213
214impl<'de, T, C, D> Deserialize<'de> for ApiResponse<T, C, D>
215where
216 T: Deserialize<'de>,
217 C: Deserialize<'de> + ApiResponseCode,
218 D: Deserialize<'de>,
219{
220 fn deserialize<E: Deserializer<'de>>(deserializer: E) -> Result<Self, E::Error> {
221 #[derive(Deserialize)]
222 #[serde(bound(
223 deserialize = "T: Deserialize<'de>, C: Deserialize<'de>, D: Deserialize<'de>"
224 ))]
225 struct Wire<T, C, D> {
226 code: C,
227 msg: String,
228 #[serde(default, deserialize_with = "present")]
229 data: Option<T>,
230 #[serde(default, deserialize_with = "present")]
231 error: Option<ApiErrorInfo<D>>,
232 }
233 let wire: Wire<T, C, D> = Wire::deserialize(deserializer)?;
234 if wire.code.is_success() && wire.error.is_some() {
235 return Err(E::Error::custom(ResponseEnvelopeError::SuccessHasError));
236 }
237 if !wire.code.is_success() && wire.data.is_some() {
238 return Err(E::Error::custom(ResponseEnvelopeError::FailureHasData));
239 }
240 Ok(Self {
241 code: wire.code,
242 msg: wire.msg,
243 data: wire.data,
244 error: wire.error,
245 })
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serde_json::{Value, json};
253
254 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255 #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
256 enum DriveCode {
257 #[serde(rename = "success")]
258 Success,
259 #[serde(rename = "auth.credentials_failed")]
260 CredentialsFailed,
261 #[serde(rename = "rate_limited")]
262 RateLimited,
263 }
264 impl ApiResponseCode for DriveCode {
265 fn is_success(&self) -> bool {
266 *self == Self::Success
267 }
268 }
269 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270 #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
271 enum GateCode {
272 #[serde(rename = "success")]
273 Success,
274 #[serde(rename = "account.identifier_conflict")]
275 IdentifierConflict,
276 }
277 impl ApiResponseCode for GateCode {
278 fn is_success(&self) -> bool {
279 *self == Self::Success
280 }
281 }
282 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283 #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
284 struct Diagnostic {
285 kind: String,
286 message: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 field: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 scope: Option<String>,
291 }
292 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293 #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
294 struct Profile {
295 id: u64,
296 name: String,
297 }
298 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299 #[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
300 struct Task {
301 id: u64,
302 completed: bool,
303 }
304
305 #[cfg(all(debug_assertions, feature = "openapi"))]
306 #[derive(Serialize, Deserialize, utoipa::ToSchema)]
307 struct Group {
308 owner: Profile,
309 }
310
311 fn round_trip<T, C, D>(response: &ApiResponse<T, C, D>, expected: &Value)
312 where
313 T: Serialize + for<'a> Deserialize<'a>,
314 C: ApiResponseCode + Serialize + for<'a> Deserialize<'a>,
315 D: Serialize + for<'a> Deserialize<'a>,
316 {
317 let value = serde_json::to_value(response).unwrap();
318 assert_eq!(&value, expected);
319 let decoded: ApiResponse<T, C, D> = serde_json::from_value(value.clone()).unwrap();
320 assert_eq!(serde_json::to_value(decoded).unwrap(), value);
321 }
322
323 #[test]
324 fn drive_wire_contract_distinguishes_data_absent_null_and_empty_object() {
325 round_trip(
326 &ApiResponse::<_, DriveCode>::ok(
327 DriveCode::Success,
328 Profile {
329 id: 7,
330 name: "cat".into(),
331 },
332 )
333 .unwrap(),
334 &json!({"code":"success","msg":"","data":{"id":7,"name":"cat"}}),
335 );
336 round_trip(
337 &ApiResponse::<(), DriveCode>::ok_empty(DriveCode::Success).unwrap(),
338 &json!({"code":"success","msg":""}),
339 );
340 round_trip(
341 &ApiResponse::<(), DriveCode>::ok(DriveCode::Success, ()).unwrap(),
342 &json!({"code":"success","msg":"","data":null}),
343 );
344 round_trip(
345 &ApiResponse::<Option<Profile>, DriveCode>::ok(DriveCode::Success, None).unwrap(),
346 &json!({"code":"success","msg":"","data":null}),
347 );
348 round_trip(
349 &ApiResponse::<ApiEmptyData, DriveCode>::ok_empty_data(DriveCode::Success).unwrap(),
350 &json!({"code":"success","msg":"","data":{}}),
351 );
352 }
353
354 #[test]
355 fn drive_failures_preserve_optional_metadata_and_typed_diagnostic() {
356 round_trip(
357 &ApiResponse::<(), DriveCode>::error(
358 DriveCode::CredentialsFailed,
359 "Invalid Credentials",
360 )
361 .unwrap(),
362 &json!({"code":"auth.credentials_failed","msg":"Invalid Credentials","error":{"retryable":false}}),
363 );
364 round_trip(
365 &ApiResponse::<(), DriveCode>::error_with_details(
366 DriveCode::CredentialsFailed,
367 "Invalid Credentials",
368 None,
369 )
370 .unwrap(),
371 &json!({"code":"auth.credentials_failed","msg":"Invalid Credentials"}),
372 );
373 let response = ApiResponse::<(), DriveCode, Diagnostic>::error_with_details(
374 DriveCode::RateLimited,
375 "Too many requests",
376 Some(ApiErrorInfo {
377 retryable: true,
378 diagnostic: Some(Diagnostic {
379 kind: "transient".into(),
380 message: "retry later".into(),
381 field: None,
382 scope: None,
383 }),
384 }),
385 )
386 .unwrap();
387 round_trip(
388 &response,
389 &json!({"code":"rate_limited","msg":"Too many requests","error":{"retryable":true,
390 "diagnostic":{"kind":"transient","message":"retry later"}}}),
391 );
392 assert!(response.data().is_none());
393 assert_eq!(response.code(), &DriveCode::RateLimited);
394 assert_eq!(response.message(), "Too many requests");
395 assert!(response.error_info().unwrap().retryable);
396 }
397
398 #[test]
399 fn independent_product_code_and_pagination_compose_without_product_semantics() {
400 let response = ApiResponse::<_, GateCode>::ok_with_message(
401 GateCode::Success,
402 "product message",
403 crate::OffsetPage::new(
404 vec![Profile {
405 id: 1,
406 name: "one".into(),
407 }],
408 1,
409 20,
410 0,
411 ),
412 )
413 .unwrap();
414 assert_eq!(
415 serde_json::to_value(response).unwrap(),
416 json!({"code":"success","msg":"product message",
417 "data":{"items":[{"id":1,"name":"one"}],"total":1,"limit":20,"offset":0}})
418 );
419 round_trip(
420 &ApiResponse::<(), GateCode>::error(GateCode::IdentifierConflict, "Already used")
421 .unwrap(),
422 &json!({"code":"account.identifier_conflict","msg":"Already used","error":{"retryable":false}}),
423 );
424
425 let data = ApiResponse::<_, GateCode>::ok(
426 GateCode::Success,
427 vec![Profile {
428 id: 2,
429 name: "two".into(),
430 }],
431 )
432 .unwrap()
433 .into_data()
434 .unwrap();
435 assert_eq!(data[0].id, 2);
436 }
437
438 #[test]
439 fn constructors_reject_codes_for_the_wrong_state() {
440 assert_eq!(
441 ApiResponse::<(), DriveCode>::ok(DriveCode::CredentialsFailed, ()).unwrap_err(),
442 ResponseEnvelopeError::SuccessCodeRequired
443 );
444 assert_eq!(
445 ApiResponse::<(), DriveCode>::ok_empty(DriveCode::CredentialsFailed).unwrap_err(),
446 ResponseEnvelopeError::SuccessCodeRequired
447 );
448 assert_eq!(
449 ApiResponse::<ApiEmptyData, GateCode>::ok_empty_data(GateCode::IdentifierConflict)
450 .unwrap_err(),
451 ResponseEnvelopeError::SuccessCodeRequired
452 );
453 assert_eq!(
454 ApiResponse::<(), DriveCode>::error(DriveCode::Success, "bad").unwrap_err(),
455 ResponseEnvelopeError::FailureCodeRequired
456 );
457 assert_eq!(
458 ApiResponse::<Task, DriveCode>::ok_with_message(
459 DriveCode::RateLimited,
460 "bad",
461 Task {
462 id: 0,
463 completed: false
464 }
465 )
466 .unwrap_err(),
467 ResponseEnvelopeError::SuccessCodeRequired
468 );
469 assert_eq!(
470 ApiResponse::<(), GateCode>::error_with_details(GateCode::Success, "bad", None)
471 .unwrap_err(),
472 ResponseEnvelopeError::FailureCodeRequired
473 );
474 }
475
476 #[test]
477 fn absence_and_null_are_preserved_for_nested_data_and_diagnostic_types() {
478 for value in [
479 json!({"code":"success","msg":""}),
480 json!({"code":"success","msg":"","data":null}),
481 json!({"code":"success","msg":"","data":[]}),
482 ] {
483 let response: ApiResponse<Option<Vec<Profile>>, DriveCode> =
484 serde_json::from_value(value.clone()).unwrap();
485 assert_eq!(response.data().is_some(), value.get("data").is_some());
486 assert_eq!(serde_json::to_value(response).unwrap(), value);
487 }
488 for value in [
489 json!({"code":"rate_limited","msg":"wait","error":{"retryable":true}}),
490 json!({"code":"rate_limited","msg":"wait","error":{"retryable":true,"diagnostic":null}}),
491 ] {
492 let response: ApiResponse<(), DriveCode> =
493 serde_json::from_value(value.clone()).unwrap();
494 assert_eq!(
495 response.error_info().unwrap().diagnostic.is_some(),
496 value["error"].get("diagnostic").is_some()
497 );
498 assert_eq!(serde_json::to_value(response).unwrap(), value);
499 }
500 }
501
502 #[test]
503 fn empty_collections_unicode_and_numeric_bounds_are_not_normalized_or_truncated() {
504 round_trip(
505 &ApiResponse::<_, GateCode>::ok(GateCode::Success, Vec::<Profile>::new()).unwrap(),
506 &json!({"code":"success","msg":"","data":[]}),
507 );
508 let response = ApiResponse::<_, DriveCode>::ok_with_message(
509 DriveCode::Success,
510 "猫猫 🐈\n\t",
511 u64::MAX,
512 )
513 .unwrap();
514 round_trip(
515 &response,
516 &json!({"code":"success","msg":"猫猫 🐈\n\t","data":u64::MAX}),
517 );
518 let response: ApiResponse<&str, GateCode> =
519 serde_json::from_str(r#"{"code":"success","msg":"","data":"borrowed"}"#).unwrap();
520 assert_eq!(response.data(), Some(&"borrowed"));
521 }
522
523 #[test]
524 fn product_diagnostic_field_and_scope_are_typed_and_omitted_independently() {
525 let diagnostic = Diagnostic {
526 kind: "connector_validation".into(),
527 message: "invalid setting".into(),
528 field: Some("endpoint".into()),
529 scope: Some("connector_config".into()),
530 };
531 let response = ApiResponse::<(), DriveCode, Diagnostic>::error_with_details(
532 DriveCode::CredentialsFailed,
533 "product policy",
534 Some(ApiErrorInfo {
535 retryable: false,
536 diagnostic: Some(diagnostic),
537 }),
538 )
539 .unwrap();
540 round_trip(
541 &response,
542 &json!({"code":"auth.credentials_failed","msg":"product policy","error":{"retryable":false,
543 "diagnostic":{"kind":"connector_validation","message":"invalid setting","field":"endpoint","scope":"connector_config"}}}),
544 );
545 }
546
547 #[test]
548 fn state_errors_are_reported_after_valid_dto_deserialization() {
549 for value in [
550 json!({"code":"auth.credentials_failed","msg":"bad","data":null}),
551 json!({"code":"auth.credentials_failed","msg":"bad","data":{"id":7,"name":"cat"}}),
552 ] {
553 let error = serde_json::from_value::<ApiResponse<Option<Profile>, DriveCode>>(value)
554 .unwrap_err();
555 assert!(
556 error
557 .to_string()
558 .contains(&ResponseEnvelopeError::FailureHasData.to_string())
559 );
560 }
561 let error = serde_json::from_value::<ApiResponse<Profile, GateCode>>(
562 json!({"code":"success","msg":"","error":{"retryable":false}}),
563 )
564 .unwrap_err();
565 assert!(
566 error
567 .to_string()
568 .contains(&ResponseEnvelopeError::SuccessHasError.to_string())
569 );
570 }
571
572 #[test]
573 fn malformed_fields_and_product_diagnostics_fail_instead_of_becoming_unknown_json() {
574 for value in [
575 json!({"code":"success","msg":null}),
576 json!({"code":"success","msg":"","data":null}),
577 json!({"code":"auth.credentials_failed","msg":"bad","error":null}),
578 json!({"code":"auth.credentials_failed","msg":"bad","error":{}}),
579 json!({"code":"auth.credentials_failed","msg":"bad","error":{"retryable":"false"}}),
580 json!({"code":"auth.credentials_failed","msg":"bad","error":{"retryable":false,"diagnostic":{"kind":7,"message":"bad"}}}),
581 ] {
582 assert!(
583 serde_json::from_value::<ApiResponse<Profile, DriveCode, Diagnostic>>(value)
584 .is_err()
585 );
586 }
587 assert!(
588 serde_json::from_str::<ApiResponse<(), DriveCode>>(
589 r#"{"code":"success","code":"rate_limited","msg":""}"#
590 )
591 .is_err()
592 );
593 assert!(
594 serde_json::from_str::<ApiResponse<(), GateCode>>(
595 r#"{"code":"account.identifier_conflict","msg":"","data":null,"data":null}"#
596 )
597 .is_err()
598 );
599 }
600
601 #[test]
602 fn deserialization_rejects_contradictions_and_unknown_product_codes() {
603 for value in [
604 json!({"code":"success","msg":"","error":{"retryable":false}}),
605 json!({"code":"auth.credentials_failed","msg":"bad","data":null}),
606 json!({"code":"auth.credentials_failed","msg":"bad","data":{}}),
607 json!({"code":"unknown.code","msg":"bad"}),
608 json!({"code":"success"}),
609 ] {
610 assert!(
611 serde_json::from_value::<ApiResponse<Option<Profile>, DriveCode>>(value).is_err()
612 );
613 }
614 }
615
616 #[cfg(all(debug_assertions, feature = "openapi"))]
617 fn verify_refs(value: &Value, schemas: &serde_json::Map<String, Value>) {
618 match value {
619 Value::Object(map) => {
620 if let Some(Value::String(reference)) = map.get("$ref") {
621 let name = reference.strip_prefix("#/components/schemas/").unwrap();
622 assert!(schemas.contains_key(name), "unresolved schema {name}");
623 }
624 for child in map.values() {
625 verify_refs(child, schemas);
626 }
627 }
628 Value::Array(values) => {
629 for child in values {
630 verify_refs(child, schemas);
631 }
632 }
633 _ => (),
634 }
635 }
636
637 #[cfg(all(debug_assertions, feature = "openapi"))]
638 #[test]
639 fn generic_openapi_names_references_and_typed_codes_are_distinct() {
640 use utoipa::{OpenApi, ToSchema};
641 type NoData = ();
642 #[derive(OpenApi)]
643 #[openapi(components(schemas(
644 ApiResponse<Profile, DriveCode, Diagnostic>,
645 ApiResponse<Task, DriveCode, Diagnostic>,
646 ApiResponse<Profile, GateCode, NoData>,
647 ApiResponse<Task, GateCode, NoData>,
648 ApiResponse<Group, GateCode, NoData>,
649 ApiResponse<NoData, DriveCode, Diagnostic>,
650 ApiResponse<NoData, GateCode, NoData>,
651 ApiErrorInfo<NoData>
652 )))]
653 struct ApiDoc;
654
655 let doc = serde_json::to_value(ApiDoc::openapi()).unwrap();
656 if let Ok(path) = std::env::var("ASTER_FORGE_RESPONSE_OPENAPI_OUT") {
657 std::fs::write(path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
658 }
659 let schemas = doc["components"]["schemas"].as_object().unwrap();
660 assert_eq!(
661 schemas["ApiErrorInfo_TupleUnit"]["properties"]["diagnostic"]["type"],
662 "null"
663 );
664 assert!(
665 schemas["ApiErrorInfo_TupleUnit"]["properties"]["diagnostic"]
666 .get("default")
667 .is_none()
668 );
669 assert_eq!(
670 schemas["ApiResponse_TupleUnit_GateCode_TupleUnit"]["properties"]["data"]["type"],
671 "null"
672 );
673 assert!(
674 schemas["ApiResponse_TupleUnit_GateCode_TupleUnit"]["properties"]["data"]
675 .get("default")
676 .is_none()
677 );
678 let names = [
679 (Profile::name(), DriveCode::name(), Diagnostic::name()),
680 (Task::name(), DriveCode::name(), Diagnostic::name()),
681 (Profile::name(), GateCode::name(), NoData::name()),
682 (Task::name(), GateCode::name(), NoData::name()),
683 (Group::name(), GateCode::name(), NoData::name()),
684 (NoData::name(), DriveCode::name(), Diagnostic::name()),
685 (NoData::name(), GateCode::name(), NoData::name()),
686 ]
687 .map(|(data, code, diagnostic)| format!("ApiResponse_{data}_{code}_{diagnostic}"));
688 assert_eq!(
689 names
690 .iter()
691 .collect::<std::collections::BTreeSet<_>>()
692 .len(),
693 names.len()
694 );
695 for name in names {
696 assert!(schemas.contains_key(&name), "missing {name}");
697 let expected = if name.contains("DriveCode") {
698 json!(["success", "auth.credentials_failed", "rate_limited"])
699 } else {
700 json!(["success", "account.identifier_conflict"])
701 };
702 let code_name = if name.contains("DriveCode") {
703 "DriveCode"
704 } else {
705 "GateCode"
706 };
707 assert_eq!(
708 schemas[&name]["properties"]["code"]["$ref"],
709 format!("#/components/schemas/{code_name}")
710 );
711 assert_eq!(schemas[code_name]["enum"], expected);
712 if name.contains("Profile") {
713 assert!(
714 schemas[&name]["properties"]["data"]["properties"]
715 .get("name")
716 .is_some()
717 );
718 assert!(
719 schemas[&name]["properties"]["data"]["properties"]
720 .get("completed")
721 .is_none()
722 );
723 }
724 if name.contains("Task") {
725 assert!(
726 schemas[&name]["properties"]["data"]["properties"]
727 .get("completed")
728 .is_some()
729 );
730 assert!(
731 schemas[&name]["properties"]["data"]["properties"]
732 .get("name")
733 .is_none()
734 );
735 }
736 }
737
738 verify_refs(&doc, schemas);
739 }
740}