Skip to content

Commit 5d5de04

Browse files
committed
feat(pulpcore): add JSON content negotiation to the content app
Serve a paginated JSON directory listing when Accept prefers application/json. Plugins can override Distribution.content_handler_json. Listing pagination is applied in the database; cache keys use only normalized JSON limit/offset. ref #7887 Assisted-By: Cursor
1 parent 107800e commit 5d5de04

8 files changed

Lines changed: 496 additions & 55 deletions

File tree

CHANGES/7887.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added Accept-header content negotiation to the content app so clients requesting `application/json` receive a paginated JSON directory listing.

CHANGES/plugin_api/7887.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `Distribution.content_handler_json()` so plugins can serve JSON from the content app when the client prefers `application/json`.

docs/dev/reference/code-api/plugins-api/content-app.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,36 @@ Making a custom Handler is a two-step process:
1313
2. Add the Handler to a route using aiohttp.server's [add_route()](https://aiohttp.readthedocs.io/en/stable/web_reference.html#aiohttp.web.UrlDispatcher.add_route) interface.
1414

1515
If content needs to be served from within the `Distribution`'s base_path,
16-
overriding the `pulpcore.plugin.models.Distribution.content_handler` and
17-
`pulpcore.plugin.models.Distribution.content_handler_directory_listing`
18-
methods in your Distribution is an easier way to serve this content. The
19-
`pulpcore.plugin.models.Distribution.content_handler` method should
20-
return an instance of `aiohttp.web_response.Response` or a
21-
`pulpcore.plugin.models.ContentArtifact`.
16+
overriding `pulpcore.plugin.models.Distribution.content_handler`,
17+
`content_handler_json`, and `content_handler_list_directory` is an easier
18+
way to serve this content.
19+
20+
`content_handler` should return an instance of `aiohttp.web_response.Response`
21+
or a `pulpcore.plugin.models.ContentArtifact`. It is used for the default
22+
HTML/binary representation.
23+
24+
`content_handler_json` is invoked when the client's `Accept` header prefers
25+
JSON (see `pulpcore.cache.accept_prefers_json`). Return `None` (the default)
26+
to use pulpcore's generic paginated JSON directory listing, a JSON-serializable
27+
dict/list, or an `aiohttp.web.StreamResponse` for full control over
28+
headers/status. Concrete artifact paths stay binary unless this method returns
29+
JSON. Missing/`*/*`/`text/html` Accept headers keep today's HTML/binary
30+
responses.
31+
32+
The generic JSON listing envelope is:
33+
34+
```json
35+
{
36+
"path": "/pulp/content/my-distro/",
37+
"packages": [{"path": "subdir/file.iso", "size": 1024, "date": "..."}],
38+
"count": 1,
39+
"limit": 1000,
40+
"offset": 0
41+
}
42+
```
43+
44+
Pagination uses `?limit=` and `?offset=` (default limit 1000, max 10000).
45+
When more pages exist the body also includes `next_offset`.
2246

2347
## Creating your Handler
2448

pulpcore/app/models/publication.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,31 @@ def content_handler_list_directory(self, rel_path):
740740
"""
741741
return set()
742742

743+
def content_handler_json(self, path):
744+
"""
745+
Handler to serve a JSON representation of the content at ``path`` for this Distribution.
746+
747+
This is the JSON counterpart to :meth:`content_handler`. It is invoked instead of (and
748+
checked before) the generic, plugin-agnostic JSON directory listing whenever the
749+
client's ``Accept`` header indicates a preference for JSON over HTML. Plugins override
750+
this to provide type-specific JSON (e.g. package metadata, a de-duplicated "package"
751+
listing, etc.) rather than falling back to the generic file/size/date listing that
752+
pulpcore builds automatically for every Distribution.
753+
754+
The default implementation returns ``None`` for every path, which is safe for any
755+
Distribution subclass that doesn't override it: pulpcore's generic JSON directory
756+
listing (or the normal HTML/binary behavior) is used instead.
757+
758+
Args:
759+
path (str): The path being requested
760+
Returns:
761+
None if there is no JSON representation to serve at path. Otherwise, a
762+
JSON-serializable object (dict/list) to be returned to the client, or an
763+
aiohttp.web.StreamResponse (e.g. built via aiohttp.web.json_response) for full
764+
control over headers/status.
765+
"""
766+
return None
767+
743768
def content_headers_for(self, path):
744769
"""
745770
Opportunity for Distribution to specify response-headers for a specific path

pulpcore/cache/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
# ruff: noqa: F401
22
from .cache import (
3+
JSON_LIST_DEFAULT_LIMIT,
4+
JSON_LIST_MAX_LIMIT,
35
AsyncCache,
46
AsyncContentCache,
57
Cache,
68
CacheKeys,
79
ConnectionError,
810
SyncContentCache,
11+
accept_prefers_json,
12+
json_listing_pagination,
913
)

