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(®istry)
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}