Skip to content

Commit 17e0e7c

Browse files
committed
Add JSON-based Simple API
closes #625
1 parent 8c06232 commit 17e0e7c

File tree

3 files changed

+138
-2
lines changed

3 files changed

+138
-2
lines changed

pulp_python/app/pypi/views.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from aiohttp.client_exceptions import ClientError
55
from rest_framework.viewsets import ViewSet
6+
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
67
from rest_framework.response import Response
78
from django.core.exceptions import ObjectDoesNotExist
89
from django.shortcuts import redirect
@@ -17,6 +18,7 @@
1718
HttpResponseBadRequest,
1819
StreamingHttpResponse,
1920
HttpResponse,
21+
JsonResponse,
2022
)
2123
from drf_spectacular.utils import extend_schema
2224
from dynaconf import settings
@@ -43,7 +45,9 @@
4345
)
4446
from pulp_python.app.utils import (
4547
write_simple_index,
48+
write_simple_index_json,
4649
write_simple_detail,
50+
write_simple_detail_json,
4751
python_content_to_json,
4852
PYPI_LAST_SERIAL,
4953
PYPI_SERIAL_CONSTANT,
@@ -57,6 +61,30 @@
5761
ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
5862
BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
5963

64+
PYPI_TEXT_HTML = "text/html"
65+
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
66+
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
67+
68+
69+
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
70+
media_type = PYPI_SIMPLE_V1_HTML
71+
72+
73+
class PyPISimpleJSONRenderer(JSONRenderer):
74+
media_type = PYPI_SIMPLE_V1_JSON
75+
76+
77+
def _select_content_type(request):
78+
"""Select content type based on Accept header."""
79+
accept_header = request.META.get("HTTP_ACCEPT", "")
80+
81+
for content_type in (PYPI_TEXT_HTML, PYPI_SIMPLE_V1_HTML, PYPI_SIMPLE_V1_JSON):
82+
if content_type in accept_header:
83+
return content_type
84+
if not accept_header:
85+
return PYPI_TEXT_HTML
86+
return None
87+
6088

6189
class PyPIMixin:
6290
"""Mixin to get index specific info."""
@@ -235,14 +263,32 @@ class SimpleView(PackageUploadMixin, ViewSet):
235263
],
236264
}
237265

266+
renderer_classes = [
267+
TemplateHTMLRenderer,
268+
PyPISimpleHTMLRenderer,
269+
PyPISimpleJSONRenderer,
270+
]
271+
238272
@extend_schema(summary="Get index simple page")
239273
def list(self, request, path):
240274
"""Gets the simple api html page for the index."""
275+
content_type = _select_content_type(request)
276+
if content_type is None:
277+
return HttpResponse("Not Acceptable Content-Type", status=406)
278+
241279
repo_version, content = self.get_rvc()
242280
if self.should_redirect(repo_version=repo_version):
243281
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244282
names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245-
return StreamingHttpResponse(write_simple_index(names, streamed=True))
283+
284+
if content_type == PYPI_SIMPLE_V1_JSON:
285+
names_list = list(names)
286+
data_dict = write_simple_index_json(names_list)
287+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
288+
response = JsonResponse(data_dict, content_type=content_type, headers=headers)
289+
return response
290+
else:
291+
return StreamingHttpResponse(write_simple_index(names, streamed=True))
246292

247293
def pull_through_package_simple(self, package, path, remote):
248294
"""Gets the package's simple page from remote."""
@@ -281,6 +327,10 @@ def parse_package(release_package):
281327
@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
282328
def retrieve(self, request, path, package):
283329
"""Retrieves the simple api html page for a package."""
330+
content_type = _select_content_type(request)
331+
if content_type is None:
332+
return HttpResponse("Not Acceptable Content-Type", status=406)
333+
284334
repo_ver, content = self.get_rvc()
285335
# Should I redirect if the normalized name is different?
286336
normalized = canonicalize_name(package)
@@ -301,7 +351,15 @@ def retrieve(self, request, path, package):
301351
packages = chain([present], packages)
302352
name = present[2]
303353
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))
354+
355+
if content_type == PYPI_SIMPLE_V1_JSON:
356+
releases_list = list(releases)
357+
data_dict = write_simple_detail_json(name, releases_list)
358+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
359+
response = JsonResponse(data_dict, content_type=content_type, headers=headers)
360+
return response
361+
else:
362+
return StreamingHttpResponse(write_simple_detail(name, releases, streamed=True))
305363

306364
@extend_schema(
307365
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
442+
# size / version 1.1
443+
# upload-time / 1.1
444+
# yanked
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

pulp_python/tests/functional/api/test_pypi_apis.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,40 @@ def test_simple_correctness_live(
279279
assert proper is True, msgs
280280

281281

282+
@pytest.mark.parallel
283+
def test_simple_json_api(python_remote_factory, python_repo_with_sync, python_distribution_factory):
284+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
285+
repo = python_repo_with_sync(remote)
286+
distro = python_distribution_factory(repository=repo)
287+
288+
index_url = urljoin(distro.base_url, "simple/")
289+
detail_url = f"{index_url}aiohttp"
290+
291+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"} # ok
292+
# headers = {"Accept": "application/json"} # nok
293+
294+
response_index = requests.get(index_url, headers=headers)
295+
data_index = response_index.json()
296+
assert data_index["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
297+
assert data_index["projects"]
298+
for project in data_index["projects"]:
299+
for i in ["_last-serial", "name"]:
300+
assert project[i]
301+
assert response_index.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
302+
assert response_index.headers["X-PyPI-Last-Serial"] == "1000000000"
303+
304+
response_detail = requests.get(detail_url, headers=headers)
305+
data_detail = response_detail.json()
306+
assert data_detail["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
307+
assert data_detail["name"] == "aiohttp"
308+
assert data_detail["files"]
309+
for file in data_detail["files"]:
310+
for i in ["filename", "url", "hashes"]:
311+
assert file[i]
312+
assert response_detail.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
313+
assert response_detail.headers["X-PyPI-Last-Serial"] == "1000000000"
314+
315+
282316
@pytest.mark.parallel
283317
def test_pypi_json(python_remote_factory, python_repo_with_sync, python_distribution_factory):
284318
"""Checks the data of `pypi/{package_name}/json` endpoint."""

0 commit comments

Comments
 (0)