Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/7887.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added Accept-header content negotiation to the content app so clients requesting `application/json` receive a paginated JSON directory listing.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/7887.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `Distribution.content_handler_json()` so plugins can serve JSON from the content app when the client prefers `application/json`.
8 changes: 8 additions & 0 deletions docs/admin/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@ The number of seconds before a content app should be considered lost.

Defaults to `30` seconds.

### CONTENT\_JSON\_LISTING\_DEFAULT\_LIMIT and CONTENT\_JSON\_LISTING\_MAX\_LIMIT

Page size for the content app's generic JSON directory listing (`?limit=` / `?offset=`), used when a client `Accept` header prefers JSON.

`CONTENT_JSON_LISTING_DEFAULT_LIMIT` is used when `limit` is omitted or invalid. Defaults to `1000`.

`CONTENT_JSON_LISTING_MAX_LIMIT` is the upper bound for `limit`. Defaults to `10000`.

### CONTENT\_ORIGIN

A string containing the `protocol`, `fqdn`, and optionally `port` where the content app is reachable by users.
Expand Down
37 changes: 31 additions & 6 deletions docs/dev/reference/code-api/plugins-api/content-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,37 @@ Making a custom Handler is a two-step process:
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.

If content needs to be served from within the `Distribution`'s base_path,
overriding the `pulpcore.plugin.models.Distribution.content_handler` and
`pulpcore.plugin.models.Distribution.content_handler_directory_listing`
methods in your Distribution is an easier way to serve this content. The
`pulpcore.plugin.models.Distribution.content_handler` method should
return an instance of `aiohttp.web_response.Response` or a
`pulpcore.plugin.models.ContentArtifact`.
overriding `pulpcore.plugin.models.Distribution.content_handler`,
`content_handler_json`, and `content_handler_list_directory` is an easier
way to serve this content.

`content_handler` should return an instance of `aiohttp.web_response.Response`
or a `pulpcore.plugin.models.ContentArtifact`. It is used for the default
HTML/binary representation.

`content_handler_json` is invoked when the client's `Accept` header prefers
JSON (see `pulpcore.cache.accept_prefers_json`). Return `None` (the default)
to use pulpcore's generic paginated JSON directory listing, a JSON-serializable
dict/list, or an `aiohttp.web.StreamResponse` for full control over
headers/status. Concrete artifact paths stay binary unless this method returns
JSON. Missing/`*/*`/`text/html` Accept headers keep today's HTML/binary
responses.

The generic JSON listing envelope is:

```json
{
"path": "/pulp/content/my-distro/",
"packages": [{"path": "subdir/file.iso", "size": 1024, "date": "..."}],
"count": 1,
"limit": 1000,
"offset": 0
}
```

Pagination uses `?limit=` and `?offset=`. The default and maximum `limit` are
`CONTENT_JSON_LISTING_DEFAULT_LIMIT` (1000) and `CONTENT_JSON_LISTING_MAX_LIMIT` (10000).
When more pages exist the body also includes `next_offset`.

## Creating your Handler

Expand Down
25 changes: 25 additions & 0 deletions pulpcore/app/models/publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,31 @@ def content_handler_list_directory(self, rel_path):
"""
return set()

def content_handler_json(self, path):
"""
Handler to serve a JSON representation of the content at `path` for this Distribution.

This is the JSON counterpart to :meth:`content_handler`. It is invoked instead of (and
checked before) the generic, plugin-agnostic JSON directory listing whenever the
client's ``Accept`` header indicates a preference for JSON over HTML. Plugins override
this to provide type-specific JSON (e.g. package metadata, a de-duplicated "package"
listing, etc.) rather than falling back to the generic file/size/date listing that
pulpcore builds automatically for every Distribution.

The default implementation returns ``None`` for every path, which is safe for any
Distribution subclass that doesn't override it: pulpcore's generic JSON directory
listing (or the normal HTML/binary behavior) is used instead.

Args:
path (str): The path being requested
Returns:
None if there is no JSON representation to serve at path. Otherwise, a
JSON-serializable object (dict/list) to be returned to the client, or an
aiohttp.web.StreamResponse (e.g. built via aiohttp.web.json_response) for full
control over headers/status.
"""
return None

def content_headers_for(self, path):
"""
Opportunity for Distribution to specify response-headers for a specific path
Expand Down
25 changes: 25 additions & 0 deletions pulpcore/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@
CONTENT_ORIGIN = None
CONTENT_PATH_PREFIX = "/pulp/content/"

# Pagination for the content app's generic JSON directory listing (?limit=&offset=).
CONTENT_JSON_LISTING_DEFAULT_LIMIT = 1000
CONTENT_JSON_LISTING_MAX_LIMIT = 10000

API_APP_TTL = 120 # The heartbeat is called from gunicorn notify (defaulting to 45 sec).
CONTENT_APP_TTL = 30
WORKER_TTL = 30
Expand Down Expand Up @@ -597,6 +601,25 @@
},
)

content_json_listing_default_limit_validator = Validator(
"CONTENT_JSON_LISTING_DEFAULT_LIMIT",
is_type_of=int,
gte=1,
messages={
"is_type_of": "{name} must be an integer.",
"gte": "{name} must be at least 1.",
},
)
content_json_listing_max_limit_validator = Validator(
"CONTENT_JSON_LISTING_MAX_LIMIT",
is_type_of=int,
gte=1,
messages={
"is_type_of": "{name} must be an integer.",
"gte": "{name} must be at least 1.",
},
)


