Skip to content

Commit

Permalink
Add a pattern key for the duration string
Browse files Browse the repository at this point in the history
The string representation of the date actually has only a restricted set
of values that can be used. By using the pattern property we can have
the json schema itself validate the string.

The regex used here for the pattern is a more lenient version of that
used to match numbers in JSON.
  • Loading branch information
swlynch99 committed Jan 29, 2024
1 parent c7ad4f0 commit cf29573
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 54 deletions.
150 changes: 99 additions & 51 deletions serde_with/src/schemars_0_8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,48 +613,76 @@ where
mod timespan {
use super::*;

// #[non_exhaustive] is not actually necessary here but it should
// help avoid warnings about semver breakage if this ever changes.
#[non_exhaustive]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum TimespanTargetType {
String,
F64,
U64,
I64,
}

impl TimespanTargetType {
pub const fn is_signed(self) -> bool {
!matches!(self, Self::U64)
}
}

/// Internal helper trait used to constrain which types we implement
/// `JsonSchemaAs<T>` for.
pub trait TimespanSchemaTarget<F> {
/// Whether F is signed.
/// The underlying type.
///
/// This is mainly used to decide which variant of the resulting schema
/// should be marked as `write_only: true`.
const TYPE: TimespanTargetType;

/// Whether the target type is signed.
///
/// This is only true for `std::time::Duration`.
const SIGNED: bool = true;

/// Whether F is String
const STRING: bool;
}

macro_rules! is_string {
macro_rules! timespan_type_of {
(String) => {
true
TimespanTargetType::String
};
($name:ty) => {
false
(f64) => {
TimespanTargetType::F64
};
(i64) => {
TimespanTargetType::I64
};
(u64) => {
TimespanTargetType::U64
};
}

macro_rules! declare_timespan_target {
( $target:ty { $($format:ty),* $(,)? } )=> {
( $target:ty { $($format:ident),* $(,)? } ) => {
$(
impl TimespanSchemaTarget<$format> for $target {
const STRING: bool = is_string!($format);
const TYPE: TimespanTargetType = timespan_type_of!($format);
}
)*
}
}

impl TimespanSchemaTarget<u64> for Duration {
const TYPE: TimespanTargetType = TimespanTargetType::U64;
const SIGNED: bool = false;
const STRING: bool = false;
}

impl TimespanSchemaTarget<f64> for Duration {
const TYPE: TimespanTargetType = TimespanTargetType::F64;
const SIGNED: bool = false;
const STRING: bool = false;
}

impl TimespanSchemaTarget<String> for Duration {
const TYPE: TimespanTargetType = TimespanTargetType::String;
const SIGNED: bool = false;
const STRING: bool = true;
}

declare_timespan_target!(SystemTime { i64, f64, String });
Expand All @@ -676,7 +704,7 @@ mod timespan {
declare_timespan_target!(::time_0_3::PrimitiveDateTime { i64, f64, String });
}

use self::timespan::TimespanSchemaTarget;
use self::timespan::{TimespanSchemaTarget, TimespanTargetType};

/// Internal type used for the base impls on DurationXXX and TimestampYYY types.
///
Expand All @@ -692,37 +720,61 @@ where
forward_schema!(F);
}

fn flexible_timespan_schema(signed: bool, is_string: bool) -> Schema {
let mut number = SchemaObject {
instance_type: Some(InstanceType::Number.into()),
number: (!signed).then(|| {
Box::new(NumberValidation {
minimum: Some(0.0),
impl TimespanTargetType {
pub(crate) fn to_flexible_schema(self, signed: bool) -> Schema {
use ::schemars_0_8::schema::StringValidation;

let mut number = SchemaObject {
instance_type: Some(InstanceType::Number.into()),
number: (!signed).then(|| {
Box::new(NumberValidation {
minimum: Some(0.0),
..Default::default()
})
}),
..Default::default()
};

// This is a more lenient version of the regex used to determine
// whether JSON numbers are valid. Specifically, it allows multiple
// leading zeroes whereas that is illegal in JSON.
let regex = r#"[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?"#;
let mut string = SchemaObject {
instance_type: Some(InstanceType::String.into()),
string: Some(Box::new(StringValidation {
pattern: Some(match signed {
true => std::format!("^-?{regex}$"),
false => std::format!("^{regex}$"),
}),
..Default::default()
})
}),
..Default::default()
};
})),
..Default::default()
};

let mut string = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
if self == Self::String {
number.metadata().write_only = true;
} else {
string.metadata().write_only = true;
}

if is_string {
number.metadata().write_only = true;
} else {
string.metadata().write_only = true;
SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(std::vec![number.into(), string.into()]),
..Default::default()
})),
..Default::default()
}
.into()
}

SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(std::vec![number.into(), string.into()]),
..Default::default()
})),
..Default::default()
pub(crate) fn schema_id(self) -> &'static str {
match self {
Self::String => "serde_with::FlexibleStringTimespan",
Self::F64 => "serde_with::FlexibleF64Timespan",
Self::U64 => "serde_with::FlexibleU64Timespan",
Self::I64 => "serde_with::FlexibleI64Timespan",
}
}
.into()
}

