From 4f2c722b2ef6be3a5d6346ced3296b5d62243e2a Mon Sep 17 00:00:00 2001 From: Leonidas Spyropoulos Date: Sat, 9 May 2026 07:41:40 +0100 Subject: [PATCH 1/2] feat(api): add Django Ninja with releng releases endpoint Introduces the api/ Django app with django-ninja==1.6.2. Mounts NinjaAPI at /api/ with OpenAPI schema at /api/openapi.json and Swagger UI at /api/docs/. Implements the first route: GET /api/v1/releng/releases/ -> ReleasesSchema Pydantic schema mirrors ReleaseJSONEncoder output. 10 tests covering endpoint behaviour and OpenAPI schema contract. Closes part of #199. Signed-off-by: Leonidas Spyropoulos --- api/__init__.py | 0 api/apps.py | 5 ++ api/router.py | 13 ++++ api/routes/__init__.py | 0 api/routes/releng.py | 44 ++++++++++++ api/schemas/__init__.py | 0 api/schemas/releng.py | 29 ++++++++ api/tests/__init__.py | 0 api/tests/test_releng.py | 67 +++++++++++++++++ api/tests/test_schema_validation.py | 41 +++++++++++ api/urls.py | 3 + pyproject.toml | 1 + settings.py | 1 + urls.py | 5 ++ uv.lock | 107 ++++++++++++++++++++++++++++ 15 files changed, 316 insertions(+) create mode 100644 api/__init__.py create mode 100644 api/apps.py create mode 100644 api/router.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/releng.py create mode 100644 api/schemas/__init__.py create mode 100644 api/schemas/releng.py create mode 100644 api/tests/__init__.py create mode 100644 api/tests/test_releng.py create mode 100644 api/tests/test_schema_validation.py create mode 100644 api/urls.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 000000000..d87006dd6 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/api/router.py b/api/router.py new file mode 100644 index 000000000..eaa1bc518 --- /dev/null +++ b/api/router.py @@ -0,0 +1,13 @@ +from ninja import NinjaAPI + +from api.routes import releng + +api = NinjaAPI( + version="1", + title="Archweb API", + description="Arch Linux web API", + openapi_url="/openapi.json", + docs_url="/docs/", +) + +api.add_router("/v1/releng/", releng.router) diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/routes/releng.py b/api/routes/releng.py new file mode 100644 index 000000000..af98b2e86 --- /dev/null +++ b/api/routes/releng.py @@ -0,0 +1,44 @@ +from django.urls import reverse +from ninja import Router + +from api.schemas.releng import ReleaseSchema, ReleasesSchema +from releng.models import Release + +router = Router(tags=["releng"]) + + +def _release_to_schema(release: Release) -> ReleaseSchema: + return ReleaseSchema( + version=release.version, + kernel_version=release.kernel_version or None, + release_date=release.release_date, + available=release.available, + info=release.info, + iso_url='/' + release.iso_url(), + magnet_uri=release.magnet_uri(), + torrent_url=reverse('releng-release-torrent', args=[release.version]), + md5_sum=release.md5_sum or None, + sha1_sum=release.sha1_sum or None, + sha256_sum=release.sha256_sum or None, + b2_sum=release.b2_sum or None, + wkd_email=release.wkd_email or None, + pgp_fingerprint=release.pgp_key or None, + created=release.created, + last_modified=release.last_modified, + ) + + +@router.get("/releases/", response=ReleasesSchema, url_name="releng-releases") +def releases(request): + all_releases = Release.objects.all() + try: + latest_version = Release.objects.filter(available=True).values_list( + 'version', flat=True).latest() + except Release.DoesNotExist: + latest_version = None + + return ReleasesSchema( + version=1, + releases=[_release_to_schema(r) for r in all_releases], + latest_version=latest_version, + ) diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/schemas/releng.py b/api/schemas/releng.py new file mode 100644 index 000000000..e1f7d24e2 --- /dev/null +++ b/api/schemas/releng.py @@ -0,0 +1,29 @@ +from datetime import date, datetime +from typing import Optional + +from ninja import Schema + + +class ReleaseSchema(Schema): + version: str + kernel_version: Optional[str] = None + release_date: date + available: bool + info: str + iso_url: Optional[str] = None + magnet_uri: Optional[str] = None + torrent_url: Optional[str] = None + md5_sum: Optional[str] = None + sha1_sum: Optional[str] = None + sha256_sum: Optional[str] = None + b2_sum: Optional[str] = None + wkd_email: Optional[str] = None + pgp_fingerprint: Optional[str] = None + created: datetime + last_modified: datetime + + +class ReleasesSchema(Schema): + version: int + releases: list[ReleaseSchema] + latest_version: Optional[str] = None diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/test_releng.py b/api/tests/test_releng.py new file mode 100644 index 000000000..95f2b1010 --- /dev/null +++ b/api/tests/test_releng.py @@ -0,0 +1,67 @@ +from datetime import datetime, timezone + +import pytest + +from releng.models import Release + +VERSION = '2025.05.01' +KERNEL_VERSION = '6.9' + + +@pytest.fixture +def release(db): + r = Release.objects.create( + release_date=datetime.now(timezone.utc), + version=VERSION, + kernel_version=KERNEL_VERSION, + available=True, + ) + yield r + r.delete() + + +def test_releases_empty(db, client): + response = client.get('/api/v1/releng/releases/') + assert response.status_code == 200 + data = response.json() + assert data['version'] == 1 + assert data['releases'] == [] + assert data['latest_version'] is None + + +def test_releases_returns_release_fields(client, release): + response = client.get('/api/v1/releng/releases/') + assert response.status_code == 200 + r = response.json()['releases'][0] + assert r['version'] == VERSION + assert r['kernel_version'] == KERNEL_VERSION + assert r['available'] is True + + +def test_releases_latest_version_is_newest_available(db, client): + now = datetime.now(timezone.utc) + Release.objects.create(release_date=now, version='2025.01.01', available=True) + Release.objects.create(release_date=now, version='2025.05.01', available=True) + data = client.get('/api/v1/releng/releases/').json() + assert data['latest_version'] == '2025.05.01' + + +def test_releases_latest_version_excludes_unavailable(db, client): + now = datetime.now(timezone.utc) + Release.objects.create(release_date=now, version='2025.01.01', available=True) + Release.objects.create(release_date=now, version='2025.05.01', available=False) + data = client.get('/api/v1/releng/releases/').json() + assert data['latest_version'] == '2025.01.01' + + +def test_releases_optional_fields_null_when_absent(db, client): + Release.objects.create( + release_date=datetime.now(timezone.utc), + version='2025.05.01', + ) + r = client.get('/api/v1/releng/releases/').json()['releases'][0] + assert r['kernel_version'] is None + assert r['md5_sum'] is None + assert r['sha1_sum'] is None + assert r['sha256_sum'] is None + assert r['b2_sum'] is None diff --git a/api/tests/test_schema_validation.py b/api/tests/test_schema_validation.py new file mode 100644 index 000000000..8e9ad432a --- /dev/null +++ b/api/tests/test_schema_validation.py @@ -0,0 +1,41 @@ +"""OpenAPI schema contract tests for the Archweb API.""" + +def test_openapi_schema_served(client): + response = client.get('/api/openapi.json') + assert response.status_code == 200 + assert response['Content-Type'] == 'application/json' + + +def test_openapi_schema_version(client): + schema = client.get('/api/openapi.json').json() + assert schema['openapi'].startswith('3.') + assert schema['info']['title'] == 'Archweb API' + assert schema['info']['version'] == '1' + + +def test_openapi_releng_releases_endpoint_present(client): + paths = client.get('/api/openapi.json').json()['paths'] + assert '/api/v1/releng/releases/' in paths + assert 'get' in paths['/api/v1/releng/releases/'] + + +def test_openapi_releases_schema_fields(client): + schema = client.get('/api/openapi.json').json() + components = schema['components']['schemas'] + assert 'ReleasesSchema' in components + assert 'ReleaseSchema' in components + + release_props = components['ReleaseSchema']['properties'] + required_fields = {'version', 'release_date', 'available', 'info'} + assert required_fields <= release_props.keys() + + nullable_fields = {'kernel_version', 'md5_sum', 'sha1_sum', 'sha256_sum', 'b2_sum'} + for field in nullable_fields: + assert field in release_props + types = {t.get('type') for t in release_props[field].get('anyOf', [])} + assert 'null' in types, f"{field} must be nullable" + + +def test_openapi_docs_url_exists(client): + response = client.get('/api/docs/') + assert response.status_code == 200 diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 000000000..a6e1df0e9 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,3 @@ +from api.router import api + +urlpatterns = api.urls diff --git a/pyproject.toml b/pyproject.toml index 8430515ce..e37ede805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pgpdump==1.5", "requests==2.33.1", "sqlparse==0.5.5", + "django-ninja==1.6.2", ] [dependency-groups] diff --git a/settings.py b/settings.py index d3ae2f9e2..47e9ae247 100644 --- a/settings.py +++ b/settings.py @@ -130,6 +130,7 @@ 'public', 'releng', 'visualize', + 'api', ) # Logging configuration for not getting overspammed diff --git a/urls.py b/urls.py index 7b36b0caf..41c4bbc3c 100644 --- a/urls.py +++ b/urls.py @@ -101,6 +101,11 @@ name='news-sitemap'), ]) +# REST API (Django Ninja) +from api.router import api # noqa: E402 + +urlpatterns += [path('api/', api.urls)] + # Authentication urlpatterns.extend([ path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'), diff --git a/uv.lock b/uv.lock index 53478066a..6ecdc3df1 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "archweb" version = "0.1.0" @@ -25,6 +34,7 @@ dependencies = [ { name = "django" }, { name = "django-countries" }, { name = "django-csp" }, + { name = "django-ninja" }, { name = "django-prometheus" }, { name = "feedparser" }, { name = "ipy" }, @@ -64,6 +74,7 @@ requires-dist = [ { name = "django", specifier = "==5.2.14" }, { name = "django-countries", specifier = "==8.2.0" }, { name = "django-csp", specifier = "==4.0" }, + { name = "django-ninja", specifier = "==1.6.2" }, { name = "django-prometheus", specifier = "==2.4.1" }, { name = "feedparser", specifier = "==6.0.12" }, { name = "ipy", specifier = "==1.1" }, @@ -295,6 +306,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/9e/d8c3c845f4b5ccac7377c19f4049e7e00c6f121846a81f69a497b45734df/django_debug_toolbar-6.3.0-py3-none-any.whl", hash = "sha256:a199ce3d0f884739a9096835ad417479fede05f3b3c4824bc8b354721ba8f629", size = 298304, upload-time = "2026-04-02T16:06:59.617Z" }, ] +[[package]] +name = "django-ninja" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, +] + [[package]] name = "django-prometheus" version = "2.4.1" @@ -452,6 +476,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -591,6 +686,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.2" From 678c7be791b0a27faf29155b7aef15e61fb71281 Mon Sep 17 00:00:00 2001 From: Leonidas Spyropoulos Date: Sat, 9 May 2026 08:14:29 +0100 Subject: [PATCH 2/2] feat(api): add deprecation headers to releng legacy JSON endpoint Add `deprecated_json_endpoint` decorator in `api/deprecation.py` (RFC 8594). Apply to `releng/views.py` `releases_json` - response now includes `Deprecation: true` and `Link: ; rel="successor-version"`. --- api/deprecation.py | 13 +++++++++++++ api/schemas/releng.py | 23 +++++++++++------------ api/tests/test_deprecation.py | 5 +++++ releng/views.py | 2 ++ 4 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 api/deprecation.py create mode 100644 api/tests/test_deprecation.py diff --git a/api/deprecation.py b/api/deprecation.py new file mode 100644 index 000000000..f494ab404 --- /dev/null +++ b/api/deprecation.py @@ -0,0 +1,13 @@ +from functools import wraps + + +def deprecated_json_endpoint(successor_path: str): + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + response = view_func(request, *args, **kwargs) + response['Deprecation'] = 'true' + response['Link'] = f'<{successor_path}>; rel="successor-version"' + return response + return wrapper + return decorator diff --git a/api/schemas/releng.py b/api/schemas/releng.py index e1f7d24e2..04bec70ed 100644 --- a/api/schemas/releng.py +++ b/api/schemas/releng.py @@ -1,24 +1,23 @@ from datetime import date, datetime -from typing import Optional from ninja import Schema class ReleaseSchema(Schema): version: str - kernel_version: Optional[str] = None + kernel_version: str | None = None release_date: date available: bool info: str - iso_url: Optional[str] = None - magnet_uri: Optional[str] = None - torrent_url: Optional[str] = None - md5_sum: Optional[str] = None - sha1_sum: Optional[str] = None - sha256_sum: Optional[str] = None - b2_sum: Optional[str] = None - wkd_email: Optional[str] = None - pgp_fingerprint: Optional[str] = None + iso_url: str | None = None + magnet_uri: str | None = None + torrent_url: str | None = None + md5_sum: str | None = None + sha1_sum: str | None = None + sha256_sum: str | None = None + b2_sum: str | None = None + wkd_email: str | None = None + pgp_fingerprint: str | None = None created: datetime last_modified: datetime @@ -26,4 +25,4 @@ class ReleaseSchema(Schema): class ReleasesSchema(Schema): version: int releases: list[ReleaseSchema] - latest_version: Optional[str] = None + latest_version: str | None = None diff --git a/api/tests/test_deprecation.py b/api/tests/test_deprecation.py new file mode 100644 index 000000000..5e265a719 --- /dev/null +++ b/api/tests/test_deprecation.py @@ -0,0 +1,5 @@ +def test_releng_releases_json_deprecation_headers(db, client): + response = client.get('/releng/releases/json/') + assert response.status_code == 200 + assert response['Deprecation'] == 'true' + assert response['Link'] == '; rel="successor-version"' diff --git a/releng/views.py b/releng/views.py index 2f7b933f9..da43358d0 100644 --- a/releng/views.py +++ b/releng/views.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.views.generic import DetailView, ListView +from api.deprecation import deprecated_json_endpoint from main.models import Package from mirrors.models import MirrorUrl @@ -59,6 +60,7 @@ def default(self, obj): return super(ReleaseJSONEncoder, self).default(obj) +@deprecated_json_endpoint('/api/v1/releng/releases/') def releases_json(request): releases = Release.objects.all() try: