Skip to main content

aster_forge_api/
response.rs

1//! Typed REST envelopes. Products own codes, messages, HTTP behavior and diagnostic policy.
2use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
3
4#[cfg(all(debug_assertions, feature = "openapi"))]
5mod schema;
6
7/// Product-provided classification of a serialized code. Forge supplies no business codes.
8pub trait ApiResponseCode {
9    /// Whether this code denotes success; all remaining codes denote failures.
10    fn is_success(&self) -> bool;
11}
12
13/// Invalid envelope state, independent of product-facing errors and HTTP status.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum ResponseEnvelopeError {
16    /// A success constructor received a failure code.
17    #[error("success response requires a success code")]
18    SuccessCodeRequired,
19    /// An error constructor received a success code.
20    #[error("error response requires a failure code")]
21    FailureCodeRequired,
22    /// A deserialized success response included error metadata.
23    #[error("success response cannot contain error metadata")]
24    SuccessHasError,
25    /// A deserialized failure included success data, including explicit null.
26    #[error("error response cannot contain success data")]
27    FailureHasData,
28}
29
30/// Empty JSON object used for `data: {}`, distinct from omitted data or JSON null.
31#[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/// Product-neutral error metadata with a typed product diagnostic extension.
36/// Products decide retryability, diagnostic visibility, classification and redaction.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(bound(deserialize = "D: Deserialize<'de>"))]
39pub struct ApiErrorInfo<D = ()> {
40    /// A product decision, not an instruction to automatically retry.
41    pub retryable: bool,
42    /// Omitted when absent. Use a product struct instead of unvalidated JSON for extensions.
43    #[serde(
44        default,
45        deserialize_with = "present",
46        skip_serializing_if = "Option::is_none"
47    )]
48    pub diagnostic: Option<D>,
49}
50
51/// REST wire contract: `{code,msg,data?,error?}`.
52///
53/// Fields are private; constructors and deserialization enforce code/state consistency.
54/// This type has no web-framework dependency and never chooses a status, header or error message.
55///
56/// ```
57/// use aster_forge_api::response::{ApiResponse, ApiResponseCode};
58/// #[derive(serde::Serialize)]
59/// enum ProductCode { Success }
60/// impl ApiResponseCode for ProductCode {
61///     fn is_success(&self) -> bool { matches!(self, Self::Success) }
62/// }
63/// let response = ApiResponse::<_, ProductCode>::ok(ProductCode::Success, vec![1, 2])?;
64/// assert_eq!(response.data(), Some(&vec![1, 2]));
65/// # Ok::<(), aster_forge_api::response::ResponseEnvelopeError>(())
66/// ```
67///
68/// Callers cannot bypass state validation with a struct literal:
69/// ```compile_fail
70/// use aster_forge_api::response::ApiResponse;
71/// let response = ApiResponse::<(), u8> {
72///     code: 0, msg: String::new(), data: Some(()), error: None,
73/// };
74/// ```
75#[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    /// Builds success with data and an empty message, preserving a null-valued `T` as data.
87    ///
88    /// # Errors
89    /// Returns [`ResponseEnvelopeError::SuccessCodeRequired`] for a failure code.
90    pub fn ok(code: C, data: T) -> Result<Self, ResponseEnvelopeError> {
91        Self::ok_with_message(code, "", data)
92    }
93
94    /// Builds success with a product-provided message and data.
95    ///
96    /// # Errors
97    /// Returns [`ResponseEnvelopeError::SuccessCodeRequired`] for a failure code.
98    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    /// Builds success with an empty message and omitted data.
117    ///
118    /// # Errors
119    /// Returns [`ResponseEnvelopeError::SuccessCodeRequired`] for a failure code.
120    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    /// Builds a failure with `retryable: false` and no diagnostic, matching AD's ordinary error.
133    ///
134    /// # Errors
135    /// Returns [`ResponseEnvelopeError::FailureCodeRequired`] for a success code.
136    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    /// Builds a failure with optional product error metadata. `None` omits the error field.
148    ///
149    /// # Errors
150    /// Returns [`ResponseEnvelopeError::FailureCodeRequired`] for a success code.
151    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    /// Builds success with `data: {}` and an empty message.
170    ///
171    /// # Errors
172    /// Returns [`ResponseEnvelopeError::SuccessCodeRequired`] for a failure code.
173    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    /// The product's typed code.
180    #[must_use]
181    pub const fn code(&self) -> &C {
182        &self.code
183    }
184    /// Product-provided message.
185    #[must_use]
186    pub fn message(&self) -> &str {
187        &self.msg
188    }
189    /// Data if the success includes a data field.
190    #[must_use]
191    pub const fn data(&self) -> Option<&T> {
192        self.data.as_ref()
193    }
194    /// Consumes the envelope and returns its optional data payload.
195    #[must_use]
196    pub fn into_data(self) -> Option<T> {
197        self.data
198    }
199    /// Optional product error metadata.
200    #[must_use]
201    pub const fn error_info(&self) -> Option<&ApiErrorInfo<D>> {
202        self.error.as_ref()
203    }
204}
205
206// Missing fields are None, while explicit null is deserialized as T (e.g. (), Option<DTO>).
207// This preserves AD's distinction between omitted data, null data and empty-object data.
208fn 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}