Skip to content

Commit ab341ec

Browse files
chore: update FastAPI and MinIO integration for large file uploads
- Added new feature tests for uploading files of varying sizes. - Refactored existing MinIO upload scenarios to use a more flexible input method for streams.
1 parent c3afbaa commit ab341ec

6 files changed

Lines changed: 217 additions & 129 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@needs-minio
2+
Feature: FastAPI MinIO large file upload
3+
As a developer
4+
I want to upload a large file through a FastAPI endpoint backed by the MinIO adapter
5+
So that streaming uploads work end-to-end
6+
7+
Scenario Outline: Upload a <size_mb> MB file through a FastAPI endpoint to MinIO
8+
Given a bucket named "uploads-bucket" exists
9+
And a FastAPI app with a MinIO upload endpoint
10+
And a temporary file of <size_mb> MB
11+
When I POST the file as "<object>" to the upload endpoint targeting bucket "uploads-bucket"
12+
Then the upload response status should be 201
13+
And the object "<object>" in bucket "uploads-bucket" should have size of at least <size_mb> MB
14+
15+
Examples:
16+
| size_mb | object |
17+
| 50 | file-50mb.bin |
18+
| 75 | file-75mb.bin |
19+
| 100 | file-100mb.bin |

features/minio_adapter.feature

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,13 @@ Feature: MinIO Operations Testing
4646
Then the object "test-copy.txt" should exist in bucket "test-bucket"
4747
And downloading "test-copy.txt" from "test-bucket" should return content "Hello World"
4848

49-
Scenario: Upload bytes buffer and retrieve via stream
49+
Scenario Outline: Upload via <input_kind> and retrieve via stream
5050
Given a bucket named "test-bucket" exists
51-
When I upload the bytes "Hello Streaming World" as "stream-bytes.txt" to bucket "test-bucket"
52-
Then the object "stream-bytes.txt" should exist in bucket "test-bucket"
53-
And the streaming download of "stream-bytes.txt" from "test-bucket" should return "Hello Streaming World"
54-
55-
Scenario: Upload binary stream and retrieve via stream
56-
Given a bucket named "test-bucket" exists
57-
When I upload a binary stream with content "Binary Stream Data" as "stream-bio.txt" to bucket "test-bucket"
58-
Then the object "stream-bio.txt" should exist in bucket "test-bucket"
59-
And the streaming download of "stream-bio.txt" from "test-bucket" should return "Binary Stream Data"
51+
When I upload <input_kind> "<content>" as "<object>" to bucket "test-bucket"
52+
Then the object "<object>" should exist in bucket "test-bucket"
53+
And the streaming download of "<object>" from "test-bucket" should return "<content>"
54+
55+
Examples:
56+
| input_kind | content | object |
57+
| the bytes | Hello Streaming World | stream-bytes.txt |
58+
| a binary stream | Binary Stream Data | stream-bio.txt |
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import os
2+
import tempfile
3+
4+
from behave import given, then, when
5+
from fastapi import UploadFile
6+
from starlette.responses import JSONResponse
7+
from starlette.testclient import TestClient
8+
9+
from archipy.adapters.minio.adapters import MinioAdapter
10+
from archipy.configs.base_config import BaseConfig
11+
from archipy.helpers.utils.app_utils import AppUtils
12+
from features.steps.minio_adapter_steps import get_minio_adapter
13+
from features.test_helpers import get_current_scenario_context
14+
15+
_MB = 1024 * 1024
16+
17+
18+
@given("a FastAPI app with a MinIO upload endpoint")
19+
def step_build_app(context):
20+
scenario_context = get_current_scenario_context(context)
21+
app = AppUtils.create_fastapi_app(BaseConfig.global_config())
22+
23+
@app.post("/buckets/{bucket_name}/objects/{object_name}")
24+
async def upload(bucket_name: str, object_name: str, file: UploadFile):
25+
adapter = MinioAdapter(BaseConfig.global_config().MINIO)
26+
adapter.put_object_stream(
27+
bucket_name,
28+
object_name,
29+
file.file,
30+
length=file.size or -1,
31+
content_type=file.content_type or "application/octet-stream",
32+
)
33+
return JSONResponse(status_code=201, content={"object": object_name, "size": file.size})
34+
35+
scenario_context.store("app", app)
36+
37+
38+
@given("a temporary file of {size_mb:d} MB")
39+
def step_make_file(context, size_mb):
40+
scenario_context = get_current_scenario_context(context)
41+
fd, path = tempfile.mkstemp(suffix=".bin")
42+
chunk = b"\0" * _MB
43+
with os.fdopen(fd, "wb") as fh:
44+
for _ in range(size_mb):
45+
fh.write(chunk)
46+
scenario_context.store("upload_path", path)
47+
48+
49+
@when('I POST the file as "{object_name}" to the upload endpoint targeting bucket "{bucket_name}"')
50+
def step_post(context, object_name, bucket_name):
51+
scenario_context = get_current_scenario_context(context)
52+
app = scenario_context.get("app")
53+
path = scenario_context.get("upload_path")
54+
client = TestClient(app)
55+
try:
56+
with open(path, "rb") as fh:
57+
response = client.post(
58+
f"/buckets/{bucket_name}/objects/{object_name}",
59+
files={"file": (object_name, fh, "application/octet-stream")},
60+
)
61+
finally:
62+
os.unlink(path)
63+
scenario_context.store("response", response)
64+
65+
66+
@then("the upload response status should be {code:d}")
67+
def step_status(context, code):
68+
scenario_context = get_current_scenario_context(context)
69+
assert scenario_context.get("response").status_code == code
70+
71+
72+
@then('the object "{object_name}" in bucket "{bucket_name}" should have size of at least {size_mb:d} MB')
73+
def step_check_size(context, object_name, bucket_name, size_mb):
74+
adapter = get_minio_adapter(context)
75+
stat = adapter.stat_object(bucket_name, object_name)
76+
assert stat["size"] >= size_mb * _MB, f"size {stat['size']} < {size_mb * _MB}"

