Skip to content

Commit 8e34a85

Browse files
anlu85olofk
authored andcommitted
Adds endpoint to download archive of core files
Adds a new API endpoint that provides a ZIP archive containing all core files and their signatures in the FuseSoC Package Directory. This allows users to download a snapshot of the entire repository. The implementation handles cases where signature files are missing. Error handling has been added to return a 500 status code if there is a problem generating the archive.
1 parent 8d846c1 commit 8e34a85

File tree

9 files changed

+222
-7
lines changed

9 files changed

+222
-7
lines changed

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ FuseSoC-PD is ideal for teams and communities who want a reliable, transparent,
6161
```
6262
App runs at [http://localhost:8000](http://localhost:8000) (if `DJANGO_DEBUG=True`).
6363

64-
> **Note on static files:**
65-
> Static files are automatically collected to the `/staticfiles` directory inside the Docker container during build or startup.
66-
> By default, static files are served by [WhiteNoise](https://whitenoise.evans.io/) within the Django application.
64+
> **Note on static files:**
65+
> Static files are automatically collected to the `/staticfiles` directory inside the Docker container during build or startup.
66+
> By default, static files are served by [WhiteNoise](https://whitenoise.evans.io/) within the Django application.
6767
> For larger or production deployments, you may optionally configure a dedicated web server (such as Nginx or Caddy) to serve static files from `/staticfiles`.
6868

6969
---
@@ -113,6 +113,7 @@ All endpoints are under `/api/v1/`:
113113
| `/health/` | GET | API health check |
114114
| `/list/?filter=...` | GET | List available core packages |
115115
| `/get/?core=...` | GET | Download a `.core` file by VLNV name |
116+
| `/get_archive/` | GET | Download a `.zip` file with all cores |
116117
| `/validate/` | POST | Validate a core file (`multipart/form`) |
117118
| `/publish/` | POST | Publish a core file to GitHub |
118119

@@ -133,6 +134,7 @@ Easily search and browse packages in a clean interface.
133134
- `/cores/<vendor>/<library>/<core>/<version>/` — Core detail by VLNV (vendor, library, name, version)
134135
- `/vendors/` — List all vendors (with optional search)
135136
- `/vendors/<sanitized_name>/` — Vendor detail (with libraries and projects)
137+
- `/fusesoc_pd` — Shortcut to API endpoint `get_archive`
136138

137139
---
138140

@@ -224,11 +226,11 @@ This repository includes recommended Visual Studio Code settings and launch conf
224226

225227
## HTTP/HTTPS and DJANGO_DEBUG
226228

227-
- **Production deployments:**
229+
- **Production deployments:**
228230
By default, HTTPS is enforced for security. You should run Django behind a reverse proxy (such as Nginx or Caddy) that handles HTTPS termination.
229-
- **Local development:**
231+
- **Local development:**
230232
Set `DJANGO_DEBUG=True` in your `.env` to disable HTTPS enforcement and allow HTTP access at [http://localhost:8000](http://localhost:8000).
231-
- **Docker:**
233+
- **Docker:**
232234
The Docker setup respects `DJANGO_DEBUG`. For local testing, set `DJANGO_DEBUG=True` in your `.env`.
233235

234236
**Example .env snippet for local development:**
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import pytest
2+
import io
3+
import zipfile
4+
from django.urls import reverse
5+
from core_directory.models import Vendor, Library, Project, CorePackage
6+
7+
@pytest.fixture(autouse=True)
8+
def patch_corepackage_storage(settings):
9+
from ...storages.dummy_storage import DummyStorage
10+
settings.DEFAULT_FILE_STORAGE = 'path.to.dummy_storage.DummyStorage'
11+
CorePackage._meta.get_field('core_file').storage = DummyStorage()
12+
CorePackage._meta.get_field('signature_file').storage = DummyStorage()
13+
14+
@pytest.mark.django_db
15+
def test_getarchive_success_core_and_signature(client, mocker):
16+
# Set up test data: one core with signature, one without
17+
vendor = Vendor.objects.create(name="Acme")
18+
library = Library.objects.create(vendor=vendor, name="Lib1")
19+
project1 = Project.objects.create(vendor=vendor, library=library, name="foo", description="desc")
20+
project2 = Project.objects.create(vendor=vendor, library=library, name="bar", description="desc")
21+
22+
cp1 = CorePackage.objects.create(
23+
project=project1,
24+
vlnv_name="acme:lib1:foo:1.0.0",
25+
version="1.0.0",
26+
version_major=1,
27+
version_minor=0,
28+
version_patch=0,
29+
core_file="foo.core",
30+
signature_file="foo.sig",
31+
description="desc"
32+
)
33+
cp2 = CorePackage.objects.create(
34+
project=project2,
35+
vlnv_name="acme:lib1:bar:1.0.0",
36+
version="1.0.0",
37+
version_major=1,
38+
version_minor=0,
39+
version_patch=0,
40+
core_file="bar.core",
41+
signature_file=None,
42+
description="desc"
43+
)
44+
45+
# Patch the storage for both core and signature files
46+
core_storage = CorePackage._meta.get_field('core_file').storage
47+
sig_storage = CorePackage._meta.get_field('signature_file').storage
48+
49+
def fake_core_open(name, mode='rb'):
50+
if name == cp1.core_file.name:
51+
return io.BytesIO(b"foo core content")
52+
elif name == cp2.core_file.name:
53+
return io.BytesIO(b"bar core content")
54+
raise FileNotFoundError(name)
55+
56+
def fake_sig_open(name, mode='rb'):
57+
if cp1.signature_file and name == cp1.signature_file.name:
58+
return io.BytesIO(b"foo signature content")
59+
raise FileNotFoundError(name)
60+
61+
mocker.patch.object(core_storage, 'open', side_effect=fake_core_open)
62+
mocker.patch.object(sig_storage, 'open', side_effect=fake_sig_open)
63+
64+
url = reverse('core_directory:archive_get')
65+
response = client.get(url)
66+
assert response.status_code == 200
67+
assert response['Content-Type'] == 'application/zip'
68+
assert response['Content-Disposition'].endswith('fusesoc_pd_archive.zip"')
69+
70+
# Check the contents of the zip archive
71+
with io.BytesIO(response.content) as zip_bytes:
72+
with zipfile.ZipFile(zip_bytes, 'r') as archive:
73+
namelist = archive.namelist()
74+
assert cp1.sanitized_vlnv + ".core" in namelist
75+
assert cp1.sanitized_vlnv + ".core.sig" in namelist
76+
assert cp2.sanitized_vlnv + ".core" in namelist
77+
assert archive.read(cp1.sanitized_vlnv + ".core") == b"foo core content"
78+
assert archive.read(cp1.sanitized_vlnv + ".core.sig") == b"foo signature content"
79+
assert archive.read(cp2.sanitized_vlnv + ".core") == b"bar core content"
80+
81+
@pytest.mark.django_db
82+
def test_getarchive_core_file_missing(client, mocker):
83+
# Set up test data: one core, but file is missing in storage
84+
vendor = Vendor.objects.create(name="Acme")
85+
library = Library.objects.create(vendor=vendor, name="Lib1")
86+
project = Project.objects.create(vendor=vendor, library=library, name="foo", description="desc")
87+
cp = CorePackage.objects.create(
88+
project=project,
89+
vlnv_name="acme:lib1:foo:1.0.0",
90+
version="1.0.0",
91+
version_major=1,
92+
version_minor=0,
93+
version_patch=0,
94+
core_file="foo.core",
95+
signature_file=None,
96+
description="desc"
97+
)
98+
core_storage = CorePackage._meta.get_field('core_file').storage
99+
mocker.patch.object(core_storage, 'open', side_effect=FileNotFoundError("not found"))
100+
101+
url = reverse('core_directory:archive_get')
102+
response = client.get(url)
103+
assert response.status_code == 500
104+
assert b"Error retrieving archive" in response.content
105+
106+
@pytest.mark.django_db
107+
def test_getarchive_signature_file_missing(client, mocker):
108+
# Set up test data: core with signature, but signature file missing
109+
vendor = Vendor.objects.create(name="Acme")
110+
library = Library.objects.create(vendor=vendor, name="Lib1")
111+
project = Project.objects.create(vendor=vendor, library=library, name="foo", description="desc")
112+
cp = CorePackage.objects.create(
113+
project=project,
114+
vlnv_name="acme:lib1:foo:1.0.0",
115+
version="1.0.0",
116+
version_major=1,
117+
version_minor=0,
118+
version_patch=0,
119+
core_file="foo.core",
120+
signature_file="foo.sig",
121+
description="desc"
122+
)
123+
core_storage = CorePackage._meta.get_field('core_file').storage
124+
sig_storage = CorePackage._meta.get_field('signature_file').storage
125+
126+
mocker.patch.object(core_storage, 'open', return_value=io.BytesIO(b"foo core content"))
127+
mocker.patch.object(sig_storage, 'open', side_effect=FileNotFoundError("not found"))
128+
129+
url = reverse('core_directory:archive_get')
130+
response = client.get(url)
131+
assert response.status_code == 500
132+
assert b"Error retrieving archive" in response.content
133+

core_directory/tests/test_urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
("health_check", {}, 200, False, "get"),
88
("core_list", {}, 200, False, "get"),
99
("core_get", {}, 400, False, "get"),
10+
("archive_get", {}, 200, False, "get"),
1011
("validate", {}, 400, False, "post"),
1112
("publish", {}, 400, False, "post"),
1213
("api_docs_landing", {}, 200, False, "get"),

core_directory/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
SpectacularSwaggerView,
3131
)
3232

33-
from .views.api_views import APIDocsLandingPageView, HealthCheckView, Validate, Publish, Cores, GetCore
33+
from .views.api_views import APIDocsLandingPageView, HealthCheckView, Validate, Publish, Cores, GetCore, GetArchive
3434

3535
app_name = "core_directory"
3636

@@ -42,6 +42,7 @@
4242
path('health/', HealthCheckView.as_view(), name='health_check'),
4343
path('list/', Cores.as_view(), name='core_list'),
4444
path('get/', GetCore.as_view(), name='core_get'),
45+
path('get_archive/', GetArchive.as_view(), name='archive_get'),
4546
path('validate/', Validate.as_view(), name='validate'),
4647
path('publish/', Publish.as_view(), name='publish'),
4748

core_directory/views/api_views.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
"""API views for FuseSoC Package Directory."""
2+
import os
3+
import tempfile
4+
import zipfile
5+
26
from django.db import IntegrityError, DatabaseError
37
from django.http import HttpResponse
48
from django.views.generic import TemplateView
@@ -300,3 +304,52 @@ def post(self, request, *args, **kwargs):
300304
if serializer.is_valid():
301305
return Response({'message': 'Core file is valid'}, status=status.HTTP_200_OK)
302306
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
307+
308+
309+
class GetArchive(APIView):
310+
"""Endpoint for downloading an archive of the FuseSoC Care Package Directory."""
311+
@extend_schema_with_429(
312+
summary='Download FuseSoC Core Package Directory Archive',
313+
description='Provide a archive containing all core files in FuseSoC Package Directory.',
314+
parameters=[
315+
],
316+
responses={
317+
200: OpenApiResponse(description='FuseSoC Core Package archive successfully retrieved'),
318+
500: OpenApiResponse(description='Error message indicating why the validation failed')
319+
}
320+
)
321+
def get(self, request):
322+
"""Download a FuseSoC core package archive.
323+
324+
Returns:
325+
HttpResponse: The archive as an attachment, or error message if not found.
326+
"""
327+
try:
328+
with tempfile.TemporaryDirectory() as temp_dir:
329+
archive_path = os.path.join(temp_dir, 'fusesoc_pd_archive.zip')
330+
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as archive:
331+
for core_package in CorePackage.objects.all():
332+
# Add core file
333+
if core_package.core_file:
334+
core_file_name = core_package.sanitized_vlnv + '.core'
335+
with core_package.core_file.open('rb') as f:
336+
archive.writestr(core_file_name, f.read())
337+
# Add signature file if present
338+
if core_package.is_signed and core_package.signature_file:
339+
sig_file_name = core_package.sanitized_vlnv + '.core.sig'
340+
with core_package.signature_file.open('rb') as f:
341+
archive.writestr(sig_file_name, f.read())
342+
343+
with open(archive_path, 'rb') as f:
344+
archive_file_data = f.read()
345+
346+
response = HttpResponse(archive_file_data, content_type='application/zip')
347+
response['Content-Disposition'] = 'attachment; filename="fusesoc_pd_archive.zip"'
348+
response['Content-Length'] = str(len(archive_file_data))
349+
return response
350+
351+
except (FileNotFoundError, OSError, zipfile.BadZipFile) as err:
352+
return Response(
353+
{'error': f'Error retrieving archive: {err}'},
354+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
355+
)

fusesoc.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[library.fusesoc_pd]
2+
location = fusesoc_libraries\fusesoc_pd
3+
sync-uri = http://127.0.0.1:8000/fusesoc_pd
4+
sync-type = url
5+
auto-sync = true
6+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CAPI=2:
2+
name: vendor:library:core:1.0.0
3+
description: "A valid core file for testing with signature."
4+
provider:
5+
name: github
6+
user: myuser
7+
repo: myrepo
8+
version: "v1.0.0"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
coresig:
2+
name: vendor:library:core:1.0.0
3+
signatures:
4+
- signature: |
5+
-----BEGIN SSH SIGNATURE-----
6+
dummy-signature-data
7+
-----END SSH SIGNATURE-----
8+
type: ssh-ed25519
9+
user_id: user@example.com

project/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from core_directory.sitemaps import CorePackageSitemap, StaticViewSitemap, VendorSitemap
2828
from core_directory.views.system_views import robots_txt
29+
from core_directory.views.api_views import GetArchive
2930
from core_directory.views.web_views import (
3031
landing,
3132
core_detail,
@@ -70,6 +71,7 @@ def guarded_sitemap_view(request, *args, **kwargs):
7071
name='vendor-detail'
7172
),
7273

74+
path('fusesoc_pd/', GetArchive.as_view(), name='archive_get'),
7375
path('admin/', admin.site.urls),
7476
path('api/', include('core_directory.urls')),
7577

0 commit comments

Comments
 (0)