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}