features/steps/minio_adapter_steps.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -221,23 +221,17 @@ def step_verify_policy(context, bucket_name):
221221
context.logger.info(f"Verified read-only policy for '{bucket_name}'")
222222

223223

224-
@when('I upload the bytes "{content}" as "{object_name}" to bucket "{bucket_name}"')
225-
def step_upload_bytes_stream(context, content, object_name, bucket_name):
226-
adapter = get_minio_adapter(context)
227-
data = content.encode("utf-8")
228-
adapter.put_object_stream(bucket_name, object_name, data, content_type="text/plain; charset=utf-8")
229-
context.logger.info(f"Uploaded bytes stream '{object_name}' to '{bucket_name}'")
230-
231-
232-
@when('I upload a binary stream with content "{content}" as "{object_name}" to bucket "{bucket_name}"')
233-
def step_upload_binary_stream(context, content, object_name, bucket_name):
224+
@when('I upload {input_kind} "{content}" as "{object_name}" to bucket "{bucket_name}"')
225+
def step_upload_stream(context, input_kind, content, object_name, bucket_name):
234226
adapter = get_minio_adapter(context)
235227
raw = content.encode("utf-8")
236-
stream = io.BytesIO(raw)
237-
adapter.put_object_stream(
238-
bucket_name, object_name, stream, length=len(raw), content_type="text/plain; charset=utf-8"
239-
)
240-
context.logger.info(f"Uploaded binary stream '{object_name}' to '{bucket_name}'")
228+
if input_kind == "a binary stream":
229+
adapter.put_object_stream(
230+
bucket_name, object_name, io.BytesIO(raw), length=len(raw), content_type="text/plain; charset=utf-8",
231+
)
232+
else:
233+
adapter.put_object_stream(bucket_name, object_name, raw, content_type="text/plain; charset=utf-8")
234+
context.logger.info(f"Uploaded {input_kind} '{object_name}' to '{bucket_name}'")
241235

242236

243237
@then('the streaming download of "{object_name}" from "{bucket_name}" should return "{expected_content}"')

pyproject.toml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ dependency-injection = ["dependency-injector>=4.49.0"]
2525
elastic-apm = ["elastic-apm>=6.26.1"]
2626
elasticsearch = ["elasticsearch>=9.4.1"]
2727
elasticsearch-async = ["elasticsearch[async]>=9.4.1"]
28-
fakeredis = ["fakeredis>=2.36.1"]
29-
fastapi = ["fastapi[all]>=0.137.1"]
28+
fakeredis = ["fakeredis>=2.36.2"]
29+
fastapi = ["fastapi[all]>=0.137.2"]
3030
grpc = ["grpcio>=1.81.1", "grpcio-health-checking>=1.81.1", "protobuf>=6.33.6"]
3131
jwt = ["pyjwt>=2.13.0"]
3232
kafka = ["confluent-kafka>=2.14.2"]
3333
keycloak = ["python-keycloak>=7.1.1", "cachetools>=7.1.4", "async-lru>=2.3.0"]
34-
minio = ["boto3>=1.43.29", "cachetools>=7.1.4", "async-lru>=2.3.0"]
34+
minio = ["boto3>=1.43.32", "cachetools>=7.1.4", "async-lru>=2.3.0"]
3535
parsian-ipg = ["zeep>=4.3.2", "requests[socks]>=2.34.2"]
3636
parsian-ipg-async = ["zeep[async]>=4.3.2", "httpx2[socks]>=2.4.0"]
3737
postgres = ["psycopg[binary,pool]>=3.3.4"]
@@ -40,9 +40,9 @@ redis = ["redis[hiredis]>=8.0.0"]
4040
saman-ipg = ["httpx2[socks]>=2.4.0"]
4141
scheduler = ["apscheduler>=3.11.2"]
4242
scylladb = ["scylla-driver>=3.29.11", "lz4>=4.4.5", "cachetools>=7.1.4", "async-lru>=2.3.0"]
43-
sentry = ["sentry-sdk>=2.62.0"]
44-
sqlalchemy = ["sqlalchemy>=2.0.50"]
45-
sqlalchemy-async = ["sqlalchemy[asyncio]>=2.0.50"]
43+
sentry = ["sentry-sdk>=2.63.0"]
44+
sqlalchemy = ["sqlalchemy>=2.0.51"]
45+
sqlalchemy-async = ["sqlalchemy[asyncio]>=2.0.51"]
4646
starrocks = ["starrocks>=1.3.3", "pymysql>=1.2.0"]
4747
starrocks-async = ["starrocks>=1.3.3", "aiomysql>=0.3.2", "asyncmy2>=0.2.20"]
4848
temporalio = ["temporalio>=1.24.0"]
@@ -56,14 +56,14 @@ build-backend = "hatchling.build"
5656
dev = [
5757
"add-trailing-comma>=4.0.0",
5858
"bandit>=1.9.4",
59-
"boto3-stubs>=1.43.14",
59+
"boto3-stubs>=1.43.32",
6060
"codespell>=2.4.2",
6161
"pre-commit-hooks>=6.0.0",
6262
"pre-commit>=4.6.0",
63-
"ruff>=0.15.14",
64-
"ty>=0.0.39",
63+
"ruff>=0.15.17",
64+
"ty>=0.0.50",
6565
"types-cachetools>=7.0.0.20260518",
66-
"types-grpcio>=1.0.0.20260518",
66+
"types-grpcio>=1.0.0.20260614",
6767
"types-protobuf>=7.34.1.20260518",
6868
"types-pymysql>=1.1.0.20260518",
6969
"types-regex>=2026.5.9.20260518",
@@ -75,7 +75,7 @@ docs = [
7575
"mkdocs-autorefs>=1.4.4",
7676
"mkdocs-material>=9.7.6",
7777
"mkdocs>=1.6.1",
78-
"mkdocstrings-python>=2.0.3",
78+
"mkdocstrings-python>=2.0.4",
7979
"mkdocstrings>=1.0.4",
8080
"pymdown-extensions>=10.21.3",
8181
]

0 commit comments

Comments
 (0)