impl<T, F> JsonSchemaAs<T> for Timespan<F, Flexible>
Expand All @@ -731,24 +783,20 @@ where
F: Format + JsonSchema,
{
fn schema_name() -> String {
match <T as TimespanSchemaTarget<F>>::STRING {
true => "FlexibleStringTimespan".into(),
false => "FlexibleTimespan".into(),
}
<T as TimespanSchemaTarget<F>>::TYPE
.schema_id()
.strip_prefix("serde_with::")
.expect("schema id did not start with `serde_with::` - this is a bug")
.into()
}

fn schema_id() -> Cow<'static, str> {
match <T as TimespanSchemaTarget<F>>::STRING {
true => "serde_with::FlexibleStringTimespan".into(),
false => "serde_with::FlexibleTimespan".into(),
}
<T as TimespanSchemaTarget<F>>::TYPE.schema_id().into()
}

fn json_schema(_: &mut SchemaGenerator) -> Schema {
flexible_timespan_schema(
<T as TimespanSchemaTarget<F>>::SIGNED,
<T as TimespanSchemaTarget<F>>::STRING,
)
<T as TimespanSchemaTarget<F>>::TYPE
.to_flexible_schema(<T as TimespanSchemaTarget<F>>::SIGNED)
}

fn is_referenceable() -> bool {
Expand Down
3 changes: 3 additions & 0 deletions serde_with/tests/schemars_0_8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ mod snapshots {
#[serde_as(as = "DurationSecondsWithFrac<f64, Flexible>")]
frac: std::time::Duration,

#[serde_as(as = "DurationSeconds<String, Flexible>")]
flexible_string: std::time::Duration,

#[serde_as(as = "DurationSeconds<u64, Strict>")]
seconds_u64_strict: std::time::Duration,

Expand Down
23 changes: 20 additions & 3 deletions serde_with/tests/schemars_0_8/snapshots/duration.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@
"title": "Test",
"type": "object",
"required": [
"flexible_string",
"frac",
"seconds",
"seconds_u64_strict",
"time_i64"
],
"properties": {
"flexible_string": {
"oneOf": [
{
"writeOnly": true,
"type": "number",
"minimum": 0.0
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"
}
]
},
"frac": {
"oneOf": [
{
Expand All @@ -17,7 +31,8 @@
},
{
"writeOnly": true,
"type": "string"
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"
}
]
},
Expand All @@ -29,7 +44,8 @@
},
{
"writeOnly": true,
"type": "string"
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"
}
]
},
Expand All @@ -45,7 +61,8 @@
},
{
"writeOnly": true,
"type": "string"
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?$"
}
]
}
Expand Down

0 comments on commit cf29573

Please sign in to comment.