|
3 | 3 |
|
4 | 4 | from aiohttp.client_exceptions import ClientError |
5 | 5 | from rest_framework.viewsets import ViewSet |
| 6 | +from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer |
6 | 7 | from rest_framework.response import Response |
| 8 | +from rest_framework.exceptions import NotAcceptable, NotFound |
7 | 9 | from django.core.exceptions import ObjectDoesNotExist |
8 | 10 | from django.shortcuts import redirect |
9 | 11 | from datetime import datetime, timezone, timedelta |
|
17 | 19 | HttpResponseBadRequest, |
18 | 20 | StreamingHttpResponse, |
19 | 21 | HttpResponse, |
| 22 | + JsonResponse, |
20 | 23 | ) |
21 | 24 | from drf_spectacular.utils import extend_schema |
22 | 25 | from dynaconf import settings |
|
43 | 46 | ) |
44 | 47 | from pulp_python.app.utils import ( |
45 | 48 | write_simple_index, |
| 49 | + write_simple_index_json, |
46 | 50 | write_simple_detail, |
| 51 | + write_simple_detail_json, |
47 | 52 | python_content_to_json, |
48 | 53 | PYPI_LAST_SERIAL, |
49 | 54 | PYPI_SERIAL_CONSTANT, |
|
57 | 62 | ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME |
58 | 63 | BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX) |
59 | 64 |
|
| 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 | + |
60 | 77 |
|
61 | 78 | class PyPIMixin: |
62 | 79 | """Mixin to get index specific info.""" |
@@ -235,14 +252,55 @@ class SimpleView(PackageUploadMixin, ViewSet): |
235 | 252 | ], |
236 | 253 | } |
237 | 254 |
|
| 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 | + |
238 | 285 | @extend_schema(summary="Get index simple page") |
239 | 286 | def list(self, request, path): |
240 | 287 | """Gets the simple api html page for the index.""" |
241 | 288 | repo_version, content = self.get_rvc() |
242 | 289 | if self.should_redirect(repo_version=repo_version): |
243 | 290 | 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 |
246 | 304 |
|
247 | 305 | def pull_through_package_simple(self, package, path, remote): |
248 | 306 | """Gets the package's simple page from remote.""" |
@@ -301,7 +359,16 @@ def retrieve(self, request, path, package): |
301 | 359 | packages = chain([present], packages) |
302 | 360 | name = present[2] |
303 | 361 | 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) |
305 | 372 |
|
306 | 373 | @extend_schema( |
307 | 374 | request=PackageUploadSerializer, |
|
0 commit comments