Skip to main content

odp_core/
validation.rs

1use std::{collections::BTreeMap, sync::OnceLock};
2
3use include_dir::{Dir, include_dir};
4use jsonschema::{Registry, Validator};
5use language_tags::LanguageTag;
6use serde::de::DeserializeOwned;
7use serde_json::Value;
8use thiserror::Error;
9
10use crate::{
11    Collection, CollectionSearchRequest, FilterDefinition, FilterOperator, FilterType, Offering,
12    OfferingPage, OfferingSearchRequest, Operation, Page, ProblemDetails, ResourceIdentity,
13    ServiceDocument, SortDefinition,
14};
15
16static SCHEMA_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/schemas");
17static SCHEMAS: OnceLock<Result<SchemaSet, String>> = OnceLock::new();
18
19struct SchemaSet {
20    validators: BTreeMap<String, Validator>,
21}
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct ValidationIssue {
25    pub keyword: String,
26    pub message: String,
27    pub params: BTreeMap<String, Value>,
28    pub path: String,
29}
30
31#[derive(Clone, Debug, Error, PartialEq)]
32#[error("invalid ODP {document_type}")]
33pub struct ValidationError {
34    pub document_type: String,
35    pub issues: Vec<ValidationIssue>,
36}
37
38#[derive(Debug, Error)]
39pub enum ParseError {
40    #[error(transparent)]
41    Validation(#[from] ValidationError),
42    #[error("failed to initialize ODP schemas: {0}")]
43    SchemaInitialization(String),
44}
45
46pub fn parse_service_document(data: &[u8]) -> Result<ServiceDocument, ParseError> {
47    parse(
48        data,
49        "service-document.schema.json",
50        "Service Document",
51        service_document_issues,
52    )
53}
54
55pub fn parse_collection(data: &[u8]) -> Result<Collection, ParseError> {
56    parse(
57        data,
58        "collection.schema.json",
59        "Collection",
60        |value: &Collection| {
61            representation_issues(&value.language, &value.localizations, &value.images)
62        },
63    )
64}
65
66pub fn parse_offering(data: &[u8]) -> Result<Offering, ParseError> {
67    parse(
68        data,
69        "offering.schema.json",
70        "Offering",
71        |value: &Offering| {
72            representation_issues(&value.language, &value.localizations, &value.images)
73        },
74    )
75}
76
77pub fn parse_problem_details(data: &[u8]) -> Result<ProblemDetails, ParseError> {
78    parse_without_refinement(data, "problem-details.schema.json", "Problem Details")
79}
80
81pub fn parse_problem_response(data: &[u8], http_status: u16) -> Result<ProblemDetails, ParseError> {
82    let value = parse_problem_details(data)?;
83    if value.status != http_status {
84        return Err(ValidationError {
85            document_type: "Problem Details".to_owned(),
86            issues: vec![issue(
87                "/status",
88                "http-status",
89                "must match the HTTP response status",
90            )],
91        }
92        .into());
93    }
94    Ok(value)
95}
96
97pub fn parse_resource_identity(data: &[u8]) -> Result<ResourceIdentity, ParseError> {
98    parse_without_refinement(data, "resource-identity.schema.json", "resource identity")
99}
100
101pub fn parse_page<T: DeserializeOwned>(data: &[u8]) -> Result<Page<T>, ParseError> {
102    parse_without_refinement(data, "page-envelope.schema.json", "page envelope")
103}
104
105pub fn parse_collection_search_request(data: &[u8]) -> Result<CollectionSearchRequest, ParseError> {
106    parse_without_refinement(
107        data,
108        "collection-search-request.schema.json",
109        "Collection search request",
110    )
111}
112
113pub fn parse_offering_search_request(data: &[u8]) -> Result<OfferingSearchRequest, ParseError> {
114    parse_without_refinement(
115        data,
116        "offering-search-request.schema.json",
117        "Offering search request",
118    )
119}
120
121pub fn parse_offering_search_response(data: &[u8]) -> Result<OfferingPage<Offering>, ParseError> {
122    parse_without_refinement(
123        data,
124        "offering-search-response.schema.json",
125        "Offering search response",
126    )
127}
128
129pub fn parse_filter_definition(data: &[u8]) -> Result<FilterDefinition, ParseError> {
130    parse(
131        data,
132        "filter-definition.schema.json",
133        "Filter Definition",
134        filter_definition_issues,
135    )
136}
137
138pub fn parse_sort_definition(data: &[u8]) -> Result<SortDefinition, ParseError> {
139    parse_without_refinement(data, "sort-definition.schema.json", "Sort Definition")
140}
141
142pub fn parse_filter_definition_page(data: &[u8]) -> Result<Page<FilterDefinition>, ParseError> {
143    parse_without_refinement(
144        data,
145        "filter-definition-page.schema.json",
146        "Filter Definition page",
147    )
148}
149
150pub fn parse_sort_definition_page(data: &[u8]) -> Result<Page<SortDefinition>, ParseError> {
151    parse_without_refinement(
152        data,
153        "sort-definition-page.schema.json",
154        "Sort Definition page",
155    )
156}
157
158pub fn validate_value(
159    value: &Value,
160    schema_name: &str,
161    document_type: &str,
162) -> Result<(), ParseError> {
163    let schemas = schemas()?;
164    let validator = schemas.validators.get(schema_name).ok_or_else(|| {
165        ParseError::SchemaInitialization(format!("missing bundled schema {schema_name}"))
166    })?;
167    let issues = validator
168        .iter_errors(value)
169        .map(|error| {
170            let schema_path = error.schema_path().to_string();
171            ValidationIssue {
172                keyword: schema_path
173                    .rsplit('/')
174                    .next()
175                    .filter(|value| !value.is_empty())
176                    .unwrap_or("schema")
177                    .to_owned(),
178                message: error.to_string(),
179                params: BTreeMap::new(),
180                path: error.instance_path().to_string(),
181            }
182        })
183        .collect::<Vec<_>>();
184    if issues.is_empty() {
185        Ok(())
186    } else {
187        Err(ValidationError {
188            document_type: document_type.to_owned(),
189            issues,
190        }
191        .into())
192    }
193}
194
195fn parse_without_refinement<T: DeserializeOwned>(
196    data: &[u8],
197    schema_name: &str,
198    document_type: &str,
199) -> Result<T, ParseError> {
200    parse(data, schema_name, document_type, |_| Vec::new())
201}
202
203fn parse<T: DeserializeOwned>(
204    data: &[u8],
205    schema_name: &str,
206    document_type: &str,
207    refine: impl FnOnce(&T) -> Vec<ValidationIssue>,
208) -> Result<T, ParseError> {
209    let raw = serde_json::from_slice(data).map_err(|error| ValidationError {
210        document_type: document_type.to_owned(),
211        issues: vec![issue("", "json", &error.to_string())],
212    })?;
213    validate_value(&raw, schema_name, document_type)?;
214    let value = serde_json::from_value(raw).map_err(|error| ValidationError {
215        document_type: document_type.to_owned(),
216        issues: vec![issue("", "decode", &error.to_string())],
217    })?;
218    let issues = refine(&value);
219    if issues.is_empty() {
220        Ok(value)
221    } else {
222        Err(ValidationError {
223            document_type: document_type.to_owned(),
224            issues,
225        }
226        .into())
227    }
228}
229
230fn schemas() -> Result<&'static SchemaSet, ParseError> {
231    SCHEMAS
232        .get_or_init(initialize_schemas)
233        .as_ref()
234        .map_err(|error| ParseError::SchemaInitialization(error.clone()))
235}
236
237fn initialize_schemas() -> Result<SchemaSet, String> {
238    let mut documents = BTreeMap::new();
239    for file in SCHEMA_FILES.files() {
240        let name = file
241            .path()
242            .file_name()
243            .and_then(|name| name.to_str())
244            .ok_or_else(|| "bundled schema has an invalid name".to_owned())?;
245        let value = serde_json::from_slice(file.contents())
246            .map_err(|error| format!("decode {name}: {error}"))?;
247        documents.insert(name.to_owned(), value);
248    }
249    let mut registry = Registry::new();
250    for (name, value) in &documents {
251        registry = registry
252            .add(
253                format!("https://offeringprotocol.org/schemas/{name}"),
254                value,
255            )
256            .map_err(|error| format!("register {name}: {error}"))?;
257    }
258    let registry = registry
259        .prepare()
260        .map_err(|error| format!("prepare schema registry: {error}"))?;
261    let mut validators = BTreeMap::new();
262    for (name, value) in &documents {
263        let validator = jsonschema::options()
264            .with_registry(&registry)
265            .should_validate_formats(true)
266            .build(value)
267            .map_err(|error| format!("compile {name}: {error}"))?;
268        validators.insert(name.clone(), validator);
269    }
270    Ok(SchemaSet { validators })
271}
272
273fn service_document_issues(value: &ServiceDocument) -> Vec<ValidationIssue> {
274    let mut issues = Vec::new();
275    if value.additional.contains_key("id") {
276        issues.push(issue(
277            "/id",
278            "prohibited",
279            "must not appear in a Service Document",
280        ));
281    }
282    if value.additional.contains_key("web_url") {
283        issues.push(issue(
284            "/web_url",
285            "prohibited",
286            "must not appear in a Service Document",
287        ));
288    }
289    if !valid_language_tag(&value.language) {
290        issues.push(issue("/language", "language-tag", "must be a language tag"));
291    }
292    validate_localizations(&value.language, &value.localizations, true, &mut issues);
293    if value
294        .keywords
295        .iter()
296        .map(|keyword| keyword.chars().count())
297        .sum::<usize>()
298        > 1024
299    {
300        issues.push(issue(
301            "/keywords",
302            "max-code-points",
303            "must contain no more than 1024 code points in total",
304        ));
305    }
306    if value.search_capabilities.is_some()
307        && !value
308            .operations
309            .iter()
310            .any(|operation| operation.name == Operation::SearchOfferings)
311    {
312        issues.push(issue(
313            "/search_capabilities",
314            "operation-support",
315            "requires the search-offerings operation",
316        ));
317    }
318    issues
319}
320
321fn representation_issues(
322    language: &str,
323    localizations: &[String],
324    images: &[crate::ResourceImage],
325) -> Vec<ValidationIssue> {
326    let mut issues = Vec::new();
327    if !language.is_empty() && !valid_language_tag(language) {
328        issues.push(issue("/language", "language-tag", "must be a language tag"));
329    }
330    validate_localizations(language, localizations, false, &mut issues);
331    let mut sources = std::collections::BTreeSet::new();
332    if images.iter().any(|image| !sources.insert(&image.src)) {
333        issues.push(issue(
334            "/images",
335            "unique-image-source",
336            "must contain unique image sources",
337        ));
338    }
339    issues
340}
341
342fn validate_localizations(
343    language: &str,
344    localizations: &[String],
345    require_default: bool,
346    issues: &mut Vec<ValidationIssue>,
347) {
348    if localizations.iter().any(|tag| !valid_language_tag(tag)) {
349        issues.push(issue(
350            "/localizations",
351            "language-tag",
352            "must contain only language tags",
353        ));
354        return;
355    }
356    let folded = localizations
357        .iter()
358        .map(|tag| tag.to_ascii_lowercase())
359        .collect::<std::collections::BTreeSet<_>>();
360    if folded.len() != localizations.len() {
361        issues.push(issue(
362            "/localizations",
363            "unique-language-tag",
364            "must be unique without regard to case",
365        ));
366    }
367    if (require_default || (!language.is_empty() && !localizations.is_empty()))
368        && !folded.contains(&language.to_ascii_lowercase())
369    {
370        issues.push(issue(
371            "/localizations",
372            if require_default {
373                "contains-default-language"
374            } else {
375                "contains-language"
376            },
377            if require_default {
378                "must contain the default language"
379            } else {
380                "must contain the representation language"
381            },
382        ));
383    }
384}
385
386fn valid_language_tag(value: &str) -> bool {
387    let Ok(tag) = value.parse::<LanguageTag>() else {
388        return false;
389    };
390    let mut variants = std::collections::BTreeSet::new();
391    if tag
392        .variant_subtags()
393        .any(|variant| !variants.insert(variant.to_ascii_lowercase()))
394    {
395        return false;
396    }
397    let mut extensions = std::collections::BTreeSet::new();
398    !tag.extension_subtags()
399        .any(|(singleton, _)| !extensions.insert(singleton.to_ascii_lowercase()))
400}
401
402fn filter_definition_issues(value: &FilterDefinition) -> Vec<ValidationIssue> {
403    let mut issues = Vec::new();
404    if matches!(value.filter_type, FilterType::String | FilterType::Boolean)
405        && value.operators.iter().any(|operator| {
406            matches!(
407                operator,
408                FilterOperator::GreaterThan
409                    | FilterOperator::GreaterThanOrEqual
410                    | FilterOperator::LessThan
411                    | FilterOperator::LessThanOrEqual
412            )
413        })
414    {
415        issues.push(issue(
416            "/operators",
417            "operator-type",
418            "contains an operator incompatible with the Filter type",
419        ));
420    }
421    if value.filter_type == FilterType::Boolean && value.unit.is_some() {
422        issues.push(issue(
423            "/unit",
424            "unit-type",
425            "must not appear on a boolean Filter",
426        ));
427    }
428    issues
429}
430
431fn issue(path: &str, keyword: &str, message: &str) -> ValidationIssue {
432    ValidationIssue {
433        keyword: keyword.to_owned(),
434        message: message.to_owned(),
435        params: BTreeMap::new(),
436        path: path.to_owned(),
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn parses_normative_service_document() {
446        let document = br#"{
447            "description":"Plant store",
448            "http":{"endpoint_base":"/odp"},
449            "language":"en",
450            "localizations":["en"],
451            "name":"Plants",
452            "odp_version":"1.0",
453            "operations":[
454                {"authentication":"not-required","name":"get-offering"},
455                {"authentication":"not-required","name":"list-offerings"}
456            ]
457        }"#;
458        assert_eq!(parse_service_document(document).unwrap().name, "Plants");
459    }
460
461    #[test]
462    fn returns_structured_validation_issues() {
463        let error = parse_service_document(br#"{}"#).unwrap_err();
464        let ParseError::Validation(error) = error else {
465            panic!("expected validation error");
466        };
467        assert!(!error.issues.is_empty());
468    }
469
470    #[test]
471    fn rejects_duplicate_language_variants() {
472        let document = br#"{
473            "description":"An example Service.",
474            "http":{"endpoint_base":"/odp"},
475            "language":"sl-rozaj-rozaj",
476            "localizations":["sl-rozaj-rozaj"],
477            "name":"Example",
478            "odp_version":"1.0",
479            "operations":[
480                {"authentication":"not-required","name":"get-offering"},
481                {"authentication":"not-required","name":"list-offerings"}
482            ]
483        }"#;
484        assert!(parse_service_document(document).is_err());
485    }
486}