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" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f00870e..d6bd3cf 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,53 @@ 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 ] + # 1.97 is the MSRV + rust-version: [ stable, "1.97" ] steps: - - uses: actions/checkout@v4 + - 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) 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 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 diff --git a/README.md b/README.md index 6e2421c..e4864a1 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)](https://github.com/oxidecomputer/json-serde/blob/main/LICENSE) + Runtime serde helpers for esoteric JSON semantics ## Overview @@ -62,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.0 (as of 1.2.2) +spuriously annotates the field `writeOnly` (when, in fact, no value can be +written!). ## Features @@ -79,5 +85,5 @@ derive-less for consumers. ## Notes -- Pre-publication; API unstable. +- Early alpha; API unstable. - Part of the typify/progenitor code-generation stack. diff --git a/src/lib.rs b/src/lib.rs index 78db334..77b8d07 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. @@ -73,18 +75,27 @@ impl<'a, S> FlattenedSequenceSerializer<'a, S> where S: serde_core::ser::SerializeSeq, { + /// Wrap the in-progress sequence serializer `seq_serializer`. pub fn new(seq_serializer: &'a mut S) -> Self { 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}", + ))) } } -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,16 +106,16 @@ 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) } fn serialize_tuple(self, _len: usize) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("tuple") } fn serialize_tuple_struct( @@ -112,7 +123,7 @@ where _name: &'static str, _len: usize, ) -> Result { - Self::wrong_type_error() + Self::wrong_type_error("tuple struct") } fn serialize_tuple_variant( @@ -122,13 +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 { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support maps", - )) + Self::wrong_type_error("map") } fn serialize_struct( @@ -136,9 +145,7 @@ where _name: &'static str, _len: usize, ) -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support structs", - )) + Self::wrong_type_error("struct") } fn serialize_struct_variant( @@ -148,84 +155,82 @@ where _variant: &'static str, _len: usize, ) -> Result { - Err(serde_core::ser::Error::custom( - "FlattenedSequenceSerializer does not support struct variants", - )) + 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( @@ -234,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( @@ -245,7 +250,7 @@ where where T: ?Sized + serde_core::Serialize, { - Self::wrong_type_error() + Self::wrong_type_error("newtype struct") } fn serialize_newtype_variant( @@ -258,11 +263,11 @@ where where T: ?Sized + serde_core::Serialize, { - Self::wrong_type_error() + Self::wrong_type_error("newtype variant") } } -impl<'a, S> SerializeSeq for FlattenedSequenceSerializer<'a, S> +impl SerializeSeq for FlattenedSequenceSerializer<'_, S> where S: serde_core::ser::SerializeSeq, { @@ -296,12 +301,23 @@ 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`. + 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 +327,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-shaped target types", + )) } serde_core::forward_to_deserialize_any! { @@ -331,12 +349,17 @@ 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 -/// 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`]. +/// `#[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.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 { true } @@ -468,7 +491,7 @@ mod tests { } #[test] - fn flatten_tuple_vec() { + fn test_flatten_tuple_vec() { #[derive(Debug, Eq, PartialEq)] struct TestType(u32, String, Vec); @@ -556,7 +579,109 @@ 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}"); + } + + /// 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, \ + not u32", + ); + } + + #[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, \ + not map", + ); + } + + #[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-shaped target types" + ), + "{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] @@ -594,6 +719,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); @@ -612,13 +739,13 @@ 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. + // 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)] + #[serde(default, skip_serializing_if = "crate::always")] absent: Absent, } @@ -631,18 +758,12 @@ mod tests { 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 - } + "absent": false } });