Skip to main content

aws_smithy_schema/schema/document/
discriminated_ext.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Extension methods on [`DiscriminatedDocument`] that need access to
7//! [`Schema`], [`SerializableStruct`], and the schema-side
8//! [`ShapeSerializer`](crate::serde::ShapeSerializer) / [`ShapeDeserializer`] machinery — types that
9//! live in `aws-smithy-schema` and therefore cannot be referenced from
10//! the inherent-impl in `aws-smithy-types` where
11//! [`DiscriminatedDocument`] is defined.
12//!
13//! Without this trait the SEP-mandated `Document.of(struct)` and
14//! `Document.asShape(deserialize)` entry points would not be reachable
15//! on `DiscriminatedDocument` because the dependency direction
16//! (`aws-smithy-schema → aws-smithy-types`) and Rust's orphan rule
17//! together prevent adding inherent methods to `DiscriminatedDocument`
18//! from this crate.
19//!
20//! Users import this trait to use the methods:
21//!
22//! ```ignore
23//! use aws_smithy_schema::document::DiscriminatedDocumentExt;
24//!
25//! let doc = DiscriminatedDocument::from_struct(MyShape::SCHEMA, &my_value)?;
26//! let restored: MyShape = doc.as_shape(|deser| MyShape::deserialize(deser))?;
27//! ```
28//!
29//! # Implementation
30//!
31//! The implementation routes through the schema-side
32//! [`DocumentShapeSerializer`] /
33//! [`DocumentShapeDeserializer`],
34//! which operate on [`aws_smithy_types::Document`] directly.
35//! Discriminator capture happens on the way out (the schema's shape
36//! ID is recorded as the resulting wrapper's [`discriminator()`]
37//! field); on `as_shape`, the deserializer reads from
38//! `self.document()` directly.
39//!
40//! [`discriminator()`]: aws_smithy_types::DiscriminatedDocument::discriminator
41
42use aws_smithy_types::{DiscriminatedDocument, Number};
43
44use super::{DocumentShapeDeserializer, DocumentShapeSerializer};
45use crate::serde::{SerdeError, SerializableStruct, ShapeDeserializer};
46use crate::{Schema, ShapeType};
47
48/// Schema-aware operations on [`DiscriminatedDocument`].
49///
50/// Implemented for [`DiscriminatedDocument`] in `aws-smithy-schema`
51/// because the methods need [`Schema`], [`SerializableStruct`], and
52/// the [`ShapeDeserializer`] trait — types that live in this crate.
53/// See the trait's documentation for the rationale.
54pub trait DiscriminatedDocumentExt {
55    /// Constructs a [`DiscriminatedDocument`] from any
56    /// [`SerializableStruct`] by driving it through the
57    /// [`DocumentShapeSerializer`] pipeline.
58    ///
59    /// This is the SEP's `Document.of(struct)` entry point. The
60    /// resulting wrapper carries `schema.shape_id().as_str()` as its
61    /// discriminator, so a downstream type registry can find the right
62    /// schema for the reverse conversion.
63    ///
64    /// Settings are not attached — serialize-side documents have no
65    /// protocol context.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`SerdeError`] if the struct's `serialize_members`
70    /// callback fails (rare — typically only on internal invariant
71    /// violations or invalid map-key writes).
72    fn from_struct(
73        schema: &Schema<'_>,
74        value: &dyn SerializableStruct,
75    ) -> Result<DiscriminatedDocument, SerdeError>;
76
77    /// Reifies this [`DiscriminatedDocument`] as a typed shape by
78    /// driving the given `deserialize` callback through a
79    /// [`DocumentShapeDeserializer`].
80    ///
81    /// This is the SEP's `Document.asShape(...)` entry point. The
82    /// callback is typically the generated `<Type>::deserialize`
83    /// function on a shape's data carrier or builder.
84    ///
85    /// # Errors
86    ///
87    /// Returns whatever error the `deserialize` callback returns —
88    /// typically a [`SerdeError::TypeMismatch`] when the document's
89    /// shape doesn't match what the callback expects.
90    fn as_shape<T, F>(&self, deserialize: F) -> Result<T, SerdeError>
91    where
92        F: FnOnce(&mut dyn ShapeDeserializer) -> Result<T, SerdeError>;
93
94    /// Returns the [`ShapeType`] this document would be reported as if
95    /// converted to a typed shape.
96    ///
97    /// For numeric values, follows the SEP "Reporting `Document`
98    /// ambiguous shape types" guidance — picks the narrowest
99    /// unambiguous container from the precedence
100    /// `Integer → Long → BigInteger → Double → BigDecimal`. `Byte`,
101    /// `IntEnum`, `Short`, and `Float` are skipped.
102    ///
103    /// `Null` is reported as [`ShapeType::Document`] since `Null`
104    /// itself is not a Smithy type variant.
105    fn shape_type(&self) -> ShapeType;
106}
107
108impl DiscriminatedDocumentExt for DiscriminatedDocument {
109    fn from_struct(
110        schema: &Schema<'_>,
111        value: &dyn SerializableStruct,
112    ) -> Result<DiscriminatedDocument, SerdeError> {
113        // The schema-side serializer produces a `DiscriminatedDocument`
114        // directly. The inherent `DocumentShapeSerializer::write_struct`
115        // captures the schema's shape ID into the resulting wrapper's
116        // discriminator slot; settings are not attached.
117        let mut ser = DocumentShapeSerializer::default();
118        ser.write_struct(schema, value)?;
119        ser.finish()
120    }
121
122    fn as_shape<T, F>(&self, deserialize: F) -> Result<T, SerdeError>
123    where
124        F: FnOnce(&mut dyn ShapeDeserializer) -> Result<T, SerdeError>,
125    {
126        // The schema-side deserializer reads directly from the inner
127        // `Document`. Settings on the wrapper are attached to the
128        // deserializer so format-aware coercion (e.g. a JSON-wire
129        // base64 string read as a blob, an epoch-seconds number read
130        // as a timestamp) succeeds when the data tree carries the
131        // wire-format representation rather than a native variant.
132        let mut deser =
133            DocumentShapeDeserializer::new_with_settings(self.document(), self.settings().cloned());
134        deserialize(&mut deser)
135    }
136
137    fn shape_type(&self) -> ShapeType {
138        use aws_smithy_types::Document as Doc;
139        // The number-disambiguation logic delegates to the private
140        // helper that drives `Document::shape_type`. Reads through the
141        // `document()` accessor for consistency with `as_shape`.
142        // `Document` carries no schema, so a `blob`, `timestamp`, or
143        // arbitrary-precision number is reported as the variant that
144        // actually holds it (`String` / `Number`). Recovering the Smithy
145        // type of such a value requires a schema — see
146        // `DiscriminatedDocumentExt::as_shape`.
147        match self.document() {
148            Doc::Null => ShapeType::Document,
149            Doc::Bool(_) => ShapeType::Boolean,
150            Doc::Number(n) => number_shape_type(n),
151            Doc::String(_) => ShapeType::String,
152            Doc::Array(_) => ShapeType::List,
153            Doc::Object(_) => ShapeType::Map,
154        }
155    }
156}
157
158/// SEP "Reporting `Document` ambiguous shape types" — for numeric
159/// values stored in a [`Number`], picks the narrowest unambiguous
160/// container from the precedence order `Integer → Long → BigInteger →
161/// Double → BigDecimal`. `Byte`, `IntEnum`, `Short`, and `Float` are
162/// skipped per the SEP.
163///
164/// Private helper for [`DiscriminatedDocumentExt::shape_type`] on
165/// numeric documents.
166fn number_shape_type(n: &Number) -> ShapeType {
167    match n {
168        Number::PosInt(v) => {
169            if *v <= i32::MAX as u64 {
170                ShapeType::Integer
171            } else if *v <= i64::MAX as u64 {
172                ShapeType::Long
173            } else {
174                ShapeType::BigInteger
175            }
176        }
177        Number::NegInt(v) => {
178            if *v >= i32::MIN as i64 && *v <= i32::MAX as i64 {
179                ShapeType::Integer
180            } else {
181                // i64 fits in Long but not BigInteger; Number::NegInt is
182                // bounded by i64 so BigInteger is unreachable here.
183                ShapeType::Long
184            }
185        }
186        Number::Float(v) => {
187            // Per the SEP, integer-valued floats are reported as the
188            // narrowest integer container that fits without precision
189            // loss, and the integer readers accept exactly those values
190            // (`float_as_i64` is shared with them). Beyond `i64` the value
191            // is reported as `Double`: f64 represents integers only up to
192            // 2^53 exactly, so such a value is already lossy as f64.
193            match crate::schema::document::deserializer::float_as_i64(*v) {
194                Some(i) if i32::try_from(i).is_ok() => ShapeType::Integer,
195                Some(_) => ShapeType::Long,
196                None => ShapeType::Double,
197            }
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    //! Tests for [`DiscriminatedDocumentExt`].
205    //!
206    //! Coverage:
207    //! - Round-trip a struct using base scalar variants
208    //!   (`Person { name: String, age: i32 }`).
209    //! - Discriminator capture on `from_struct`.
210    //! - `as_shape` reverses the round-trip.
211    //! - Shape-type reporting for the extended variants
212    //!   (Blob/Timestamp/BigInteger/BigDecimal).
213    //! - End-to-end round-trip through `from_struct` for structs whose
214    //!   members use the extended variants.
215    //! - End-to-end `as_shape` on a top-level extended-variant document.
216    //!
217    //! All tests use `Schema<'static>` because the schema-crate
218    //! prelude and codegen-emitted schemas are `'static`. The trait
219    //! method signatures accept any lifetime via `&Schema<'_>`.
220
221    use super::*;
222    use crate::serde::{SerdeError, ShapeSerializer};
223    use crate::{prelude, shape_id, Schema, ShapeId, ShapeType};
224
225    // -- Test schemas + types ------------------------------------------
226
227    const PERSON_ID: ShapeId<'static> = shape_id!("smithy.example", "Person");
228    const PERSON_NAME_ID: ShapeId<'static> = shape_id!("smithy.example", "Person", "name");
229    const PERSON_AGE_ID: ShapeId<'static> = shape_id!("smithy.example", "Person", "age");
230
231    static PERSON_NAME_MEMBER: Schema<'static> =
232        Schema::new_member(PERSON_NAME_ID, ShapeType::String, "name", 0);
233    static PERSON_AGE_MEMBER: Schema<'static> =
234        Schema::new_member(PERSON_AGE_ID, ShapeType::Integer, "age", 1);
235    static PERSON_SCHEMA: Schema<'static> = Schema::new_struct(
236        PERSON_ID,
237        ShapeType::Structure,
238        &[&PERSON_NAME_MEMBER, &PERSON_AGE_MEMBER],
239    );
240
241    #[derive(Debug, Default, PartialEq)]
242    struct Person {
243        name: Option<String>,
244        age: Option<i32>,
245    }
246
247    impl SerializableStruct for Person {
248        fn schema(&self) -> &Schema<'_> {
249            &PERSON_SCHEMA
250        }
251
252        fn serialize_members(&self, ser: &mut dyn ShapeSerializer) -> Result<(), SerdeError> {
253            if let Some(n) = &self.name {
254                ser.write_string(&PERSON_NAME_MEMBER, n)?;
255            }
256            if let Some(a) = self.age {
257                ser.write_integer(&PERSON_AGE_MEMBER, a)?;
258            }
259            Ok(())
260        }
261    }
262
263    fn deserialize_person(deser: &mut dyn ShapeDeserializer) -> Result<Person, SerdeError> {
264        let mut out = Person::default();
265        deser.read_struct(&PERSON_SCHEMA, &mut |member, sub| {
266            match member.member_index() {
267                Some(0) => out.name = Some(sub.read_string(member)?),
268                Some(1) => out.age = Some(sub.read_integer(member)?),
269                _ => {}
270            }
271            Ok(())
272        })?;
273        Ok(out)
274    }
275
276    // -- from_struct happy path ----------------------------------------
277
278    #[test]
279    fn from_struct_attaches_fqn_discriminator() {
280        let p = Person {
281            name: Some("Iago".into()),
282            age: Some(7),
283        };
284        let doc = DiscriminatedDocument::from_struct(&PERSON_SCHEMA, &p).unwrap();
285        assert_eq!(doc.discriminator(), Some("smithy.example#Person"));
286        assert!(doc.settings().is_none());
287        // The inner document is a map with the two members.
288        let map = doc.document().as_object().unwrap();
289        assert_eq!(map.len(), 2);
290        assert!(map.contains_key("name"));
291        assert!(map.contains_key("age"));
292    }
293
294    #[test]
295    fn from_struct_with_only_optional_members_set() {
296        let p = Person {
297            name: Some("Sam".into()),
298            age: None,
299        };
300        let doc = DiscriminatedDocument::from_struct(&PERSON_SCHEMA, &p).unwrap();
301        let map = doc.document().as_object().unwrap();
302        assert_eq!(map.len(), 1);
303        assert!(map.contains_key("name"));
304    }
305
306    // -- as_shape happy path -------------------------------------------
307
308    #[test]
309    fn as_shape_reverses_from_struct() {
310        let original = Person {
311            name: Some("Alex".into()),
312            age: Some(30),
313        };
314        let doc = DiscriminatedDocument::from_struct(&PERSON_SCHEMA, &original).unwrap();
315        let restored: Person = doc.as_shape(|deser| deserialize_person(deser)).unwrap();
316        assert_eq!(restored, original);
317    }
318
319    #[test]
320    fn as_shape_works_on_directly_constructed_document() {
321        // Construct a DiscriminatedDocument directly from a map of
322        // base scalar variants (no schema-side intermediate). Confirms
323        // as_shape works on documents that didn't come from
324        // `from_struct`.
325        let mut map = std::collections::HashMap::new();
326        map.insert(
327            "name".to_string(),
328            aws_smithy_types::Document::String("Joe".into()),
329        );
330        map.insert(
331            "age".to_string(),
332            aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(42)),
333        );
334        let doc = DiscriminatedDocument::new(aws_smithy_types::Document::Object(map));
335        let person: Person = doc.as_shape(|deser| deserialize_person(deser)).unwrap();
336        assert_eq!(
337            person,
338            Person {
339                name: Some("Joe".into()),
340                age: Some(42),
341            }
342        );
343    }
344
345    // -- shape_type reporting ------------------------------------------
346
347    #[test]
348    fn shape_type_reports_each_base_variant() {
349        use aws_smithy_types::{Document, Number};
350        let cases = [
351            (Document::Null, ShapeType::Document),
352            (Document::Bool(true), ShapeType::Boolean),
353            (Document::String("x".into()), ShapeType::String),
354            (Document::Number(Number::PosInt(0)), ShapeType::Integer),
355            (
356                Document::Number(Number::PosInt(u64::MAX)),
357                ShapeType::BigInteger,
358            ),
359            (Document::Array(vec![]), ShapeType::List),
360            (Document::Object(Default::default()), ShapeType::Map),
361        ];
362        for (doc, expected) in cases {
363            let wrapped = DiscriminatedDocument::new(doc);
364            assert_eq!(wrapped.shape_type(), expected);
365        }
366    }
367
368    /// From review of PR #4871: `shape_type()` must report a type that the reader can then
369    /// read. It asserts agreement rather than a specific answer, extended with the `i32` and
370    /// `i64` boundaries (`i64::MAX as f64` rounds up to 2⁶³, one past `i64::MAX`).
371    #[test]
372    fn reported_shape_type_can_be_read_back() {
373        use crate::prelude::{DOUBLE, INTEGER, LONG};
374        use aws_smithy_types::{Document, Number};
375        const TWO_POW_63: f64 = 9_223_372_036_854_775_808.0;
376        for (value, expected) in [
377            (42.0, ShapeType::Integer),
378            (-1.0, ShapeType::Integer),
379            (2_147_483_647.0, ShapeType::Integer),
380            (-2_147_483_648.0, ShapeType::Integer),
381            (2_147_483_648.0, ShapeType::Long),
382            (-TWO_POW_63, ShapeType::Long),
383            (TWO_POW_63, ShapeType::Double),
384            (1e20, ShapeType::Double),
385            (1.5, ShapeType::Double),
386            (f64::NAN, ShapeType::Double),
387        ] {
388            let doc = DiscriminatedDocument::new(Document::Number(Number::Float(value)));
389            let reported = doc.shape_type();
390            assert_eq!(reported, expected, "{value}");
391            let read = match reported {
392                ShapeType::Integer => doc.as_shape(|de| de.read_integer(&INTEGER)).map(|_| ()),
393                ShapeType::Long => doc.as_shape(|de| de.read_long(&LONG)).map(|_| ()),
394                ShapeType::Double => doc.as_shape(|de| de.read_double(&DOUBLE)).map(|_| ()),
395                other => panic!("unexpected report {other:?} for {value:?}"),
396            };
397            assert!(
398                read.is_ok(),
399                "reported {reported:?} for {value:?}, read failed: {read:?}"
400            );
401        }
402    }
403
404    // -- Blob / timestamp legacy representation round-trips --------------
405
406    #[test]
407    fn from_struct_with_blob_member_round_trips() {
408        const BLOBBY_ID: ShapeId<'static> = shape_id!("smithy.example", "Blobby");
409        const BLOBBY_DATA_ID: ShapeId<'static> = shape_id!("smithy.example", "Blobby", "data");
410        static BLOBBY_DATA_MEMBER: Schema<'static> =
411            Schema::new_member(BLOBBY_DATA_ID, ShapeType::Blob, "data", 0);
412        static BLOBBY_SCHEMA: Schema<'static> =
413            Schema::new_struct(BLOBBY_ID, ShapeType::Structure, &[&BLOBBY_DATA_MEMBER]);
414
415        struct Blobby {
416            data: Vec<u8>,
417        }
418        impl SerializableStruct for Blobby {
419            fn schema(&self) -> &Schema<'_> {
420                &BLOBBY_SCHEMA
421            }
422
423            fn serialize_members(&self, ser: &mut dyn ShapeSerializer) -> Result<(), SerdeError> {
424                ser.write_blob(
425                    &BLOBBY_DATA_MEMBER,
426                    aws_smithy_types::Blob::new(self.data.clone()),
427                )
428            }
429        }
430
431        let doc = DiscriminatedDocument::from_struct(
432            &BLOBBY_SCHEMA,
433            &Blobby {
434                data: b"raw".to_vec(),
435            },
436        )
437        .expect("from_struct should succeed for blob members");
438        assert_eq!(doc.discriminator(), Some("smithy.example#Blobby"));
439        let map = doc.document().as_object().unwrap();
440        // The legacy representation of a blob is a base64 string.
441        match map.get("data").unwrap() {
442            aws_smithy_types::Document::String(s) => assert_eq!(s, "cmF3"),
443            other => panic!("expected a base64 String in 'data' field, got {other:?}"),
444        }
445        // And the member reverses through the schema-driven read path.
446        let member = DiscriminatedDocument::new(map.get("data").unwrap().clone());
447        assert_eq!(member.as_blob().unwrap().as_ref(), b"raw");
448    }
449
450    #[test]
451    fn as_shape_on_top_level_blob_document() {
452        // A top-level blob rides the legacy base64-string
453        // representation; `read_blob` reverses it using the schema.
454        let doc = DiscriminatedDocument::new(aws_smithy_types::Document::String("eA==".to_owned()));
455        let blob = doc
456            .as_shape(|deser| deser.read_blob(&prelude::BLOB))
457            .expect("as_shape on a Blob document should succeed");
458        assert_eq!(blob.as_ref(), b"x");
459    }
460
461    // -- Discriminator preservation across construction paths ----------
462
463    #[test]
464    fn from_struct_then_as_shape_preserves_data_but_ignores_discriminator() {
465        // The discriminator captured during from_struct is not
466        // re-read during as_shape's deserialize callback (the callback
467        // receives the document data, not the discriminator). This
468        // matches the SEP behavior: callers wanting discriminator-
469        // dispatched deserialization use a TypeRegistry, not as_shape.
470        let original = Person {
471            name: Some("Lee".into()),
472            age: Some(25),
473        };
474        let doc = DiscriminatedDocument::from_struct(&PERSON_SCHEMA, &original).unwrap();
475        assert_eq!(doc.discriminator(), Some("smithy.example#Person"));
476        let restored: Person = doc.as_shape(|d| deserialize_person(d)).unwrap();
477        assert_eq!(restored, original);
478    }
479}