Skip to content

Commit 6647217

Browse files
committed
Add JSON-based Simple API
1 parent 2b34643 commit 6647217

File tree

3 files changed

+185
-3
lines changed

3 files changed

+185
-3
lines changed

pulp_python/app/pypi/views.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
from aiohttp.client_exceptions import ClientError
55
from rest_framework.viewsets import ViewSet
6+
from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
67
from rest_framework.response import Response
8+
from rest_framework.exceptions import NotAcceptable, NotFound
79
from django.core.exceptions import ObjectDoesNotExist
810
from django.shortcuts import redirect
911
from datetime import datetime, timezone, timedelta
@@ -17,6 +19,7 @@
1719
HttpResponseBadRequest,
1820
StreamingHttpResponse,
1921
HttpResponse,
22+
JsonResponse,
2023
)
2124
from drf_spectacular.utils import extend_schema
2225
from dynaconf import settings
@@ -43,7 +46,9 @@
4346
)
4447
from pulp_python.app.utils import (
4548
write_simple_index,
49+
write_simple_index_json,
4650
write_simple_detail,
51+
write_simple_detail_json,
4752
python_content_to_json,
4853
PYPI_LAST_SERIAL,
4954
PYPI_SERIAL_CONSTANT,
@@ -57,6 +62,18 @@
5762
ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
5863
BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
5964

65+
# PYPI_TEXT_HTML = "text/html"
66+
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
67+
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
68+
69+
70+
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
71+
media_type = PYPI_SIMPLE_V1_HTML
72+
73+
74+
class PyPISimpleJSONRenderer(JSONRenderer):
75+
media_type = PYPI_SIMPLE_V1_JSON
76+
6077

6178
class PyPIMixin:
6279
"""Mixin to get index specific info."""
@@ -235,14 +252,55 @@ class SimpleView(PackageUploadMixin, ViewSet):
235252
],
236253
}
237254

255+
def perform_content_negotiation(self, request, force=False):
256+
"""
257+
Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
258+
"""
259+
try:
260+
return super().perform_content_negotiation(request, force)
261+
except NotAcceptable:
262+
return TemplateHTMLRenderer(), "text/html"
263+
264+
def get_renderers(self):
265+
"""
266+
Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
267+
"""
268+
if self.action in ["list", "retrieve"]:
269+
# Ordered by priority if multiple content types are present
270+
return [
271+
TemplateHTMLRenderer(),
272+
PyPISimpleHTMLRenderer(),
273+
PyPISimpleJSONRenderer(),
274+
]
275+
else:
276+
return [JSONRenderer(), BrowsableAPIRenderer()]
277+
278+
def handle_exception(self, exc):
279+
# todo: fixes test_pull_through_filter?
280+
if isinstance(exc, (Http404, NotFound)):
281+
# Force JSON response since JSONRenderer is not in get_renderers()
282+
return JsonResponse({"detail": str(exc)}, status=404)
283+
return super().handle_exception(exc)
284+
238285
@extend_schema(summary="Get index simple page")
239286
def list(self, request, path):
240287
"""Gets the simple api html page for the index."""
241288
repo_version, content = self.get_rvc()
242289
if self.should_redirect(repo_version=repo_version):
243290
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244-
names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245-
return StreamingHttpResponse(write_simple_index(names, streamed=True))
291+
292+
names = content.order_by("name").values_list("name", flat=True).distinct()
293+
media_type = request.accepted_renderer.media_type
294+
295+
if media_type == PYPI_SIMPLE_V1_JSON:
296+
index_data = write_simple_index_json(list(names))
297+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
298+
return Response(index_data, headers=headers)
299+
else:
300+
index_data = write_simple_index(names.iterator(), streamed=True)
301+
kwargs = {"content_type": media_type}
302+
response = StreamingHttpResponse(index_data, **kwargs)
303+
return response
246304

247305
def pull_through_package_simple(self, package, path, remote):
248306
"""Gets the package's simple page from remote."""
@@ -301,7 +359,16 @@ def retrieve(self, request, path, package):
301359
packages = chain([present], packages)
302360
name = present[2]
303361
releases = ((f, urljoin(self.base_content_url, f"{path}/{f}"), d) for f, d, _ in packages)
304-
return StreamingHttpResponse(write_simple_detail(name, releases, streamed=True))
362+
media_type = request.accepted_renderer.media_type
363+
364+
if media_type == PYPI_SIMPLE_V1_JSON:
365+
detail_data = write_simple_detail_json(name, list(releases))
366+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
367+
return Response(detail_data, headers=headers)
368+
else:
369+
detail_data = write_simple_detail(name, releases, streamed=True)
370+
kwargs = {"content_type": media_type}
371+
return StreamingHttpResponse(detail_data, kwargs)
305372

