Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 44 additions & 39 deletions typify-impl/src/convert.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use std::collections::BTreeSet;

Expand Down Expand Up @@ -801,41 +801,6 @@ impl TypeSpace {
validation: Option<&StringValidation>,
) -> Result<(TypeEntry, &'a Option<Box<Metadata>>)> {
match format.as_ref().map(String::as_str) {
None => match validation {
// It should not be possible for the StringValidation to be
// Some, but all its fields to be None, but... just to be sure.
None
| Some(schemars::schema::StringValidation {
max_length: None,
min_length: None,
pattern: None,
}) => Ok((TypeEntryDetails::String.into(), metadata)),

Some(validation) => {
if let Some(pattern) = &validation.pattern {
let _ = regress::Regex::new(pattern).map_err(|e| Error::InvalidSchema {
type_name: type_name.clone().into_option(),
reason: format!("invalid pattern '{}' {}", pattern, e),
})?;
self.uses_regress = true;
}

let string = TypeEntryDetails::String.into();
let type_id = self.assign_type(string);
Ok((
TypeEntryNewtype::from_metadata_with_string_validation(
self,
type_name,
metadata,
type_id,
validation,
original_schema.clone(),
),
metadata,
))
}
},

Some("uuid") => {
self.uses_uuid = true;
Ok((
Expand Down Expand Up @@ -890,9 +855,49 @@ impl TypeSpace {
metadata,
)),

Some(unhandled) => {
info!("treating a string format '{}' as a String", unhandled);
Ok((TypeEntryDetails::String.into(), metadata))
// Apply constaints when there is no format or the format isn't
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simplified this rather than duplicating the code.

// one of the recognized values above.
other => {
if let Some(unhandled) = other {
debug!("treating a string format '{}' as a String", unhandled);
}

match validation {
// It should not be possible for the StringValidation to be
// Some, but all its fields to be None, but... just to be
// sure.
None
| Some(schemars::schema::StringValidation {
max_length: None,
min_length: None,
pattern: None,
}) => Ok((TypeEntryDetails::String.into(), metadata)),

Some(validation) => {
if let Some(pattern) = &validation.pattern {
let _ =
regress::Regex::new(pattern).map_err(|e| Error::InvalidSchema {
type_name: type_name.clone().into_option(),
reason: format!("invalid pattern '{}' {}", pattern, e),
})?;
self.uses_regress = true;
}

let string = TypeEntryDetails::String.into();
let type_id = self.assign_type(string);
Ok((
TypeEntryNewtype::from_metadata_with_string_validation(
self,
type_name,
metadata,
type_id,
validation,
original_schema.clone(),
),
metadata,
))
}
}
}
}
}
Expand Down
39 changes: 37 additions & 2 deletions typify-impl/src/merge.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2024 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use std::{
collections::{BTreeMap, BTreeSet},
Expand Down Expand Up @@ -783,7 +783,12 @@ fn merge_so_string(
let pattern = match (&a.pattern, &b.pattern) {
(None, v) | (v, None) => v.clone(),
(Some(x), Some(y)) if x == y => Some(x.clone()),
_ => unimplemented!("merging distinct patterns is impractical"),
// Combine distinct patterns using lookaheads so the merged
// string must satisfy all constraints. If x is already a
// sequence of lookaheads (produced by a prior merge), append
// rather than re-wrap nested lookaheads.
(Some(x), Some(y)) if x.starts_with("(?=") => Some(format!("{x}(?={y})")),
(Some(x), Some(y)) => Some(format!("(?={x})(?={y})")),
};

if let (Some(min), Some(max)) = (min_length, max_length) {
Expand Down Expand Up @@ -1885,4 +1890,34 @@ mod tests {
serde_json::to_string_pretty(&merged).unwrap(),
)
}

#[test]
fn test_merge_multiple_patterns() {
// Multiple schemas with distinct string patterns that must all be
// satisfied. The merged pattern should be a flat sequence of
// lookaheads: (?=p1)(?=p2)(?=p3).
let schemas: Vec<schemars::schema::Schema> = [
json!({"type": "string", "pattern": "^[a-z]+$"}),
json!({"type": "string", "pattern": "^.+[0-9].+$"}),
json!({"type": "string", "pattern": ".+[A-Z]$"}),
]
.into_iter()
.map(|v| serde_json::from_value(v).unwrap())
.collect();

let merged = super::merge_all(&schemas, &BTreeMap::default());

let expected: schemars::schema::Schema = serde_json::from_value(json!({
"type": "string",
"pattern": "(?=^[a-z]+$)(?=^.+[0-9].+$)(?=.+[A-Z]$)"
}))
.unwrap();

assert_eq!(
merged,
expected,
"{}",
serde_json::to_string_pretty(&merged).unwrap(),
);
}
}
1 change: 1 addition & 0 deletions typify-test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ ipnetwork = { workspace = true }
prettyplease = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
syn = { workspace = true }
46 changes: 45 additions & 1 deletion typify-test/build.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use std::collections::{HashMap, HashSet};
use std::{env, fs, path::Path};
Expand Down Expand Up @@ -125,6 +125,49 @@ struct UnknownFormat {
pancakes: Pancakes,
}

struct TriplePattern;
impl JsonSchema for TriplePattern {
fn schema_name() -> String {
"TriplePattern".to_string()
}

fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> Schema {
schemars::schema::SchemaObject {
subschemas: Some(Box::new(schemars::schema::SubschemaValidation {
all_of: Some(vec![
schemars::schema::SchemaObject {
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some("^[a-z].+$".to_string()),
..Default::default()
})),
..Default::default()
}
.into(),
schemars::schema::SchemaObject {
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some("^.{4,8}$".to_string()),
..Default::default()
})),
..Default::default()
}
.into(),
schemars::schema::SchemaObject {
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some(".+[a-z]$".to_string()),
..Default::default()
})),
..Default::default()
}
.into(),
]),
..Default::default()
})),
..Default::default()
}
.into()
}
}

fn main() {
let mut type_space = TypeSpace::default();

Expand All @@ -133,6 +176,7 @@ fn main() {
NonAsciiChars::add(&mut type_space);
UnknownFormat::add(&mut type_space);
ipnetwork::IpNetwork::add(&mut type_space);
TriplePattern::add(&mut type_space);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used a more consistent pattern here.


let contents =
prettyplease::unparse(&syn::parse2::<syn::File>(type_space.to_stream()).unwrap());
Expand Down
24 changes: 23 additions & 1 deletion typify-test/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

// Include the generated code to make sure it compiles.
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
Expand Down Expand Up @@ -56,6 +56,28 @@ fn test_unknown_format() {
};
}

#[test]
fn test_triple_pattern() {
// Must satisfy all three patterns simultaneously:
// 1. ^[a-z].+$ — starts with lowercase
// 2. ^.{4,8}$ — 4–8 characters long
// 3. .+[a-z]$ — ends with lowercase

// Valid: 4 lowercase letters
assert!(TriplePattern::try_from("abcd").is_ok());
// Valid: 6 lowercase letters
assert!(TriplePattern::try_from("abcdef").is_ok());

// Fails: starts with uppercase
assert!(TriplePattern::try_from("Abcd").is_err());
// Fails: ends with uppercase
assert!(TriplePattern::try_from("abcD").is_err());
// Fails: too short
assert!(TriplePattern::try_from("abc").is_err());
// Fails: too long
assert!(TriplePattern::try_from("abcdefghijkl").is_err());
}

mod hashmap {
#![allow(dead_code)]

Expand Down
17 changes: 17 additions & 0 deletions typify/tests/schemas/merged-schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,23 @@
}
}
]
},
"TriplePattern": {
"allOf": [
{
"type": "string",
"pattern": "^[a-z].+$",
"format": "custom-id"
},
{
"type": "string",
"pattern": "^.{4,8}$"
},
{
"type": "string",
"pattern": ".+[a-z]$"
}
]
}
}
}
85 changes: 85 additions & 0 deletions typify/tests/schemas/merged-schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,91 @@ impl TrimFat {
Default::default()
}
}
#[doc = "`TriplePattern`"]
#[doc = r""]
#[doc = r" <details><summary>JSON schema</summary>"]
#[doc = r""]
#[doc = r" ```json"]
#[doc = "{"]
#[doc = " \"allOf\": ["]
#[doc = " {"]
#[doc = " \"type\": \"string\","]
#[doc = " \"format\": \"custom-id\","]
#[doc = " \"pattern\": \"^[a-z].+$\""]
#[doc = " },"]
#[doc = " {"]
#[doc = " \"type\": \"string\","]
#[doc = " \"pattern\": \"^.{4,8}$\""]
#[doc = " },"]
#[doc = " {"]
#[doc = " \"type\": \"string\","]
#[doc = " \"pattern\": \".+[a-z]$\""]
#[doc = " }"]
#[doc = " ]"]
#[doc = "}"]
#[doc = r" ```"]
#[doc = r" </details>"]
#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[serde(transparent)]
pub struct TriplePattern(::std::string::String);
impl ::std::ops::Deref for TriplePattern {
type Target = ::std::string::String;
fn deref(&self) -> &::std::string::String {
&self.0
}
}
impl ::std::convert::From<TriplePattern> for ::std::string::String {
fn from(value: TriplePattern) -> Self {
value.0
}
}
impl ::std::str::FromStr for TriplePattern {
type Err = self::error::ConversionError;
fn from_str(value: &str) -> ::std::result::Result<Self, self::error::ConversionError> {
static PATTERN: ::std::sync::LazyLock<::regress::Regex> =
::std::sync::LazyLock::new(|| {
::regress::Regex::new("(?=^[a-z].+$)(?=^.{4,8}$)(?=.+[a-z]$)").unwrap()
});
if PATTERN.find(value).is_none() {
return Err("doesn't match pattern \"(?=^[a-z].+$)(?=^.{4,8}$)(?=.+[a-z]$)\"".into());
}
Ok(Self(value.to_string()))
}
}
impl ::std::convert::TryFrom<&str> for TriplePattern {
type Error = self::error::ConversionError;
fn try_from(value: &str) -> ::std::result::Result<Self, self::error::ConversionError> {
value.parse()
}
}
impl ::std::convert::TryFrom<&::std::string::String> for TriplePattern {
type Error = self::error::ConversionError;
fn try_from(
value: &::std::string::String,
) -> ::std::result::Result<Self, self::error::ConversionError> {
value.parse()
}
}
impl ::std::convert::TryFrom<::std::string::String> for TriplePattern {
type Error = self::error::ConversionError;
fn try_from(
value: ::std::string::String,
) -> ::std::result::Result<Self, self::error::ConversionError> {
value.parse()
}
}
impl<'de> ::serde::Deserialize<'de> for TriplePattern {
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
where
D: ::serde::Deserializer<'de>,
{
::std::string::String::deserialize(deserializer)?
.parse()
.map_err(|e: self::error::ConversionError| {
<D::Error as ::serde::de::Error>::custom(e.to_string())
})
}
}
#[doc = "`UnchangedByMerge`"]
#[doc = r""]
#[doc = r" <details><summary>JSON schema</summary>"]
Expand Down
Loading