diff --git a/apps/downloads/tests/test_views.py b/apps/downloads/tests/test_views.py index b5eb75246..c1bed04a1 100644 --- a/apps/downloads/tests/test_views.py +++ b/apps/downloads/tests/test_views.py @@ -737,6 +737,31 @@ def test_v1_release_file_delete_by_release_still_works(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(self.get_json(response)), 0) + def test_unsupported_request_content_type_returns_415(self): + url = self.create_url("os") + cases = [ + ("application/yaml", "name: Unsupported OS\nslug: unsupported-os\n"), + ("application/x-www-form-urlencoded", "name=Unsupported+OS&slug=unsupported-os"), + ] + + for content_type, body in cases: + with self.subTest(content_type=content_type): + response = self.client.post( + url, body, content_type=content_type, headers={"authorization": self.Authorization} + ) + + self.assertEqual(response.status_code, 415) + self.assertIn(content_type, response.json()["error"]) + self.assertFalse(OS.objects.filter(slug="unsupported-os").exists()) + + def test_non_integer_detail_lookup_returns_404(self): + url = self.create_url("release", "latest") + + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"], "Invalid resource lookup data provided (mismatched type).") + class DownloadApiV2ViewsTest(BaseDownloadApiViewsTest, BaseDownloadTests, APITestCase): api_version = "v2" diff --git a/pydotorg/resources.py b/pydotorg/resources.py index 6c8f013d5..ef67cf48e 100644 --- a/pydotorg/resources.py +++ b/pydotorg/resources.py @@ -4,9 +4,9 @@ from django.contrib.auth.models import AnonymousUser from tastypie.authentication import ApiKeyAuthentication from tastypie.authorization import Authorization -from tastypie.exceptions import Unauthorized -from tastypie.http import HttpUnauthorized -from tastypie.resources import ModelResource +from tastypie.exceptions import ImmediateHttpResponse, NotFound, Unauthorized, UnsupportedDeserializationFormat +from tastypie.http import HttpNotFound, HttpUnauthorized, HttpUnsupportedMediaType +from tastypie.resources import ModelResource, sanitize from tastypie.throttle import CacheThrottle API_KEY_AUTHENTICATED_ATTR = "_pydotorg_api_key_authenticated" @@ -184,3 +184,21 @@ class Meta: detail_allowed_methods = ["get", "delete"] throttle = CacheThrottle(throttle_at=600) # default is 150 req/hr abstract = True + + def deserialize(self, request, data, format="application/json"): # noqa: A002 - tastypie hook signature + """Return a 415 for unsupported request content types without firing the 500 signal.""" + try: + return super().deserialize(request, data, format=format) + except UnsupportedDeserializationFormat as exc: + response = self.error_response( + request, {"error": sanitize(str(exc))}, response_class=HttpUnsupportedMediaType + ) + raise ImmediateHttpResponse(response=response) from exc + + def get_detail(self, request, **kwargs): + """Return a 404 for malformed detail lookups without firing the 500 signal.""" + try: + return super().get_detail(request, **kwargs) + except NotFound as exc: + response = self.error_response(request, {"error": sanitize(str(exc))}, response_class=HttpNotFound) + raise ImmediateHttpResponse(response=response) from exc