feat(JSON): add skip_null_fields option to omit null-valued record fields#44
Open
ggreif wants to merge 6 commits intoNatLabs:mainfrom
Open
feat(JSON): add skip_null_fields option to omit null-valued record fields#44ggreif wants to merge 6 commits intoNatLabs:mainfrom
ggreif wants to merge 6 commits intoNatLabs:mainfrom
Conversation
…elds External HTTP APIs frequently reject payloads that carry optional fields as explicit null (e.g. Twitter's /2/tweets rejects 16 such entries in a single request). Motoko records serialised through `to_candid |> JSON.toText` always emit every field, including `?T = null`, which hits exactly that rejection case for any OpenAPI-style client. This commit adds a new `skip_null_fields : Bool` option to `CandidType.Options`. When `true`, the JSON encoder omits entries in `#Record`/`#Map` whose value resolves to `#Null`. Default is `false` — all existing behaviour is preserved. Also exposes `fromCandidWith` so callers working with an already- decoded Candid value can request the same behaviour without going through `toText`. Test: `tests/JSON.Test.mo` verifies (a) default keeps the nulls, (b) flag drops them, (c) non-null optionals still survive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Complements the JSON encoder's treatment of the new `Options.skip_null_fields` flag. Both CBOR and UrlEncoded walk `#Record`/`#Map` unconditionally today, emitting CBOR null and `key=null` respectively for `?T = null` optional fields; those land in outbound HTTP bodies and trip the same class of strict type-validator rejections (e.g. Twitter /2/tweets). * CBOR `transpile_candid_to_cbor`: skip record entries whose value encodes to `#majorType7(#_null)` when `options.skip_null_fields` is true. * UrlEncoded: introduce `fromCandidWith(candid, skip_null_fields)` (keeping `fromCandid` as the default-behaviour wrapper), thread the flag through `toKeyValuePairs`, and elide the `key=null` pair at the `#Null` leaf. Tests added in tests/CBOR.Test.mo and tests/UrlEncoded.Test.mo verify both default (keep nulls) and opt-in (omit) paths; 10/10 test files pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
|
Extended the patch to CBOR and UrlEncoded as well — both had the same unconditional record/map walk that emits null-valued optional fields (CBOR as
Tests added in Commit: a63d60b. |
ggreif
added a commit
to caffeinelabs/x-client
that referenced
this pull request
Apr 20, 2026
…dies Twitter's /2/tweets (and other strict X endpoints) reject payloads that carry optional fields as explicit null. The generated toJSON always emitted every ?T field, which made valid-looking Motoko calls unable to produce accepted requests. This regen swaps the JSON serialisation dependency from serde@3.5.0 to serde-core@0.1.0 (a fork carrying NatLabs/serde#44) and threads `?{ Candid.defaultOptions with skip_null_fields = true }` through every JSON.toText call in the API layer. Null-valued entries are now omitted from outbound JSON, matching how external schemas read "field absent". Public Motoko API of this client is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ggreif
added a commit
to ggreif/openapi-generator
that referenced
this pull request
Apr 20, 2026
- modules/.../motoko/mops.toml.mustache: swap serde@3.5.0 for serde-core@0.1.0, a ggreif/serde fork carrying the `skip_null_fields` JSON option pending NatLabs/serde#44. - modules/.../motoko/api.mustache: import `JSON` and `Candid` from `mo:serde-core`; pass `?{ Candid.defaultOptions with skip_null_fields = true }` as the options argument to every `JSON.toText` call. Null-valued optional fields no longer appear in outbound JSON, unblocking strict-validation endpoints such as Twitter's `/2/tweets` (16 fields were being rejected there). - bin/configs/motoko-x.yaml: bump to 0.1.2. - samples/client/x/motoko/generate.sh: align prologue with OpenAI's style (set -euo pipefail, explicit SCRIPT_DIR/ REPO_ROOT, --skip-validate-spec) — the previous version had `cd ../../..` which only climbed three levels, silently failing to find the JAR. - samples/client/x/motoko/typecheck.sh: pin the tmp-build moc toolchain to 1.4.1 (was 1.3.0, pre-Float32 — `core@2.4.0` uses Float32 and failed to build). - samples/client/x/motoko/generated: submodule bump to v0.1.2 (caffeinelabs/x-client@bfd7cd3, published to mops). Public Motoko API of x-client is unchanged; this is a patch-level bugfix release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nfinite re-expansion
`_build_compound_type` only marked a recursive `pos` and returned `#Recursive(pos)`
the *first* time a cycle was detected. The guard
if (Set.has(visited, pos) and not Set.has(is_recursive_set, pos))
falls through for sibling references to the same recursive node (the second
`left`/`right` field of an RBT, etc.), and the function re-descends into the
cyclic body — unbounded recursion.
Trigger: any `to_candid(value)` where the type table contains a self-referential
type referenced from two sibling fields. Hits in practice on `Map<K, V>` from
`mo:core/pure/Map` (left + right children both point back to the variant root),
and therefore on every `JSON.toText(to_candid(req), ...)` call where `req`
carries a `?Map<_,_>` field — including OpenAI's `CreateChatCompletionRequest`
(`metadata`, `logit_bias`).
Fix: short-circuit when `pos` is already in `is_recursive_set`, before the
visited-detection branch that flips it on for the first time.
Regression test in tests/CyclicTypeTable.test.mo reproduces the unbounded
recursion against the unfixed decoder (wasmtime trap: call stack exhausted)
and passes against the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the Decoder cycle-detection fix from 3afab63: any consumer that serialises a value containing `Map<K, V>` (e.g. OpenAI's CreateChatCompletionRequest with `metadata` and `logit_bias` of type `?Map<Text, …>`) no longer traps with "call stack exhausted" while walking the recursive type table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additive re-exports so consumers don't have to reach into `Candid.Decoder` / `JSON.ToText` directly: - `Candid.decodeOne` — Result<Candid, Text> companion to `Candid.decode`, for the (very common) one-value Candid blob case. Previously only `Candid.Decoder.decodeOne` was reachable. - `JSON.fromCandidWith` — variant of `JSON.fromCandid` that takes a `skip_null_fields : Bool` parameter. Useful when serialising a Candid ADT value (no blob roundtrip) and you still want skip-null behaviour. Pure additions; no behaviour change on existing surface. Full test suite (11 files) green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…typo
Two bugs in the vendored parser-combinator stack that together prevented
parsing any JSON string containing a `\u`-escaped non-ASCII character:
1) submodules/parser-combinators.mo `Character.hex()` had a typo:
'A' <= x and x <= 'A'
accepted only the literal `'A'`, rejecting B–F. JSON `\u` escapes for
any uppercase-hex codepoint above U+00AF (which is most of them, e.g.
any surrogate D800–DFFF) silently failed. Fixed to `<= 'F'`.
2) submodules/json.mo `character()` had a `// TODO: u hex{4}` for the
`\u` escape entirely. Implemented:
- `\u XXXX` for BMP codepoints (U+0000–U+FFFF) → `Char.fromNat32(n)`.
- Surrogate pairs for non-BMP: when `n` is a high surrogate
(D800..DBFF), expect another `\u YYYY` immediately and combine into
`0x10000 + (high-D800)*0x400 + (low-DC00)`. e.g.
`🎓` → 🎓 (U+1F393, GRADUATION CAP).
Trigger: Twitter's `/2/tweets` POST response body uses surrogate-pair
escapes for emoji that the user submitted (e.g. 🎓 → `🎓`),
which serde-core couldn't parse. `JSON.toCandid(twitterResponseText)`
returned `#err("Failed to parse JSON text")` for any tweet containing
non-BMP chars, breaking response decode for x-client end-to-end.
Regression test: tests/JSONUnicodeEscape.test.mo. All 12 test files green.
Bumps serde-core to 0.1.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
External HTTP APIs frequently reject request bodies that carry optional fields as explicit
null— they validate field types strictly and consider"field": nulla type mismatch. Twitter'sPOST /2/tweetsis a concrete example: it rejects 16 such entries in a single request when the generated client forwards a partly-filled payload.Motoko records serialised through
to_candid |> JSON.toTextalways emit every field, including?T = nullvalues, which hits exactly that rejection case for any OpenAPI-style client that mirrors a nullable-optional spec field as an?Ton the Motoko side. Today there is no option to suppress them in serde.What this PR does
Adds a new
skip_null_fields : Booloption toCandidType.Options:When
true, the JSON encoder omits entries in#Record/#Mapwhose value resolves to#Null.#Option(#Null)— which is what?T = nullbecomes after the candid round-trip — is the common case; deeply nested#Option(#Option(#Null))also collapses correctly.Defaults to
false, so all existing runtime behaviour is preserved.Also exposes
fromCandidWith(candid, skip_null_fields)for callers that already hold a decodedCandidvalue and want the same behaviour without going throughtoText.Adding a required field to the public
Optionsrecord is a type-level breaking change under Motoko's structural record typing. Code that constructsOptionsas a full record literal will no longer type-check:The idiomatic
{ Candid.defaultOptions with … }pattern is unaffected — it inherits the new field fromdefaultOptions = false. All of serde's own tests and (spot-checked) the main consumers I know of use that pattern and continue to compile and pass.Recommended version bump: 3.5.0 → 4.0.0. Happy to land the change under whichever bump the maintainers prefer.
Before / after
Tests
tests/JSON.Test.mogains askip_null_fields omits null optional fieldscase that verifies:{"text":"hello"}exactly.?false→false,?"everyone"→"everyone").nulltokens leak through.Full test suite:
npx ic-mops test— 10/10 files pass (9 previously-green + the new one).🤖 Generated with Claude Code