use std::fmt;
use serde_json::Value;
use super::SchemaError;
use crate::{Degradations, Error, RequestField, Result};
#[cfg_attr(
not(feature = "extract"),
doc = "[crate::extract::SchemaEnforcement::NotEnforced]: crate#cargo-features"
)]
pub fn apply_strict_policy(
schema: &Value,
field: RequestField,
subject: &str,
degradations: &mut Degradations,
dialect: impl FnOnce(&Value, &mut Degradations) -> std::result::Result<Value, StrictSchemaError>,
degradable: Degradable,
) -> Result<StrictOutcome> {
match dialect(schema, degradations) {
Ok(schema) => Ok(StrictOutcome {
schema,
strict: true,
}),
Err(StrictSchemaError::NotRepresentable { reason }) => match degradable {
Degradable::Yes => {
degradations.unsupported_hint(
field,
format!("{subject} is not strict-representable: {reason}; it was sent without `strict`"),
)?;
Ok(StrictOutcome {
schema: without_schema_uri(schema),
strict: false,
})
}
Degradable::NoUnconstrainedForm => Err(degradations.unsupported(
field,
format!("{subject} needs strict mode, which cannot express it: {reason}"),
)),
},
Err(error @ StrictSchemaError::Invalid(_)) => {
Err(Error::invalid_request(format!("{field}: {subject}: {error}")).authored())
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct StrictOutcome {
pub schema: Value,
pub strict: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Degradable {
Yes,
NoUnconstrainedForm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StrictSchemaError {
#[non_exhaustive]
NotRepresentable {
reason: String,
},
Invalid(SchemaError),
}
impl StrictSchemaError {
#[must_use]
pub fn not_representable(reason: impl Into<String>) -> Self {
Self::NotRepresentable {
reason: reason.into(),
}
}
}
impl fmt::Display for StrictSchemaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotRepresentable { reason } => f.write_str(reason),
Self::Invalid(error) => write!(f, "invalid JSON schema: {error}"),
}
}
}
impl std::error::Error for StrictSchemaError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotRepresentable { .. } => None,
Self::Invalid(error) => Some(error),
}
}
}
impl From<SchemaError> for StrictSchemaError {
fn from(error: SchemaError) -> Self {
Self::Invalid(error)
}
}
fn without_schema_uri(schema: &Value) -> Value {
let mut out = schema.clone();
if let Value::Object(map) = &mut out {
map.remove("$schema");
}
out
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::{Subject, UnsupportedPolicy, WarningKind};
fn map() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": {"type": "string"}
})
}
fn approximating(
schema: &Value,
d: &mut Degradations,
) -> std::result::Result<Value, StrictSchemaError> {
let mut out = schema.clone();
out["additionalProperties"] = json!(false);
d.approximated(
Subject::Field {
field: RequestField::Tools,
},
"closed",
);
Ok(out)
}
fn not_representable(
_: &Value,
_: &mut Degradations,
) -> std::result::Result<Value, StrictSchemaError> {
Err(StrictSchemaError::not_representable(
"maps are not supported",
))
}
fn invalid(_: &Value, _: &mut Degradations) -> std::result::Result<Value, StrictSchemaError> {
Err(SchemaError::UnresolvedRef("#/nope".into()).into())
}
const POLICIES: [UnsupportedPolicy; 2] = [UnsupportedPolicy::Warn, UnsupportedPolicy::Error];
#[test]
fn a_representable_schema_goes_out_strict_with_the_dialect_warnings() {
for policy in POLICIES {
let mut d = Degradations::new("demo", policy);
let schema = json!({"type": "object"});
let outcome = apply_strict_policy(
&schema,
RequestField::Tools,
"tool `f`",
&mut d,
approximating,
Degradable::Yes,
)
.unwrap();
assert_eq!(
outcome,
StrictOutcome {
schema: json!({"type": "object", "additionalProperties": false}),
strict: true
}
);
let warnings = d.into_warnings();
assert_eq!(warnings.len(), 1, "{policy:?}");
assert_eq!(warnings[0].kind, WarningKind::Approximated);
}
}
#[test]
fn a_degradable_field_goes_out_non_strict_under_warn() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
let outcome = apply_strict_policy(
&map(),
RequestField::Tools,
"tool `f`",
&mut d,
not_representable,
Degradable::Yes,
)
.unwrap();
assert!(!outcome.strict);
assert_eq!(
outcome.schema,
json!({"type": "object", "additionalProperties": {"type": "string"}})
);
let warnings = d.into_warnings();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, WarningKind::Unsupported);
assert_eq!(
warnings[0].subject,
Subject::Field {
field: RequestField::Tools
}
);
assert_eq!(
warnings[0].message,
"tool `f` is not strict-representable: maps are not supported; it was sent without \
`strict`"
);
}
#[test]
fn a_degradable_field_fails_under_error() {
let mut d = Degradations::new("demo", UnsupportedPolicy::Error);
let err = apply_strict_policy(
&map(),
RequestField::Tools,
"tool `f`",
&mut d,
not_representable,
Degradable::Yes,
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported { .. }), "{err:?}");
assert_eq!(err.unsupported_field(), Some(RequestField::Tools));
assert!(d.warnings().is_empty());
}
#[test]
fn a_field_with_no_unconstrained_form_fails_under_every_policy() {
for policy in POLICIES {
let mut d = Degradations::new("demo", policy);
let err = apply_strict_policy(
&map(),
RequestField::ResponseFormat,
"the response schema",
&mut d,
not_representable,
Degradable::NoUnconstrainedForm,
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported { .. }), "{err:?}");
assert_eq!(
err.unsupported_field(),
Some(RequestField::ResponseFormat),
"{policy:?}"
);
assert!(err.to_string().contains("maps are not supported"), "{err}");
assert!(d.warnings().is_empty(), "{policy:?}");
}
}
#[test]
fn a_malformed_schema_fails_under_every_policy_and_field() {
for policy in POLICIES {
for degradable in [Degradable::Yes, Degradable::NoUnconstrainedForm] {
let mut d = Degradations::new("demo", policy);
let err = apply_strict_policy(
&map(),
RequestField::Tools,
"tool `f`",
&mut d,
invalid,
degradable,
)
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest { .. }), "{err:?}");
assert!(err.to_string().contains("#/nope"), "{err}");
assert_eq!(err.unsupported_field(), None);
assert!(d.warnings().is_empty());
}
}
}
#[test]
fn the_dialect_records_into_the_request_degradations() {
let mut scratch = Degradations::new("demo", UnsupportedPolicy::Warn);
scratch.approximated(
Subject::Field {
field: RequestField::Tools,
},
"removed `pattern`",
);
let held = scratch.into_warnings();
let mut d = Degradations::new("demo", UnsupportedPolicy::Warn);
let outcome = apply_strict_policy(
&json!({"type": "object"}),
RequestField::Tools,
"tool `f`",
&mut d,
|schema, d| {
d.extend(held);
Ok(schema.clone())
},
Degradable::Yes,
)
.unwrap();
assert!(outcome.strict);
assert_eq!(d.warnings()[0].message, "removed `pattern`");
}
#[test]
fn an_unmerged_ref_is_not_representable_and_a_merge_error_is_invalid() {
let conflict = json!({
"type": "object",
"properties": {"v": {"$ref": "#/$defs/S", "type": "integer"}},
"$defs": {"S": {"type": "string"}}
});
let merged = super::super::merge_sibling_refs(&conflict).unwrap();
assert!(matches!(
merged.into_strict(),
Err(StrictSchemaError::NotRepresentable { reason }) if reason.contains("/properties/v")
));
let broken = json!({"properties": {"a": {"$ref": "#/nope", "title": "A"}}});
let err: StrictSchemaError = super::super::merge_sibling_refs(&broken)
.unwrap_err()
.into();
assert_eq!(
err,
StrictSchemaError::Invalid(SchemaError::UnresolvedRef("#/nope".into()))
);
assert_eq!(
err.to_string(),
"invalid JSON schema: unresolved schema reference `#/nope`"
);
}
}