Skip to content

Commit a9436a4

Browse files
authored
OpenConceptLab/ocl_issues#2454 | Export API to return 302 with Location (#841)
* OpenConceptLab/ocl_issues#2454 | Export API to return 302 with Location * OpenConceptLab/ocl_issues#2454 | remove unused streaming response method * OpenConceptLab/ocl_issues#2454 | 500 on unable to generate signed url
1 parent aa11ae3 commit a9436a4

7 files changed

Lines changed: 72 additions & 82 deletions

File tree

core/common/mixins.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.db import transaction
1010
from django.db.models import Q, F, QuerySet
1111
from django.http import HttpResponseForbidden, Http404
12-
from django.shortcuts import get_object_or_404
12+
from django.shortcuts import get_object_or_404, redirect
1313
from django.urls import resolve, Resolver404
1414
from django.utils.functional import cached_property
1515
from ocldev.checksum import Checksum
@@ -984,7 +984,19 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
984984
return Response(status=status.HTTP_208_ALREADY_REPORTED)
985985

986986
if version.has_export():
987-
return get_export_service().get_streaming_response(version.get_export_path())
987+
export_path = version.get_export_path()
988+
export_url = get_export_service().url_for(export_path)
989+
if export_url:
990+
return redirect(export_url)
991+
logger.error(
992+
'Export exists for %s version %s but no signed URL was generated.',
993+
self.entity.lower(),
994+
version.version,
995+
)
996+
return Response(
997+
{'detail': 'Export exists but could not generate a download URL.'},
998+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
999+
)
9881000

9891001
return Response(status=status.HTTP_204_NO_CONTENT)
9901002

core/integration_tests/tests_collections.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import zipfile
33

44
from celery_once import AlreadyQueued
5-
from django.http import StreamingHttpResponse
65
from mock import patch, Mock, ANY
76
from rest_framework.exceptions import ErrorDetail
87

@@ -2608,12 +2607,12 @@ def test_get_204_for_version(self, s3_has_path_mock):
26082607
self.assertEqual(response.status_code, 204)
26092608
s3_has_path_mock.assert_called_once_with("users/username/username_coll_v1.")
26102609

2611-
@patch('core.services.storages.cloud.aws.S3.get_streaming_response')
2610+
@patch('core.services.storages.cloud.aws.S3.url_for')
26122611
@patch('core.services.storages.cloud.aws.S3.get_last_key_from_path')
26132612
@patch('core.services.storages.cloud.aws.S3.has_path')
2614-
def test_get_200_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock, s3_streaming_response):
2613+
def test_get_302_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock, s3_url_for_mock):
26152614
s3_has_path_mock.return_value = True
2616-
s3_streaming_response.return_value = StreamingHttpResponse(content_type='application/zip')
2615+
s3_url_for_mock.return_value = 'https://signed.example/coll-v1.zip'
26172616
s3_get_last_key_from_path_mock.return_value = f'users/username/username_coll_v1.{self.v1_updated_at}.zip'
26182617

26192618
response = self.client.get(
@@ -2622,24 +2621,45 @@ def test_get_200_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock,
26222621
format='json'
26232622
)
26242623

2625-
self.assertEqual(response.status_code, 200)
2624+
self.assertEqual(response.status_code, 302)
2625+
self.assertEqual(response['Location'], 'https://signed.example/coll-v1.zip')
26262626
s3_has_path_mock.assert_called_once_with("users/username/username_coll_v1.")
26272627
s3_get_last_key_from_path_mock.assert_called_once_with("users/username/username_coll_v1.")
2628+
s3_url_for_mock.assert_called_once_with(f'users/username/username_coll_v1.{self.v1_updated_at}.zip')
26282629

