diff --git a/CHANGES/1338.feature b/CHANGES/1338.feature new file mode 100644 index 000000000..207203535 --- /dev/null +++ b/CHANGES/1338.feature @@ -0,0 +1 @@ +Added conditional request support (`Last-Modified` / `If-Modified-Since` and `ETag` / `If-None-Match`) to the Simple API and JSON Metadata API for Akamai Centralized Authorization and client cache efficiency. diff --git a/pulp_python/app/pypi/views.py b/pulp_python/app/pypi/views.py index 348a91e4e..daa51b0d1 100644 --- a/pulp_python/app/pypi/views.py +++ b/pulp_python/app/pypi/views.py @@ -72,17 +72,32 @@ PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json" -def _etag_func(request, path, **kwargs): - """Compute unquoted ETag for the condition decorator. Returns None if no repo.""" +def _get_repo_version(path): + """Resolve path to a RepositoryVersion, or None if not found.""" try: distro = PyPIMixin.get_distribution(path) - repo_ver = PyPIMixin.get_repository_version(distro) + return PyPIMixin.get_repository_version(distro) except Http404: return None + + +def _etag_func(request, path, **kwargs): + """Compute unquoted ETag for the condition decorator. Returns None if no repo.""" + repo_ver = _get_repo_version(path) + if repo_ver is None: + return None raw = f"{repo_ver.number}:{repo_ver.pulp_created.isoformat()}" return hashlib.sha256(raw.encode()).hexdigest()[:16] +def _last_modified_func(request, path, **kwargs): + """Return the repository version creation timestamp for Last-Modified.""" + repo_ver = _get_repo_version(path) + if repo_ver is None: + return None + return repo_ver.pulp_created + + class PyPISimpleHTMLRenderer(TemplateHTMLRenderer): media_type = PYPI_SIMPLE_V1_HTML @@ -317,7 +332,7 @@ def get_provenance_url(self, package, version, filename): @extend_schema(summary="Get index simple page") @method_decorator(cache_control(max_age=600, public=True)) - @method_decorator(condition(etag_func=_etag_func)) + @method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func)) @PythonApiCache(base_key=find_base_path_cached) def list(self, request, path): """Gets the simple api html page for the index.""" @@ -379,7 +394,7 @@ def parse_package(release_package): @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page") @method_decorator(cache_control(max_age=600, public=True)) - @method_decorator(condition(etag_func=_etag_func)) + @method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func)) @PythonApiCache(base_key=find_base_path_cached) def retrieve(self, request, path, package): """Retrieves the simple api html/json page for a package.""" @@ -482,6 +497,8 @@ class MetadataView(PyPIMixin, ViewSet): responses={200: PackageMetadataSerializer}, summary="Get package metadata", ) + @method_decorator(cache_control(max_age=900, public=True)) + @method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func)) def retrieve(self, request, path, meta): """ Retrieves the package's core-metadata specified by diff --git a/pulp_python/tests/functional/api/test_simple_cache.py b/pulp_python/tests/functional/api/test_simple_cache.py index 448902656..fa7a6bdac 100644 --- a/pulp_python/tests/functional/api/test_simple_cache.py +++ b/pulp_python/tests/functional/api/test_simple_cache.py @@ -1,3 +1,4 @@ +from email.utils import parsedate_to_datetime from urllib.parse import urljoin import pytest @@ -35,6 +36,20 @@ def synced_distro( return python_distribution_factory(repository=repo) +@pytest.fixture +def synced_distro_no_cache( + python_remote_factory, + python_repo_with_sync, + python_distribution_factory, +): + """ + Sync a repo and create a distribution (no cache requirement). + """ + remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER) + repo = python_repo_with_sync(remote) + return python_distribution_factory(repository=repo) + + @pytest.mark.parallel def test_simple_cache_hit_miss_and_headers(synced_distro): """ @@ -139,3 +154,102 @@ def test_simple_cache_etag_conditional_request(synced_distro): assert r3.headers["Cache-Control"] == cache_control assert r3.headers["X-PULP-CACHE"] == "HIT" assert len(r3.content) > 0 + + +@pytest.mark.parallel +def test_simple_last_modified_header(synced_distro_no_cache): + """Simple API responses include Last-Modified header.""" + index_url = urljoin(synced_distro_no_cache.base_url, "simple/") + detail_url = f"{index_url}aiohttp" + + for url in [index_url, detail_url]: + r = requests.get(url) + assert r.status_code == 200 + assert "Last-Modified" in r.headers + parsedate_to_datetime(r.headers["Last-Modified"]) + + +@pytest.mark.parallel +def test_simple_if_modified_since_304(synced_distro_no_cache): + """If-Modified-Since with matching timestamp returns 304.""" + url = urljoin(synced_distro_no_cache.base_url, "simple/") + + r1 = requests.get(url) + assert r1.status_code == 200 + last_modified = r1.headers["Last-Modified"] + + r2 = requests.get(url, headers={"If-Modified-Since": last_modified}) + assert r2.status_code == 304 + assert len(r2.content) == 0 + + +@pytest.mark.parallel +def test_simple_if_modified_since_old_timestamp_200(synced_distro_no_cache): + """If-Modified-Since with old timestamp returns 200 with content.""" + url = urljoin(synced_distro_no_cache.base_url, "simple/") + + r1 = requests.get(url) + assert r1.status_code == 200 + + r2 = requests.get(url, headers={"If-Modified-Since": "Thu, 01 Jan 2009 00:00:00 GMT"}) + assert r2.status_code == 200 + assert len(r2.content) > 0 + + +@pytest.mark.parallel +def test_metadata_conditional_request_headers(synced_distro_no_cache): + """JSON metadata responses include ETag, Last-Modified, and Cache-Control headers.""" + url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/") + + r = requests.get(url) + assert r.status_code == 200 + assert r.headers["Cache-Control"] == "max-age=900, public" + assert "ETag" in r.headers + assert r.headers["ETag"].startswith('"') and r.headers["ETag"].endswith('"') + assert "Last-Modified" in r.headers + parsedate_to_datetime(r.headers["Last-Modified"]) + + +@pytest.mark.parallel +def test_metadata_etag_conditional_request(synced_distro_no_cache): + """JSON metadata: matching If-None-Match returns 304, non-matching returns 200.""" + url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/") + + r1 = requests.get(url) + assert r1.status_code == 200 + etag = r1.headers["ETag"] + + r2 = requests.get(url, headers={"If-None-Match": etag}) + assert r2.status_code == 304 + assert len(r2.content) == 0 + + r3 = requests.get(url, headers={"If-None-Match": '"old"'}) + assert r3.status_code == 200 + assert r3.headers["ETag"] == etag + + +@pytest.mark.parallel +def test_metadata_if_modified_since_304(synced_distro_no_cache): + """JSON metadata: If-Modified-Since with matching timestamp returns 304.""" + url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/") + + r1 = requests.get(url) + assert r1.status_code == 200 + last_modified = r1.headers["Last-Modified"] + + r2 = requests.get(url, headers={"If-Modified-Since": last_modified}) + assert r2.status_code == 304 + assert len(r2.content) == 0 + + +@pytest.mark.parallel +def test_metadata_if_modified_since_old_timestamp_200(synced_distro_no_cache): + """JSON metadata: If-Modified-Since with old timestamp returns 200.""" + url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/") + + r1 = requests.get(url) + assert r1.status_code == 200 + + r2 = requests.get(url, headers={"If-Modified-Since": "Thu, 01 Jan 2009 00:00:00 GMT"}) + assert r2.status_code == 200 + assert len(r2.content) > 0