Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added api/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions api/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class ApiConfig(AppConfig):
name = 'api'
13 changes: 13 additions & 0 deletions api/deprecation.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions api/router.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file added api/routes/__init__.py
Empty file.
44 changes: 44 additions & 0 deletions api/routes/releng.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file added api/schemas/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions api/schemas/releng.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from datetime import date, datetime

from ninja import Schema


class ReleaseSchema(Schema):
version: str
kernel_version: str | None = None
release_date: date
available: bool
info: str
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


class ReleasesSchema(Schema):
version: int
releases: list[ReleaseSchema]
latest_version: str | None = None
Empty file added api/tests/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions api/tests/test_deprecation.py
Original file line number Diff line number Diff line change
@@ -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'] == '</api/v1/releng/releases/>; rel="successor-version"'
67 changes: 67 additions & 0 deletions api/tests/test_releng.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions api/tests/test_schema_validation.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from api.router import api

urlpatterns = api.urls
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"pgpdump==1.5",
"requests==2.33.1",
"sqlparse==0.5.5",
"django-ninja==1.6.2",
]

[dependency-groups]
Expand Down
2 changes: 2 additions & 0 deletions releng/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
'public',
'releng',
'visualize',
'api',
)

# Logging configuration for not getting overspammed
Expand Down
5 changes: 5 additions & 0 deletions urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading