use super::ip;
use super::steps::Steps;
use super::unexpected;
use crate::decoder::Decoder;
use crate::issue::{Issue, Issues};
use crate::java;
use crate::path::Path;
use crate::{codes, message_keys};
use serde_json::Value;
use std::marker::PhantomData;
use std::str::FromStr;
const MAX_EMAIL_LENGTH: usize = 254;
#[derive(Clone, Debug, Default)]
pub struct StringDecoder {
steps: Steps<String>,
}
pub fn string() -> StringDecoder {
StringDecoder::default()
}
impl Decoder<Value> for StringDecoder {
type Output = String;
fn decode_at(&self, input: &Value, path: &Path<'_>) -> Result<String, Issues> {
match input {
Value::String(s) => self.steps.run(s.clone(), path),
other => Err(self.steps.base_issue(unexpected(path, "string", other))),
}
}
}
fn char_count(s: &str) -> usize {
s.chars().count()
}
fn invalid_format(key: &'static str) -> Issue {
Issue::new(codes::INVALID_FORMAT).with_message_key(key)
}
impl StringDecoder {
fn transform(mut self, f: fn(&str) -> String) -> Self {
self.steps.transform(move |s| f(&s));
self
}
fn format(
mut self,
ok: impl Fn(&str) -> bool + Send + Sync + 'static,
key: &'static str,
) -> Self {
self.steps
.require(move |s| ok(s), move |_| invalid_format(key));
self
}
pub fn message(mut self, message: impl Into<String>) -> Self {
self.steps.set_message(message.into());
self
}
pub fn trim(self) -> Self {
self.transform(|s| s.trim().to_owned())
}
pub fn lowercase(self) -> Self {
self.transform(str::to_lowercase)
}
pub fn uppercase(self) -> Self {
self.transform(str::to_uppercase)
}
pub fn non_blank(mut self) -> Self {
self.steps.require(
|s| !s.chars().all(char::is_whitespace),
|_| Issue::new(codes::BLANK),
);
self
}
pub fn min_length(mut self, n: usize) -> Self {
self.steps.require(
move |s| char_count(s) >= n,
move |s| {
Issue::new(codes::TOO_SHORT)
.with_meta("min", n)
.with_meta("actual", char_count(s))
},
);
self
}
pub fn max_length(mut self, n: usize) -> Self {
self.steps.require(
move |s| char_count(s) <= n,
move |s| {
Issue::new(codes::TOO_LONG)
.with_meta("max", n)
.with_meta("actual", char_count(s))
},
);
self
}
pub fn length(mut self, n: usize) -> Self {
self.steps.require(
move |s| char_count(s) == n,
move |s| {
Issue::new(codes::INVALID_LENGTH)
.with_meta("expected", n)
.with_meta("actual", char_count(s))
},
);
self
}
pub fn starts_with(mut self, prefix: impl Into<String>) -> Self {
let prefix = prefix.into();
let expected = prefix.clone();
self.steps.require(
move |s| s.starts_with(expected.as_str()),
move |_| {
invalid_format(message_keys::INVALID_FORMAT_STARTS_WITH)
.with_meta("prefix", prefix.clone())
},
);
self
}
pub fn ends_with(mut self, suffix: impl Into<String>) -> Self {
let suffix = suffix.into();
let expected = suffix.clone();
self.steps.require(
move |s| s.ends_with(expected.as_str()),
move |_| {
invalid_format(message_keys::INVALID_FORMAT_ENDS_WITH)
.with_meta("suffix", suffix.clone())
},
);
self
}
pub fn contains(mut self, substring: impl Into<String>) -> Self {
let substring = substring.into();
let expected = substring.clone();
self.steps.require(
move |s| s.contains(expected.as_str()),
move |_| {
invalid_format(message_keys::INVALID_FORMAT_INCLUDES)
.with_meta("substring", substring.clone())
},
);
self
}
pub fn one_of<S: Into<String>>(mut self, allowed: impl IntoIterator<Item = S>) -> Self {
let mut allowed: Vec<String> = allowed.into_iter().map(Into::into).collect();
allowed.sort();
allowed.dedup();
let check = allowed.clone();
self.steps.require(
move |s| check.contains(s),
move |s| {
Issue::new(codes::NOT_ALLOWED)
.with_meta("allowed", allowed.clone())
.with_meta("actual", s.clone())
},
);
self
}
pub fn email(self) -> Self {
self.format(is_email, message_keys::INVALID_FORMAT_EMAIL)
}
pub fn ipv4(self) -> Self {
self.format(ip::is_ipv4, message_keys::INVALID_FORMAT_IPV4)
}
pub fn ipv6(self) -> Self {
self.format(ip::is_ipv6, message_keys::INVALID_FORMAT_IPV6)
}
pub fn ip(self) -> Self {
self.format(
|s| ip::is_ipv4(s) || ip::is_ipv6(s),
message_keys::INVALID_FORMAT_IP,
)
}
pub fn ulid(self) -> Self {
self.format(
|s| {
s.len() == 26
&& s.bytes().all(|b| {
b.is_ascii_digit()
|| (b.is_ascii_uppercase() && !matches!(b, b'I' | b'L' | b'O' | b'U'))
})
},
message_keys::INVALID_FORMAT_ULID,
)
}
pub fn cuid(self) -> Self {
self.format(
|s| {
s.len() == 25
&& s.starts_with('c')
&& s[1..]
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
},
message_keys::INVALID_FORMAT_CUID,
)
}
#[cfg(feature = "regex")]
pub fn pattern(mut self, pattern: &str) -> Self {
let anchored = regex::Regex::new(&format!("^(?:{pattern})$"))
.unwrap_or_else(|e| panic!("invalid pattern {pattern:?}: {e}"));
let pattern = pattern.to_owned();
self.steps.require(
move |s| anchored.is_match(s),
move |_| Issue::new(codes::INVALID_FORMAT).with_meta("pattern", pattern.clone()),
);
self
}
pub fn parse<T: FromStr>(self) -> Parse<T> {
Parse {
string: self,
message: None,
target: PhantomData,
}
}
#[cfg(feature = "uuid")]
pub fn uuid(self) -> UuidDecoder {
UuidDecoder {
string: self,
message: None,
}
}
#[cfg(feature = "url")]
pub fn url(self) -> UrlDecoder {
UrlDecoder {
string: self,
message: None,
}
}
}
pub struct Parse<T> {
string: StringDecoder,
message: Option<String>,
target: PhantomData<fn() -> T>,
}
impl<T> std::fmt::Debug for Parse<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Parse")
.field("string", &self.string)
.field("target", &std::any::type_name::<T>())
.finish()
}
}
impl<T> Clone for Parse<T> {
fn clone(&self) -> Self {
Self {
string: self.string.clone(),
message: self.message.clone(),
target: PhantomData,
}
}
}
impl<T> Parse<T> {
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
}
fn conversion_failed(path: &Path<'_>, key: &'static str, custom: &Option<String>) -> Issues {
let issue = Issue::at_path(path, codes::INVALID_FORMAT).with_message_key(key);
match custom {
Some(custom) => issue.with_message(custom.clone()).into(),
None => issue.into(),
}
}
impl<T: FromStr> Decoder<Value> for Parse<T> {
type Output = T;
fn decode_at(&self, input: &Value, path: &Path<'_>) -> Result<T, Issues> {
let s = self.string.decode_at(input, path)?;
s.parse()
.map_err(|_| conversion_failed(path, codes::INVALID_FORMAT, &self.message))
}
}
#[cfg(feature = "uuid")]
#[derive(Clone, Debug)]
pub struct UuidDecoder {
string: StringDecoder,
message: Option<String>,
}
#[cfg(feature = "uuid")]
impl UuidDecoder {
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
}
#[cfg(feature = "uuid")]
impl Decoder<Value> for UuidDecoder {
type Output = uuid::Uuid;
fn decode_at(&self, input: &Value, path: &Path<'_>) -> Result<uuid::Uuid, Issues> {
let s = self.string.decode_at(input, path)?;
uuid::Uuid::parse_str(&s)
.map_err(|_| conversion_failed(path, message_keys::INVALID_FORMAT_UUID, &self.message))
}
}
#[cfg(feature = "url")]
#[derive(Clone, Debug)]
pub struct UrlDecoder {
string: StringDecoder,
message: Option<String>,
}
#[cfg(feature = "url")]
impl UrlDecoder {
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
}
#[cfg(feature = "url")]
impl Decoder<Value> for UrlDecoder {
type Output = url::Url;
fn decode_at(&self, input: &Value, path: &Path<'_>) -> Result<url::Url, Issues> {
let s = self.string.decode_at(input, path)?;
let fail = || conversion_failed(path, message_keys::INVALID_FORMAT_URL, &self.message);
let url = url::Url::parse(&s).map_err(|_| fail())?;
let web = matches!(url.scheme(), "http" | "https");
let has_host = url.host_str().is_some_and(|h| !h.is_empty());
if web && has_host {
Ok(url)
} else {
Err(fail())
}
}
}
fn is_email(s: &str) -> bool {
if java::utf16_len(s) > MAX_EMAIL_LENGTH {
return false;
}
let Some((local, domain)) = s.split_once('@') else {
return false;
};
let local_ok = (1..=64).contains(&local.len())
&& local
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'%' | b'+' | b'-'));
let Some((host, tld)) = domain.rsplit_once('.') else {
return false;
};
let host_ok = (1..=255).contains(&host.len())
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-'));
let tld_ok = tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic());
local_ok && host_ok && tld_ok
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn first<T: std::fmt::Debug>(result: Result<T, Issues>) -> Issue {
result.unwrap_err().into_iter().next().unwrap()
}
#[test]
fn missing_and_null_are_required_and_other_types_mismatch() {
assert_eq!(first(string().decode(&Value::Null)).code(), "required");
let issue = first(string().decode(&json!(1)));
assert_eq!(issue.code(), "type_mismatch");
assert_eq!(issue.meta()["actual"], "number");
}
#[test]
fn the_first_failing_constraint_is_reported() {
let issues = string()
.min_length(3)
.email()
.decode(&json!("a"))
.unwrap_err();
assert_eq!(issues.len(), 1);
assert_eq!(issues.iter().next().unwrap().code(), "too_short");
}
#[test]
fn length_counts_code_points() {
assert!(string().max_length(2).decode(&json!("日本")).is_ok());
assert!(string().length(1).decode(&json!("😀")).is_ok());
}
#[test]
fn trim_and_non_blank_share_unicode_white_space() {
let trim = |s: &str| string().trim().decode(&json!(s)).unwrap();
assert_eq!(trim("\u{3000}a\u{a0}"), "a");
assert_eq!(trim("\u{0}a\u{1f}"), "\u{0}a\u{1f}");
assert_eq!(trim("\u{85}a\u{2028}"), "a");
assert_eq!(trim("\u{feff}a\u{180e}"), "\u{feff}a\u{180e}");
for blank in ["", "\u{a0}", "\u{3000}", "\u{2007}"] {
assert_eq!(
first(string().non_blank().decode(&json!(blank))).code(),
"blank"
);
}
for not_blank in ["\u{1c}", "\u{0}", "\u{200b}"] {
assert!(string().non_blank().decode(&json!(not_blank)).is_ok());
}
}
#[test]
fn email_follows_the_java_pattern() {
for ok in ["a@b.co", "first.last+tag@sub.example.com"] {
assert!(is_email(ok), "{ok}");
}
for bad in ["a@b", "@b.co", "a@@b.co", "a@b.c", "a b@c.co", "a@b.c0"] {
assert!(!is_email(bad), "{bad}");
}
}
#[test]
fn one_of_sorts_by_code_point() {
let issue = first(
string()
.one_of(["\u{1f600}", "\u{ff21}"])
.decode(&json!("z")),
);
assert_eq!(issue.meta()["allowed"], json!(["\u{ff21}", "\u{1f600}"]));
assert_eq!(issue.message(), "must be one of [\u{ff21}, \u{1f600}]");
}
#[test]
fn format_issues_name_the_check_in_their_key() {
let issue = first(string().email().decode(&json!("x")));
assert_eq!(issue.code(), "invalid_format");
assert_eq!(issue.message_key(), "invalid_format.email");
assert_eq!(issue.message(), "not a valid email");
}
#[test]
fn a_message_goes_to_the_latest_constraint_past_transformations() {
let decoder = string().min_length(3).trim().message("three or more");
assert_eq!(
first(decoder.decode(&json!("ab"))).message(),
"three or more"
);
let decoder = string().trim().message("give a name");
assert_eq!(first(decoder.decode(&Value::Null)).message(), "give a name");
}
}