Skip to content

Commit be50f07

Browse files
committed
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 <artafinde@archlinux.org>
1 parent a7a872f commit be50f07

15 files changed

Lines changed: 316 additions & 0 deletions

api/__init__.py

Whitespace-only changes.

api/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ApiConfig(AppConfig):
5+
name = 'api'

api/router.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from ninja import NinjaAPI
2+
3+
from api.routes import releng
4+
5+
api = NinjaAPI(
6+
version="1",
7+
title="Archweb API",
8+
description="Arch Linux web API",
9+
openapi_url="/openapi.json",
10+
docs_url="/docs/",
11+
)
12+
13+
api.add_router("/v1/releng/", releng.router)

api/routes/__init__.py

Whitespace-only changes.

api/routes/releng.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from django.urls import reverse
2+
from ninja import Router
3+
4+
from api.schemas.releng import ReleaseSchema, ReleasesSchema
5+
from releng.models import Release
6+
7+
router = Router(tags=["releng"])
8+
9+
10+
def _release_to_schema(release: Release) -> ReleaseSchema:
11+
return ReleaseSchema(
12+
version=release.version,
13+
kernel_version=release.kernel_version or None,
14+
release_date=release.release_date,
15+
available=release.available,
16+
info=release.info,
17+
iso_url='/' + release.iso_url(),
18+
magnet_uri=release.magnet_uri(),
19+
torrent_url=reverse('releng-release-torrent', args=[release.version]),
20+
md5_sum=release.md5_sum or None,
21+
sha1_sum=release.sha1_sum or None,
22+
sha256_sum=release.sha256_sum or None,
23+
b2_sum=release.b2_sum or None,
24+
wkd_email=release.wkd_email or None,
25+
pgp_fingerprint=release.pgp_key or None,
26+
created=release.created,
27+
last_modified=release.last_modified,
28+
)
29+
30+
31+
@router.get("/releases/", response=ReleasesSchema, url_name="releng-releases")
32+
def releases(request):
33+
all_releases = Release.objects.all()
34+
try:
35+
latest_version = Release.objects.filter(available=True).values_list(
36+
'version', flat=True).latest()
37+
except Release.DoesNotExist:
38+
latest_version = None
39+
40+
return ReleasesSchema(
41+
version=1,
42+
releases=[_release_to_schema(r) for r in all_releases],
43+
latest_version=latest_version,
44+
)

api/schemas/__init__.py

Whitespace-only changes.

api/schemas/releng.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from datetime import date, datetime
2+
from typing import Optional
3+
4+
from ninja import Schema
5+
6+
7+
class ReleaseSchema(Schema):
8+
version: str
9+
kernel_version: Optional[str] = None
10+
release_date: date
11+
available: bool
12+
info: str
13+
iso_url: Optional[str] = None
14+
magnet_uri: Optional[str] = None
15+
torrent_url: Optional[str] = None
16+
md5_sum: Optional[str] = None
17+
sha1_sum: Optional[str] = None
18+
sha256_sum: Optional[str] = None
19+
b2_sum: Optional[str] = None
20+
wkd_email: Optional[str] = None
21+
pgp_fingerprint: Optional[str] = None
22+
created: datetime
23+
last_modified: datetime
24+
25+
26+
class ReleasesSchema(Schema):
27+
version: int
28+
releases: list[ReleaseSchema]
29+
latest_version: Optional[str] = None

api/tests/__init__.py

Whitespace-only changes.

api/tests/test_releng.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from datetime import datetime, timezone
2+
3+
import pytest
4+
5+
from releng.models import Release
6+
7+
VERSION = '2025.05.01'
8+
KERNEL_VERSION = '6.9'
9+
10+
11+
@pytest.fixture
12+
def release(db):
13+
r = Release.objects.create(
14+
release_date=datetime.now(timezone.utc),
15+
version=VERSION,
16+
kernel_version=KERNEL_VERSION,
17+
available=True,
18+
)
19+
yield r
20+
r.delete()
21+
22+
23+
def test_releases_empty(db, client):
24+
response = client.get('/api/v1/releng/releases/')
25+
assert response.status_code == 200
26+
data = response.json()
27+
assert data['version'] == 1
28+
assert data['releases'] == []
29+
assert data['latest_version'] is None
30+
31+
32+
def test_releases_returns_release_fields(client, release):
33+
response = client.get('/api/v1/releng/releases/')
34+
assert response.status_code == 200
35+
r = response.json()['releases'][0]
36+
assert r['version'] == VERSION
37+
assert r['kernel_version'] == KERNEL_VERSION
38+
assert r['available'] is True
39+
40+
41+
def test_releases_latest_version_is_newest_available(db, client):
42+
now = datetime.now(timezone.utc)
43+
Release.objects.create(release_date=now, version='2025.01.01', available=True)
44+
Release.objects.create(release_date=now, version='2025.05.01', available=True)
45+
data = client.get('/api/v1/releng/releases/').json()
46+
assert data['latest_version'] == '2025.05.01'
47+
48+
49+
def test_releases_latest_version_excludes_unavailable(db, client):
50+
now = datetime.now(timezone.utc)
51+
Release.objects.create(release_date=now, version='2025.01.01', available=True)
52+
Release.objects.create(release_date=now, version='2025.05.01', available=False)
53+
data = client.get('/api/v1/releng/releases/').json()
54+
assert data['latest_version'] == '2025.01.01'
55+
56+
57+
def test_releases_optional_fields_null_when_absent(db, client):
58+
Release.objects.create(
59+
release_date=datetime.now(timezone.utc),
60+
version='2025.05.01',
61+
)
62+
r = client.get('/api/v1/releng/releases/').json()['releases'][0]
63+
assert r['kernel_version'] is None
64+
assert r['md5_sum'] is None
65+
assert r['sha1_sum'] is None
66+
assert r['sha256_sum'] is None
67+
assert r['b2_sum'] is None
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""OpenAPI schema contract tests for the Archweb API."""
2+
3+
def test_openapi_schema_served(client):
4+
response = client.get('/api/openapi.json')
5+
assert response.status_code == 200
6+
assert response['Content-Type'] == 'application/json'
7+
8+
9+
def test_openapi_schema_version(client):
10+
schema = client.get('/api/openapi.json').json()
11+
assert schema['openapi'].startswith('3.')
12+
assert schema['info']['title'] == 'Archweb API'
13+
assert schema['info']['version'] == '1'
14+
15+
16+
def test_openapi_releng_releases_endpoint_present(client):
17+
paths = client.get('/api/openapi.json').json()['paths']
18+
assert '/api/v1/releng/releases/' in paths
19+
assert 'get' in paths['/api/v1/releng/releases/']
20+
21+
22+
def test_openapi_releases_schema_fields(client):
23+
schema = client.get('/api/openapi.json').json()
24+
components = schema['components']['schemas']
25+
assert 'ReleasesSchema' in components
26+
assert 'ReleaseSchema' in components
27+
28+
release_props = components['ReleaseSchema']['properties']
29+
required_fields = {'version', 'release_date', 'available', 'info'}
30+
assert required_fields <= release_props.keys()
31+
32+
nullable_fields = {'kernel_version', 'md5_sum', 'sha1_sum', 'sha256_sum', 'b2_sum'}
33+
for field in nullable_fields:
34+
assert field in release_props
35+
types = {t.get('type') for t in release_props[field].get('anyOf', [])}
36+
assert 'null' in types, f"{field} must be nullable"
37+
38+
39+
def test_openapi_docs_url_exists(client):
40+
response = client.get('/api/docs/')
41+
assert response.status_code == 200

0 commit comments

Comments
 (0)