306373
@extend_schema(
307374
request=PackageUploadSerializer,

pulp_python/app/utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"""TODO This serial constant is temporary until Python repositories implements serials"""
1717
PYPI_SERIAL_CONSTANT = 1000000000
1818

19+
# todo: update to reflect supported version
20+
# https://packaging.python.org/en/latest/specifications/simple-repository-api/#api-version-history
21+
PYPI_API_VERSION = "1.4"
22+
1923
simple_index_template = """<!DOCTYPE html>
2024
<html>
2125
<head>
@@ -414,6 +418,46 @@ def write_simple_detail(project_name, project_packages, streamed=False):
414418
return detail.stream(**context) if streamed else detail.render(**context)
415419

416420

421+
def write_simple_index_json(project_names):
422+
"""Writes the simple index in JSON format."""
423+
projects = [{"_last-serial": PYPI_SERIAL_CONSTANT, "name": name} for name in project_names]
424+
425+
return {
426+
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
427+
"projects": projects,
428+
}
429+
430+
431+
# todo: fields
432+
def write_simple_detail_json(project_name, project_packages):
433+
"""Writes the simple detail page in JSON format."""
434+
files = []
435+
for filename, url, sha256 in project_packages:
436+
files.append(
437+
{
438+
"filename": filename,
439+
"url": url,
440+
"hashes": {"sha256": sha256},
441+
# requires_python # todo
442+
# size / version 1.1
443+
# upload-time / 1.1
444+
# yanked # todo
445+
# data-dist-info-metadata
446+
# core-metadata
447+
# provenance / 1.3
448+
}
449+
)
450+
451+
return {
452+
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
453+
"name": project_name, # should be normalized
454+
# project-status / 1.4
455+
# versions / 1.1
456+
# alternate-locations
457+
"files": files,
458+
}
459+
460+
417461
class PackageIncludeFilter:
418462
"""A special class to help filter Package's based on a remote's include/exclude"""
419463

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from urllib.parse import urljoin
2+
3+
import pytest
4+
import requests
5+
6+
from pulp_python.tests.functional.constants import PYTHON_SM_PROJECT_SPECIFIER
7+
8+
9+
@pytest.mark.parallel
10+
def test_simple_json_api(python_remote_factory, python_repo_with_sync, python_distribution_factory):
11+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
12+
repo = python_repo_with_sync(remote)
13+
distro = python_distribution_factory(repository=repo)
14+
15+
index_url = urljoin(distro.base_url, "simple/")
16+
detail_url = f"{index_url}aiohttp"
17+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
18+
19+
response_index = requests.get(index_url, headers=headers)
20+
data_index = response_index.json()
21+
assert data_index["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
22+
assert data_index["projects"]
23+
for project in data_index["projects"]:
24+
for i in ["_last-serial", "name"]:
25+
assert project[i]
26+
assert response_index.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
27+
assert response_index.headers["X-PyPI-Last-Serial"] == "1000000000"
28+
29+
response_detail = requests.get(detail_url, headers=headers)
30+
data_detail = response_detail.json()
31+
assert data_detail["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
32+
assert data_detail["name"] == "aiohttp"
33+
assert data_detail["files"]
34+
for file in data_detail["files"]:
35+
for i in ["filename", "url", "hashes"]:
36+
assert file[i]
37+
assert response_detail.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
38+
assert response_detail.headers["X-PyPI-Last-Serial"] == "1000000000"
39+
40+
41+
@pytest.mark.parametrize(
42+
"header, result",
43+
[
44+
("text/html", "text/html"),
45+
("application/vnd.pypi.simple.v1+html", "application/vnd.pypi.simple.v1+html"),
46+
("application/vnd.pypi.simple.v1+json", "application/vnd.pypi.simple.v1+json"),
47+
# Follows defined ordering (html, pypi html, pypi json)
48+
(
49+
"application/vnd.pypi.simple.v1+json, application/vnd.pypi.simple.v1+html",
50+
"application/vnd.pypi.simple.v1+html",
51+
),
52+
# Everything else should be html
53+
("", "text/html"),
54+
("application/json", "text/html"),
55+
("sth/else", "text/html"),
56+
],
57+
)
58+
def test_simple_json_api_headers(
59+
python_remote_factory, python_repo_with_sync, python_distribution_factory, header, result
60+
):
61+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
62+
repo = python_repo_with_sync(remote)
63+
distro = python_distribution_factory(repository=repo)
64+
65+
index_url = urljoin(distro.base_url, "simple/")
66+
detail_url = f"{index_url}aiohttp"
67+
68+
for url in [index_url, detail_url]:
69+
response = requests.get(url, headers={"Accept": header})
70+
assert response.status_code == 200
71+
assert result in response.headers["Content-Type"]

0 commit comments

Comments
 (0)