def otel_middleware_hook(settings):
data = {"dynaconf_merge": True}
Expand Down Expand Up @@ -694,6 +717,8 @@ def validate_db_encryption_key_hook(settings):
otel_pulp_api_histogram_buckets_validator,
otel_metrics_dispatch_interval_validator,
distributed_publication_retention_period_validator,
content_json_listing_default_limit_validator,
content_json_listing_max_limit_validator,
],
post_hooks=(
otel_middleware_hook,
Expand Down
2 changes: 2 additions & 0 deletions pulpcore/cache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
CacheKeys,
ConnectionError,
SyncContentCache,
accept_prefers_json,
json_listing_pagination,
)
106 changes: 103 additions & 3 deletions pulpcore/cache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.conf import settings
from django.http import FileResponse as ApiFileResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.http.request import MediaType
from redis import ConnectionError
from redis.asyncio import ConnectionError as AConnectionError
from rest_framework.request import Request as ApiRequest
Expand All @@ -29,6 +30,89 @@ class CacheKeys(enum.Enum):
path = "path"
host = "host"
method = "method"
format = "format"
query = "query"


def accept_prefers_json(accept_header):
"""
Determine whether an HTTP Accept header value prefers application/json over other types.

A missing/empty header, or one whose highest-quality (per RFC 9110 q-values) entry isn't
"application/json" or a "+json" subtype, is treated as "does not prefer JSON". This is the
single source of truth for JSON content negotiation in the content app so that both the
content app's response logic and its cache key (see ``AsyncContentCache.make_key``) use
the exact same decision.

Quality values are parsed with Django's :class:`~django.http.request.MediaType`. ``*/*`` is not
treated as JSON even though it matches every type.

Args:
accept_header (str): The raw value of the request's Accept header, or None.

Returns:
bool: True if the client's top choice is JSON, False otherwise.
"""
if not isinstance(accept_header, str) or not accept_header:
return False

best_type = None
best_q = -1.0
for token in accept_header.split(","):
token = token.strip()
if not token:
continue
media_type = MediaType(token)
if media_type.quality > best_q:
best_q = media_type.quality
best_type = media_type

if best_type is None or best_q <= 0:
return False

# RFC 9110 media types are case-insensitive; Django's MediaType keeps the original case.
if best_type.main_type.lower() == "application" and best_type.sub_type.lower() == "json":
return True
return best_type.sub_type.lower().endswith("+json")


def json_listing_pagination(query):
"""
Parse and bound ``limit``/``offset`` from a request query mapping.

Invalid or missing values fall back to defaults rather than raising. This is shared by
the content app's JSON listing and its cache key so paginated pages cannot collide, and
unrecognized query params cannot fragment the cache.

Defaults and the upper bound come from ``CONTENT_JSON_LISTING_DEFAULT_LIMIT`` and
``CONTENT_JSON_LISTING_MAX_LIMIT``.

Args:
query: A mapping with ``.get()`` (e.g. aiohttp ``request.query``), or None.

Returns:
tuple: ``(limit, offset)`` integers.
"""

def parse_int(name, default, minimum, maximum):
if query is None:
raw = default
else:
try:
raw = query.get(name, default)
except (AttributeError, TypeError):
raw = default
try:
value = int(raw)
except (TypeError, ValueError):
value = default
return max(minimum, min(value, maximum))

default_limit = settings.CONTENT_JSON_LISTING_DEFAULT_LIMIT
max_limit = settings.CONTENT_JSON_LISTING_MAX_LIMIT
limit = parse_int("limit", default_limit, 1, max_limit)
offset = parse_int("offset", 0, 0, 2**31 - 1)
return limit, offset


def connection_error_wrapper(func):
Expand Down Expand Up @@ -323,7 +407,10 @@ def __init__(self, base_key=None, expires_ttl=None, keys=None, auth=None):
can be a callable taking the request and cache instance as arguments
expires_ttl: length in seconds entries should live in the cache, EXPIRES_TTL is default
keys: a list of CacheKeys to use for key creation upon entry placement,
(path, method) is default
(path, method) is default. Pass CacheKeys.format if responses for the same
path/method can differ based on the request's Accept header (e.g. JSON vs.
HTML). Pass CacheKeys.query to include normalized JSON ``limit``/``offset``
(other query params and HTML requests are ignored).
auth: a callable to check authorization of the request; takes the request, cache
instance, and base_key as arguments.
"""
Expand Down Expand Up @@ -444,10 +531,23 @@ async def make_entry(self, key, base_key, handler, args, kwargs, expires=DEFAULT
def make_key(self, request):
"""Makes the key based off the request"""
# Might potentially have to make this async if keys require async data from request
wants_json = accept_prefers_json(request.headers.get("Accept"))
if wants_json:
limit, offset = json_listing_pagination(getattr(request, "query", None))
query_key = f"{limit}:{offset}"
Comment on lines +534 to +537

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we want this to be specific for json.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify please?

else:
query_key = ""
all_keys = {
CacheKeys.path: request.path,
CacheKeys.method: request.method,
CacheKeys.host: request.url.host,
CacheKeys.format: "json" if wants_json else "other",
CacheKeys.query: query_key,
}
key = ":".join(all_keys[k] for k in self.keys)
return key
parts = []
for key_name in self.keys:
value = all_keys[key_name]
if key_name is CacheKeys.query and value == "":
continue
parts.append(value)
return ":".join(parts)
Loading
Loading