From 5a5e01e33b73fba4a1b8204dda0cf45d34fb4538 Mon Sep 17 00:00:00 2001 From: jrhoads Date: Tue, 18 Nov 2025 12:22:20 +0100 Subject: [PATCH 1/3] feat: Implement V1 deprecation middleware to return 410 status for deprecated API endpoints --- rorapi/middleware/__init__.py | 0 rorapi/middleware/deprecation.py | 33 ++++++++++++++++++++++++++++++++ rorapi/settings.py | 4 ++++ 3 files changed, 37 insertions(+) create mode 100644 rorapi/middleware/__init__.py create mode 100644 rorapi/middleware/deprecation.py diff --git a/rorapi/middleware/__init__.py b/rorapi/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rorapi/middleware/deprecation.py b/rorapi/middleware/deprecation.py new file mode 100644 index 00000000..905ab933 --- /dev/null +++ b/rorapi/middleware/deprecation.py @@ -0,0 +1,33 @@ +from django.http import JsonResponse +from django.conf import settings + + +class V1DeprecationMiddleware: + """ + Middleware to return 410 Gone status for deprecated v1 API endpoints. + + This middleware checks if V1_DEPRECATED setting is enabled, and if so, + returns a 410 status code with a deprecation message for any requests + to /v1 endpoints. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Check if v1 deprecation is enabled and path starts with /v1 + if getattr(settings, 'V1_DEPRECATED', False): + if request.path.startswith('/v1/') or request.path == '/v1': + return JsonResponse( + { + 'errors': [{ + 'status': '410', + 'title': 'API Version Deprecated', + 'detail': 'The v1 API has been deprecated. Please migrate to v2.' + }] + }, + status=410 + ) + + response = self.get_response(request) + return response diff --git a/rorapi/settings.py b/rorapi/settings.py index 1ccf72e6..09255a02 100644 --- a/rorapi/settings.py +++ b/rorapi/settings.py @@ -64,6 +64,7 @@ MIDDLEWARE = [ 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', + 'rorapi.middleware.deprecation.V1DeprecationMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -305,3 +306,6 @@ AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') AWS_SES_REGION_NAME = os.environ.get('AWS_REGION', 'eu-west-1') + +# API Deprecation +V1_DEPRECATED = os.environ.get("V1_DEPRECATED", "False") == "True" \ No newline at end of file From 645d7998baba945ceeec2a8c6926f1392a5eae94 Mon Sep 17 00:00:00 2001 From: jrhoads Date: Tue, 18 Nov 2025 13:56:04 +0100 Subject: [PATCH 2/3] test: Add unit tests for V1DeprecationMiddleware to validate 410 responses for deprecated endpoints --- .../tests_deprecation_middleware.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 rorapi/tests/tests_unit/tests_deprecation_middleware.py diff --git a/rorapi/tests/tests_unit/tests_deprecation_middleware.py b/rorapi/tests/tests_unit/tests_deprecation_middleware.py new file mode 100644 index 00000000..b7147f15 --- /dev/null +++ b/rorapi/tests/tests_unit/tests_deprecation_middleware.py @@ -0,0 +1,131 @@ +from django.test import TestCase, RequestFactory, override_settings +from django.http import JsonResponse +from rorapi.middleware.deprecation import V1DeprecationMiddleware +import json + + +class V1DeprecationMiddlewareTestCase(TestCase): + """ + Tests for V1DeprecationMiddleware that returns 410 Gone for deprecated v1 endpoints. + """ + + def setUp(self): + self.factory = RequestFactory() + + # Mock get_response function + def get_response(request): + return JsonResponse({'message': 'success'}, status=200) + + self.get_response = get_response + self.middleware = V1DeprecationMiddleware(self.get_response) + + @override_settings(V1_DEPRECATED=True) + def test_v1_path_returns_410_when_deprecated(self): + """Test that /v1/ paths return 410 when V1_DEPRECATED is True""" + request = self.factory.get('/v1/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 410) + content = json.loads(response.content.decode('utf-8')) + self.assertIn('errors', content) + self.assertEqual(content['errors'][0]['status'], '410') + self.assertEqual(content['errors'][0]['title'], 'API Version Deprecated') + + @override_settings(V1_DEPRECATED=True) + def test_v1_exact_path_returns_410_when_deprecated(self): + """Test that exact /v1 path returns 410 when V1_DEPRECATED is True""" + request = self.factory.get('/v1') + response = self.middleware(request) + + self.assertEqual(response.status_code, 410) + content = json.loads(response.content.decode('utf-8')) + self.assertIn('errors', content) + + @override_settings(V1_DEPRECATED=True) + def test_v2_path_passes_through_when_v1_deprecated(self): + """Test that /v2/ paths work normally even when V1_DEPRECATED is True""" + request = self.factory.get('/v2/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['message'], 'success') + + @override_settings(V1_DEPRECATED=False) + def test_v1_path_passes_through_when_not_deprecated(self): + """Test that /v1/ paths work normally when V1_DEPRECATED is False""" + request = self.factory.get('/v1/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['message'], 'success') + + @override_settings(V1_DEPRECATED=False) + def test_v2_path_passes_through_when_v1_not_deprecated(self): + """Test that /v2/ paths work normally when V1_DEPRECATED is False""" + request = self.factory.get('/v2/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['message'], 'success') + + @override_settings(V1_DEPRECATED=None) + def test_v1_path_passes_through_when_setting_not_set(self): + """Test that /v1/ paths work when V1_DEPRECATED setting doesn't exist""" + # Don't use override_settings, rely on default behavior + request = self.factory.get('/v1/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['message'], 'success') + + @override_settings(V1_DEPRECATED=True) + def test_root_path_passes_through(self): + """Test that root path is not affected by middleware""" + request = self.factory.get('/') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + + @override_settings(V1_DEPRECATED=True) + def test_other_paths_pass_through(self): + """Test that non-v1 paths pass through normally""" + request = self.factory.get('/heartbeat') + response = self.middleware(request) + + self.assertEqual(response.status_code, 200) + + @override_settings(V1_DEPRECATED=True) + def test_v1_with_query_params_returns_410(self): + """Test that /v1/ paths with query parameters return 410""" + request = self.factory.get('/v1/organizations?query=test') + response = self.middleware(request) + + self.assertEqual(response.status_code, 410) + + @override_settings(V1_DEPRECATED=True) + def test_v1_post_request_returns_410(self): + """Test that POST requests to /v1/ paths return 410""" + request = self.factory.post('/v1/organizations') + response = self.middleware(request) + + self.assertEqual(response.status_code, 410) + + @override_settings(V1_DEPRECATED=True) + def test_deprecation_error_message_format(self): + """Test that the deprecation error message follows the expected format""" + request = self.factory.get('/v1/organizations') + response = self.middleware(request) + + content = json.loads(response.content.decode('utf-8')) + self.assertIn('errors', content) + self.assertEqual(len(content['errors']), 1) + + error = content['errors'][0] + self.assertIn('status', error) + self.assertIn('title', error) + self.assertIn('detail', error) + self.assertIn('migrate to v2', error['detail']) From b196f3b3dde5f04b89da3fc66d3bff62584860fb Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Tue, 18 Nov 2025 09:49:42 -0500 Subject: [PATCH 3/3] Update rorapi/settings.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- rorapi/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rorapi/settings.py b/rorapi/settings.py index 09255a02..513550ee 100644 --- a/rorapi/settings.py +++ b/rorapi/settings.py @@ -308,4 +308,4 @@ AWS_SES_REGION_NAME = os.environ.get('AWS_REGION', 'eu-west-1') # API Deprecation -V1_DEPRECATED = os.environ.get("V1_DEPRECATED", "False") == "True" \ No newline at end of file +V1_DEPRECATED = os.environ.get("V1_DEPRECATED", "False").lower() in ("true", "1", "yes") \ No newline at end of file