2629-
@patch('core.services.storages.cloud.aws.S3.get_streaming_response')
2630+
@patch('core.services.storages.cloud.aws.S3.url_for')
26302631
@patch('core.services.storages.cloud.aws.S3.exists')
2631-
def test_get_200_head(self, s3_exists_mock, s3_streaming_response):
2632+
def test_get_302_head(self, s3_exists_mock, s3_url_for_mock):
26322633
s3_exists_mock.return_value = True
2633-
s3_streaming_response.return_value = StreamingHttpResponse(content_type='application/zip')
2634+
s3_url_for_mock.return_value = 'https://signed.example/coll-head.zip'
26342635

26352636
response = self.client.get(
26362637
self.collection.uri + 'HEAD/export/',
26372638
HTTP_AUTHORIZATION='Token ' + self.admin_token,
26382639
format='json'
26392640
)
26402641

2641-
self.assertEqual(response.status_code, 200)
2642+
self.assertEqual(response.status_code, 302)
2643+
self.assertEqual(response['Location'], 'https://signed.example/coll-head.zip')
2644+
s3_exists_mock.assert_called_once_with(f"users/username/username_coll_vHEAD.{self.HEAD_updated_at}.zip")
2645+
s3_url_for_mock.assert_called_once_with(f"users/username/username_coll_vHEAD.{self.HEAD_updated_at}.zip")
2646+
2647+
@patch('core.services.storages.cloud.aws.S3.url_for')
2648+
@patch('core.services.storages.cloud.aws.S3.exists')
2649+
def test_get_500_head_when_signed_url_generation_fails(self, s3_exists_mock, s3_url_for_mock):
2650+
s3_exists_mock.return_value = True
2651+
s3_url_for_mock.return_value = None
2652+
2653+
response = self.client.get(
2654+
self.collection.uri + 'HEAD/export/',
2655+
HTTP_AUTHORIZATION='Token ' + self.admin_token,
2656+
format='json'
2657+
)
2658+
2659+
self.assertEqual(response.status_code, 500)
2660+
self.assertEqual(response.data, {'detail': 'Export exists but could not generate a download URL.'})
26422661
s3_exists_mock.assert_called_once_with(f"users/username/username_coll_vHEAD.{self.HEAD_updated_at}.zip")
2662+
s3_url_for_mock.assert_called_once_with(f"users/username/username_coll_vHEAD.{self.HEAD_updated_at}.zip")
26432663

26442664
def test_get_405(self):
26452665
response = self.client.get(

core/integration_tests/tests_sources.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from celery_once import AlreadyQueued
77
from django.conf import settings
88
from django.db import transaction
9-
from django.http import StreamingHttpResponse
109
from mock import patch, Mock, ANY, PropertyMock
1110
from mock.mock import call
1211
from rest_framework.exceptions import ErrorDetail
@@ -1004,11 +1003,10 @@ def test_get_204_version(self, s3_has_path_mock):
10041003
self.assertEqual(response.status_code, 204)
10051004
s3_has_path_mock.assert_called_once_with("users/username/username_source1_v1.")
10061005

1007-
@patch('core.services.storages.cloud.aws.S3.get_streaming_response')
1006+
@patch('core.services.storages.cloud.aws.S3.url_for')
10081007
@patch('core.services.storages.cloud.aws.S3.exists')
1009-
def test_get_200_head(self, s3_exists_mock, s3_streaming_response):
1010-
response = StreamingHttpResponse()
1011-
s3_streaming_response.return_value = response
1008+
def test_get_302_head(self, s3_exists_mock, s3_url_for_mock):
1009+
s3_url_for_mock.return_value = 'https://signed.example/head.zip'
10121010
s3_exists_mock.return_value = True
10131011

10141012
response = self.client.get(
@@ -1017,15 +1015,16 @@ def test_get_200_head(self, s3_exists_mock, s3_streaming_response):
10171015
format='json'
10181016
)
10191017

1020-
self.assertEqual(response.status_code, 200)
1018+
self.assertEqual(response.status_code, 302)
1019+
self.assertEqual(response['Location'], 'https://signed.example/head.zip')
10211020
s3_exists_mock.assert_called_once_with(f"users/username/username_source1_vHEAD.{self.HEAD_updated_at}.zip")
1021+
s3_url_for_mock.assert_called_once_with(f"users/username/username_source1_vHEAD.{self.HEAD_updated_at}.zip")
10221022

1023-
@patch('core.services.storages.cloud.aws.S3.get_streaming_response')
1023+
@patch('core.services.storages.cloud.aws.S3.url_for')
10241024
@patch('core.services.storages.cloud.aws.S3.get_last_key_from_path')
10251025
@patch('core.services.storages.cloud.aws.S3.has_path')
1026-
def test_get_200_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock, s3_streaming_response):
1027-
response = StreamingHttpResponse()
1028-
s3_streaming_response.return_value = response
1026+
def test_get_302_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock, s3_url_for_mock):
1027+
s3_url_for_mock.return_value = 'https://signed.example/v1.zip'
10291028
s3_has_path_mock.return_value = True
10301029
s3_get_last_key_from_path_mock.return_value = f'users/username/username_source1_v1.{self.v1_updated_at}.zip'
10311030

@@ -1035,10 +1034,28 @@ def test_get_200_version(self, s3_has_path_mock, s3_get_last_key_from_path_mock,
10351034
format='json'
10361035
)
10371036

1038-
self.assertEqual(response.status_code, 200)
1039-
self.assertEqual(response, response)
1037+
self.assertEqual(response.status_code, 302)
1038+
self.assertEqual(response['Location'], 'https://signed.example/v1.zip')
10401039
s3_has_path_mock.assert_called_once_with("users/username/username_source1_v1.")
10411040
s3_get_last_key_from_path_mock.assert_called_once_with("users/username/username_source1_v1.")
1041+
s3_url_for_mock.assert_called_once_with(f'users/username/username_source1_v1.{self.v1_updated_at}.zip')
1042+
1043+
@patch('core.services.storages.cloud.aws.S3.url_for')
1044+
@patch('core.services.storages.cloud.aws.S3.exists')
1045+
def test_get_500_head_when_signed_url_generation_fails(self, s3_exists_mock, s3_url_for_mock):
1046+
s3_exists_mock.return_value = True
1047+
s3_url_for_mock.return_value = None
1048+
1049+
response = self.client.get(
1050+
self.source.uri + 'HEAD/export/',
1051+
HTTP_AUTHORIZATION='Token ' + self.admin_token,
1052+
format='json'
1053+
)
1054+
1055+
self.assertEqual(response.status_code, 500)
1056+
self.assertEqual(response.data, {'detail': 'Export exists but could not generate a download URL.'})
1057+
s3_exists_mock.assert_called_once_with(f"users/username/username_source1_vHEAD.{self.HEAD_updated_at}.zip")
1058+
s3_url_for_mock.assert_called_once_with(f"users/username/username_source1_vHEAD.{self.HEAD_updated_at}.zip")
10421059

10431060
@patch('core.sources.models.Source.is_exporting', new_callable=PropertyMock)
10441061
@patch('core.services.storages.cloud.aws.S3.exists')

core/services/storages/cloud/aws.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from botocore.exceptions import ClientError, NoCredentialsError, WaiterError
66
from django.conf import settings
77
from django.core.files.base import ContentFile
8-
from django.http import StreamingHttpResponse
98
from pydash import get
109

1110
from core.services.storages.cloud.core import CloudStorageServiceInterface
@@ -119,16 +118,6 @@ def get_object(self, key):
119118
def file_iterator(response):
120119
yield from response['Body'].iter_chunks()
121120

122-
def get_streaming_response(self, key):
123-
s3_response = self.get_object(key)
124-
response = StreamingHttpResponse(
125-
self.file_iterator(s3_response),
126-
content_type=s3_response['ContentType']
127-
)
128-
response['Content-Disposition'] = f'attachment; filename={key.split("/")[-1]}'
129-
130-
return response
131-
132121
# private
133122
def _generate_signed_url(self, accessor, key, metadata=None):
134123
params = {

core/services/storages/cloud/azure.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from azure.storage.blob import BlobServiceClient, ContentSettings, BlobPrefix
44
from django.conf import settings
55
from django.core.files.base import ContentFile
6-
from django.http import StreamingHttpResponse
76
from pydash import get
87

98
from core.services.storages.cloud.core import CloudStorageServiceInterface
@@ -114,17 +113,6 @@ def file_iterator(blob_client):
114113
data = stream.readall()
115114
yield data
116115

117-
def get_streaming_response(self, key):
118-
blob_client = self.get_object(key)
119-
blob_properties = blob_client.get_blob_properties()
120-
response = StreamingHttpResponse(
121-
self.file_iterator(blob_client),
122-
content_type=blob_properties.content_settings.content_type
123-
)
124-
response['Content-Disposition'] = f'attachment; filename={key.split("/")[-1]}'
125-
126-
return response
127-
128116
# private
129117
def _upload(self, blob_name, file_content=None, file_path=None, read_directive=None, metadata=None): # pylint: disable=too-many-arguments
130118
if not file_path and not file_content:

core/services/storages/cloud/minio.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import mimetypes
33
from io import BytesIO
44

5-
from django.http import StreamingHttpResponse
65
from minio import Minio, S3Error
76
from minio.deleteobjects import DeleteObject
87
from pydash import get
@@ -168,21 +167,6 @@ def get_object(self, key):
168167
response = self.client.get_object(bucket_name=self.bucket_name, object_name=key)
169168
return response
170169

171-
def get_streaming_response(self, key):
172-
"""
173-
Streams the file from MinIO using Django's StreamingHttpResponse.
174-
"""
175-
try:
176-
response = self.get_object(key)
177-
streaming_http_response = StreamingHttpResponse(
178-
self.file_iterator(response),
179-
content_type=response.headers['Content-Type']
180-
)
181-
streaming_http_response['Content-Disposition'] = f'attachment; filename={key.split("/")[-1]}'
182-
return streaming_http_response
183-
except S3Error as e:
184-
raise FileNotFoundError(f"File {key} not found in bucket {self.bucket_name}. Error: {e}") from e
185-
186170
@staticmethod
187171
def file_iterator(file_obj, chunk_size=8192):
188172
"""

core/services/storages/cloud/tests.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from azure.storage.blob import BlobPrefix
99
from botocore.exceptions import ClientError
1010
from django.core.files.base import ContentFile
11-
from django.http import StreamingHttpResponse
1211
from django.test import TestCase
1312
from django.utils import timezone
1413
from minio.deleteobjects import DeleteError
@@ -501,25 +500,6 @@ def test_delete_objects(self, mock_minio_client):
501500
# Assert that remove_objects was called with the correct file keys
502501
mock_client.remove_objects.assert_called_once()
503502

504-
@patch("core.services.storages.cloud.minio.Minio")
505-
def test_get_streaming_response(self, mock_minio_client):
506-
# Mock get_object method
507-
mock_client = mock_minio_client.return_value
508-
mock_file_obj = MagicMock()
509-
mock_file_obj.read = MagicMock(side_effect=[b"chunk1", b"chunk2", b""])
510-
mock_client.get_object = MagicMock(return_value=mock_file_obj)
511-
MinIO.file_iterator(mock_file_obj)
512-
513-
client = MinIO()
514-
# Call the get_streaming_response method
515-
response = client.get_streaming_response('test-file1.txt')
516-
517-
# Assert that the response is a StreamingHttpResponse
518-
self.assertIsInstance(response, StreamingHttpResponse)
519-
# Convert response content into a list for easier assertion
520-
response_content = b"".join(list(response.streaming_content))
521-
self.assertEqual(response_content, b"chunk1chunk2")
522-
523503
@patch("core.services.storages.cloud.minio.Minio", Mock())
524504
def test_file_iterator(self):
525505
# Mock file object with read method

0 commit comments

Comments
 (0)