From d0ba4188c164d519cbb1b050ef88b435fcdd6772 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Tue, 18 Aug 2026 19:14:43 -0700 Subject: [PATCH 01/15] README: add crates.io, docs.rs, and license badges --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e2421c..6962faf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # json-serde +[![json-serde on crates.io](https://img.shields.io/crates/v/json-serde)](https://crates.io/crates/json-serde) +[![Documentation (latest release)](https://img.shields.io/badge/docs-latest%20version-brightgreen.svg)](https://docs.rs/json-serde) +[![License](https://img.shields.io/badge/license-Apache-green.svg)](LICENSE) + Runtime serde helpers for esoteric JSON semantics ## Overview From 7209f5f3ca77aff53249cecfae1ed86bef40e0a0 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 09:53:30 -0700 Subject: [PATCH 02/15] CI: add clippy and docs jobs, permissions, concurrency, caching, and pinned actions Add a clippy job (--all-targets --all-features, -D warnings) and a docs job (RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features) so lint and rustdoc regressions fail CI. Harden and streamline the workflow itself: top-level read-only permissions, a concurrency group that cancels superseded runs, and workflow_dispatch for manual runs. Pin actions/checkout by full SHA to match release.yml, and add Swatinem/rust-cache (also SHA-pinned) to the compiling jobs. Run the feature-matrix test legs on ubuntu only; windows and macos keep the default-feature leg since the features are platform-independent. --- .github/workflows/rust.yml | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f00870e..38f1b5c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,12 +8,20 @@ on: branches: [ main ] pull_request: branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: check-style: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - name: Report cargo version run: cargo --version - name: Report rustfmt version @@ -21,20 +29,46 @@ jobs: - name: Check style run: cargo fmt -- --check + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Report clippy version + run: cargo clippy --version + - name: Run clippy + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Build documentation + run: cargo doc --locked --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings + build-and-test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ ubuntu-latest, windows-latest, macos-latest ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Build run: cargo build --locked --tests --verbose - name: Run tests (no features) run: cargo test --locked --verbose + # The feature legs exercise platform-independent code; run them on + # ubuntu only and keep windows/macos to the default-feature leg. - name: Run tests (schemars 0.8) + if: matrix.os == 'ubuntu-latest' run: cargo test --locked --features schemars08 --verbose - name: Run tests (schemars 1.x) + if: matrix.os == 'ubuntu-latest' run: cargo test --locked --features schemars1 --verbose - name: Run tests (all features) + if: matrix.os == 'ubuntu-latest' run: cargo test --locked --all-features --verbose From 2298658eb66d339cd063b97d2e164dec468d50c4 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 09:55:03 -0700 Subject: [PATCH 03/15] Code cleanup from pre-publish review follow-ups Unify the FlattenedSequenceSerializer rejection messages: maps, structs, and struct variants now report the same "only supports sequence values" message as every other non-seq shape, via wrong_type_error. Make FlattenedSequenceDeserializer's rejection name the type the same way. Give FlattenedSequenceDeserializer::new the SeqAccess bound its serializer twin has, and document both new functions. Add manual Debug impls for both wrapper types (manual so they don't require S: Debug). Add #[must_use] to always; #[inline] on always, deserialize_some, and both new functions; a "# Errors" section on deserialize_some's docs. Add crate lints: #![forbid(unsafe_code)] and #![warn(missing_docs, missing_debug_implementations)]. Nits: elide the three elidable impl lifetimes, import Impossible once instead of mixing spellings, and drop a trailing comma in a test assert. --- src/lib.rs | 71 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 78db334..886e369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ // Copyright 2026 Oxide Computer Company #![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] +#![warn(missing_docs, missing_debug_implementations)] // Alias the crate under its external name so the unit tests can use the // documented attribute recipes verbatim. @@ -49,6 +51,12 @@ use serde_core::{ /// cannot be deserialized from `null`. In the second case, a `null` value /// results in `field` having a value of `Some(None)` since `Option` /// *can* be deserialized from `null`. +/// +/// # Errors +/// +/// Fails if `T` cannot be deserialized from the input--notably, when the +/// value is `null` and `T` itself does not accept `null`. +#[inline] pub fn deserialize_some<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -73,6 +81,12 @@ impl<'a, S> FlattenedSequenceSerializer<'a, S> where S: serde_core::ser::SerializeSeq, { + /// Wrap the in-progress sequence serializer `seq_serializer`. + /// + /// Elements of a sequence-shaped value serialized into the returned + /// serializer are appended to `seq_serializer`'s sequence; see the + /// type-level docs. + #[inline] pub fn new(seq_serializer: &'a mut S) -> Self { Self(seq_serializer) } @@ -84,7 +98,14 @@ where } } -impl<'a, S> Serializer for FlattenedSequenceSerializer<'a, S> +impl std::fmt::Debug for FlattenedSequenceSerializer<'_, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlattenedSequenceSerializer") + .finish_non_exhaustive() + } +} + +impl Serializer for FlattenedSequenceSerializer<'_, S> where S: serde_core::ser::SerializeSeq, { @@ -95,9 +116,9 @@ where type SerializeTuple = Impossible; type SerializeTupleStruct = Impossible; type SerializeTupleVariant = Impossible; - type SerializeMap = serde_core::ser::Impossible; - type SerializeStruct = serde_core::ser::Impossible; - type SerializeStructVariant = serde_core::ser::Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; fn serialize_seq(self, _len: Option) -> Result { Ok(self) @@ -126,9 +147,7 @@ where } fn serialize_map(self, _len: Option) -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support maps", - )) + Self::wrong_type_error() } fn serialize_struct( @@ -136,9 +155,7 @@ where _name: &'static str, _len: usize, ) -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support structs", - )) + Self::wrong_type_error() } fn serialize_struct_variant( @@ -148,9 +165,7 @@ where _variant: &'static str, _len: usize, ) -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support struct variants", - )) + Self::wrong_type_error() } fn serialize_bool(self, _v: bool) -> Result { @@ -262,7 +277,7 @@ where } } -impl<'a, S> SerializeSeq for FlattenedSequenceSerializer<'a, S> +impl SerializeSeq for FlattenedSequenceSerializer<'_, S> where S: serde_core::ser::SerializeSeq, { @@ -296,12 +311,28 @@ where pub struct FlattenedSequenceDeserializer<'a, S>(&'a mut S); impl<'a, S> FlattenedSequenceDeserializer<'a, S> { - pub fn new(seq_access: &'a mut S) -> Self { + /// Wrap the in-progress sequence access `seq_access`. + /// + /// A sequence-shaped value deserialized from the returned deserializer + /// consumes the remaining elements of `seq_access`'s sequence; see the + /// type-level docs. + #[inline] + pub fn new<'de>(seq_access: &'a mut S) -> Self + where + S: serde_core::de::SeqAccess<'de>, + { Self(seq_access) } } -impl<'de, 'a, S> Deserializer<'de> for FlattenedSequenceDeserializer<'a, S> +impl std::fmt::Debug for FlattenedSequenceDeserializer<'_, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlattenedSequenceDeserializer") + .finish_non_exhaustive() + } +} + +impl<'de, S> Deserializer<'de> for FlattenedSequenceDeserializer<'_, S> where S: serde_core::de::SeqAccess<'de>, { @@ -311,7 +342,9 @@ where where V: serde_core::de::Visitor<'de>, { - Err(S::Error::custom("type must expect a sequence")) + Err(S::Error::custom( + "FlattenedSequenceDeserializer only supports sequence values", + )) } serde_core::forward_to_deserialize_any! { @@ -337,6 +370,8 @@ where /// fields as required in the generated schema, while conditionally-skipped /// fields are correctly optional. The two attribute forms serialize /// identically. See [`Absent`]. +#[must_use] +#[inline] pub fn always(_: &T) -> bool { true } @@ -556,7 +591,7 @@ mod tests { let input = "[1, \"Two\", \"Three\"]"; let de_result = serde_json::from_str::(input); let e = de_result.unwrap_err().to_string(); - assert!(e.starts_with("invalid type"), "{e}",); + assert!(e.starts_with("invalid type"), "{e}"); } #[test] From 81a1ad33adc527546e2d89a224089efd6fe7afa6 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 09:56:43 -0700 Subject: [PATCH 04/15] Add tests for previously untested error paths Cover the flattening serializer's rejection of scalar and map values, the flattening deserializer's rejection of non-sequence targets, and Absent's serialize error when a field lacks the skip_serializing annotation, asserting the error messages. Add the from_str("{}") success assertion to the schemars 0.8 schema test to match its 1.x sibling, and rename flatten_tuple_vec to test_flatten_tuple_vec so all tests share the test_ prefix. --- src/lib.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 886e369..359fa24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -503,7 +503,7 @@ mod tests { } #[test] - fn flatten_tuple_vec() { + fn test_flatten_tuple_vec() { #[derive(Debug, Eq, PartialEq)] struct TestType(u32, String, Vec); @@ -594,6 +594,104 @@ mod tests { assert!(e.starts_with("invalid type"), "{e}"); } + /// Serialize `value` into a flattening serializer wrapped around a + /// JSON array and return the resulting error message. + fn flatten_ser_err(value: impl Serialize) -> String { + struct Wrapper(T); + + impl Serialize for Wrapper { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(None)?; + self.0 + .serialize(FlattenedSequenceSerializer::new(&mut seq))?; + seq.end() + } + } + + serde_json::to_string(&Wrapper(value)) + .unwrap_err() + .to_string() + } + + #[test] + fn test_flatten_serializer_rejects_scalar() { + assert_eq!( + flatten_ser_err(42u32), + "FlattenedSequenceSerializer only supports sequence values", + ); + } + + #[test] + fn test_flatten_serializer_rejects_map() { + let map = std::collections::BTreeMap::from([("key", "value")]); + assert_eq!( + flatten_ser_err(map), + "FlattenedSequenceSerializer only supports sequence values", + ); + } + + #[test] + fn test_flatten_deserializer_rejects_non_seq() { + #[derive(Debug)] + struct TestType; + + impl<'de> Deserialize<'de> for TestType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = TestType; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + // A non-seq target: u32 forwards to deserialize_any. + let _ = u32::deserialize(FlattenedSequenceDeserializer::new(&mut seq))?; + Ok(TestType) + } + } + deserializer.deserialize_seq(Visitor) + } + } + + let e = serde_json::from_str::("[1, 2, 3]") + .unwrap_err() + .to_string(); + assert!( + e.starts_with("FlattenedSequenceDeserializer only supports sequence values"), + "{e}", + ); + } + + #[test] + fn test_absent_serialize_requires_skip() { + // Without skip_serializing (or the always predicate), serializing + // a struct containing Absent is an error. + #[derive(Serialize)] + struct Test { + absent: Absent, + } + + let e = serde_json::to_string(&Test { absent: Absent }) + .unwrap_err() + .to_string(); + assert_eq!( + e, + "field must be annotated with `skip_serializing` (or \ + `skip_serializing_if = \"json_serde::always\"`)", + ); + } + #[test] fn test_absent() { #[derive(Serialize, Deserialize)] @@ -629,6 +727,8 @@ mod tests { assert_eq!(serde_json::to_string(&test).unwrap(), "{}"); + let de = serde_json::from_str::("{}").unwrap(); + let Absent = de.absent; assert!(serde_json::from_str::(r#"{ "absent": null }"#).is_err()); let schema = schemars08::schema_for!(Test); From e1144cfbe746ea4df9914015d30386469cd405b4 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 09:57:31 -0700 Subject: [PATCH 05/15] Cargo.toml: align description with README; exclude CI files from the package Use the README's tagline ("Runtime serde helpers for esoteric JSON semantics") as the crates.io description so the two match, and exclude .github and rust-toolchain.toml from the published package--they are repo plumbing, not crate contents. --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1d67936..09d3c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,10 @@ edition = "2024" rust-version = "1.97" license = "Apache-2.0" repository = "https://github.com/oxidecomputer/json-serde" -description = "Serde helpers for JSON-specific serialization semantics" +description = "Runtime serde helpers for esoteric JSON semantics" keywords = ["serde", "json", "json-schema", "codegen"] categories = ["encoding"] +exclude = [".github", "rust-toolchain.toml"] [package.metadata.docs.rs] all-features = true From 8a9c7778821007eccf00d7e795f26a6275673639 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 09:57:54 -0700 Subject: [PATCH 06/15] README: the crate is published now, so "pre-publication" is stale The notes section said "Pre-publication; API unstable", but 0.0.1-alpha.1 is on crates.io. Say "Early alpha" instead; the API remains unstable. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6962faf..49ab0e7 100644 --- a/README.md +++ b/README.md @@ -83,5 +83,5 @@ derive-less for consumers. ## Notes -- Pre-publication; API unstable. +- Early alpha; API unstable. - Part of the typify/progenitor code-generation stack. From 949e762fb26e123e5b230426df8f75440d334d58 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:01:00 -0700 Subject: [PATCH 07/15] README: use an absolute URL for the license badge link The README doubles as the crate docs via include_str!, where the relative LICENSE link is a broken intra-doc link (and it is equally dead on crates.io and docs.rs). Point the badge at the file on GitHub. This also lets the new docs CI job pass with -D warnings. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 49ab0e7..1a6f7c6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![json-serde on crates.io](https://img.shields.io/crates/v/json-serde)](https://crates.io/crates/json-serde) [![Documentation (latest release)](https://img.shields.io/badge/docs-latest%20version-brightgreen.svg)](https://docs.rs/json-serde) -[![License](https://img.shields.io/badge/license-Apache-green.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-Apache-green.svg)](https://github.com/oxidecomputer/json-serde/blob/main/LICENSE) Runtime serde helpers for esoteric JSON semantics From 79005fc6799984d2d6bc12eb801f398f79211e6b Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:21:41 -0700 Subject: [PATCH 08/15] Add dependabot --- .github/dependabot.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..55d6dd5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# +# Dependabot configuration file +# + +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Both schemars08 and schemars1 rename the schemars package; block + # major-version bumps so each stays on its line, while minor and + # patch updates flow to both. + - dependency-name: "schemars" + update-types: ["version-update:semver-major"] + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From 8b92ca8ce416722d33c9cebbe242cfbbec2fb7c5 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:25:51 -0700 Subject: [PATCH 09/15] CI: test against stable and the 1.97 MSRV --- .github/workflows/rust.yml | 7 +++++++ js.json | 1 + src/lib.rs | 11 ----------- 3 files changed, 8 insertions(+), 11 deletions(-) create mode 100644 js.json diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 38f1b5c..d6bd3cf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -54,9 +54,16 @@ jobs: strategy: matrix: os: [ ubuntu-latest, windows-latest, macos-latest ] + # 1.97 is the MSRV + rust-version: [ stable, "1.97" ] steps: - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: ${{ matrix.rust-version }} - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Report rustc version + run: rustc --version - name: Build run: cargo build --locked --tests --verbose - name: Run tests (no features) diff --git a/js.json b/js.json new file mode 100644 index 0000000..419d765 --- /dev/null +++ b/js.json @@ -0,0 +1 @@ +{"crate":{"id":"json-serde","name":"json-serde","updated_at":"2026-08-19T02:13:01.107539Z","versions":[3028344],"keywords":["codegen","json","json-schema","serde"],"categories":["encoding"],"badges":[],"created_at":"2026-08-19T02:13:01.107539Z","downloads":0,"recent_downloads":null,"default_version":"0.0.1-alpha.1","num_versions":1,"yanked":false,"max_version":"0.0.1-alpha.1","newest_version":"0.0.1-alpha.1","max_stable_version":null,"description":"Serde helpers for JSON-specific serialization semantics","homepage":null,"documentation":null,"repository":"https://github.com/oxidecomputer/json-serde","links":{"version_downloads":"/api/v1/crates/json-serde/downloads","versions":null,"owners":"/api/v1/crates/json-serde/owners","owner_team":"/api/v1/crates/json-serde/owner_team","owner_user":"/api/v1/crates/json-serde/owner_user","reverse_dependencies":"/api/v1/crates/json-serde/reverse_dependencies"},"exact_match":false,"trustpub_only":false},"versions":[{"id":3028344,"crate":"json-serde","num":"0.0.1-alpha.1","dl_path":"/api/v1/crates/json-serde/0.0.1-alpha.1/download","readme_path":"/api/v1/crates/json-serde/0.0.1-alpha.1/readme","updated_at":"2026-08-19T02:13:01.107539Z","created_at":"2026-08-19T02:13:01.107539Z","downloads":0,"features":{"default":[],"schemars08":["dep:schemars08"],"schemars1":["dep:schemars1"]},"yanked":false,"yank_message":null,"lib_links":null,"license":"Apache-2.0","links":{"dependencies":"/api/v1/crates/json-serde/0.0.1-alpha.1/dependencies","version_downloads":"/api/v1/crates/json-serde/0.0.1-alpha.1/downloads","authors":"/api/v1/crates/json-serde/0.0.1-alpha.1/authors"},"crate_size":13395,"published_by":{"id":84477,"login":"ahl","name":"Adam Leventhal","avatar":"https://avatars.githubusercontent.com/u/677483?v=4","url":"https://github.com/ahl","created_at":"2011-03-18T17:15:11Z"},"audit_actions":[{"action":"publish","user":{"id":84477,"login":"ahl","name":"Adam Leventhal","avatar":"https://avatars.githubusercontent.com/u/677483?v=4","url":"https://github.com/ahl","created_at":"2011-03-18T17:15:11Z"},"time":"2026-08-19T02:13:01.107539Z"}],"checksum":"65a9d0d846e2f9ddd1718ee6b1c2b648d9563e529962c5f68873f1bb07b2ac09","rust_version":"1.97","has_lib":true,"bin_names":[],"edition":"2024","description":"Serde helpers for JSON-specific serialization semantics","homepage":null,"documentation":null,"repository":"https://github.com/oxidecomputer/json-serde","trustpub_data":null,"linecounts":{"languages":{"Rust":{"code_lines":449,"comment_lines":20,"files":1}},"total_code_lines":449,"total_comment_lines":20}}],"keywords":[{"id":"codegen","keyword":"codegen","created_at":"2015-06-30T13:18:14.922176Z","crates_cnt":1035},{"id":"json","keyword":"json","created_at":"2014-11-21T08:29:54.507667Z","crates_cnt":1900},{"id":"json-schema","keyword":"json-schema","created_at":"2015-02-02T14:53:03.583910Z","crates_cnt":133},{"id":"serde","keyword":"serde","created_at":"2015-08-10T17:10:09.021436Z","crates_cnt":1508}],"categories":[{"id":"encoding","category":"Encoding","slug":"encoding","description":"Encoding and/or decoding data from one data format to another.","created_at":"2017-01-17T19:13:05.112025Z","crates_cnt":6638}]} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 359fa24..f5407f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,12 +51,6 @@ use serde_core::{ /// cannot be deserialized from `null`. In the second case, a `null` value /// results in `field` having a value of `Some(None)` since `Option` /// *can* be deserialized from `null`. -/// -/// # Errors -/// -/// Fails if `T` cannot be deserialized from the input--notably, when the -/// value is `null` and `T` itself does not accept `null`. -#[inline] pub fn deserialize_some<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -82,11 +76,6 @@ where S: serde_core::ser::SerializeSeq, { /// Wrap the in-progress sequence serializer `seq_serializer`. - /// - /// Elements of a sequence-shaped value serialized into the returned - /// serializer are appended to `seq_serializer`'s sequence; see the - /// type-level docs. - #[inline] pub fn new(seq_serializer: &'a mut S) -> Self { Self(seq_serializer) } From 779c0958e60cf1965ea559371e6a6c24f3636875 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:29:44 -0700 Subject: [PATCH 10/15] Remove inline attributes except always; trim redundant doc --- src/lib.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f5407f6..3628bff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -301,11 +301,6 @@ pub struct FlattenedSequenceDeserializer<'a, S>(&'a mut S); impl<'a, S> FlattenedSequenceDeserializer<'a, S> { /// Wrap the in-progress sequence access `seq_access`. - /// - /// A sequence-shaped value deserialized from the returned deserializer - /// consumes the remaining elements of `seq_access`'s sequence; see the - /// type-level docs. - #[inline] pub fn new<'de>(seq_access: &'a mut S) -> Self where S: serde_core::de::SeqAccess<'de>, From 7692d7bb8f7071c52ac0c436ce6dadcf641cfed8 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:32:52 -0700 Subject: [PATCH 11/15] Name the encountered kind in flattened-sequence serializer errors --- src/lib.rs | 69 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3628bff..b7f88a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,10 +80,11 @@ where Self(seq_serializer) } - fn wrong_type_error() -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer only supports sequence values", - )) + fn wrong_type_error(kind: &str) -> Result { + Err(serde_core::ser::Error::custom(format!( + "FlattenedSequenceSerializer only supports sequence values, \ + not {kind}", + ))) } } @@ -114,7 +115,7 @@ where } fn serialize_tuple(self, _len: usize) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("tuple") } fn serialize_tuple_struct( @@ -122,7 +123,7 @@ where _name: &'static str, _len: usize, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("tuple struct") } fn serialize_tuple_variant( @@ -132,11 +133,11 @@ where _variant: &'static str, _len: usize, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("tuple variant") } fn serialize_map(self, _len: Option) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("map") } fn serialize_struct( @@ -144,7 +145,7 @@ where _name: &'static str, _len: usize, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("struct") } fn serialize_struct_variant( @@ -154,82 +155,82 @@ where _variant: &'static str, _len: usize, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("struct variant") } fn serialize_bool(self, _v: bool) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("bool") } fn serialize_i8(self, _v: i8) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("i8") } fn serialize_i16(self, _v: i16) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("i16") } fn serialize_i32(self, _v: i32) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("i32") } fn serialize_i64(self, _v: i64) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("i64") } fn serialize_u8(self, _v: u8) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("u8") } fn serialize_u16(self, _v: u16) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("u16") } fn serialize_u32(self, _v: u32) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("u32") } fn serialize_u64(self, _v: u64) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("u64") } fn serialize_f32(self, _v: f32) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("f32") } fn serialize_f64(self, _v: f64) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("f64") } fn serialize_char(self, _v: char) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("char") } fn serialize_str(self, _v: &str) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("str") } fn serialize_bytes(self, _v: &[u8]) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("bytes") } fn serialize_none(self) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("None") } fn serialize_some(self, _value: &T) -> Result where T: ?Sized + serde_core::Serialize, { - Self::wrong_type_error() + Self::wrong_type_error("Some") } fn serialize_unit(self) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("unit") } fn serialize_unit_struct(self, _name: &'static str) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("unit struct") } fn serialize_unit_variant( @@ -238,7 +239,7 @@ where _variant_index: u32, _variant: &'static str, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("unit variant") } fn serialize_newtype_struct( @@ -249,7 +250,7 @@ where where T: ?Sized + serde_core::Serialize, { - Self::wrong_type_error() + Self::wrong_type_error("newtype struct") } fn serialize_newtype_variant( @@ -262,7 +263,7 @@ where where T: ?Sized + serde_core::Serialize, { - Self::wrong_type_error() + Self::wrong_type_error("newtype variant") } } @@ -604,7 +605,8 @@ mod tests { fn test_flatten_serializer_rejects_scalar() { assert_eq!( flatten_ser_err(42u32), - "FlattenedSequenceSerializer only supports sequence values", + "FlattenedSequenceSerializer only supports sequence values, \ + not u32", ); } @@ -613,7 +615,8 @@ mod tests { let map = std::collections::BTreeMap::from([("key", "value")]); assert_eq!( flatten_ser_err(map), - "FlattenedSequenceSerializer only supports sequence values", + "FlattenedSequenceSerializer only supports sequence values, \ + not map", ); } From 319c8e09c36fea3b8f20377593468d5e7355ee29 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:38:50 -0700 Subject: [PATCH 12/15] Remove stray scratch file --- js.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 js.json diff --git a/js.json b/js.json deleted file mode 100644 index 419d765..0000000 --- a/js.json +++ /dev/null @@ -1 +0,0 @@ -{"crate":{"id":"json-serde","name":"json-serde","updated_at":"2026-08-19T02:13:01.107539Z","versions":[3028344],"keywords":["codegen","json","json-schema","serde"],"categories":["encoding"],"badges":[],"created_at":"2026-08-19T02:13:01.107539Z","downloads":0,"recent_downloads":null,"default_version":"0.0.1-alpha.1","num_versions":1,"yanked":false,"max_version":"0.0.1-alpha.1","newest_version":"0.0.1-alpha.1","max_stable_version":null,"description":"Serde helpers for JSON-specific serialization semantics","homepage":null,"documentation":null,"repository":"https://github.com/oxidecomputer/json-serde","links":{"version_downloads":"/api/v1/crates/json-serde/downloads","versions":null,"owners":"/api/v1/crates/json-serde/owners","owner_team":"/api/v1/crates/json-serde/owner_team","owner_user":"/api/v1/crates/json-serde/owner_user","reverse_dependencies":"/api/v1/crates/json-serde/reverse_dependencies"},"exact_match":false,"trustpub_only":false},"versions":[{"id":3028344,"crate":"json-serde","num":"0.0.1-alpha.1","dl_path":"/api/v1/crates/json-serde/0.0.1-alpha.1/download","readme_path":"/api/v1/crates/json-serde/0.0.1-alpha.1/readme","updated_at":"2026-08-19T02:13:01.107539Z","created_at":"2026-08-19T02:13:01.107539Z","downloads":0,"features":{"default":[],"schemars08":["dep:schemars08"],"schemars1":["dep:schemars1"]},"yanked":false,"yank_message":null,"lib_links":null,"license":"Apache-2.0","links":{"dependencies":"/api/v1/crates/json-serde/0.0.1-alpha.1/dependencies","version_downloads":"/api/v1/crates/json-serde/0.0.1-alpha.1/downloads","authors":"/api/v1/crates/json-serde/0.0.1-alpha.1/authors"},"crate_size":13395,"published_by":{"id":84477,"login":"ahl","name":"Adam Leventhal","avatar":"https://avatars.githubusercontent.com/u/677483?v=4","url":"https://github.com/ahl","created_at":"2011-03-18T17:15:11Z"},"audit_actions":[{"action":"publish","user":{"id":84477,"login":"ahl","name":"Adam Leventhal","avatar":"https://avatars.githubusercontent.com/u/677483?v=4","url":"https://github.com/ahl","created_at":"2011-03-18T17:15:11Z"},"time":"2026-08-19T02:13:01.107539Z"}],"checksum":"65a9d0d846e2f9ddd1718ee6b1c2b648d9563e529962c5f68873f1bb07b2ac09","rust_version":"1.97","has_lib":true,"bin_names":[],"edition":"2024","description":"Serde helpers for JSON-specific serialization semantics","homepage":null,"documentation":null,"repository":"https://github.com/oxidecomputer/json-serde","trustpub_data":null,"linecounts":{"languages":{"Rust":{"code_lines":449,"comment_lines":20,"files":1}},"total_code_lines":449,"total_comment_lines":20}}],"keywords":[{"id":"codegen","keyword":"codegen","created_at":"2015-06-30T13:18:14.922176Z","crates_cnt":1035},{"id":"json","keyword":"json","created_at":"2014-11-21T08:29:54.507667Z","crates_cnt":1900},{"id":"json-schema","keyword":"json-schema","created_at":"2015-02-02T14:53:03.583910Z","crates_cnt":133},{"id":"serde","keyword":"serde","created_at":"2015-08-10T17:10:09.021436Z","crates_cnt":1508}],"categories":[{"id":"encoding","category":"Encoding","slug":"encoding","description":"Encoding and/or decoding data from one data format to another.","created_at":"2017-01-17T19:13:05.112025Z","crates_cnt":6638}]} \ No newline at end of file From e3bd5f8e06367e500b40a34921c5c1605fbaf28f Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 10:48:08 -0700 Subject: [PATCH 13/15] Recommend the always form for both schemars versions skip_serializing_if avoids 0.8's required-property bug and 1.x's writeOnly decoration alike; the false schema survives intact. Add a test pinning the clean 1.x schema under the always form. --- README.md | 12 +++++++----- src/lib.rs | 45 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1a6f7c6..bc65377 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,13 @@ specific, named properties may be disallowed. To handle these cases, the With the `schemars1` or `schemars08` feature enabled, its `JsonSchema` impl emits the `false`--unsatisfiable--schema. -Note that schemars 0.8 (through 0.8.22) incorrectly marks `default` + -`skip_serializing` fields as required; on types deriving the schemars 0.8 -`JsonSchema`, use `#[serde(skip_serializing_if = "::json_serde::always")]` -instead of `skip_serializing`. The `always` predicate serializes -identically and works around the schemars bug. +On types deriving `JsonSchema` (either version), use +`#[serde(skip_serializing_if = "::json_serde::always")]` instead of +`skip_serializing`. The `always` predicate serializes identically and +produces better schemas: schemars 0.8 (through 0.8.22) incorrectly marks +`default` + `skip_serializing` fields as required, and schemars 1.x +decorates them with `writeOnly`, rewriting the `false` schema into its +object form to do so. ## Features diff --git a/src/lib.rs b/src/lib.rs index b7f88a1..8871650 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -350,11 +350,12 @@ where /// /// Use `#[serde(skip_serializing_if = "::json_serde::always")]` in place of /// `#[serde(skip_serializing)]` on fields that must never serialize when -/// the containing type also derives the schemars 0.8 `JsonSchema`: schemars -/// 0.8 (through 0.8.22) incorrectly marks `default` + `skip_serializing` -/// fields as required in the generated schema, while conditionally-skipped -/// fields are correctly optional. The two attribute forms serialize -/// identically. See [`Absent`]. +/// the containing type also derives `JsonSchema` (either schemars version). +/// The two attribute forms serialize identically, but the schemas differ: +/// schemars 0.8 (through 0.8.22) incorrectly marks `default` + +/// `skip_serializing` fields as required, and schemars 1.x decorates them +/// with `writeOnly`; conditionally-skipped fields avoid both. See +/// [`Absent`]. #[must_use] #[inline] pub fn always(_: &T) -> bool { @@ -770,4 +771,38 @@ mod tests { assert_eq!(serde_json::to_value(&schema).unwrap(), expected); } + + #[cfg(feature = "schemars1")] + #[test] + fn test_absent_schema_v1_always() { + // The `always` form is the recommended annotation: a conditionally + // skipped field gets no `writeOnly` decoration, so the `false` + // schema survives intact. + #[derive(Serialize, Deserialize, schemars1::JsonSchema)] + #[schemars(crate = "schemars1")] + struct Test { + #[serde(default, skip_serializing_if = "crate::always")] + absent: Absent, + } + + let test = Test { absent: Absent }; + + assert_eq!(serde_json::to_string(&test).unwrap(), "{}"); + + let de = serde_json::from_str::("{}").unwrap(); + let Absent = de.absent; + assert!(serde_json::from_str::(r#"{ "absent": null }"#).is_err()); + + let schema = schemars1::schema_for!(Test); + let expected = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "type": "object", + "properties": { + "absent": false + } + }); + + assert_eq!(serde_json::to_value(&schema).unwrap(), expected); + } } From b69121a6820b140dbeb8dcd65060d7523f090195 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 11:02:33 -0700 Subject: [PATCH 14/15] Test only the recommended always form; tighten docs --- README.md | 10 +++++----- src/lib.rs | 50 ++++++-------------------------------------------- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index bc65377..e4864a1 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,11 @@ emits the `false`--unsatisfiable--schema. On types deriving `JsonSchema` (either version), use `#[serde(skip_serializing_if = "::json_serde::always")]` instead of -`skip_serializing`. The `always` predicate serializes identically and -produces better schemas: schemars 0.8 (through 0.8.22) incorrectly marks -`default` + `skip_serializing` fields as required, and schemars 1.x -decorates them with `writeOnly`, rewriting the `false` schema into its -object form to do so. +`skip_serializing`. The `always` predicate serializes identically and produces +better schemas: schemars 0.8 (through 0.8.22) incorrectly marks `default` + +`skip_serializing` fields as required, and schemars 1.0 (as of 1.2.2) +spuriously annotates the field `writeOnly` (when, in fact, no value can be +written!). ## Features diff --git a/src/lib.rs b/src/lib.rs index 8871650..8b3f82d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -349,13 +349,15 @@ where /// Always returns `true`; a predicate for `#[serde(skip_serializing_if)]`. /// /// Use `#[serde(skip_serializing_if = "::json_serde::always")]` in place of -/// `#[serde(skip_serializing)]` on fields that must never serialize when +/// `#[serde(skip_serializing)]` on fields with the [`Absent`] type when /// the containing type also derives `JsonSchema` (either schemars version). /// The two attribute forms serialize identically, but the schemas differ: /// schemars 0.8 (through 0.8.22) incorrectly marks `default` + -/// `skip_serializing` fields as required, and schemars 1.x decorates them -/// with `writeOnly`; conditionally-skipped fields avoid both. See -/// [`Absent`]. +/// `skip_serializing` fields as required, and schemars 1.0 (as of 1.2.2) +/// spuriously annotates the field `writeOnly` (when, in fact, no value can be +/// written!). +/// +/// See [`Absent`]. #[must_use] #[inline] pub fn always(_: &T) -> bool { @@ -735,46 +737,6 @@ mod tests { #[cfg(feature = "schemars1")] #[test] fn test_absent_schema_v1() { - // Unlike schemars 0.8.22, schemars 1.x correctly treats default + - // skip_serializing as an optional property, so no workaround akin to - // the `always` helper is needed here. - #[derive(Serialize, Deserialize, schemars1::JsonSchema)] - #[schemars(crate = "schemars1")] - struct Test { - #[serde(default, skip_serializing)] - absent: Absent, - } - - let test = Test { absent: Absent }; - - assert_eq!(serde_json::to_string(&test).unwrap(), "{}"); - - let de = serde_json::from_str::("{}").unwrap(); - let Absent = de.absent; - assert!(serde_json::from_str::(r#"{ "absent": null }"#).is_err()); - - let schema = schemars1::schema_for!(Test); - // schemars 1.x marks skip_serializing fields as `writeOnly`; to - // attach that keyword it rewrites the `false` schema as its object - // form, `{"not": {}}`, which is equivalent. - let expected = serde_json::json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Test", - "type": "object", - "properties": { - "absent": { - "not": {}, - "writeOnly": true - } - } - }); - - assert_eq!(serde_json::to_value(&schema).unwrap(), expected); - } - - #[cfg(feature = "schemars1")] - #[test] - fn test_absent_schema_v1_always() { // The `always` form is the recommended annotation: a conditionally // skipped field gets no `writeOnly` decoration, so the `false` // schema survives intact. From 690f268cb62016fe9b912d14e96d45ea2ace3f32 Mon Sep 17 00:00:00 2001 From: "Adam H. Leventhal" Date: Wed, 19 Aug 2026 11:29:57 -0700 Subject: [PATCH 15/15] Deserializer error names the real failure: a non-seq target type --- src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8b3f82d..77b8d07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -328,7 +328,7 @@ where V: serde_core::de::Visitor<'de>, { Err(S::Error::custom( - "FlattenedSequenceDeserializer only supports sequence values", + "FlattenedSequenceDeserializer only supports sequence-shaped target types", )) } @@ -658,7 +658,9 @@ mod tests { .unwrap_err() .to_string(); assert!( - e.starts_with("FlattenedSequenceDeserializer only supports sequence values"), + e.starts_with( + "FlattenedSequenceDeserializer only supports sequence-shaped target types" + ), "{e}", ); }