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}