pulpcore/cache/cache.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,93 @@ class CacheKeys(enum.Enum):
2929
path = "path"
3030
host = "host"
3131
method = "method"
32+
format = "format"
33+
query = "query"
34+
35+
36+
def accept_prefers_json(accept_header):
37+
"""
38+
Determine whether an HTTP Accept header value prefers application/json over other types.
39+
40+
A missing/empty header, or one whose highest-quality (per RFC 9110 q-values) entry isn't
41+
"application/json" or a "+json" subtype, is treated as "does not prefer JSON". This is the
42+
single source of truth for JSON content negotiation in the content app; it lives here (a
43+
dependency-free leaf module) rather than in ``pulpcore.content.handler`` so that both the
44+
content app's response logic and its cache key (see ``AsyncContentCache.make_key``) can use
45+
the exact same decision, avoiding any risk of a JSON response being cached/served for an
46+
HTML request or vice versa.
47+
48+
Args:
49+
accept_header (str): The raw value of the request's Accept header, or None.
50+
51+
Returns:
52+
bool: True if the client's top choice is JSON, False otherwise.
53+
"""
54+
if not isinstance(accept_header, str) or not accept_header:
55+
return False
56+
57+
best_type = None
58+
best_q = -1.0
59+
for part in accept_header.split(","):
60+
part = part.strip()
61+
if not part:
62+
continue
63+
media_type, _, params_str = part.partition(";")
64+
media_type = media_type.strip().lower()
65+
q = 1.0
66+
for param in params_str.split(";"):
67+
param = param.strip()
68+
if param.startswith("q="):
69+
try:
70+
q = float(param[2:])
71+
except ValueError:
72+
q = 1.0
73+
if q > best_q:
74+
best_q = q
75+
best_type = media_type
76+
77+
if not best_type or best_q <= 0:
78+
return False
79+
80+
return best_type == "application/json" or best_type.endswith("+json")
81+
82+
83+
JSON_LIST_DEFAULT_LIMIT = 1000
84+
JSON_LIST_MAX_LIMIT = 10000
85+
86+
87+
def json_listing_pagination(query):
88+
"""
89+
Parse and bound ``limit``/``offset`` from a request query mapping.
90+
91+
Invalid or missing values fall back to defaults rather than raising. This is shared by
92+
the content app's JSON listing and its cache key so paginated pages cannot collide, and
93+
unrecognized query params cannot fragment the cache.
94+
95+
Args:
96+
query: A mapping with ``.get()`` (e.g. aiohttp ``request.query``), or None.
97+
98+
Returns:
99+
tuple: ``(limit, offset)`` integers.
100+
"""
101+
102+
def parse_int(name, default, minimum, maximum):
103+
if query is None:
104+
raw = default
105+
else:
106+
try:
107+
raw = query.get(name, default)
108+
except (AttributeError, TypeError):
109+
raw = default
110+
try:
111+
value = int(raw)
112+
except (TypeError, ValueError):
113+
value = default
114+
return max(minimum, min(value, maximum))
115+
116+
limit = parse_int("limit", JSON_LIST_DEFAULT_LIMIT, 1, JSON_LIST_MAX_LIMIT)
117+
offset = parse_int("offset", 0, 0, 2**31 - 1)
118+
return limit, offset
32119

33120

34121
def connection_error_wrapper(func):
@@ -323,7 +410,10 @@ def __init__(self, base_key=None, expires_ttl=None, keys=None, auth=None):
323410
can be a callable taking the request and cache instance as arguments
324411
expires_ttl: length in seconds entries should live in the cache, EXPIRES_TTL is default
325412
keys: a list of CacheKeys to use for key creation upon entry placement,
326-
(path, method) is default
413+
(path, method) is default. Pass CacheKeys.format if responses for the same
414+
path/method can differ based on the request's Accept header (e.g. JSON vs.
415+
HTML). Pass CacheKeys.query to include normalized JSON ``limit``/``offset``
416+
(other query params and HTML requests are ignored).
327417
auth: a callable to check authorization of the request; takes the request, cache
328418
instance, and base_key as arguments.
329419
"""
@@ -444,10 +534,23 @@ async def make_entry(self, key, base_key, handler, args, kwargs, expires=DEFAULT
444534
def make_key(self, request):
445535
"""Makes the key based off the request"""
446536
# Might potentially have to make this async if keys require async data from request
537+
wants_json = accept_prefers_json(request.headers.get("Accept"))
538+
if wants_json:
539+
limit, offset = json_listing_pagination(getattr(request, "query", None))
540+
query_key = f"{limit}:{offset}"
541+
else:
542+
query_key = ""
447543
all_keys = {
448544
CacheKeys.path: request.path,
449545
CacheKeys.method: request.method,
450546
CacheKeys.host: request.url.host,
547+
CacheKeys.format: "json" if wants_json else "other",
548+
CacheKeys.query: query_key,
451549
}
452-
key = ":".join(all_keys[k] for k in self.keys)
453-
return key
550+
parts = []
551+
for key_name in self.keys:
552+
value = all_keys[key_name]
553+
if key_name is CacheKeys.query and value == "":
554+
continue
555+
parts.append(value)
556+
return ":".join(parts)

0 commit comments

Comments
 (0)