Skip to main content

aws_smithy_types/document/
discriminated.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Schema-aware wrapper around [`Document`].
7//!
8//! [`DiscriminatedDocument`] adds two pieces of context that the bare
9//! `Document` data type deliberately doesn't carry:
10//!
11//! - An optional **discriminator** — the Smithy fully-qualified shape
12//!   ID of the type the document was produced from (or is intended to
13//!   deserialize as). This drives the type-registry path: a JSON
14//!   `__type` field gets lifted into the discriminator slot during
15//!   wire parsing, and the type registry uses it to dispatch to the
16//!   right schema.
17//!
18//! - Optional **protocol settings** — a
19//!   [`DocumentSettings`] trait object
20//!   describing how the source protocol encodes Smithy types that
21//!   don't have native wire representations. Used by the format-aware
22//!   accessors [`as_blob`](DiscriminatedDocument::as_blob) and
23//!   [`as_timestamp`](DiscriminatedDocument::as_timestamp) to coerce
24//!   JSON-side base64 strings into bytes, ISO-8601 strings into
25//!   timestamps, etc.
26//!
27//! Why split this from `Document`? Two reasons:
28//!
29//! 1. The bare `Document` is the everyday user-facing type — it
30//!    appears on operation input/output struct fields,
31//!    `AuthSchemeEndpointConfig::as_document`, and in user code
32//!    constructing values. Pattern matching, builders, and round-trip
33//!    semantics on it should stay simple. A user holding an
34//!    `Option<Document>` doesn't need to think about
35//!    discriminators.
36//! 2. The schema-serde pipeline does need both pieces of context, and
37//!    the cleanest place for it is right here on the wrapper. The
38//!    type registry's `deserialize_document` flow consumes
39//!    `&DiscriminatedDocument`; codec deserializers produce
40//!    `DiscriminatedDocument` (with discriminator lifted from
41//!    `__type` and settings attached); type-typed shape construction
42//!    via `DiscriminatedDocumentExt::from_struct` (in
43//!    `aws-smithy-schema`) returns a `DiscriminatedDocument` too.
44
45use std::borrow::Cow;
46use std::sync::Arc;
47
48use crate::date_time::Format;
49use crate::document::document_variant_name;
50use crate::{DateTime, Document, DocumentError, DocumentSettings, Number};
51
52/// A [`Document`] together with an optional discriminator and
53/// optional protocol settings.
54///
55/// See the module-level documentation for the rationale behind
56/// splitting this off `Document`.
57///
58/// This type is `#[non_exhaustive]` so that future fields (e.g. a typed
59/// `Schema` reference, if the schema crate ever adds schema-binding to
60/// this wrapper) can land additively.
61///
62/// Note that this is deliberately *unlike* [`Document`], which is
63/// intentionally **not** `#[non_exhaustive]`: `Document` is a released,
64/// exhaustively-matchable enum, and callers are expected to `match` it
65/// without a wildcard arm. Adding a variant to `Document` would be a
66/// breaking change, which is precisely why the extra context this
67/// wrapper carries lives here instead of on `Document` itself.
68#[non_exhaustive]
69#[derive(Clone, Debug, Default)]
70pub struct DiscriminatedDocument {
71    /// The wrapped document data. Always present.
72    document: Document,
73    /// The fully-qualified shape ID of the source type, if known.
74    /// Lifted from `__type` on the wire, set by
75    /// `DiscriminatedDocumentExt::from_struct` (in `aws-smithy-schema`)
76    /// callers, or left `None` for documents constructed directly from
77    /// data.
78    discriminator: Option<String>,
79    /// Protocol-specific settings used by format-aware coercion. Set
80    /// by codec deserializers (e.g. JSON's
81    /// `read_discriminated_document`), left `None` on user-built
82    /// documents.
83    settings: Option<Arc<dyn DocumentSettings>>,
84}
85
86impl DiscriminatedDocument {
87    /// Creates a new `DiscriminatedDocument` wrapping `document`,
88    /// with no discriminator and no settings attached.
89    pub fn new(document: Document) -> Self {
90        Self {
91            document,
92            discriminator: None,
93            settings: None,
94        }
95    }
96
97    /// Attaches a discriminator (a Smithy fully-qualified shape ID)
98    /// to this document.
99    ///
100    /// Used by the schema-serde pipeline when constructing a
101    /// document from a typed shape: the schema's `shape_id` (in its
102    /// `namespace#name` FQN form) gets attached as the discriminator
103    /// so downstream consumers (the type registry, the `__type`
104    /// write path) know what shape the document represents.
105    ///
106    /// The discriminator MUST be an absolute shape ID — the
107    /// `namespace#name` FQN form, never a bare shape name. The SEP
108    /// requires a serialized `__type` to always be absolute so the
109    /// document stays context-free for downstream readers, and the
110    /// type registry keys on the FQN. Passing a relative id is a
111    /// caller error and trips a `debug_assert!`.
112    pub fn with_discriminator(mut self, fqn: impl Into<String>) -> Self {
113        let fqn = fqn.into();
114        debug_assert!(
115            fqn.contains('#'),
116            "discriminator `{fqn}` must be an absolute shape id (namespace#name)"
117        );
118        self.discriminator = Some(fqn);
119        self
120    }
121
122    /// Attaches protocol settings to this document.
123    ///
124    /// Used by codec deserializers to plumb format-specific coercion
125    /// rules through to downstream consumers of the document tree.
126    /// The same `Arc` is cloned into nested documents so the entire
127    /// tree shares one settings instance.
128    pub fn with_settings(mut self, settings: Arc<dyn DocumentSettings>) -> Self {
129        self.settings = Some(settings);
130        self
131    }
132
133    /// Sets the discriminator **without** the absolute-shape-id check
134    /// that [`with_discriminator`](Self::with_discriminator) enforces.
135    ///
136    /// Test-only escape hatch (behind the `test-util` feature) so that
137    /// downstream crates can construct a document carrying a relative
138    /// (non-absolute) discriminator to exercise their own guards
139    /// against one reaching the wire. Production code MUST use
140    /// [`with_discriminator`](Self::with_discriminator), which
141    /// guarantees the absolute `namespace#name` form the SEP requires.
142    #[cfg(feature = "test-util")]
143    #[doc(hidden)]
144    pub fn set_discriminator_unchecked(&mut self, discriminator: impl Into<String>) {
145        self.discriminator = Some(discriminator.into());
146    }
147
148    /// Returns the discriminator, if attached.
149    pub fn discriminator(&self) -> Option<&str> {
150        self.discriminator.as_deref()
151    }
152
153    /// Returns a reference to the attached protocol settings, if any.
154    pub fn settings(&self) -> Option<&Arc<dyn DocumentSettings>> {
155        self.settings.as_ref()
156    }
157
158    /// Returns a reference to the wrapped document data.
159    pub fn document(&self) -> &Document {
160        &self.document
161    }
162
163    /// Consumes this wrapper and returns the inner document.
164    pub fn into_document(self) -> Document {
165        self.document
166    }
167
168    /// Returns this document's value as bytes.
169    ///
170    /// `Document` has no native blob variant: the schema-driven legacy
171    /// representation of a Smithy `blob` is a base64-encoded
172    /// [`Document::String`]. This accessor therefore dispatches two
173    /// ways:
174    /// - With protocol settings attached, delegates to
175    ///   [`DocumentSettings::coerce_string_to_blob`].
176    /// - With no settings attached, falls back to the deterministic
177    ///   default: standard base64 decode.
178    ///
179    /// Non-string variants return [`DocumentError::TypeMismatch`].
180    pub fn as_blob(&self) -> Result<Cow<'_, [u8]>, DocumentError> {
181        match &self.document {
182            Document::String(s) => match &self.settings {
183                Some(settings) => settings.coerce_string_to_blob(s).map(Cow::Owned),
184                // Deterministic default: base64, the representation every
185                // JSON-family protocol uses and the one the schema
186                // serializer writes.
187                None => crate::base64::decode(s).map(Cow::Owned).map_err(|e| {
188                    DocumentError::invalid_input(format!(
189                        "cannot base64-decode string as blob: {e}"
190                    ))
191                }),
192            },
193            other => Err(DocumentError::type_mismatch(format!(
194                "expected blob, found {}",
195                document_variant_name(other)
196            ))),
197        }
198    }
199
200    /// Returns this document's value as a timestamp.
201    ///
202    /// `Document` has no native timestamp variant: the schema-driven
203    /// legacy representation of a Smithy `timestamp` is a
204    /// [`Document::Number`] holding epoch seconds. This accessor
205    /// dispatches four ways:
206    /// - For [`Document::Number`] with settings attached, delegates to
207    ///   [`DocumentSettings::coerce_number_to_timestamp`]; with no
208    ///   settings, falls back to the deterministic epoch-seconds
209    ///   default.
210    /// - For [`Document::String`] with settings attached, delegates to
211    ///   [`DocumentSettings::coerce_string_to_timestamp`]; with no
212    ///   settings, falls back to parsing RFC-3339 (`date-time`), which
213    ///   is Smithy's default string timestamp format.
214    ///
215    /// Other variants return [`DocumentError::TypeMismatch`].
216    pub fn as_timestamp(&self) -> Result<DateTime, DocumentError> {
217        match (&self.document, &self.settings) {
218            (Document::Number(n), Some(settings)) => settings.coerce_number_to_timestamp(n),
219            (Document::Number(n), None) => number_as_epoch_seconds(n),
220            (Document::String(s), Some(settings)) => settings.coerce_string_to_timestamp(s),
221            (Document::String(s), None) => DateTime::from_str(s, Format::DateTime).map_err(|e| {
222                DocumentError::invalid_input(format!(
223                    "cannot parse string as a date-time timestamp: {e}"
224                ))
225            }),
226            (other, _) => Err(DocumentError::type_mismatch(format!(
227                "expected timestamp, found {}",
228                document_variant_name(other)
229            ))),
230        }
231    }
232}
233
234/// `PartialEq` is implemented manually to compare `document` and
235/// `discriminator` only — `settings` is metadata about how the
236/// document was produced (and `dyn DocumentSettings` doesn't admit
237/// equality anyway).
238///
239/// Two discriminated documents holding the same data with different
240/// settings are considered equal: this matches the behavior of the
241/// schema-crate type that this design replaces, and matches user
242/// intent ("are these the same document?" doesn't depend on protocol
243/// metadata).
244impl PartialEq for DiscriminatedDocument {
245    fn eq(&self, other: &Self) -> bool {
246        self.document == other.document && self.discriminator == other.discriminator
247    }
248}
249
250impl From<Document> for DiscriminatedDocument {
251    fn from(document: Document) -> Self {
252        Self::new(document)
253    }
254}
255
256/// Interprets a [`Number`] as epoch seconds, retaining fractional
257/// seconds when the wire value carried them.
258///
259/// This is the deterministic default used when no protocol settings are
260/// attached. `epoch-seconds` is the format the schema serializer writes
261/// for a `timestamp` shape in the legacy `Document` representation.
262fn number_as_epoch_seconds(n: &Number) -> Result<DateTime, DocumentError> {
263    match n {
264        Number::PosInt(v) => i64::try_from(*v)
265            .map(DateTime::from_secs)
266            .map_err(|_| DocumentError::invalid_input(format!("epoch seconds {v} out of range"))),
267        Number::NegInt(v) => Ok(DateTime::from_secs(*v)),
268        Number::Float(v) => {
269            if !v.is_finite() {
270                return Err(DocumentError::invalid_input(format!(
271                    "epoch seconds {v} is not finite"
272                )));
273            }
274            Ok(DateTime::from_secs_f64(*v))
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    //! Tests for the `DiscriminatedDocument` wrapper and the
282    //! `DocumentSettings` dispatch path.
283    //!
284    //! `TestSettings` is a minimal mock implementation of
285    //! `DocumentSettings` — it doesn't try to do anything realistic
286    //! (the JSON codec's settings will base64-decode, parse RFC-3339
287    //! timestamps, etc.). The point is to exercise dispatch and
288    //! error-path coverage; realistic implementations live in the
289    //! codec crates that pair with this type.
290
291    use super::*;
292    use crate::Number;
293
294    #[derive(Debug)]
295    struct TestSettings {
296        protocol: String,
297    }
298
299    impl DocumentSettings for TestSettings {
300        fn protocol_id(&self) -> &str {
301            &self.protocol
302        }
303
304        fn coerce_string_to_blob(&self, s: &str) -> Result<Vec<u8>, DocumentError> {
305            // Mock: just return the bytes of the string itself.
306            // A real impl would base64-decode for JSON.
307            Ok(s.as_bytes().to_vec())
308        }
309
310        fn coerce_string_to_timestamp(&self, _s: &str) -> Result<DateTime, DocumentError> {
311            // Mock: always return epoch.
312            Ok(DateTime::from_secs(0))
313        }
314
315        fn coerce_number_to_timestamp(&self, n: &Number) -> Result<DateTime, DocumentError> {
316            let secs = match n {
317                Number::PosInt(v) => *v as i64,
318                Number::NegInt(v) => *v,
319                Number::Float(v) => *v as i64,
320            };
321            Ok(DateTime::from_secs(secs))
322        }
323    }
324
325    fn test_settings() -> Arc<dyn DocumentSettings> {
326        Arc::new(TestSettings {
327            protocol: "com.example#Test".to_owned(),
328        })
329    }
330
331    // -- Constructors and accessors -------------------------------------
332
333    #[test]
334    fn new_attaches_no_discriminator_or_settings() {
335        let d = DiscriminatedDocument::new(Document::String("hi".to_owned()));
336        assert_eq!(d.discriminator(), None);
337        assert!(d.settings().is_none());
338        assert_eq!(d.document().as_string(), Some("hi"));
339    }
340
341    #[test]
342    fn with_discriminator_attaches_fqn() {
343        let d =
344            DiscriminatedDocument::new(Document::Null).with_discriminator("com.example#MyShape");
345        assert_eq!(d.discriminator(), Some("com.example#MyShape"));
346    }
347
348    // The discriminator must be an absolute shape id; a bare name is a
349    // caller error and trips the debug-build assertion.
350    #[cfg(debug_assertions)]
351    #[test]
352    #[should_panic(expected = "absolute shape id")]
353    fn with_discriminator_rejects_relative_id() {
354        let _ = DiscriminatedDocument::new(Document::Null).with_discriminator("RelativeOnly");
355    }
356
357    #[test]
358    fn with_settings_attaches_settings() {
359        let d = DiscriminatedDocument::new(Document::String("x".to_owned()))
360            .with_settings(test_settings());
361        assert!(d.settings().is_some());
362        assert_eq!(d.settings().unwrap().protocol_id(), "com.example#Test");
363    }
364
365    #[test]
366    fn into_document_unwraps_to_inner() {
367        let inner = Document::String("hi".to_owned());
368        let d = DiscriminatedDocument::new(inner.clone()).with_discriminator("com.example#X");
369        assert_eq!(d.into_document(), inner);
370    }
371
372    #[test]
373    fn from_document_blanket_impl_works() {
374        let d: DiscriminatedDocument = Document::Bool(true).into();
375        assert_eq!(d.document().as_bool(), Some(true));
376        assert_eq!(d.discriminator(), None);
377    }
378
379    // -- Equality ignores settings --------------------------------------
380
381    #[test]
382    fn partial_eq_compares_document_and_discriminator_only() {
383        let a = DiscriminatedDocument::new(Document::String("x".to_owned()))
384            .with_discriminator("com.example#A");
385        let b = DiscriminatedDocument::new(Document::String("x".to_owned()))
386            .with_discriminator("com.example#A")
387            .with_settings(test_settings());
388        // Different settings (none vs Some), but same data + same
389        // discriminator: equal.
390        assert_eq!(a, b);
391
392        let c = DiscriminatedDocument::new(Document::String("x".to_owned()))
393            .with_discriminator("com.example#B");
394        // Different discriminator: NOT equal.
395        assert_ne!(a, c);
396
397        let d = DiscriminatedDocument::new(Document::String("y".to_owned()))
398            .with_discriminator("com.example#A");
399        // Different data: NOT equal.
400        assert_ne!(a, d);
401    }
402
403    // -- as_blob dispatch -----------------------------------------------
404
405    #[test]
406    fn as_blob_coerces_string_when_settings_present() {
407        let d = DiscriminatedDocument::new(Document::String("hello".to_owned()))
408            .with_settings(test_settings());
409        match d.as_blob().unwrap() {
410            // TestSettings returns the raw bytes of the string.
411            Cow::Owned(bytes) => assert_eq!(bytes, b"hello"),
412            Cow::Borrowed(_) => panic!("expected Cow::Owned for coerced String"),
413        }
414    }
415
416    #[test]
417    fn as_blob_falls_back_to_base64_without_settings() {
418        // No settings attached: the deterministic default is a standard
419        // base64 decode, matching what the schema serializer writes.
420        let d = DiscriminatedDocument::new(Document::String("YWJjZA==".to_owned()));
421        assert!(d.settings().is_none());
422        assert_eq!(d.as_blob().unwrap().as_ref(), b"abcd");
423    }
424
425    #[test]
426    fn as_blob_invalid_base64_without_settings_is_invalid_input() {
427        let d = DiscriminatedDocument::new(Document::String("not base64!!!".to_owned()));
428        let err = d.as_blob().unwrap_err();
429        assert!(matches!(err, DocumentError::InvalidInput { .. }));
430    }
431
432    #[test]
433    fn as_blob_type_mismatch_for_non_string_variants() {
434        // Numeric variant: TypeMismatch regardless of settings.
435        let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(42)))
436            .with_settings(test_settings());
437        let err = d.as_blob().unwrap_err();
438        assert!(matches!(err, DocumentError::TypeMismatch { .. }));
439    }
440
441    // -- as_timestamp dispatch ------------------------------------------
442
443    #[test]
444    fn as_timestamp_coerces_string_with_settings() {
445        let d = DiscriminatedDocument::new(Document::String("any string".to_owned()))
446            .with_settings(test_settings());
447        // Mock returns epoch.
448        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(0));
449    }
450
451    #[test]
452    fn as_timestamp_coerces_number_with_settings() {
453        let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(1234)))
454            .with_settings(test_settings());
455        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(1234));
456    }
457
458    #[test]
459    fn as_timestamp_number_defaults_to_epoch_seconds_without_settings() {
460        // The deterministic default: a number is epoch seconds. This is
461        // the legacy representation the schema serializer writes for a
462        // `timestamp` shape.
463        let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(1234)));
464        assert!(d.settings().is_none());
465        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(1234));
466
467        let d = DiscriminatedDocument::new(Document::Number(Number::NegInt(-5)));
468        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(-5));
469    }
470
471    #[test]
472    fn as_timestamp_retains_fractional_epoch_seconds() {
473        let d = DiscriminatedDocument::new(Document::Number(Number::Float(1234.5)));
474        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs_f64(1234.5));
475    }
476
477    #[test]
478    fn as_timestamp_string_defaults_to_date_time_without_settings() {
479        let d = DiscriminatedDocument::new(Document::String("1970-01-01T00:00:00Z".to_owned()));
480        assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(0));
481    }
482
483    #[test]
484    fn as_timestamp_malformed_string_without_settings_is_invalid_input() {
485        let d = DiscriminatedDocument::new(Document::String("not a timestamp".to_owned()));
486        let err = d.as_timestamp().unwrap_err();
487        assert!(matches!(err, DocumentError::InvalidInput { .. }));
488    }
489
490    #[test]
491    fn as_timestamp_type_mismatch_for_non_coercible_variant() {
492        // Boolean can never coerce to timestamp regardless of settings.
493        let d = DiscriminatedDocument::new(Document::Bool(true)).with_settings(test_settings());
494        let err = d.as_timestamp().unwrap_err();
495        assert!(matches!(err, DocumentError::TypeMismatch { .. }));
496    }
497
498    // -- Default trait method bodies emit UnsupportedOperation ----------
499
500    #[test]
501    fn default_settings_methods_return_unsupported_operation() {
502        // A minimal impl that only sets `protocol_id` should fall
503        // through to defaults that produce UnsupportedOperation. This
504        // is the path CBOR-style protocols will rely on.
505        #[derive(Debug)]
506        struct MinimalSettings;
507        impl DocumentSettings for MinimalSettings {
508            fn protocol_id(&self) -> &str {
509                "com.example#Minimal"
510            }
511        }
512
513        let s = MinimalSettings;
514        assert!(matches!(
515            s.coerce_string_to_blob("anything"),
516            Err(DocumentError::UnsupportedOperation { .. })
517        ));
518        assert!(matches!(
519            s.coerce_string_to_timestamp("anything"),
520            Err(DocumentError::UnsupportedOperation { .. })
521        ));
522        assert!(matches!(
523            s.coerce_number_to_timestamp(&Number::PosInt(0)),
524            Err(DocumentError::UnsupportedOperation { .. })
525        ));
526    }
527}