Skip to content

Schema Serde Document type & Codec Settings - #3947

Draft
sbaluja wants to merge 12 commits into
mainfrom
document-type
Draft

sbaluja wants to merge 12 commits into
mainfrom
document-type

Conversation

@sbaluja

@sbaluja sbaluja commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Issue #, if available:

Description of changes:

  • Added smithy::schema::Document (open-content value). JSON builds and serializes it, CBOR/XML/Query reject or ignore it per protocol.
  • JSON document coercion: base64 string to blob, string/number to timestamp, plus numeric overflow/UB guards (ReadLong clamps, AsFloat/AsLong range-guarded).
  • Added CodecSettings: per-protocol default @timestampFormat. Format is required (no default construction, explicit ctor).
  • Threaded CodecSettings through the JSON, XML, and Query serializers and deserializers. Removed the scattered hardcoded date-time/epoch literals so there's one source per protocol.
  • Gated document string/number to timestamp coercion by the configured format (SEP): string only under date-time/http-date, number only under epoch.
  • CBOR ignores @timestampFormat (epoch only), locked in with a test.
  • JSON floats serialize at float precision, doubles keep a .0 marker so the type round-trips.

Check all that applies:

  • Did a review by yourself.
  • Added proper tests to cover this PR. (If tests are not applicable, explain.)
  • Checked if this PR is a breaking (APIs have been changed) change.
  • Checked if this PR will not introduce cross-platform inconsistent behavior.
  • Checked if this PR would require a ReadMe/Wiki update.

Check which platforms you have built SDK on to verify the correctness of this PR.

  • Linux
  • Windows
  • Android
  • MacOS
  • IOS
  • Other Platforms

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Adds an open-content document value type to the schema-serde runtime and
wires it through the JSON/CBOR/XML/Query serializers and deserializers.

- smithy::schema::Document: a copyable value handle over a polymorphic,
  AWS_CORE_LOCAL DocumentImpl. The agnostic base serves serialize-built (and
  future CBOR) documents; JsonDocumentImpl adds JSON string->blob and
  string/number->timestamp coercion, parameterized by the protocol-default
  timestamp format threaded from JsonCodec.
- Serialization: JSON and CBOR emit documents; REST-XML, AWS/Query, and
  EC2/Query reject with a SerializationException.
- Deserialization: JSON builds the document tree; CBOR and XML reject (CBOR
  ReadDocument consumes the value so sibling members stay aligned).
- JSON numeric safety: ReadLong's double fallback rejects non-finite /
  out-of-range doubles instead of casting them (undefined behavior), while a
  plain-integer overflow still clamps to INT64_MAX/MIN; open-content document
  integer overflow preserves magnitude as a double; Document::AsFloat rejects
  finite doubles beyond float range (out-of-range double->float is undefined
  behavior).
- JSON floats and doubles serialize with shortest-round-trip precision (float
  precision for floats, not the double expansion of their imprecision) and a
  decimal marker, so a document float or double round-trips without losing
  precision or type.

Deferred (documented): map insertion order, int/bigInteger reporting,
unsigned 64-bit, a nesting depth cap of 64, and the tagged-union node layout.
class Schema;

// Per-protocol timestamp default threaded to a codec's serializer and deserializer.
struct CodecSettings {

@pulimsr pulimsr Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: could we add useJsonName and useTimestampFormat booleans to CodecSettings, like smithy-java's JsonCodecbuilder. Fine as a follow-up.

}

private:
static constexpr int MAX_DOCUMENT_DEPTH = 64;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_DOCUMENT_DEPTH = 64 may be too low: cJSON (Aws::Utils::Document) allows 1000

m_bool = value;
}

void DocumentImpl::SetInteger(int64_t value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetInteger always reports ShapeType::Long, so a JSON 0 comes back as Long. I think it should report the first type that holds the value without loss, in this order: Integer, Long, BigInteger, Double, BigDecimal, like smithy-java (parsing, type mapping).

// Per-protocol timestamp default threaded to a codec's serializer and deserializer.
struct CodecSettings {
explicit CodecSettings(TimestampFormatTrait::Format defaultTimestampFormat) : defaultTimestampFormat(defaultTimestampFormat) {}
TimestampFormatTrait::Format defaultTimestampFormat;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit prolly should be m_timestampForamt

Document MakeDocument(std::shared_ptr<const DocumentImpl> impl) { return Document(std::move(impl)); }
} // namespace detail

Document Document::Null() { return detail::MakeDocument(DocumentImpl::MakeNull()); }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like DocumentImpl:: we shoudlnt be accessing this things statically if we have a pointer to implementation in this class. it feels like we are side-caring static functions in polymorphic containter, where we should just be delegating to the underlying implementation.

return HashingUtils::Base64Decode(*encoded);
}

