Skip to content

Commit ae7961a

Browse files
committed
fix(sdk): correct discovery and response negotiation
1 parent 2767f73 commit ae7961a

11 files changed

Lines changed: 302 additions & 64 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,10 +341,19 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
341341
credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
342342
the appropriate protocol clients around an Action resolved through ODP.
343343

344-
`parse_service_document` is the strict current-version Service parser. Agent inspection and
344+
`parse_service_document` validates Service metadata against the supported ODP major version.
345+
Compatible minor versions such as `1.7` are accepted without rewriting the received version;
346+
SDK-generated documents use `1.0`. Agent inspection and
345347
Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
346348
strict validation for recognized descriptors.
347349

350+
Individual Offering and Collection GETs default to full representations; list and search operations
351+
default to terse items. A Catalog receives the requested language in `CatalogRequest.language` and
352+
declares the language it actually returns on each resource. Static catalogs do not translate content.
353+
354+
Refinement parsing detects duplicate JSON values without guessing the type of a string. Comparing
355+
decimal or date-time strings by their meaning requires the referenced Filter Definition.
356+
348357
## Errors and validation
349358

350359
Each role exposes typed errors:

‎scripts/conformance_adapter.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ async def evaluate_errors_limits(case: dict[str, Any]) -> bool | None:
242242

243243

244244
async def evaluate_case(subject: str, case: dict[str, Any], role: str) -> bool | None:
245+
if subject == "protocol-version":
246+
document = {"odp_version": case["received"], "id": "item", "name": "Item"}
247+
return succeeds(lambda: parse_offering(json.dumps(document))) == case["compatible"]
245248
if subject == "local-identifier":
246249
return is_local_resource_identifier(case["value"]) == case["valid"]
247250
if subject == "identity-comparison":

‎src/offering_protocol/agent/client.py‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,7 @@ async def list_collections(
189189
self, representation: Representation = Representation.TERSE, limit: int = 0
190190
) -> Page[Collection]:
191191
body = await self._get_page(Operation.LIST_COLLECTIONS, None, representation, limit)
192-
page = parse_collection_page(body)
193-
for item in page.items:
194-
parse_collection(_encode(item))
195-
return page
192+
return parse_collection_page(body)
196193

