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_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(&registry)
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}