aws_smithy_json/codec.rs
1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! JSON codec implementation for schema-based serialization.
7
8use aws_smithy_schema::codec::Codec;
9use aws_smithy_schema::extension::SchemaExtensionKey;
10use aws_smithy_schema::{shape_id, Schema, ShapeId};
11use aws_smithy_types::date_time::{DateTime, Format as TimestampFormat};
12use aws_smithy_types::{DocumentError, DocumentSettings, Number};
13use std::sync::Arc;
14
15mod deserializer;
16mod serializer;
17
18pub use deserializer::JsonDeserializer;
19pub use serializer::JsonSerializer;
20
21/// The JSON object keys (`"name":`) for one member schema, quoted and escaped once
22/// and cached on the schema by [`JSON_MEMBER_KEYS`].
23///
24/// Both [`JsonFieldMapper`] variants are cached because a schema is shared by every
25/// codec in the process, and codecs that honor `@jsonName` and codecs that ignore it
26/// can serialize the same shape.
27#[derive(Debug)]
28struct JsonMemberKeys {
29 /// `"<member name>":`
30 member_name: Box<str>,
31 /// `"<@jsonName value>":`, present only when the member has `@jsonName`.
32 json_name: Option<Box<str>>,
33}
34
35/// Quotes and escapes `name` as a JSON object key, including the trailing colon.
36fn encode_key(name: &str) -> Box<str> {
37 let escaped = crate::escape::escape_string(name);
38 let mut key = String::with_capacity(escaped.len() + 3);
39 key.push('"');
40 key.push_str(&escaped);
41 key.push_str("\":");
42 key.into_boxed_str()
43}
44
45/// Caches a member schema's JSON object keys, so the serializer writes each field
46/// name with one `push_str` instead of quoting and escaping it on every request.
47///
48/// Only requested for schemas that have a member name; see
49/// [`JsonFieldMapper::member_key`].
50static JSON_MEMBER_KEYS: SchemaExtensionKey<JsonMemberKeys> =
51 SchemaExtensionKey::new(|member| JsonMemberKeys {
52 member_name: encode_key(member.member_name().unwrap_or_default()),
53 json_name: member
54 .json_name()
55 .map(|json_name| encode_key(json_name.value())),
56 });
57
58/// Maps between Smithy member names and JSON wire field names.
59///
60/// When `@jsonName` is enabled, the wire name may differ from the member name.
61/// This type handles the mapping in both directions.
62///
63/// The mapper itself is a fieldless `Copy` enum. Encoded keys for serialization are
64/// cached on the member schemas (see [`JsonFieldMapper::member_key`]); reverse
65/// lookups are a linear scan over the struct's members (see
66/// [`JsonFieldMapper::field_to_member`]).
67#[derive(Debug, Clone, Copy)]
68enum JsonFieldMapper {
69 /// Uses member names directly, ignoring `@jsonName`.
70 UseMemberName,
71 /// Uses `@jsonName` trait values when present, falling back to member name.
72 UseJsonName,
73}
74
75impl JsonFieldMapper {
76 /// Returns the encoded JSON object key (`"name":`) for a member schema, or `None`
77 /// if the schema is not a member.
78 #[inline]
79 fn member_key<'s>(&self, member: &'s Schema<'_>) -> Option<&'s str> {
80 // Checked first so that schemas without a field name, such as a top-level
81 // structure, never allocate extension storage.
82 member.member_name()?;
83 let keys = member.extension(&JSON_MEMBER_KEYS);
84 Some(match self {
85 JsonFieldMapper::UseMemberName => &keys.member_name,
86 JsonFieldMapper::UseJsonName => keys.json_name.as_ref().unwrap_or(&keys.member_name),
87 })
88 }
89
90 /// Resolves a JSON wire field name to a member schema within a struct schema.
91 ///
92 /// Returns the first member whose wire name matches, so a field name shared by
93 /// several members (a member name equal to another member's `@jsonName`) always
94 /// resolves to the same member.
95 ///
96 /// This is `O(M)` in the number of struct members and is called once per field
97 /// present on the wire, so deserializing a struct is `O(M²)` in the worst case.
98 /// An unknown field costs a full scan before returning `None`.
99 ///
100 /// **Both** arms scan: `UseJsonName` loops below, and `UseMemberName` delegates
101 /// to `Schema::member_schema`, which is itself a `find` over the members.
102 ///
103 /// Real member counts across AWS models are p50=2, p90=6, p99=20, so this is
104 /// not measurable for the large majority of structs.
105 fn field_to_member<'s>(
106 &self,
107 schema: &'s Schema<'s>,
108 field_name: &str,
109 ) -> Option<&'s Schema<'s>> {
110 match self {
111 JsonFieldMapper::UseMemberName => schema.member_schema(field_name),
112 JsonFieldMapper::UseJsonName => {
113 for member in schema.members() {
114 if let Some(jn) = member.json_name() {
115 if jn.value() == field_name {
116 return Some(member);
117 }
118 } else if member.member_name() == Some(field_name) {
119 return Some(member);
120 }
121 }
122 None
123 }
124 }
125 }
126}
127
128/// Configuration for JSON codec behavior.
129///
130/// Use the builder methods to construct settings:
131/// ```
132/// use aws_smithy_json::codec::JsonCodecSettings;
133///
134/// let settings = JsonCodecSettings::builder()
135/// .use_json_name(false)
136/// .build();
137/// ```
138///
139/// # As `DocumentSettings`
140///
141/// `JsonCodecSettings` implements [`DocumentSettings`]. The
142/// [`JsonDeserializer`] attaches `Arc<JsonCodecSettings>` to every
143/// document it produces so the format-aware accessors
144/// ([`DiscriminatedDocument::as_blob`](aws_smithy_types::DiscriminatedDocument::as_blob),
145/// [`DiscriminatedDocument::as_timestamp`](aws_smithy_types::DiscriminatedDocument::as_timestamp))
146/// can coerce JSON-encoded blobs (base64 strings) and timestamps
147/// (date-time strings or epoch-seconds numbers) back to typed Rust
148/// values. The same instance is reused on the serialization side so a
149/// document round-trips through `JsonDeserializer` →
150/// `JsonSerializer` losslessly.
151#[derive(Debug, Clone)]
152pub struct JsonCodecSettings {
153 field_mapper: JsonFieldMapper,
154 default_timestamp_format: TimestampFormat,
155 max_depth: u32,
156 /// When `true`, a timestamp must use exactly the wire form its resolved
157 /// `@timestampFormat` (or the codec default) prescribes: a JSON number for
158 /// `epoch-seconds`, an RFC 3339 string without a UTC offset for `date-time`,
159 /// and an IMF-fixdate string for `http-date`. Any other form is rejected.
160 /// When `false` (default) the deserializer is tolerant: a number is always
161 /// read as epoch seconds and a string for a `date-time` or `epoch-seconds`
162 /// member is parsed as an offset-aware `date-time`.
163 strict_timestamp_formats: bool,
164 dispatch_null_members: bool,
165 enforce_strictness: bool,
166 validate_skipped_values: bool,
167 validate_skipped_string_encoding: bool,
168 allow_integral_float_numbers: bool,
169 allow_leading_zeros: bool,
170 allow_trailing_decimal_point: bool,
171 allow_empty_integer_part: bool,
172 /// Identifies the protocol that produced this codec — used by
173 /// `DocumentSettings` for diagnostics on coercion failures.
174 /// Default: `aws.smithy.json#JsonCodec`. AWS protocols (awsJson1_0,
175 /// awsJson1_1, restJson1) override this to their own shape id.
176 protocol_id: ShapeId<'static>,
177 /// When `true`, `bigInteger` and `bigDecimal` shapes serialize as
178 /// JSON strings to preserve precision for receivers using
179 /// `f64`-routed JSON parsers. When `false` (default), they emit as
180 /// raw JSON numbers — interoperable with arbitrary-precision JSON
181 /// parsers but lossy when the receiver routes through `f64`.
182 ///
183 /// The read path always accepts both wire forms, regardless of
184 /// this setting, so a sender configured for one form interoperates
185 /// with a receiver configured for the other.
186 use_string_for_arbitrary_precision: bool,
187 /// When `true`, a `bigInteger` or `bigDecimal` whose text is a valid decimal but not an
188 /// RFC 8259 JSON number (a leading `+`, leading zeros, `.5`, `1.`) is rewritten to the
189 /// equivalent JSON number on the raw-number path. When `false` (default) it is rejected with
190 /// [`SerdeError::InvalidInput`](aws_smithy_schema::serde::SerdeError). Has no effect when
191 /// `use_string_for_arbitrary_precision` is set.
192 normalize_arbitrary_precision: bool,
193 /// Default Smithy namespace used to resolve relative shape IDs in
194 /// JSON `__type` discriminator fields produced by services that
195 /// emit relative names (e.g., awsJson1_0/1_1 senders).
196 ///
197 /// When set, [`crate::codec::deserializer::JsonDeserializer::read_discriminated_document`]
198 /// resolves a relative `__type` value (e.g., `"Capacity"`) to its
199 /// fully-qualified shape ID (`"<default_namespace>#Capacity"`)
200 /// before lifting it onto the resulting [`DiscriminatedDocument`](aws_smithy_types::DiscriminatedDocument)
201 /// discriminator slot. Absolute `__type` values (already
202 /// containing `#`) are taken as-is regardless of this setting.
203 ///
204 /// `None` (default) preserves the prior behavior where relative
205 /// names are left in the resulting map as plain string entries.
206 default_namespace: Option<String>,
207}
208
209impl JsonCodecSettings {
210 /// Whether null structure members are passed to the consumer instead of skipped.
211 /// Schema servers use this to clear optional fields while leaving required validation to builders.
212 pub fn dispatch_null_members(&self) -> bool {
213 self.dispatch_null_members
214 }
215
216 /// Creates a builder for `JsonCodecSettings`.
217 pub fn builder() -> JsonCodecSettingsBuilder {
218 JsonCodecSettingsBuilder::default()
219 }
220
221 /// Default timestamp format when not specified by `@timestampFormat` trait.
222 pub fn default_timestamp_format(&self) -> TimestampFormat {
223 self.default_timestamp_format
224 }
225
226 /// Maximum aggregate nesting depth the deserializer will accept before
227 /// returning an error. Defends against stack overflow on recursive shapes
228 /// and deeply-nested document payloads.
229 pub fn max_depth(&self) -> u32 {
230 self.max_depth
231 }
232
233 /// Whether timestamps must use exactly the wire form their format prescribes.
234 /// See [`JsonCodecSettingsBuilder::strict_timestamp_formats`].
235 pub fn strict_timestamp_formats(&self) -> bool {
236 self.strict_timestamp_formats
237 }
238
239 /// Whether `bigInteger` and `bigDecimal` shapes emit as JSON
240 /// strings (when `true`) or as raw JSON numbers
241 /// (when `false`, the default). The read path is always lenient —
242 /// `read_big_integer` and `read_big_decimal` accept either wire form.
243 pub fn use_string_for_arbitrary_precision(&self) -> bool {
244 self.use_string_for_arbitrary_precision
245 }
246
247 /// Whether non-JSON-number arbitrary-precision text is rewritten to the equivalent JSON
248 /// number (`true`) or rejected (`false`, the default). See the field documentation.
249 pub fn normalize_arbitrary_precision(&self) -> bool {
250 self.normalize_arbitrary_precision
251 }
252
253 /// Default Smithy namespace used to resolve relative shape IDs in
254 /// JSON `__type` discriminator fields. `None` if unset.
255 ///
256 /// See the field-level doc comment for [`Self::default_namespace`]'s
257 /// builder method for the resolution behavior.
258 pub fn default_namespace(&self) -> Option<&str> {
259 self.default_namespace.as_deref()
260 }
261
262 /// Returns a [`JsonCodecSettingsBuilder`] pre-populated with this
263 /// instance's current values. Useful for protocol wrappers that
264 /// need to rebuild a codec with one setting overridden:
265 ///
266 /// ```
267 /// use aws_smithy_json::codec::JsonCodecSettings;
268 ///
269 /// let original = JsonCodecSettings::default();
270 /// let modified = original.to_builder()
271 /// .default_namespace("com.example")
272 /// .build();
273 /// ```
274 pub fn to_builder(&self) -> JsonCodecSettingsBuilder {
275 JsonCodecSettingsBuilder {
276 use_json_name: matches!(self.field_mapper, JsonFieldMapper::UseJsonName),
277 default_timestamp_format: self.default_timestamp_format,
278 max_depth: self.max_depth,
279 enforce_strictness: self.enforce_strictness,
280 validate_skipped_values: self.validate_skipped_values,
281 validate_skipped_string_encoding: self.validate_skipped_string_encoding,
282 allow_integral_float_numbers: self.allow_integral_float_numbers,
283 allow_leading_zeros: self.allow_leading_zeros,
284 allow_trailing_decimal_point: self.allow_trailing_decimal_point,
285 allow_empty_integer_part: self.allow_empty_integer_part,
286 strict_timestamp_formats: self.strict_timestamp_formats,
287 dispatch_null_members: self.dispatch_null_members,
288 protocol_id: self.protocol_id.clone(),
289 use_string_for_arbitrary_precision: self.use_string_for_arbitrary_precision,
290 normalize_arbitrary_precision: self.normalize_arbitrary_precision,
291 default_namespace: self.default_namespace.clone(),
292 }
293 }
294
295 /// Returns the encoded JSON object key (`"name":`) for a member schema.
296 #[inline]
297 pub(crate) fn member_key<'s>(&self, member: &'s Schema<'_>) -> Option<&'s str> {
298 self.field_mapper.member_key(member)
299 }
300
301 /// Resolves a JSON wire field name to a member schema.
302 pub(crate) fn field_to_member<'s>(
303 &self,
304 schema: &'s Schema<'s>,
305 field_name: &str,
306 ) -> Option<&'s Schema<'s>> {
307 self.field_mapper.field_to_member(schema, field_name)
308 }
309}
310
311impl Default for JsonCodecSettings {
312 fn default() -> Self {
313 Self {
314 field_mapper: JsonFieldMapper::UseJsonName,
315 default_timestamp_format: TimestampFormat::EpochSeconds,
316 max_depth: crate::codec::deserializer::MAX_DESERIALIZE_DEPTH,
317 enforce_strictness: false,
318 validate_skipped_values: true,
319 validate_skipped_string_encoding: false,
320 allow_integral_float_numbers: false,
321 allow_leading_zeros: false,
322 allow_trailing_decimal_point: false,
323 allow_empty_integer_part: false,
324 strict_timestamp_formats: false,
325 dispatch_null_members: false,
326 protocol_id: DEFAULT_JSON_CODEC_ID,
327 use_string_for_arbitrary_precision: false,
328 normalize_arbitrary_precision: false,
329 default_namespace: None,
330 }
331 }
332}
333
334/// Sentinel protocol id used by `JsonCodecSettings` when none is
335/// supplied — surfaces in `DocumentSettings` diagnostics. AWS
336/// protocols override this with their own ids.
337const DEFAULT_JSON_CODEC_ID: ShapeId<'static> = shape_id!("aws.smithy.json", "JsonCodec");
338
339/// Selects the timestamp [`Format`](aws_smithy_types::date_time::Format) used
340/// to parse a *string*-encoded timestamp, given a codec's configured default
341/// format.
342///
343/// A JSON string never encodes `epoch-seconds` (a number), so every default
344/// other than `http-date` resolves to the offset-aware `date-time` form — the
345/// common JSON string-timestamp encoding and a lenient superset of strict
346/// `date-time`. `http-date` is used as-is.
347///
348/// Shared by the untyped-document coercion path (`coerce_string_to_timestamp`)
349/// and the schema-based read path (`read_timestamp`) so the two string-
350/// timestamp paths cannot drift.
351pub(crate) fn string_timestamp_format(default: TimestampFormat) -> TimestampFormat {
352 match default {
353 TimestampFormat::HttpDate => TimestampFormat::HttpDate,
354 _ => TimestampFormat::DateTimeWithOffset,
355 }
356}
357
358/// Builder for [`JsonCodecSettings`].
359#[derive(Debug, Clone)]
360pub struct JsonCodecSettingsBuilder {
361 use_json_name: bool,
362 default_timestamp_format: TimestampFormat,
363 max_depth: u32,
364 strict_timestamp_formats: bool,
365 dispatch_null_members: bool,
366 enforce_strictness: bool,
367 validate_skipped_values: bool,
368 validate_skipped_string_encoding: bool,
369 allow_integral_float_numbers: bool,
370 allow_leading_zeros: bool,
371 allow_trailing_decimal_point: bool,
372 allow_empty_integer_part: bool,
373 protocol_id: ShapeId<'static>,
374 use_string_for_arbitrary_precision: bool,
375 normalize_arbitrary_precision: bool,
376 default_namespace: Option<String>,
377}
378
379impl Default for JsonCodecSettingsBuilder {
380 fn default() -> Self {
381 Self {
382 use_json_name: true,
383 default_timestamp_format: TimestampFormat::EpochSeconds,
384 max_depth: crate::codec::deserializer::MAX_DESERIALIZE_DEPTH,
385 enforce_strictness: false,
386 validate_skipped_values: true,
387 validate_skipped_string_encoding: false,
388 allow_integral_float_numbers: false,
389 allow_leading_zeros: false,
390 allow_trailing_decimal_point: false,
391 allow_empty_integer_part: false,
392 strict_timestamp_formats: false,
393 dispatch_null_members: false,
394 protocol_id: DEFAULT_JSON_CODEC_ID,
395 use_string_for_arbitrary_precision: false,
396 normalize_arbitrary_precision: false,
397 default_namespace: None,
398 }
399 }
400}
401
402impl JsonCodecSettingsBuilder {
403 /// Enforces JSON grammar and request document validity. Disabled by default.
404 pub fn enforce_strictness(mut self, value: bool) -> Self {
405 self.enforce_strictness = value;
406 self
407 }
408
409 /// Whether discarded string contents are validated. Enabled by default.
410 ///
411 /// When enabled, escape syntax is checked without decoding Unicode escapes.
412 /// `enforce_strictness` applies only to values read. When disabled, strings and keys inside
413 /// discarded objects are scanned without decoding or validating escapes.
414 /// Container syntax, number/literal grammar, truncation, and depth limits
415 /// are still checked. Values actually read are unaffected.
416 pub fn validate_skipped_values(mut self, value: bool) -> Self {
417 self.validate_skipped_values = value;
418 self
419 }
420
421 /// Checks discarded strings for UTF-8 and unescaped control characters without
422 /// decoding or validating their escape sequences. Disabled by default.
423 pub fn validate_skipped_string_encoding(mut self, value: bool) -> Self {
424 self.validate_skipped_string_encoding = value;
425 self
426 }
427
428 /// Accepts exactly integral decimal/exponent numbers for integer members.
429 /// Disabled by default; ordinary integer spellings retain exact parsing.
430 pub fn allow_integral_float_numbers(mut self, value: bool) -> Self {
431 self.allow_integral_float_numbers = value;
432 self
433 }
434
435 /// Permits leading zeros in JSON numbers even with `enforce_strictness(true)`.
436 /// Disabled by default. This matches the legacy JSON parser without relaxing
437 /// other grammar checks. Applies to read and skipped numbers alike.
438 pub fn allow_leading_zeros(mut self, value: bool) -> Self {
439 self.allow_leading_zeros = value;
440 self
441 }
442
443 /// Permits an empty fraction after a decimal point, such as `12.` or `12.e2`,
444 /// even with `enforce_strictness(true)`. Disabled by default. Integer members
445 /// additionally require `allow_integral_float_numbers(true)` and a whole value.
446 /// Applies to read and skipped numbers alike.
447 pub fn allow_trailing_decimal_point(mut self, value: bool) -> Self {
448 self.allow_trailing_decimal_point = value;
449 self
450 }
451
452 /// Permits negative fractions with no integer digits, such as `-.123` or
453 /// `-.123e2`, even with `enforce_strictness(true)`. Disabled by default.
454 /// Under strict number validation, `.123`, `-.`, and `-.e2` remain invalid.
455 /// Applies to read and skipped numbers alike.
456 pub fn allow_empty_integer_part(mut self, value: bool) -> Self {
457 self.allow_empty_integer_part = value;
458 self
459 }
460
461 /// Whether to use the `@jsonName` trait for member names.
462 pub fn use_json_name(mut self, value: bool) -> Self {
463 self.use_json_name = value;
464 self
465 }
466
467 /// Default timestamp format when not specified by `@timestampFormat` trait.
468 pub fn default_timestamp_format(mut self, value: TimestampFormat) -> Self {
469 self.default_timestamp_format = value;
470 self
471 }
472
473 /// Sets the maximum aggregate nesting depth the deserializer will accept
474 /// before returning an error. Defaults to 128.
475 pub fn max_depth(mut self, value: u32) -> Self {
476 self.max_depth = value;
477 self
478 }
479
480 /// Require timestamps to use exactly the wire form their resolved format
481 /// prescribes: a JSON number for `epoch-seconds`, an RFC 3339 string without
482 /// a UTC offset for `date-time`, an IMF-fixdate string for `http-date`.
483 /// Off by default; when enabled, any other form is rejected.
484 pub fn strict_timestamp_formats(mut self, value: bool) -> Self {
485 self.strict_timestamp_formats = value;
486 self
487 }
488
489 /// Pass null structure members to the consumer. Defaults to false, preserving existing
490 /// codec callers; schema server readers decide how each null affects their builders.
491 pub fn dispatch_null_members(mut self, value: bool) -> Self {
492 self.dispatch_null_members = value;
493 self
494 }
495
496 /// Sets the protocol id surfaced in `DocumentSettings`
497 /// diagnostics. Defaults to `aws.smithy.json#JsonCodec`. AWS
498 /// protocols (awsJson1_0, awsJson1_1, restJson1) supply their own
499 /// shape id so coercion errors point at the originating protocol.
500 pub fn protocol_id(mut self, value: ShapeId<'static>) -> Self {
501 self.protocol_id = value;
502 self
503 }
504
505 /// Configures whether `bigInteger` and `bigDecimal` shapes
506 /// serialize as JSON strings (when `true`) or as raw JSON numbers
507 /// (when `false`, the default).
508 ///
509 /// This is schema-driven: a `Document` carries no schema and has no
510 /// arbitrary-precision variant, so `write_document` is unaffected.
511 ///
512 /// Set to `true` for interop with receivers using `f64`-routed
513 /// JSON parsers that would otherwise lose precision on large
514 /// integers or decimals. The read path always accepts both forms.
515 pub fn use_string_for_arbitrary_precision(mut self, value: bool) -> Self {
516 self.use_string_for_arbitrary_precision = value;
517 self
518 }
519
520 /// Rewrites arbitrary-precision text that is a decimal but not a JSON number (`+007`, `.5`,
521 /// `1.`) to the equivalent JSON number instead of rejecting it. Defaults to `false`, so
522 /// existing callers keep rejecting it; AWS servers set it to `true`, matching their lenient
523 /// handling of such numbers on the read path (`allow_leading_zeros`,
524 /// `allow_trailing_decimal_point`).
525 pub fn normalize_arbitrary_precision(mut self, value: bool) -> Self {
526 self.normalize_arbitrary_precision = value;
527 self
528 }
529
530 /// Sets the default Smithy namespace used to resolve relative shape
531 /// IDs in JSON `__type` discriminator fields. When set, a relative
532 /// `__type: "Capacity"` is lifted to `<namespace>#Capacity` on the
533 /// resulting [`DiscriminatedDocument`](aws_smithy_types::DiscriminatedDocument). Absolute `__type` values
534 /// (already containing `#`) are taken as-is regardless.
535 ///
536 /// Defaults to `None` — relative names are not lifted.
537 pub fn default_namespace(mut self, namespace: impl Into<String>) -> Self {
538 self.default_namespace = Some(namespace.into());
539 self
540 }
541
542 /// Builds the settings.
543 pub fn build(self) -> JsonCodecSettings {
544 let field_mapper = if self.use_json_name {
545 JsonFieldMapper::UseJsonName
546 } else {
547 JsonFieldMapper::UseMemberName
548 };
549 JsonCodecSettings {
550 field_mapper,
551 default_timestamp_format: self.default_timestamp_format,
552 max_depth: self.max_depth,
553 enforce_strictness: self.enforce_strictness,
554 validate_skipped_values: self.validate_skipped_values,
555 validate_skipped_string_encoding: self.validate_skipped_string_encoding,
556 allow_integral_float_numbers: self.allow_integral_float_numbers,
557 allow_leading_zeros: self.allow_leading_zeros,
558 allow_trailing_decimal_point: self.allow_trailing_decimal_point,
559 allow_empty_integer_part: self.allow_empty_integer_part,
560 strict_timestamp_formats: self.strict_timestamp_formats,
561 dispatch_null_members: self.dispatch_null_members,
562 protocol_id: self.protocol_id,
563 use_string_for_arbitrary_precision: self.use_string_for_arbitrary_precision,
564 normalize_arbitrary_precision: self.normalize_arbitrary_precision,
565 default_namespace: self.default_namespace,
566 }
567 }
568}
569
570impl DocumentSettings for JsonCodecSettings {
571 fn protocol_id(&self) -> &str {
572 // The internal field is `ShapeId<'static>` (preserved for
573 // ergonomic construction via `shape_id!`); the trait surface
574 // returns the FQN string per [`DocumentSettings`].
575 self.protocol_id.as_str()
576 }
577
578 /// Decodes a base64 string into a blob.
579 ///
580 /// JSON has no native blob type; the
581 /// [Smithy spec](https://smithy.io/2.0/spec/simple-types.html#blob)
582 /// requires blobs to be transmitted as base64-encoded strings on
583 /// the JSON wire. This method consumes those strings on the
584 /// deserialization side; the corresponding encode happens in
585 /// `JsonSerializer::write_document`.
586 fn coerce_string_to_blob(&self, s: &str) -> Result<Vec<u8>, DocumentError> {
587 aws_smithy_types::base64::decode(s)
588 .map_err(|e| DocumentError::invalid_input(format!("base64 decode failed: {e}")))
589 }
590
591 /// Parses a string-formatted timestamp using the codec's default
592 /// timestamp format ([`JsonCodecSettings::default_timestamp_format`]).
593 ///
594 /// Per the SEP, untyped JSON documents have no schema-level
595 /// `@timestampFormat` trait to consult, so the codec's default
596 /// format is the only signal. AWS JSON / restJson1 default to
597 /// `epoch-seconds` — but a string-typed JSON value cannot be
598 /// epoch-seconds (which is a number). For this case we attempt
599 /// `date-time` parsing as a fallback so common ISO-8601 strings
600 /// still coerce.
601 fn coerce_string_to_timestamp(&self, s: &str) -> Result<DateTime, DocumentError> {
602 // A JSON string can't encode the number-typed `epoch-seconds`, so the
603 // parse format is resolved via the shared `string_timestamp_format`
604 // helper (also used by the schema-based read path).
605 let format = string_timestamp_format(self.default_timestamp_format);
606 DateTime::from_str(s, format)
607 .map_err(|e| DocumentError::invalid_input(format!("timestamp parse failed: {e}")))
608 }
609
610 /// Coerces a number to a timestamp interpreted as
611 /// `epoch-seconds`.
612 ///
613 /// Number-typed JSON values are only valid timestamps when the
614 /// codec's default format is `epoch-seconds`. Per the SEP this is
615 /// the AWS JSON / restJson1 default. If the codec has been
616 /// configured with a non-numeric default format
617 /// (`date-time` / `http-date`), this method returns an
618 /// `UnsupportedOperation` error — a number value isn't valid for
619 /// those formats.
620 fn coerce_number_to_timestamp(&self, n: &Number) -> Result<DateTime, DocumentError> {
621 if !matches!(self.default_timestamp_format, TimestampFormat::EpochSeconds) {
622 return Err(DocumentError::unsupported(format!(
623 "JSON codec configured with timestamp format {:?}; \
624 number-to-timestamp coercion only valid for epoch-seconds",
625 self.default_timestamp_format
626 )));
627 }
628 Ok(match *n {
629 Number::PosInt(u) => {
630 if u > i64::MAX as u64 {
631 return Err(DocumentError::invalid_input(format!(
632 "epoch-seconds value {u} overflows i64; cannot construct DateTime"
633 )));
634 }
635 DateTime::from_secs(u as i64)
636 }
637 Number::NegInt(i) => DateTime::from_secs(i),
638 Number::Float(f) => {
639 if !f.is_finite() {
640 return Err(DocumentError::invalid_input(format!(
641 "non-finite epoch-seconds value {f}; cannot construct DateTime"
642 )));
643 }
644 DateTime::from_secs_f64(f)
645 }
646 })
647 }
648}
649
650/// JSON codec for schema-based serialization and deserialization.
651///
652/// # Examples
653///
654/// ```
655/// use aws_smithy_json::codec::{JsonCodec, JsonCodecSettings};
656/// use aws_smithy_schema::codec::Codec;
657///
658/// // Create codec with default settings (REST JSON style)
659/// let codec = JsonCodec::new(JsonCodecSettings::default());
660///
661/// // Create codec for AWS JSON RPC (no jsonName, epoch-seconds timestamps)
662/// let codec = JsonCodec::new(
663/// JsonCodecSettings::builder()
664/// .use_json_name(false)
665/// .build()
666/// );
667/// ```
668#[derive(Debug)]
669pub struct JsonCodec {
670 settings: Arc<JsonCodecSettings>,
671}
672
673impl JsonCodec {
674 /// Creates a new JSON codec with the given settings.
675 pub fn new(settings: JsonCodecSettings) -> Self {
676 Self {
677 settings: Arc::new(settings),
678 }
679 }
680
681 /// Returns the codec settings.
682 pub fn settings(&self) -> &JsonCodecSettings {
683 &self.settings
684 }
685}
686
687impl Default for JsonCodec {
688 fn default() -> Self {
689 Self::new(JsonCodecSettings::default())
690 }
691}
692
693impl Codec for JsonCodec {
694 type Serializer = JsonSerializer;
695 type Deserializer<'a> = JsonDeserializer<'a>;
696
697 fn create_serializer(&self) -> Self::Serializer {
698 JsonSerializer::new(self.settings.clone())
699 }
700
701 fn create_deserializer<'a>(&self, input: &'a [u8]) -> Self::Deserializer<'a> {
702 JsonDeserializer::new(input, self.settings.clone())
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn test_default_settings() {
712 let settings = JsonCodecSettings::default();
713 assert_eq!(
714 settings.default_timestamp_format(),
715 TimestampFormat::EpochSeconds
716 );
717 }
718
719 #[test]
720 fn test_builder() {
721 let settings = JsonCodecSettings::builder()
722 .use_json_name(false)
723 .default_timestamp_format(TimestampFormat::DateTime)
724 .build();
725 assert_eq!(
726 settings.default_timestamp_format(),
727 TimestampFormat::DateTime
728 );
729 }
730
731 #[test]
732 fn test_codec_creation() {
733 let codec = JsonCodec::default();
734 let _serializer = codec.create_serializer();
735 let _deserializer = codec.create_deserializer(b"{}");
736 }
737
738 #[test]
739 fn document_settings_protocol_id_default_is_codec_sentinel() {
740 let settings = JsonCodecSettings::default();
741 assert_eq!(
742 DocumentSettings::protocol_id(&settings),
743 "aws.smithy.json#JsonCodec"
744 );
745 }
746
747 #[test]
748 fn document_settings_protocol_id_can_be_overridden() {
749 let settings = JsonCodecSettings::builder()
750 .protocol_id(shape_id!("aws.protocols", "restJson1"))
751 .build();
752 assert_eq!(
753 DocumentSettings::protocol_id(&settings),
754 "aws.protocols#restJson1"
755 );
756 }
757
758 #[test]
759 fn document_settings_coerce_string_to_blob_decodes_base64() {
760 let settings = JsonCodecSettings::default();
761 // base64 of "hello"
762 let bytes = settings.coerce_string_to_blob("aGVsbG8=").unwrap();
763 assert_eq!(bytes, b"hello");
764 }
765
766 #[test]
767 fn document_settings_coerce_string_to_blob_returns_error_on_invalid() {
768 let settings = JsonCodecSettings::default();
769 let err = settings
770 .coerce_string_to_blob("not!valid!base64!")
771 .unwrap_err();
772 match err {
773 DocumentError::InvalidInput { .. } => {}
774 other => panic!("expected DocumentError::InvalidInput, got {other:?}"),
775 }
776 }
777
778 #[test]
779 fn document_settings_coerce_string_to_timestamp_with_date_time_default() {
780 let settings = JsonCodecSettings::builder()
781 .default_timestamp_format(TimestampFormat::DateTime)
782 .build();
783 let dt = settings
784 .coerce_string_to_timestamp("1970-01-01T00:00:00Z")
785 .unwrap();
786 assert_eq!(dt.secs(), 0);
787 }
788
789 #[test]
790 fn document_settings_coerce_string_to_timestamp_falls_back_to_date_time_for_epoch_default() {
791 // Default format is EpochSeconds (numeric); a string can't
792 // satisfy that, so the impl falls back to date-time parsing.
793 let settings = JsonCodecSettings::default();
794 let dt = settings
795 .coerce_string_to_timestamp("1970-01-01T00:00:00Z")
796 .unwrap();
797 assert_eq!(dt.secs(), 0);
798 }
799
800 #[test]
801 fn document_settings_coerce_string_to_timestamp_error() {
802 let settings = JsonCodecSettings::default();
803 let err = settings
804 .coerce_string_to_timestamp("not a timestamp")
805 .unwrap_err();
806 match err {
807 DocumentError::InvalidInput { .. } => {}
808 other => panic!("expected DocumentError::InvalidInput, got {other:?}"),
809 }
810 }
811
812 #[test]
813 fn document_settings_coerce_number_to_timestamp_epoch_seconds() {
814 let settings = JsonCodecSettings::default();
815 // PosInt
816 let dt = settings
817 .coerce_number_to_timestamp(&Number::PosInt(1_700_000_000))
818 .unwrap();
819 assert_eq!(dt.secs(), 1_700_000_000);
820 // NegInt (pre-epoch)
821 let dt = settings
822 .coerce_number_to_timestamp(&Number::NegInt(-100))
823 .unwrap();
824 assert_eq!(dt.secs(), -100);
825 // Float (fractional seconds)
826 let dt = settings
827 .coerce_number_to_timestamp(&Number::Float(1.5))
828 .unwrap();
829 assert_eq!(dt.secs(), 1);
830 assert_eq!(dt.subsec_nanos(), 500_000_000);
831 }
832
833 #[test]
834 fn document_settings_coerce_number_to_timestamp_unsupported_for_string_format() {
835 let settings = JsonCodecSettings::builder()
836 .default_timestamp_format(TimestampFormat::DateTime)
837 .build();
838 let err = settings
839 .coerce_number_to_timestamp(&Number::PosInt(0))
840 .unwrap_err();
841 match err {
842 DocumentError::UnsupportedOperation { .. } => {}
843 other => panic!("expected DocumentError::UnsupportedOperation, got {other:?}"),
844 }
845 }
846
847 #[test]
848 fn document_settings_coerce_number_to_timestamp_rejects_non_finite_float() {
849 let settings = JsonCodecSettings::default();
850 let err = settings
851 .coerce_number_to_timestamp(&Number::Float(f64::NAN))
852 .unwrap_err();
853 match err {
854 DocumentError::InvalidInput { .. } => {}
855 other => panic!("expected DocumentError::InvalidInput, got {other:?}"),
856 }
857 }
858
859 #[test]
860 fn to_builder_round_trips_every_setting() {
861 // Regression guard: `to_builder` names each field explicitly, so a
862 // setting added later is silently reset to its default here while still
863 // compiling. That is how a codec rebuilt by a protocol wrapper (see
864 // `protocol::codec_with_bag_namespace`, which overrides only
865 // `default_namespace`) can lose strictness. Every setting below is set
866 // to the OPPOSITE of its default so a dropped field fails this test.
867 let original = JsonCodecSettings::builder()
868 .use_json_name(false)
869 .default_timestamp_format(TimestampFormat::HttpDate)
870 .max_depth(7)
871 .protocol_id(shape_id!("aws.protocols", "restJson1"))
872 .use_string_for_arbitrary_precision(true)
873 .default_namespace("com.example")
874 .enforce_strictness(true)
875 .validate_skipped_values(false)
876 .allow_integral_float_numbers(true)
877 .allow_leading_zeros(true)
878 .allow_trailing_decimal_point(true)
879 .allow_empty_integer_part(true)
880 .strict_timestamp_formats(true)
881 .dispatch_null_members(true)
882 .build();
883
884 let round_tripped = original.to_builder().build();
885
886 assert!(matches!(
887 round_tripped.field_mapper,
888 JsonFieldMapper::UseMemberName
889 ));
890 assert_eq!(
891 round_tripped.default_timestamp_format(),
892 TimestampFormat::HttpDate
893 );
894 assert_eq!(round_tripped.max_depth(), 7);
895 assert_eq!(
896 DocumentSettings::protocol_id(&round_tripped),
897 "aws.protocols#restJson1"
898 );
899 assert!(round_tripped.use_string_for_arbitrary_precision());
900 assert_eq!(round_tripped.default_namespace(), Some("com.example"));
901 assert!(round_tripped.enforce_strictness);
902 assert!(!round_tripped.validate_skipped_values);
903 assert!(round_tripped.allow_integral_float_numbers);
904 assert!(round_tripped.allow_leading_zeros);
905 assert!(round_tripped.allow_trailing_decimal_point);
906 assert!(round_tripped.allow_empty_integer_part);
907 assert!(round_tripped.strict_timestamp_formats());
908 assert!(round_tripped.dispatch_null_members());
909 }
910}