Skip to content

Commit b24c841

Browse files
authored
Add RestrictFormats transform (#405)
1 parent 04db03b commit b24c841

4 files changed

Lines changed: 275 additions & 10 deletions

File tree

schemars/src/consts.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*!
2+
Constants associated with JSON Schema generation.
3+
*/
4+
5+
/// Known values of the `$schema` property.
6+
pub mod meta_schemas {
7+
/// The mata-schema for [JSON Schema Draft 7](https://json-schema.org/specification-links#draft-7)
8+
/// (`http://json-schema.org/draft-07/schema#`).
9+
pub const DRAFT07: &str = "http://json-schema.org/draft-07/schema#";
10+
11+
/// The mata-schema for [JSON Schema 2019-09](https://json-schema.org/specification-links#draft-2019-09-(formerly-known-as-draft-8))
12+
/// (`https://json-schema.org/draft/2019-09/schema`).
13+
pub const DRAFT2019_09: &str = "https://json-schema.org/draft/2019-09/schema";
14+
15+
/// The mata-schema for [JSON Schema 2020-12](https://json-schema.org/specification-links#2020-12)
16+
/// (`https://json-schema.org/draft/2020-12/schema`).
17+
pub const DRAFT2020_12: &str = "https://json-schema.org/draft/2020-12/schema";
18+
19+
/// The mata-schema for [OpenAPI 3.0 schemas](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#schema)
20+
/// (`https://spec.openapis.org/oas/3.0/schema/2024-10-18#/definitions/Schema`).
21+
///
22+
/// This should rarely be encountered in practice, as OpenAPI schemas are typically only
23+
/// embedded within OpenAPI documents, so do not have a `$schema` property set.
24+
pub const OPENAPI3: &str =
25+
"https://spec.openapis.org/oas/3.0/schema/2024-10-18#/definitions/Schema";
26+
}

schemars/src/generate.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ There are two main types in this module:
77
* [`SchemaGenerator`], which manages the generation of a schema document.
88
*/
99

10+
use crate::consts::meta_schemas;
1011
use crate::Schema;
1112
use crate::_alloc_prelude::*;
1213
use crate::{transform::*, JsonSchema};
@@ -45,7 +46,7 @@ pub struct SchemaSettings {
4546
pub definitions_path: CowStr,
4647
/// The URI of the meta-schema describing the structure of the generated schemas.
4748
///
48-
/// Defaults to `"https://json-schema.org/draft/2020-12/schema"`.
49+
/// Defaults to [`meta_schemas::DRAFT2020_12`] (`https://json-schema.org/draft/2020-12/schema`).
4950
pub meta_schema: Option<CowStr>,
5051
/// A list of [`Transform`]s that get applied to generated root schemas.
5152
pub transforms: Vec<Box<dyn GenTransform>>,
@@ -78,7 +79,7 @@ impl SchemaSettings {
7879
option_nullable: false,
7980
option_add_null_type: true,
8081
definitions_path: "/definitions".into(),
81-
meta_schema: Some("http://json-schema.org/draft-07/schema#".into()),
82+
meta_schema: Some(meta_schemas::DRAFT07.into()),
8283
transforms: vec![
8384
Box::new(ReplaceUnevaluatedProperties),
8485
Box::new(RemoveRefSiblings),
@@ -96,7 +97,7 @@ impl SchemaSettings {
9697
option_nullable: false,
9798
option_add_null_type: true,
9899
definitions_path: "/$defs".into(),
99-
meta_schema: Some("https://json-schema.org/draft/2019-09/schema".into()),
100+
meta_schema: Some(meta_schemas::DRAFT2019_09.into()),
100101
transforms: vec![Box::new(ReplacePrefixItems)],
101102
inline_subschemas: false,
102103
contract: Contract::Deserialize,
@@ -110,7 +111,7 @@ impl SchemaSettings {
110111
option_nullable: false,
111112
option_add_null_type: true,
112113
definitions_path: "/$defs".into(),
113-
meta_schema: Some("https://json-schema.org/draft/2020-12/schema".into()),
114+
meta_schema: Some(meta_schemas::DRAFT2020_12.into()),
114115
transforms: Vec::new(),
115116
inline_subschemas: false,
116117
contract: Contract::Deserialize,
@@ -124,9 +125,7 @@ impl SchemaSettings {
124125
option_nullable: true,
125126
option_add_null_type: false,
126127
definitions_path: "/components/schemas".into(),
127-
meta_schema: Some(
128-
"https://spec.openapis.org/oas/3.0/schema/2024-10-18#/definitions/Schema".into(),
129-
),
128+
meta_schema: Some(meta_schemas::OPENAPI3.into()),
130129
transforms: vec![
131130
Box::new(ReplaceUnevaluatedProperties),
132131
Box::new(ReplaceBoolSchemas {

schemars/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ mod macros;
3131
#[doc(hidden)]
3232
#[allow(clippy::exhaustive_structs)]
3333
pub mod _private;
34+
pub mod consts;
3435
pub mod generate;
3536
pub mod transform;
3637

schemars/src/transform.rs

Lines changed: 242 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,9 @@ assert_eq!(
106106
```
107107
108108
*/
109-
use crate::Schema;
110109
use crate::_alloc_prelude::*;
110+
use crate::{consts::meta_schemas, Schema};
111+
use alloc::borrow::Cow;
111112
use alloc::collections::BTreeSet;
112113
use serde_json::{json, Map, Value};
113114

@@ -271,6 +272,7 @@ where
271272
}
272273

273274
/// Replaces boolean JSON Schemas with equivalent object schemas.
275+
///
274276
/// This also applies to subschemas.
275277
///
276278
/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support booleans as
@@ -306,7 +308,9 @@ impl Transform for ReplaceBoolSchemas {
306308
}
307309

308310
/// Restructures JSON Schema objects so that the `$ref` property will never appear alongside any
309-
/// other properties. This also applies to subschemas.
311+
/// other properties.
312+
///
313+
/// This also applies to subschemas.
310314
///
311315
/// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support other properties
312316
/// alongside `$ref`.
@@ -332,7 +336,9 @@ impl Transform for RemoveRefSiblings {
332336
}
333337

334338
/// Removes the `examples` schema property and (if present) set its first value as the `example`
335-
/// property. This also applies to subschemas.
339+
/// property.
340+
///
341+
/// This also applies to subschemas.
336342
///
337343
/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `examples`
338344
/// property.
@@ -353,6 +359,7 @@ impl Transform for SetSingleExample {
353359
}
354360

355361
/// Replaces the `const` schema property with a single-valued `enum` property.
362+
///
356363
/// This also applies to subschemas.
357364
///
358365
/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `const`
@@ -372,6 +379,7 @@ impl Transform for ReplaceConstValue {
372379
}
373380

374381
/// Rename the `prefixItems` schema property to `items`.
382+
///
375383
/// This also applies to subschemas.
376384
///
377385
/// If the schema contains both `prefixItems` and `items`, then this additionally renames `items` to
@@ -398,6 +406,7 @@ impl Transform for ReplacePrefixItems {
398406
}
399407

400408
/// Adds a `"nullable": true` property to schemas that allow `null` types.
409+
///
401410
/// This also applies to subschemas.
402411
///
403412
/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that use `nullable` instead of
@@ -461,6 +470,7 @@ impl Transform for AddNullable {
461470

462471
/// Replaces the `unevaluatedProperties` schema property with the `additionalProperties` property,
463472
/// adding properties from a schema's subschemas to its `properties` where necessary.
473+
///
464474
/// This also applies to subschemas.
465475
///
466476
/// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support the
@@ -519,3 +529,232 @@ impl Transform for GatherPropertyNames {
519529
transform_immediate_subschemas(self, schema);
520530
}
521531
}
532+
533+
/// Removes any `format` values that are not defined by the JSON Schema standard or explicitly
534+
/// allowed by a custom list.
535+
///
536+
/// This also applies to subschemas.
537+
///
538+
/// By default, this will infer the version of JSON Schema from the schema's `$schema` property,
539+
/// and no additional formats will be allowed (even when the JSON schema allows nonstandard
540+
/// formats).
541+
///
542+
/// # Example
543+
/// ```
544+
/// use schemars::json_schema;
545+
/// use schemars::transform::{RestrictFormats, Transform};
546+
///
547+
/// let mut schema = schemars::json_schema!({
548+
/// "$schema": "https://json-schema.org/draft/2020-12/schema",
549+
/// "anyOf": [
550+
/// {
551+
/// "type": "string",
552+
/// "format": "uuid"
553+
/// },
554+
/// {
555+
/// "$schema": "http://json-schema.org/draft-07/schema#",
556+
/// "type": "string",
557+
/// "format": "uuid"
558+
/// },
559+
/// {
560+
/// "type": "string",
561+
/// "format": "allowed-custom-format"
562+
/// },
563+
/// {
564+
/// "type": "string",
565+
/// "format": "forbidden-custom-format"
566+
/// }
567+
/// ]
568+
/// });
569+
///
570+
/// let mut transform = RestrictFormats::default();
571+
/// transform.allowed_formats.insert("allowed-custom-format".into());
572+
/// transform.transform(&mut schema);
573+
///
574+
/// assert_eq!(
575+
/// schema,
576+
/// json_schema!({
577+
/// "$schema": "https://json-schema.org/draft/2020-12/schema",
578+
/// "anyOf": [
579+
/// {
580+
/// // "uuid" format is defined in draft 2020-12.
581+
/// "type": "string",
582+
/// "format": "uuid"
583+
/// },
584+
/// {
585+
/// // "uuid" format is not defined in draft-07, so is removed from this subschema.
586+
/// "$schema": "http://json-schema.org/draft-07/schema#",
587+
/// "type": "string"
588+
/// },
589+
/// {
590+
/// // "allowed-custom-format" format was present in `allowed_formats`...
591+
/// "type": "string",
592+
/// "format": "allowed-custom-format"
593+
/// },
594+
/// {
595+
/// // ...but "forbidden-custom-format" format was not, so is also removed.
596+
/// "type": "string"
597+
/// }
598+
/// ]
599+
/// })
600+
/// );
601+
/// ```
602+
#[derive(Debug, Clone)]
603+
#[non_exhaustive]
604+
pub struct RestrictFormats {
605+
/// Whether to read the schema's `$schema` property to determine which version of JSON Schema
606+
/// is being used, and allow only formats defined in that standard. If this is `true` but the
607+
/// JSON Schema version can't be determined because `$schema` is missing or unknown, then no
608+
/// `format` values will be removed.
609+
///
610+
/// If this is set to `false`, then only the formats explicitly included in
611+
/// [`allowed_formats`](Self::allowed_formats) will be allowed.
612+
///
613+
/// By default, this is `true`.
614+
pub infer_from_meta_schema: bool,
615+
/// Values of the `format` property in schemas that will always be allowed, regardless of the
616+
/// inferred version of JSON Schema.
617+
pub allowed_formats: BTreeSet<Cow<'static, str>>,
618+
}
619+
620+
impl Default for RestrictFormats {
621+
fn default() -> Self {
622+
Self {
623+
infer_from_meta_schema: true,
624+
allowed_formats: BTreeSet::new(),
625+
}
626+
}
627+
}
628+
629+
impl Transform for RestrictFormats {
630+
fn transform(&mut self, schema: &mut Schema) {
631+
let mut implementation = RestrictFormatsImpl {
632+
infer_from_meta_schema: self.infer_from_meta_schema,
633+
inferred_formats: None,
634+
allowed_formats: &self.allowed_formats,
635+
};
636+
637+
implementation.transform(schema);
638+
}
639+
}
640+
641+
static DEFINED_FORMATS: &[&str] = &[
642+
// `duration` and `uuid` are defined only in draft 2019-09+
643+
"duration",
644+
"uuid",
645+
// The rest are also defined in draft-07:
646+
"date-time",
647+
"date",
648+
"time",
649+
"email",
650+
"idn-email",
651+
"hostname",
652+
"idn-hostname",
653+
"ipv4",
654+
"ipv6",
655+
"uri",
656+
"uri-reference",
657+
"iri",
658+
"iri-reference",
659+
"uri-template",
660+
"json-pointer",
661+
"relative-json-pointer",
662+
"regex",
663+
];
664+
665+
struct RestrictFormatsImpl<'a> {
666+
infer_from_meta_schema: bool,
667+
inferred_formats: Option<&'static [&'static str]>,
668+
allowed_formats: &'a BTreeSet<Cow<'static, str>>,
669+
}
670+
671+
impl Transform for RestrictFormatsImpl<'_> {
672+
fn transform(&mut self, schema: &mut Schema) {
673+
let Some(obj) = schema.as_object_mut() else {
674+
return;
675+
};
676+
677+
let previous_inferred_formats = self.inferred_formats;
678+
679+
if self.infer_from_meta_schema && obj.contains_key("$schema") {
680+
self.inferred_formats = match obj
681+
.get("$schema")
682+
.and_then(Value::as_str)
683+
.unwrap_or_default()
684+
{
685+
meta_schemas::DRAFT07 => Some(&DEFINED_FORMATS[2..]),
686+
meta_schemas::DRAFT2019_09 | meta_schemas::DRAFT2020_12 => Some(DEFINED_FORMATS),
687+
_ => {
688+
// we can't handle an unrecognised meta-schema
689+
return;
690+
}
691+
};
692+
}
693+
694+
if let Some(format) = obj.get("format").and_then(Value::as_str) {
695+
if !self.allowed_formats.contains(format)
696+
&& !self
697+
.inferred_formats
698+
.is_some_and(|formats| formats.contains(&format))
699+
{
700+
obj.remove("format");
701+
}
702+
}
703+
704+
transform_subschemas(self, schema);
705+
706+
self.inferred_formats = previous_inferred_formats;
707+
}
708+
}
709+
710+
#[cfg(test)]
711+
mod tests {
712+
use super::*;
713+
use pretty_assertions::assert_eq;
714+
715+
#[test]
716+
fn restrict_formats() {
717+
let mut schema = json_schema!({
718+
"$schema": meta_schemas::DRAFT2020_12,
719+
"anyOf": [
720+
{ "format": "uuid" },
721+
{ "$schema": meta_schemas::DRAFT07, "format": "uuid" },
722+
{ "$schema": "http://unknown", "format": "uuid" },
723+
{ "format": "date" },
724+
{ "$schema": meta_schemas::DRAFT07, "format": "date" },
725+
{ "$schema": "http://unknown", "format": "date" },
726+
{ "format": "custom1" },
727+
{ "$schema": meta_schemas::DRAFT07, "format": "custom1" },
728+
{ "$schema": "http://unknown", "format": "custom1" },
729+
{ "format": "custom2" },
730+
{ "$schema": meta_schemas::DRAFT07, "format": "custom2" },
731+
{ "$schema": "http://unknown", "format": "custom2" },
732+
]
733+
});
734+
735+
let mut transform = RestrictFormats::default();
736+
transform.allowed_formats.insert("custom1".into());
737+
transform.transform(&mut schema);
738+
739+
assert_eq!(
740+
schema,
741+
json_schema!({
742+
"$schema": meta_schemas::DRAFT2020_12,
743+
"anyOf": [
744+
{ "format": "uuid" },
745+
{ "$schema": meta_schemas::DRAFT07 },
746+
{ "$schema": "http://unknown", "format": "uuid" },
747+
{ "format": "date" },
748+
{ "$schema": meta_schemas::DRAFT07, "format": "date" },
749+
{ "$schema": "http://unknown", "format": "date" },
750+
{ "format": "custom1" },
751+
{ "$schema": meta_schemas::DRAFT07, "format": "custom1" },
752+
{ "$schema": "http://unknown", "format": "custom1" },
753+
{ },
754+
{ "$schema": meta_schemas::DRAFT07 },
755+
{ "$schema": "http://unknown", "format": "custom2" },
756+
]
757+
})
758+
);
759+
}
760+
}

0 commit comments

Comments
 (0)