Aws::Crt::Optional<Document> ReadDocument(const Schema&) override { return ReadDocumentValue(0); }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other reads all skip the value before returning empty when they fail, like ReadLong and ReadBoolean both call SkipValue first. ReadDocument doesn't, so if ReadDocumentValue fails partway through we're left sitting in the middle of the value and the struct reader just keeps going from there. so something like

{"doc": [[[[ ...65 levels... ]]]], "name": "bob"}

hits the depth limit and returns empty, which is fine for doc, but then ReadStruct sees the leftover ], thinks the object is done, and name never gets read. no error either, the data's just missing. pretty niche since you need a 64+ deep document but it's an easy fix to match the other reads

Aws::Crt::Optional<Document> ReadDocument(const Schema&) override {
  const size_t start = m_pos;
  auto doc = ReadDocumentValue(0);
  if (!doc.has_value()) {
    m_pos = start;
    SkipValue();
  }
  return doc;
}

might also be worth adding a member after the document in ReadDocumentRejectsExcessiveNesting, since right now it only checks a document on its own

class Document;
class DocumentImpl;
namespace detail {
Document MakeDocument(std::shared_ptr<const DocumentImpl> impl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this looks like a pimpl but it's not really one. DocumentImpl.h is public, AsList and AsMap hand back pointers into its storage, and detail::MakeDocument lets anyone make one anyway. i think what's really going on is there's an interface hiding in here.

in smithy-java Document is an interface and each codec has its own implementation. the json deserializer makes its documents with JsonDocuments.of(value, settings) and that's how they know to base64 decode strings and parse timestamps. the From statics here return Document by value so it has to be a concrete class, and the interface ends up buried behind virtual calls on DocumentImpl. that's also why you can have two documents that are == but give you different answers from AsBlob.

i think we should just make it an interface and let each codec own its document type, something like

// Document.h
class SMITHY_API Document {
 public:
  virtual ~Document() = default;
  virtual Aws::Crt::Optional<Aws::Utils::ByteBuffer> AsBlob() const = 0;

  static std::shared_ptr<const Document> FromString(Aws::String value);
};

// Document.cpp
namespace {
class PlainDoc final : public Document { /* only returns a blob if it is a blob */ };
}

// JsonShapeDeserializer.cpp
namespace {
class JsonDoc final : public Document { /* base64 decodes the string */ };
}

and using it would look like

// built by hand, no protocol rules
auto plain = Document::FromString("aGk=");
plain->AsBlob();  // empty

// read off the wire, the codec passes its settings to the deserializer which makes a JsonDoc
JsonCodec codec{settings};
auto deserializer = codec.CreateDeserializer(body);
auto parsed = deserializer->ReadDocument(schema);
parsed->AsBlob();  // {'h','i'}

that way json's rules live with json and cbor can do its own thing later.

Document was shaped like a PIMPL but was not one: DocumentImpl was a public
header, AsList/AsMap handed back pointers into its storage, and
detail::MakeDocument let any caller build a Document over any impl. Coercion
behavior was therefore smuggled through a hidden interface -- two documents
could compare equal yet disagree on AsBlob().

Document is now a public abstract interface. AbstractDocument (internal, not
installed) holds the tagged storage and the protocol-independent behavior,
leaving AsBlob/AsTimestamp pure so a node cannot exist without a coercion
rule. The concrete leaves live in Document.cpp's anonymous namespace:
PlainDocument applies no reinterpretation and backs the From* factories,
while JsonDocument base64-decodes strings and gates timestamp coercion on
the configured format. The JSON deserializer builds its nodes through
NewJsonDocument, so neither leaf is nameable outside that translation unit.

ReadDocument now returns shared_ptr<const Document> and document collections
carry shared_ptr<const Document>. Coercion, timestamp-format gating, CBOR
trait-ignore adherence, and the numeric overflow guards are unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants