refactor(web): inject file upload service (#41586)

This commit is contained in:
非法操作 2026-09-03 04:42:30 +00:00 committed by GitHub
parent fc7193cf04
commit 5f4b1f867d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 117 additions and 38 deletions

View File

@ -2,22 +2,22 @@ from flask import request
import services
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import register_schema_models
from controllers.common.schema import JsonResponseWithStatus, register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse
from libs.helper import dump_response
from models.model import App, EndUser
from services.file_service import FileService
register_schema_models(web_ns, FileResponse)
register_response_schema_models(web_ns, FileResponse)
@web_ns.route("/files/upload")
@ -27,13 +27,18 @@ class FileApi(WebApiResource):
@web_ns.doc(
responses={
201: "File uploaded successfully",
400: "Bad request - invalid file or parameters",
413: "File too large",
415: "Unsupported file type",
400: (
"- `no_file_uploaded` : No file was provided in the request.\n"
"- `too_many_files` : Only one file is allowed per request.\n"
"- `filename_not_exists_error` : The uploaded file has no filename.\n"
"- `file_extension_blocked` : The file extension is blocked for security reasons."
),
413: "`file_too_large` : File size exceeded.",
415: "`unsupported_file_type` : File type not allowed.",
}
)
@web_ns.response(201, "File uploaded successfully", web_ns.models[FileResponse.__name__])
def post(self, app_model: App, end_user: EndUser):
def post(self, app_model: App, end_user: EndUser) -> JsonResponseWithStatus:
"""Upload a file for use in web applications.
Accepts file uploads for use within web applications, supporting
@ -57,6 +62,7 @@ class FileApi(WebApiResource):
FilenameNotExistsError: File has no filename
FileTooLargeError: File exceeds size limit
UnsupportedFileTypeError: File type not supported
BlockedFileExtensionError: File extension is blocked
"""
if "file" not in request.files:
raise NoFileUploadedError()
@ -66,14 +72,14 @@ class FileApi(WebApiResource):
file = request.files["file"]
if not file.filename:
raise FilenameNotExistsError
raise FilenameNotExistsError()
source = request.form.get("source")
if source not in ("datasets", None):
source = None
try:
upload_file = FileService(db.engine).upload_file(
upload_file = application_services().files.upload_file(
filename=file.filename,
content=file.stream.read(),
mimetype=file.mimetype,
@ -81,8 +87,10 @@ class FileApi(WebApiResource):
source="datasets" if source == "datasets" else None,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
raise FileTooLargeError(file_too_large_error.description) from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as unsupported_file_type_error:
raise UnsupportedFileTypeError() from unsupported_file_type_error
except services.errors.file.BlockedFileExtensionError as blocked_file_extension_error:
raise BlockedFileExtensionError() from blocked_file_extension_error
return dump_response(FileResponse, upload_file), 201

View File

@ -280,15 +280,16 @@ Raises:
FilenameNotExistsError: File has no filename
FileTooLargeError: File exceeds size limit
UnsupportedFileTypeError: File type not supported
BlockedFileExtensionError: File extension is blocked
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)<br> |
| 400 | Bad request - invalid file or parameters | |
| 413 | File too large | |
| 415 | Unsupported file type | |
| 400 | - `no_file_uploaded` : No file was provided in the request. - `too_many_files` : Only one file is allowed per request. - `filename_not_exists_error` : The uploaded file has no filename. - `file_extension_blocked` : The file extension is blocked for security reasons. | |
| 413 | `file_too_large` : File size exceeded. | |
| 415 | `unsupported_file_type` : File type not allowed. | |
### [POST] /forgot-password
Send password reset email

View File

@ -605,6 +605,17 @@ def test_generate_specs_writes_service_api_reference_descriptions(tmp_path: Path
}
def test_generate_specs_writes_web_file_upload_error_codes(tmp_path: Path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
web_path = next(path for path in written_paths if path.name == "web-openapi.json")
payload = json.loads(web_path.read_text(encoding="utf-8"))
upload_bad_request = payload["paths"]["/files/upload"]["post"]["responses"]["400"]["description"]
assert "`file_extension_blocked`" in upload_bad_request
def test_standalone_inline_model_name_includes_list_constraints():
module = _load_generate_swagger_specs_module()

View File

@ -8,18 +8,21 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy import Engine
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.web.files import FileApi
from extensions.storage.storage_type import StorageType
from libs.exception import BaseHTTPException
from models.enums import CreatorUserRole, EndUserType
from models.model import App, AppMode, EndUser, UploadFile
from services.errors import file as file_errors
def _app_model() -> App:
@ -85,35 +88,90 @@ class TestFileApi:
with pytest.raises(FilenameNotExistsError):
FileApi().post(_app_model(), _end_user())
@patch("controllers.web.files.FileService")
@patch("controllers.web.files.db")
@pytest.mark.parametrize(
("path", "form_source", "expected_source"),
[
("/files/upload", None, None),
("/files/upload", "datasets", "datasets"),
("/files/upload", "invalid", None),
("/files/upload?source=datasets", None, None),
],
)
@patch("controllers.web.files.application_services")
def test_upload_success(
self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask, sqlite_engine: Engine
self,
mock_application_services: MagicMock,
app: Flask,
path: str,
form_source: str | None,
expected_source: str | None,
) -> None:
mock_db.engine = sqlite_engine
mock_file_svc_cls.return_value.upload_file.return_value = _upload_file()
file_service = mock_application_services.return_value.files
file_service.upload_file.return_value = _upload_file()
app_model = _app_model()
end_user = _end_user()
data = {"file": (BytesIO(b"content"), "test.txt")}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
result, status = FileApi().post(_app_model(), _end_user())
data: dict[str, object] = {"file": (BytesIO(b"content"), "test.txt", "text/plain")}
if form_source is not None:
data["source"] = form_source
with app.test_request_context(path, method="POST", data=data, content_type="multipart/form-data"):
result, status = FileApi().post(app_model, end_user)
assert status == 201
assert result["id"] == "file-1"
assert result["name"] == "test.txt"
@patch("controllers.web.files.FileService")
@patch("controllers.web.files.db")
def test_file_too_large_from_service(
self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask, sqlite_engine: Engine
) -> None:
import services.errors.file
mock_db.engine = sqlite_engine
mock_file_svc_cls.return_value.upload_file.side_effect = services.errors.file.FileTooLargeError(
description="max 10MB"
assert result["tenant_id"] == app_model.tenant_id
file_service.upload_file.assert_called_once_with(
filename="test.txt",
content=b"content",
mimetype="text/plain",
user=end_user,
source=expected_source,
)
@pytest.mark.parametrize(
("service_error", "expected_error", "expected_status", "expected_code", "expected_message"),
[
(file_errors.FileTooLargeError("max 10MB"), FileTooLargeError, 413, "file_too_large", "max 10MB"),
(
file_errors.UnsupportedFileTypeError(),
UnsupportedFileTypeError,
415,
"unsupported_file_type",
"File type not allowed.",
),
(
file_errors.BlockedFileExtensionError("File extension '.exe' is blocked"),
BlockedFileExtensionError,
400,
"file_extension_blocked",
"The file extension is blocked for security reasons.",
),
],
)
@patch("controllers.web.files.application_services")
def test_service_error_mapping(
self,
mock_application_services: MagicMock,
app: Flask,
service_error: Exception,
expected_error: type[BaseHTTPException],
expected_status: int,
expected_code: str,
expected_message: str,
) -> None:
mock_application_services.return_value.files.upload_file.side_effect = service_error
data = {"file": (BytesIO(b"big"), "big.txt")}
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
with pytest.raises(FileTooLargeError):
with pytest.raises(expected_error) as raised:
FileApi().post(_app_model(), _end_user())
assert raised.value.code == expected_status
assert raised.value.error_code == expected_code
assert raised.value.data == {
"code": expected_code,
"message": expected_message,
"status": expected_status,
}
assert raised.value.__cause__ is service_error

View File

@ -367,11 +367,12 @@ export const emailCodeLogin = {
* FilenameNotExistsError: File has no filename
* FileTooLargeError: File exceeds size limit
* UnsupportedFileTypeError: File type not supported
* BlockedFileExtensionError: File extension is blocked
*/
export const post9 = oc
.route({
description:
'Upload a file for use in web applications\nAccepts file uploads for use within web applications, supporting\nmultiple file types with automatic validation and storage.\n\nArgs:\n app_model: The associated application model\n end_user: The end user uploading the file\n\nForm Parameters:\n file: The file to upload (required)\n source: Optional source type (datasets or None)\n\nReturns:\n dict: File information including ID, URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n NoFileUploadedError: No file provided in request\n TooManyFilesError: Multiple files provided (only one allowed)\n FilenameNotExistsError: File has no filename\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported',
'Upload a file for use in web applications\nAccepts file uploads for use within web applications, supporting\nmultiple file types with automatic validation and storage.\n\nArgs:\n app_model: The associated application model\n end_user: The end user uploading the file\n\nForm Parameters:\n file: The file to upload (required)\n source: Optional source type (datasets or None)\n\nReturns:\n dict: File information including ID, URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n NoFileUploadedError: No file provided in request\n TooManyFilesError: Multiple files provided (only one allowed)\n FilenameNotExistsError: File has no filename\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported\n BlockedFileExtensionError: File extension is blocked',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postFilesUpload',