197194
async def get_collection(self, identifier: str) -> Collection:
198195
body = await self._get_page(Operation.GET_COLLECTION, identifier, Representation.FULL, 0)
@@ -206,10 +203,7 @@ async def search_collections(
206203
body = await self._post_search(
207204
Operation.SEARCH_COLLECTIONS, request.to_dict(), representation
208205
)
209-
page = parse_collection_page(body)
210-
for item in page.items:
211-
parse_collection(_encode(item))
212-
return page
206+
return parse_collection_page(body)
213207

214208
async def list_offerings(
215209
self, representation: Representation = Representation.TERSE, limit: int = 0
@@ -498,7 +492,11 @@ async def _supporting_json(
498492
conditional["if-none-match"] = cached.etag
499493
if cached.last_modified:
500494
conditional["if-modified-since"] = cached.last_modified
495+
visited: set[str] = set()
501496
for redirects in range(_MAXIMUM_REDIRECTS + 1):
497+
if current in visited:
498+
raise AgentError("ODP supporting document contains a redirect loop")
499+
visited.add(current)
502500
try:
503501
response = await self._supporting_transport.send(
504502
HttpRequest("GET", current, {"accept": accept, **conditional})
@@ -511,9 +509,14 @@ async def _supporting_json(
511509
location = response.headers.get("location")
512510
if location is None:
513511
raise AgentError("ODP supporting document redirect omitted Location")
514-
current = urljoin(current, location)
515-
if not _is_https_url(current):
512+
target_url = urljoin(current, location)
513+
if not _is_https_url(target_url):
516514
raise AgentError("ODP supporting document redirect must use HTTPS")
515+
if derive_service_origin(target_url) != derive_service_origin(current):
516+
raise AgentError(
517+
"ODP supporting document redirect must remain on the same origin"
518+
)
519+
current = target_url
517520
continue
518521
if response.status == 304:
519522
if cached is None:
@@ -612,13 +615,6 @@ def parse_problem_response(data: bytes | str, status: int) -> ProblemDetails:
612615
return parse_problem_response_strict(_agent_body(data, "problem"), status)
613616

614617

615-
def _encode(value: object) -> bytes:
616-
if not hasattr(value, "model_dump_json"):
617-
raise TypeError("ODP model is not serializable")
618-
encoded = value.model_dump_json(by_alias=True, exclude_unset=True)
619-
return cast(str, encoded).encode()
620-
621-
622618
def _append_query(target: str, values: dict[str, str]) -> str:
623619
parts = urlsplit(target)
624620
query = dict(parse_qsl(parts.query, keep_blank_values=True))

‎src/offering_protocol/core/validation.py‎

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import re
77
from dataclasses import dataclass, field
8+
from decimal import Decimal
89
from functools import lru_cache
910
from importlib.resources import files
1011
from typing import Any, TypeVar
@@ -17,6 +18,7 @@
1718
from referencing import Registry, Resource
1819

1920
from offering_protocol.core.models import (
21+
VERSION,
2022
Collection,
2123
CollectionSearchRequest,
2224
FilterDefinition,
@@ -715,7 +717,13 @@ def validate_value(value: object, schema_name: str, document_type: str) -> None:
715717
validator = _validators().get(schema_name)
716718
if validator is None:
717719
raise RuntimeError(f"missing bundled schema {schema_name}")
718-
issues = [_schema_issue(error) for error in validator.iter_errors(value)]
720+
validation_value = value
721+
if isinstance(value, dict):
722+
version = value.get("odp_version")
723+
if isinstance(version, str) and re.fullmatch(r"1\.(0|[1-9][0-9]*)", version):
724+
# The bundled schemas describe 1.0; compatible minor versions use the same rules.
725+
validation_value = {**value, "odp_version": VERSION}
726+
issues = [_schema_issue(error) for error in validator.iter_errors(validation_value)]
719727
if issues:
720728
issues.sort(key=lambda issue: (issue.path, issue.keyword, issue.message))
721729
raise OdpValidationError(document_type, issues)
@@ -889,9 +897,6 @@ def _is_language_tag(value: str) -> bool:
889897
return not in_extension or len(subtags[-1]) > 1
890898

891899

892-
_DECIMAL = re.compile(r"^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$")
893-
894-
895900
def _compare_decimals(left: str, right: str) -> int:
896901
"""Orders two ODP monetary values, which are decimal strings rather than JSON numbers (OFR-48).
897902
@@ -916,25 +921,15 @@ def _split_decimal(value: str) -> tuple[str, str]:
916921
return whole.lstrip("0"), fraction.rstrip("0")
917922

918923

919-
def _bucket_key(value: object) -> str:
920-
"""Compares two bucket values the way the referenced Filter Definition would.
921-
922-
A response does not carry its Filter Definitions, so the type behind a JSON string -- `string`,
923-
`decimal`, `date` or `date-time` -- is not known here. Every one of those compares two strings
924-
exactly except `decimal`, whose equality is numeric, so a string that can only be a decimal is
925-
reduced to one spelling per value. JSON numbers compare numerically.
926-
"""
924+
def _bucket_key(value: object) -> tuple[str, object]:
925+
"""Check structural duplicates without guessing the type of a string-valued Filter."""
927926
if isinstance(value, bool):
928-
return f"b{value}"
927+
return "boolean", value
929928
if isinstance(value, int | float):
930-
return f"n{float(value)}"
929+
return "number", Decimal(str(value))
931930
if isinstance(value, str):
932-
if _DECIMAL.match(value):
933-
whole, fraction = _split_decimal(value.removeprefix("-"))
934-
sign = "-" if value.startswith("-") and (whole or fraction) else ""
935-
return f"d{sign}{whole}.{fraction}"
936-
return f"s{value}"
937-
return f"o{json.dumps(value, separators=(',', ':'), sort_keys=True)}"
931+
return "string", value
932+
return "other", json.dumps(value, separators=(",", ":"), sort_keys=True)
938933

939934

940935
def _agent_body(data: bytes | str, kind: str) -> str:

‎src/offering_protocol/directory/client.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,11 @@ def _parse_search_page(body: bytes) -> SearchPage:
248248
raise DirectoryError("invalid Directory response: search page has no items")
249249
if len(raw["items"]) > 100:
250250
raise DirectoryError("Directory search page exceeds 100 Services")
251-
items: list[dict[str, object]] = []
251+
items: list[DirectoryService] = []
252252
issues: list[ServiceIssue] = []
253253
for index, entry in enumerate(raw["items"]):
254254
try:
255-
items.append(_read_service(entry))
255+
items.append(DirectoryService.model_validate(_read_service(entry)))
256256
except (ModelValidationError, OdpValidationError, ReferenceError, DirectoryError) as error:
257257
issues.append(ServiceIssue(index=index, message=str(error)))
258258
try:
@@ -292,6 +292,7 @@ def _read_service(entry: object) -> dict[str, object]:
292292
item.pop(member, None)
293293
if document.protocols is not None:
294294
item["protocols"] = document.protocols.model_dump(mode="json", exclude_defaults=True)
295+
item["operations"] = [operation.model_dump(mode="json") for operation in document.operations]
295296
return item
296297

297298

‎src/offering_protocol/service/service.py‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ async def _handle(self, request: Request) -> Response:
281281
self._document.language,
282282
list(self._document.localizations),
283283
)
284-
exchange = _Exchange(headers=headers, language=language, method=method)
284+
exchange = _Exchange(headers=headers, language=self._document.language, method=method)
285285
if request.path == "/.well-known/odp":
286286
_require_method(effective, ("GET",))
287287
return _json_response(self._document, _MAXIMUM_DOCUMENT_BYTES, exchange)
@@ -295,7 +295,7 @@ async def _handle(self, request: Request) -> Response:
295295
item.name for item in self._document.operations
296296
}:
297297
raise RequestError(404, "NOT_FOUND", "ODP operation is not supported")
298-
catalog_request = _catalog_request(request, headers, language)
298+
catalog_request = _catalog_request(request, headers, language, operation)
299299
if (effective, path) == ("GET", "/offerings"):
300300
offering_page = await self._catalog.list_offerings(catalog_request)
301301
return _json_response(
@@ -370,7 +370,9 @@ async def _get_path(self, path: str, request: CatalogRequest, exchange: _Exchang
370370
raise RequestError(404, "NOT_FOUND", "ODP resource not found")
371371

372372

373-
def _catalog_request(request: Request, headers: dict[str, str], language: str) -> CatalogRequest:
373+
def _catalog_request(
374+
request: Request, headers: dict[str, str], language: str, operation: Operation | None
375+
) -> CatalogRequest:
374376
parameters = parse_qsl(request.query, keep_blank_values=True)
375377
# SVC-73: a repeated `representation` is rejected rather than resolved. Collapsing repeats into
376378
# a dict silently honoured whichever copy came last, so `representation=terse&representation=
@@ -381,7 +383,10 @@ def _catalog_request(request: Request, headers: dict[str, str], language: str) -
381383
raise RequestError(400, "INVALID_REQUEST", f"{name} must not be repeated")
382384
values[name] = value
383385
try:
384-
representation = Representation(values.get("representation", "terse"))
386+
default = (
387+
"full" if operation in {Operation.GET_COLLECTION, Operation.GET_OFFERING} else "terse"
388+
)
389+
representation = Representation(values.get("representation", default))
385390
limit = int(values.get("limit", "0"))
386391
except ValueError as error:
387392
raise RequestError(400, "INVALID_REQUEST", "query parameter is invalid") from error
@@ -434,9 +439,20 @@ def _json_response(value: object, maximum_bytes: int, exchange: _Exchange) -> Re
434439
body = _encode(value)
435440
if len(body) > maximum_bytes:
436441
raise ServiceError("response body is too large")
437-
etag = _entity_tag(exchange.language, body)
442+
if isinstance(value, Page):
443+
language = (
444+
", ".join(
445+
dict.fromkeys(
446+
getattr(item, "language", "") or exchange.language for item in value.items
447+
)
448+
)
449+
or exchange.language
450+
)
451+
else:
452+
language = getattr(value, "language", "") or exchange.language
453+
etag = _entity_tag(language, body)
438454
headers = {
439-
"content-language": exchange.language,
455+
"content-language": language,
440456
"content-type": MEDIA_TYPE,
441457
"etag": etag,
442458
"vary": "Accept, Accept-Language",
@@ -554,12 +570,20 @@ def _require_accept(value: str | None) -> None:
554570
"""
555571
if value is None:
556572
return
573+
specificity = -1
574+
quality = 0.0
557575
for entry in value.split(","):
558576
media_type = entry.split(";", 1)[0].strip().lower()
559577
if media_type not in {"*/*", "application/*", MEDIA_TYPE}:
560578
continue
561-
if _quality_of(entry) > 0:
562-
return
579+
precision = {"*/*": 0, "application/*": 1, MEDIA_TYPE: 2}[media_type]
580+
weight = _quality_of(entry)
581+
if precision > specificity:
582+
specificity, quality = precision, weight
583+
elif precision == specificity:
584+
quality = max(quality, weight)
585+
if quality > 0:
586+
return
563587
raise RequestError(406, "NOT_ACCEPTABLE", f"Accept must allow {MEDIA_TYPE}")
564588

565589

‎tests/test_agent.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
)
2828
from offering_protocol.agent.client import (
2929
_decode_json_object,
30-
_encode,
3130
_expiration,
3231
_invoke_parser,
3332
)
@@ -665,8 +664,6 @@ async def test_agent_remaining_traversal_and_supporting_document_edges() -> None
665664
assert not (await client.list_collection_offerings("plants", limit=1)).items
666665
assert not await client.search_all_offerings(OfferingSearchRequest(query="plant"))
667666

668-
with pytest.raises(TypeError):
669-
_encode({"not": "a model"})
670667
with pytest.raises(AgentError):
671668
_invoke_parser(lambda _: (_ for _ in ()).throw(ValueError("bad")), b"{}")
672669

‎tests/test_agent_conformance.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from offering_protocol.core import (
3535
CapabilityLink,
36+
CollectionSearchRequest,
3637
FilterCapabilitySource,
3738
FilterDefinition,
3839
FilterOperator,
@@ -534,3 +535,75 @@ def exhausted(*args: object, **kwargs: object) -> object:
534535

535536
with pytest.raises(AgentError, match="nested too deeply"):
536537
_decode_json_object(b"[[[]]]")
538+
539+
540+
@pytest.mark.asyncio
541+
@pytest.mark.parametrize("search", [False, True])
542+
async def test_collection_page_inherits_version_without_requiring_it_on_items(search: bool) -> None:
543+
document = json.loads(SERVICE_DOCUMENT)
544+
operation = "search-collections" if search else "list-collections"
545+
document["operations"].append({"name": operation, "authentication": "not-required"})
546+
transport = QueueTransport(
547+
response(json.dumps(document)),
548+
response('{"odp_version":"1.7","items":[{"id":"plants","name":"Plants"}]}'),
549+
)
550+
client = ServiceClient("https://store.example", transport=transport)
551+
page = (
552+
await client.search_collections(CollectionSearchRequest())
553+
if search
554+
else await client.list_collections()
555+
)
556+
assert page.odp_version == "1.7"
557+
assert page.items[0].id == "plants"
558+
assert "odp_version" not in page.items[0].model_fields_set
559+
560+
561+
@pytest.mark.asyncio
562+
@pytest.mark.parametrize(
563+
"location",
564+
[
565+
"https://other.example/schema.json",
566+
"https://schemas.example:444/schema.json",
567+
"/schema.json",
568+
],
569+
)
570+
async def test_supporting_redirects_refuse_origin_changes_and_loops(location: str) -> None:
571+
offering = {
572+
"odp_version": "1.0",
573+
"id": "item",
574+
"name": "Item",
575+
"schema": {"url": "https://schemas.example/schema.json"},
576+
"attributes": {"colour": "green"},
577+
}
578+
supporting = QueueTransport(response("", status=302, headers={"location": location}))
579+
client = ServiceClient(
580+
"https://store.example",
581+
transport=QueueTransport(response(SERVICE_DOCUMENT), response(json.dumps(offering))),
582+
supporting_transport=supporting,
583+
)
584+
details = await client.get_offering_details("item")
585+
assert len(supporting.requests) == 1
586+
assert len(details.issues) == 1
587+
assert "redirect" in details.issues[0].message
588+
assert details.offering.name == "Item"
589+
assert not details.offering.attributes
590+
591+
592+
@pytest.mark.asyncio
593+
async def test_supporting_redirects_allow_explicit_default_port() -> None:
594+
supporting = QueueTransport(
595+
response("", status=302, headers={"location": "https://schemas.example:443/final.json"}),
596+
response("{}", content_type="application/json"),
597+
)
598+
client = ServiceClient("https://store.example", supporting_transport=supporting)
599+
assert (
600+
await client._supporting_json(
601+
"https://schemas.example/start.json",
602+
"schema",
603+
"application/json",
604+
{"application/json"},
605+
100,
606+
)
607+
== {}
608+
)
609+
assert len(supporting.requests) == 2

0 commit comments

Comments
 (0)