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_agent_service_document(data: &[u8]) -> Result<ServiceDocument, ParseError> {
56 let encoded = normalize_agent_response(data, "service-document")?;
57 parse_service_document(&encoded)
58}
59
60pub fn normalize_agent_response(data: &[u8], kind: &str) -> Result<Vec<u8>, ParseError> {
61 let mut raw = serde_json::from_slice::<Value>(data).map_err(|error| ValidationError {
62 document_type: "Agent response".to_owned(),
63 issues: vec![issue("", "json", &error.to_string())],
64 })?;
65 normalize_agent_document(&mut raw, kind);
66 serde_json::to_vec(&raw)
67 .map_err(|error| ValidationError {
68 document_type: "Agent response".to_owned(),
69 issues: vec![issue("", "json", &error.to_string())],
70 })
71 .map_err(ParseError::from)
72}
73
74fn normalize_agent_document(document: &mut Value, kind: &str) {
75 match kind {
76 "service-document" => {
77 filter_agent_protocols(document);
78 if let Some(protocols) = document.get_mut("protocols") {
79 filter_unknown_authentication(protocols, "payments");
80 }
81 filter_list(
82 document,
83 "operations",
84 "name",
85 &[
86 "get-collection",
87 "get-offering",
88 "list-collection-offerings",
89 "list-collections",
90 "list-offerings",
91 "search-collections",
92 "search-offerings",
93 ],
94 );
95 filter_unknown_authentication(document, "operations");
96 filter_list(document, "mcp", "type", &["streamable-http"]);
97 filter_closed_object_list(document, "operations", &["authentication", "name"]);
98 filter_closed_object_list(document, "mcp", &["description", "name", "type", "url"]);
99 filter_payment_options(document);
100 normalize_branding(document);
101 normalize_search_capabilities(document);
102 }
103 "collection" | "offering" => {
104 filter_list(
105 document,
106 "images",
107 "type",
108 &[
109 "image/avif",
110 "image/jpeg",
111 "image/png",
112 "image/svg+xml",
113 "image/webp",
114 ],
115 );
116 strip_object_list(
117 document,
118 "images",
119 &["alt", "height", "src", "type", "width"],
120 );
121 normalize_search_capabilities(document);
122 if kind == "offering" {
123 normalize_offering(document);
124 }
125 }
126 "collection-page" | "offering-page" => {
127 let item_kind = if kind == "offering-page" {
128 "offering"
129 } else {
130 "collection"
131 };
132 if let Some(items) = document
133 .as_object_mut()
134 .and_then(|value| value.get_mut("items"))
135 .and_then(Value::as_array_mut)
136 {
137 for item in items {
138 normalize_agent_document(item, item_kind);
139 }
140 }
141 }
142 "filter-page" => filter_definitions(document, known_filter),
143 "sort-page" => filter_definitions(document, known_sort),
144 "problem" => filter_problem_parameters(document),
145 _ => {}
146 }
147}
148
149fn filter_list(document: &mut Value, member: &str, discriminator: &str, recognized: &[&str]) {
150 let Some(object) = document.as_object_mut() else {
151 return;
152 };
153 let Some(Value::Array(items)) = object.get_mut(member) else {
154 return;
155 };
156 items.retain(|item| {
157 item.as_object()
158 .and_then(|value| value.get(discriminator))
159 .and_then(Value::as_str)
160 .is_none_or(|value| recognized.contains(&value))
161 });
162 if items.is_empty() {
163 object.remove(member);
164 }
165}
166
167fn filter_closed_object_list(document: &mut Value, member: &str, allowed: &[&str]) {
168 let Some(object) = document.as_object_mut() else {
169 return;
170 };
171 let Some(Value::Array(items)) = object.get_mut(member) else {
172 return;
173 };
174 items.retain(|item| {
175 item.as_object()
176 .is_none_or(|value| value.keys().all(|key| allowed.contains(&key.as_str())))
177 });
178 if items.is_empty() {
179 object.remove(member);
180 }
181}
182
183fn filter_unknown_authentication(document: &mut Value, member: &str) {
184 let Some(object) = document.as_object_mut() else {
185 return;
186 };
187 let Some(Value::Array(items)) = object.get_mut(member) else {
188 return;
189 };
190 items.retain(|item| !has_unknown_authentication(item));
191 if items.is_empty() {
192 object.remove(member);
193 }
194}
195
196fn has_unknown_authentication(value: &Value) -> bool {
197 value
198 .get("authentication")
199 .and_then(Value::as_str)
200 .is_some_and(|authentication| {
201 !["not-required", "optional", "required"].contains(&authentication)
202 })
203}
204
205fn strip_object_list(document: &mut Value, member: &str, allowed: &[&str]) {
206 let Some(items) = document.get_mut(member).and_then(Value::as_array_mut) else {
207 return;
208 };
209 for item in items {
210 if let Some(object) = item.as_object_mut() {
211 object.retain(|key, _value| allowed.contains(&key.as_str()));
212 }
213 }
214}
215
216fn filter_payment_options(document: &mut Value) {
217 let Some(payments) = document
218 .pointer_mut("/protocols/payments")
219 .and_then(Value::as_array_mut)
220 else {
221 return;
222 };
223 let recognized = [
224 "algorand",
225 "aptos",
226 "arbitrum",
227 "avalanche",
228 "base",
229 "card",
230 "ethereum",
231 "hedera",
232 "inflow",
233 "lightning",
234 "polygon",
235 "solana",
236 "stellar",
237 "stripe",
238 "tempo",
239 "ton",
240 ];
241 for payment in payments {
242 let Some(object) = payment.as_object_mut() else {
243 continue;
244 };
245 let Some(Value::Array(options)) = object.get_mut("options") else {
246 continue;
247 };
248 options.retain(|option| {
249 option
250 .as_str()
251 .is_none_or(|value| recognized.contains(&value))
252 });
253 if options.is_empty() {
254 object.remove("options");
255 }
256 }
257}
258
259fn normalize_branding(document: &mut Value) {
260 let Some(object) = document.as_object_mut() else {
261 return;
262 };
263 let Some(Value::Object(branding)) = object.get_mut("branding") else {
264 return;
265 };
266 branding.retain(|key, _value| matches!(key.as_str(), "icon" | "logo"));
267 let recognized = ["image/png", "image/svg+xml", "image/webp"];
268 for member in ["icon", "logo"] {
269 let unknown = branding
270 .get(member)
271 .and_then(Value::as_object)
272 .and_then(|image| image.get("type"))
273 .and_then(Value::as_str)
274 .is_some_and(|image_type| !recognized.contains(&image_type));
275 if unknown {
276 branding.remove(member);
277 } else if let Some(Value::Object(image)) = branding.get_mut(member) {
278 image.retain(|key, _value| matches!(key.as_str(), "src" | "type"));
279 }
280 }
281 if branding.is_empty() {
282 object.remove("branding");
283 }
284}
285
286fn normalize_search_capabilities(document: &mut Value) {
287 let Some(object) = document.as_object_mut() else {
288 return;
289 };
290 let Some(Value::Object(capabilities)) = object.get_mut("search_capabilities") else {
291 return;
292 };
293 filter_inline_definitions(capabilities, "filters", known_filter);
294 filter_inline_definitions(capabilities, "sorts", known_sort);
295 if capabilities.is_empty() {
296 object.remove("search_capabilities");
297 }
298}
299
300fn filter_inline_definitions(
301 capabilities: &mut serde_json::Map<String, Value>,
302 member: &str,
303 recognized: fn(&Value) -> bool,
304) {
305 let Some(Value::Array(items)) = capabilities
306 .get_mut(member)
307 .and_then(Value::as_object_mut)
308 .and_then(|source| source.get_mut("inline"))
309 else {
310 return;
311 };
312 items.retain(recognized);
313 if items.is_empty() {
314 capabilities.remove(member);
315 }
316}
317
318fn normalize_offering(document: &mut Value) {
319 let Some(object) = document.as_object_mut() else {
320 return;
321 };
322 if object
323 .get("schema")
324 .and_then(Value::as_object)
325 .is_some_and(|schema| schema.keys().any(|key| key != "url"))
326 {
327 object.remove("schema");
328 }
329 let known_prices = ["fixed", "free", "metered", "quote", "range", "starting_at"];
330 let unknown_price = object
331 .get("price")
332 .and_then(Value::as_object)
333 .and_then(|price| price.get("type"))
334 .and_then(Value::as_str)
335 .is_some_and(|price_type| !known_prices.contains(&price_type));
336 if unknown_price {
337 object.remove("price");
338 }
339 let Some(Value::Array(actions)) = object.get_mut("actions") else {
340 return;
341 };
342 actions.retain(|action| {
343 if has_unknown_authentication(action) {
344 return false;
345 }
346 if action.as_object().is_some_and(|value| {
347 value.keys().any(|key| {
348 ![
349 "authentication",
350 "description",
351 "http",
352 "id",
353 "openapi",
354 "rel",
355 ]
356 .contains(&key.as_str())
357 })
358 }) {
359 return false;
360 }
361 if action
362 .pointer("/http")
363 .and_then(Value::as_object)
364 .is_some_and(|value| {
365 value.keys().any(|key| {
366 !["href", "method", "request", "response_content_types"].contains(&key.as_str())
367 })
368 })
369 {
370 return false;
371 }
372 if action
373 .pointer("/http/request")
374 .and_then(Value::as_object)
375 .is_some_and(|value| {
376 value
377 .keys()
378 .any(|key| !["content_type", "schema"].contains(&key.as_str()))
379 })
380 {
381 return false;
382 }
383 if action
384 .pointer("/http/request/schema")
385 .and_then(Value::as_object)
386 .is_some_and(|value| value.keys().any(|key| key != "url"))
387 {
388 return false;
389 }
390 if action
391 .get("openapi")
392 .and_then(Value::as_object)
393 .is_some_and(|value| {
394 value
395 .keys()
396 .any(|key| !["operation_id", "url"].contains(&key.as_str()))
397 })
398 {
399 return false;
400 }
401 action
402 .pointer("/http/method")
403 .and_then(Value::as_str)
404 .is_none_or(|method| method == "GET" || method == "POST")
405 });
406 if actions.is_empty() {
407 object.remove("actions");
408 }
409}
410
411fn filter_definitions(document: &mut Value, recognized: fn(&Value) -> bool) {
412 if let Some(items) = document
413 .as_object_mut()
414 .and_then(|value| value.get_mut("items"))
415 .and_then(Value::as_array_mut)
416 {
417 items.retain(recognized);
418 }
419}
420
421fn known_filter(definition: &Value) -> bool {
422 let Some(object) = definition.as_object() else {
423 return true;
424 };
425 let types = [
426 "boolean",
427 "date",
428 "date-time",
429 "decimal",
430 "integer",
431 "number",
432 "string",
433 ];
434 if object
435 .get("type")
436 .and_then(Value::as_str)
437 .is_some_and(|value| !types.contains(&value))
438 {
439 return false;
440 }
441 let operators = ["eq", "exists", "gt", "gte", "in", "lt", "lte"];
442 if object
443 .get("operators")
444 .and_then(Value::as_array)
445 .is_some_and(|values| {
446 values.iter().any(|value| {
447 value
448 .as_str()
449 .is_some_and(|operator| !operators.contains(&operator))
450 })
451 })
452 {
453 return false;
454 }
455 !definition
456 .pointer("/unit/system")
457 .and_then(Value::as_str)
458 .is_some_and(|system| system != "service" && system != "ucum")
459}
460
461fn known_sort(definition: &Value) -> bool {
462 !definition
463 .get("keys")
464 .and_then(Value::as_array)
465 .is_some_and(|keys| {
466 keys.iter().any(|key| {
467 key.get("direction")
468 .and_then(Value::as_str)
469 .is_some_and(|value| value != "ascending" && value != "descending")
470 || key
471 .get("missing")
472 .and_then(Value::as_str)
473 .is_some_and(|value| value != "first" && value != "last")
474 })
475 })
476}
477
478fn filter_problem_parameters(document: &mut Value) {
479 let Some(parameters) = document
480 .get_mut("invalid_params")
481 .and_then(Value::as_array_mut)
482 else {
483 return;
484 };
485 let recognized = ["body", "header", "path", "query"];
486 parameters.retain(|parameter| {
487 parameter
488 .get("in")
489 .and_then(Value::as_str)
490 .is_none_or(|location| recognized.contains(&location))
491 });
492}
493
494fn filter_agent_protocols(document: &mut Value) {
495 let Some(protocols) = document
496 .as_object_mut()
497 .and_then(|document| document.get_mut("protocols"))
498 .and_then(Value::as_object_mut)
499 else {
500 return;
501 };
502 filter_agent_protocol_category(protocols, "enrollment", &["aep"]);
503 filter_agent_protocol_category(protocols, "payments", &["mpp", "x402"]);
504 filter_agent_protocol_category(protocols, "trust", &["tap"]);
505 let remove_protocols = protocols.is_empty();
506 if remove_protocols {
507 if let Some(document) = document.as_object_mut() {
508 document.remove("protocols");
509 }
510 }
511}
512
513fn filter_agent_protocol_category(
514 protocols: &mut serde_json::Map<String, Value>,
515 category: &str,
516 recognized: &[&str],
517) {
518 let Some(Value::Array(descriptors)) = protocols.get_mut(category) else {
519 return;
520 };
521 let original_length = descriptors.len();
522 descriptors.retain(|descriptor| {
523 descriptor
524 .as_object()
525 .and_then(|value| value.get("name"))
526 .and_then(Value::as_str)
527 .is_none_or(|name| recognized.contains(&name))
528 });
529 if original_length != 0 && descriptors.is_empty() {
530 protocols.remove(category);
531 }
532}
533
534pub fn parse_collection(data: &[u8]) -> Result<Collection, ParseError> {
535 parse(
536 data,
537 "collection.schema.json",
538 "Collection",
539 |value: &Collection| {
540 representation_issues(&value.language, &value.localizations, &value.images)
541 },
542 )
543}
544
545pub fn parse_offering(data: &[u8]) -> Result<Offering, ParseError> {
546 parse(
547 data,
548 "offering.schema.json",
549 "Offering",
550 |value: &Offering| {
551 representation_issues(&value.language, &value.localizations, &value.images)
552 },
553 )
554}
555
556pub fn parse_problem_details(data: &[u8]) -> Result<ProblemDetails, ParseError> {
557 parse(
558 data,
559 "problem-details.schema.json",
560 "Problem Details",
561 problem_details_issues,
562 )
563}
564
565pub fn parse_problem_response(data: &[u8], http_status: u16) -> Result<ProblemDetails, ParseError> {
566 let value = parse_problem_details(data)?;
567 if value.status != http_status {
568 return Err(ValidationError {
569 document_type: "Problem Details".to_owned(),
570 issues: vec![issue(
571 "/status",
572 "http-status",
573 "must match the HTTP response status",
574 )],
575 }
576 .into());
577 }
578 Ok(value)
579}
580
581pub fn parse_resource_identity(data: &[u8]) -> Result<ResourceIdentity, ParseError> {
582 parse_without_refinement(data, "resource-identity.schema.json", "resource identity")
583}
584
585pub fn parse_page<T: DeserializeOwned>(data: &[u8]) -> Result<Page<T>, ParseError> {
586 parse_without_refinement(data, "page-envelope.schema.json", "page envelope")
587}
588
589pub fn parse_collection_search_request(data: &[u8]) -> Result<CollectionSearchRequest, ParseError> {
590 parse_without_refinement(
591 data,
592 "collection-search-request.schema.json",
593 "Collection search request",
594 )
595}
596
597pub fn parse_offering_search_request(data: &[u8]) -> Result<OfferingSearchRequest, ParseError> {
598 parse_without_refinement(
599 data,
600 "offering-search-request.schema.json",
601 "Offering search request",
602 )
603}
604
605pub fn parse_offering_search_response(data: &[u8]) -> Result<OfferingPage<Offering>, ParseError> {
606 parse_without_refinement(
607 data,
608 "offering-search-response.schema.json",
609 "Offering search response",
610 )
611}
612
613pub fn parse_filter_definition(data: &[u8]) -> Result<FilterDefinition, ParseError> {
614 parse(
615 data,
616 "filter-definition.schema.json",
617 "Filter Definition",
618 filter_definition_issues,
619 )
620}
621
622pub fn parse_sort_definition(data: &[u8]) -> Result<SortDefinition, ParseError> {
623 parse_without_refinement(data, "sort-definition.schema.json", "Sort Definition")
624}
625
626pub fn parse_filter_definition_page(data: &[u8]) -> Result<Page<FilterDefinition>, ParseError> {
627 parse_without_refinement(
628 data,
629 "filter-definition-page.schema.json",
630 "Filter Definition page",
631 )
632}
633
634pub fn parse_sort_definition_page(data: &[u8]) -> Result<Page<SortDefinition>, ParseError> {
635 parse_without_refinement(
636 data,
637 "sort-definition-page.schema.json",
638 "Sort Definition page",
639 )
640}
641
642pub fn validate_value(
643 value: &Value,
644 schema_name: &str,
645 document_type: &str,
646) -> Result<(), ParseError> {
647 let schemas = schemas()?;
648 let validator = schemas.validators.get(schema_name).ok_or_else(|| {
649 ParseError::SchemaInitialization(format!("missing bundled schema {schema_name}"))
650 })?;
651 let issues = validator
652 .iter_errors(value)
653 .map(|error| {
654 let schema_path = error.schema_path().to_string();
655 ValidationIssue {
656 keyword: schema_path
657 .rsplit('/')
658 .next()
659 .filter(|value| !value.is_empty())
660 .unwrap_or("schema")
661 .to_owned(),
662 message: error.to_string(),
663 params: BTreeMap::new(),
664 path: error.instance_path().to_string(),
665 }
666 })
667 .collect::<Vec<_>>();
668 if issues.is_empty() {
669 Ok(())
670 } else {
671 Err(ValidationError {
672 document_type: document_type.to_owned(),
673 issues,
674 }
675 .into())
676 }
677}
678
679fn parse_without_refinement<T: DeserializeOwned>(
680 data: &[u8],
681 schema_name: &str,
682 document_type: &str,
683) -> Result<T, ParseError> {
684 parse(data, schema_name, document_type, |_| Vec::new())
685}
686
687fn problem_details_issues(value: &ProblemDetails) -> Vec<ValidationIssue> {
688 let expected_type = format!(
689 "https://offeringprotocol.org/problems/{}",
690 value.code.to_ascii_lowercase().replace('_', "-")
691 );
692 if value.problem_type == expected_type {
693 Vec::new()
694 } else {
695 vec![issue(
696 "/type",
697 "problem-type",
698 "must correspond to the problem code",
699 )]
700 }
701}
702
703fn parse<T: DeserializeOwned>(
704 data: &[u8],
705 schema_name: &str,
706 document_type: &str,
707 refine: impl FnOnce(&T) -> Vec<ValidationIssue>,
708) -> Result<T, ParseError> {
709 let raw = serde_json::from_slice(data).map_err(|error| ValidationError {
710 document_type: document_type.to_owned(),
711 issues: vec![issue("", "json", &error.to_string())],
712 })?;
713 validate_value(&raw, schema_name, document_type)?;
714 let value = serde_json::from_value(raw).map_err(|error| ValidationError {
715 document_type: document_type.to_owned(),
716 issues: vec![issue("", "decode", &error.to_string())],
717 })?;
718 let issues = refine(&value);
719 if issues.is_empty() {
720 Ok(value)
721 } else {
722 Err(ValidationError {
723 document_type: document_type.to_owned(),
724 issues,
725 }
726 .into())
727 }
728}
729
730fn schemas() -> Result<&'static SchemaSet, ParseError> {
731 SCHEMAS
732 .get_or_init(initialize_schemas)
733 .as_ref()
734 .map_err(|error| ParseError::SchemaInitialization(error.clone()))
735}
736
737fn initialize_schemas() -> Result<SchemaSet, String> {
738 let mut documents = BTreeMap::new();
739 for file in SCHEMA_FILES.files() {
740 let name = file
741 .path()
742 .file_name()
743 .and_then(|name| name.to_str())
744 .ok_or_else(|| "bundled schema has an invalid name".to_owned())?;
745 let value = serde_json::from_slice(file.contents())
746 .map_err(|error| format!("decode {name}: {error}"))?;
747 documents.insert(name.to_owned(), value);
748 }
749 let mut registry = Registry::new();
750 for (name, value) in &documents {
751 registry = registry
752 .add(
753 format!("https://offeringprotocol.org/schemas/{name}"),
754 value,
755 )
756 .map_err(|error| format!("register {name}: {error}"))?;
757 }
758 let registry = registry
759 .prepare()
760 .map_err(|error| format!("prepare schema registry: {error}"))?;
761 let mut validators = BTreeMap::new();
762 for (name, value) in &documents {
763 let validator = jsonschema::options()
764 .with_registry(®istry)
765 .should_validate_formats(true)
766 .build(value)
767 .map_err(|error| format!("compile {name}: {error}"))?;
768 validators.insert(name.clone(), validator);
769 }
770 Ok(SchemaSet { validators })
771}
772
773fn service_document_issues(value: &ServiceDocument) -> Vec<ValidationIssue> {
774 let mut issues = Vec::new();
775 if value.additional.contains_key("id") {
776 issues.push(issue(
777 "/id",
778 "prohibited",
779 "must not appear in a Service Document",
780 ));
781 }
782 if value.additional.contains_key("web_url") {
783 issues.push(issue(
784 "/web_url",
785 "prohibited",
786 "must not appear in a Service Document",
787 ));
788 }
789 if !valid_language_tag(&value.language) {
790 issues.push(issue("/language", "language-tag", "must be a language tag"));
791 }
792 validate_localizations(&value.language, &value.localizations, true, &mut issues);
793 if value
794 .keywords
795 .iter()
796 .map(|keyword| keyword.chars().count())
797 .sum::<usize>()
798 > 1024
799 {
800 issues.push(issue(
801 "/keywords",
802 "max-code-points",
803 "must contain no more than 1024 code points in total",
804 ));
805 }
806 if value.search_capabilities.is_some()
807 && !value
808 .operations
809 .iter()
810 .any(|operation| operation.name == Operation::SearchOfferings)
811 {
812 issues.push(issue(
813 "/search_capabilities",
814 "operation-support",
815 "requires the search-offerings operation",
816 ));
817 }
818 issues
819}
820
821fn representation_issues(
822 language: &str,
823 localizations: &[String],
824 images: &[crate::ResourceImage],
825) -> Vec<ValidationIssue> {
826 let mut issues = Vec::new();
827 if !language.is_empty() && !valid_language_tag(language) {
828 issues.push(issue("/language", "language-tag", "must be a language tag"));
829 }
830 validate_localizations(language, localizations, false, &mut issues);
831 let mut sources = std::collections::BTreeSet::new();
832 if images.iter().any(|image| !sources.insert(&image.src)) {
833 issues.push(issue(
834 "/images",
835 "unique-image-source",
836 "must contain unique image sources",
837 ));
838 }
839 issues
840}
841
842fn validate_localizations(
843 language: &str,
844 localizations: &[String],
845 require_default: bool,
846 issues: &mut Vec<ValidationIssue>,
847) {
848 if localizations.iter().any(|tag| !valid_language_tag(tag)) {
849 issues.push(issue(
850 "/localizations",
851 "language-tag",
852 "must contain only language tags",
853 ));
854 return;
855 }
856 let folded = localizations
857 .iter()
858 .map(|tag| tag.to_ascii_lowercase())
859 .collect::<std::collections::BTreeSet<_>>();
860 if folded.len() != localizations.len() {
861 issues.push(issue(
862 "/localizations",
863 "unique-language-tag",
864 "must be unique without regard to case",
865 ));
866 }
867 if (require_default || (!language.is_empty() && !localizations.is_empty()))
868 && !folded.contains(&language.to_ascii_lowercase())
869 {
870 issues.push(issue(
871 "/localizations",
872 if require_default {
873 "contains-default-language"
874 } else {
875 "contains-language"
876 },
877 if require_default {
878 "must contain the default language"
879 } else {
880 "must contain the representation language"
881 },
882 ));
883 }
884}
885
886fn valid_language_tag(value: &str) -> bool {
887 let Ok(tag) = value.parse::<LanguageTag>() else {
888 return false;
889 };
890 let mut variants = std::collections::BTreeSet::new();
891 if tag
892 .variant_subtags()
893 .any(|variant| !variants.insert(variant.to_ascii_lowercase()))
894 {
895 return false;
896 }
897 let mut extensions = std::collections::BTreeSet::new();
898 !tag.extension_subtags()
899 .any(|(singleton, _)| !extensions.insert(singleton.to_ascii_lowercase()))
900}
901
902fn filter_definition_issues(value: &FilterDefinition) -> Vec<ValidationIssue> {
903 let mut issues = Vec::new();
904 if matches!(value.filter_type, FilterType::String | FilterType::Boolean)
905 && value.operators.iter().any(|operator| {
906 matches!(
907 operator,
908 FilterOperator::GreaterThan
909 | FilterOperator::GreaterThanOrEqual
910 | FilterOperator::LessThan
911 | FilterOperator::LessThanOrEqual
912 )
913 })
914 {
915 issues.push(issue(
916 "/operators",
917 "operator-type",
918 "contains an operator incompatible with the Filter type",
919 ));
920 }
921 if value.filter_type == FilterType::Boolean && value.unit.is_some() {
922 issues.push(issue(
923 "/unit",
924 "unit-type",
925 "must not appear on a boolean Filter",
926 ));
927 }
928 issues
929}
930
931fn issue(path: &str, keyword: &str, message: &str) -> ValidationIssue {
932 ValidationIssue {
933 keyword: keyword.to_owned(),
934 message: message.to_owned(),
935 params: BTreeMap::new(),
936 path: path.to_owned(),
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
945 fn parses_normative_service_document() {
946 let document = br#"{
947 "description":"Plant store",
948 "http":{"endpoint_base":"/odp"},
949 "language":"en",
950 "localizations":["en"],
951 "name":"Plants",
952 "odp_version":"1.0",
953 "operations":[
954 {"authentication":"not-required","name":"get-offering"},
955 {"authentication":"not-required","name":"list-offerings"}
956 ]
957 }"#;
958 assert_eq!(parse_service_document(document).unwrap().name, "Plants");
959 }
960
961 #[test]
962 fn rejects_problem_types_that_do_not_correspond_to_the_code() {
963 let problem = br#"{
964 "code":"NOT_FOUND",
965 "status":404,
966 "title":"Not found",
967 "type":"https://offeringprotocol.org/problems/validation-failed"
968 }"#;
969
970 assert!(parse_problem_details(problem).is_err());
971 }
972
973 #[test]
974 fn parses_tap_trust_protocol_support() {
975 let document = br#"{
976 "description":"Plant store",
977 "http":{"endpoint_base":"/odp"},
978 "language":"en",
979 "localizations":["en"],
980 "name":"Plants",
981 "odp_version":"1.0",
982 "operations":[
983 {"authentication":"not-required","name":"get-offering"},
984 {"authentication":"not-required","name":"list-offerings"}
985 ],
986 "protocols":{"trust":[{"name":"tap"}]}
987 }"#;
988 let parsed = parse_service_document(document).unwrap();
989 assert_eq!(
990 parsed.protocols.unwrap().trust,
991 [crate::TrustProtocol {
992 name: crate::Protocol::Tap
993 }]
994 );
995 }
996
997 #[test]
998 fn agent_parser_filters_unknown_protocols_strictly() {
999 let document = br#"{
1000 "description":"Plants","http":{"endpoint_base":"/odp"},"language":"en",
1001 "localizations":["en"],"name":"Plants","odp_version":"1.0",
1002 "operations":[{"authentication":"not-required","name":"get-offering"},
1003 {"authentication":"not-required","name":"list-offerings"}],
1004 "protocols":{
1005 "enrollment":[{"name":"future-enrollment"},{"name":"aep"}],
1006 "payments":[{"authentication":"not-required","name":"future-payment"},
1007 {"authentication":"not-required","name":"mpp"},
1008 {"authentication":"not-required","name":"x402"}],
1009 "trust":[{"name":"future-trust"},{"name":"tap"}]
1010 }
1011 }"#;
1012 assert!(parse_service_document(document).is_err());
1013 let protocols = parse_agent_service_document(document)
1014 .unwrap()
1015 .protocols
1016 .unwrap();
1017 assert_eq!(protocols.enrollment[0].name, crate::Protocol::Aep);
1018 assert_eq!(protocols.payments.len(), 2);
1019 assert_eq!(protocols.trust[0].name, crate::Protocol::Tap);
1020
1021 let unknown_only = br#"{
1022 "description":"Plants","http":{"endpoint_base":"/odp"},"language":"en",
1023 "localizations":["en"],"name":"Plants","odp_version":"1.0",
1024 "operations":[{"authentication":"not-required","name":"get-offering"},
1025 {"authentication":"not-required","name":"list-offerings"}],
1026 "protocols":{"trust":[{"name":"future-trust"}]}
1027 }"#;
1028 assert!(
1029 parse_agent_service_document(unknown_only)
1030 .unwrap()
1031 .protocols
1032 .is_none()
1033 );
1034
1035 let malformed = br#"{
1036 "description":"Plants","http":{"endpoint_base":"/odp"},"language":"en",
1037 "localizations":["en"],"name":"Plants","odp_version":"1.0",
1038 "operations":[{"authentication":"not-required","name":"get-offering"},
1039 {"authentication":"not-required","name":"list-offerings"}],
1040 "protocols":{"trust":[{"name":"tap","unexpected":true}]}
1041 }"#;
1042 assert!(parse_agent_service_document(malformed).is_err());
1043 assert!(parse_agent_service_document(b"invalid").is_err());
1044 }
1045
1046 #[test]
1047 fn returns_structured_validation_issues() {
1048 let error = parse_service_document(br#"{}"#).unwrap_err();
1049 let ParseError::Validation(error) = error else {
1050 panic!("expected validation error");
1051 };
1052 assert!(!error.issues.is_empty());
1053 }
1054
1055 #[test]
1056 fn rejects_duplicate_language_variants() {
1057 let document = br#"{
1058 "description":"An example Service.",
1059 "http":{"endpoint_base":"/odp"},
1060 "language":"sl-rozaj-rozaj",
1061 "localizations":["sl-rozaj-rozaj"],
1062 "name":"Example",
1063 "odp_version":"1.0",
1064 "operations":[
1065 {"authentication":"not-required","name":"get-offering"},
1066 {"authentication":"not-required","name":"list-offerings"}
1067 ]
1068 }"#;
1069 assert!(parse_service_document(document).is_err());
1070 }
1071}