diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index b87438f77b8..9ee711830b2 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -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 diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 3cc99e099ce..1cb85bf66d8 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -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)
| -| 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 diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index e1dede7756a..0c82e704572 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -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() diff --git a/api/tests/unit_tests/controllers/web/test_files.py b/api/tests/unit_tests/controllers/web/test_files.py index d25ef232a04..03d4241b62f 100644 --- a/api/tests/unit_tests/controllers/web/test_files.py +++ b/api/tests/unit_tests/controllers/web/test_files.py @@ -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 diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 1b583366d38..583c974803d 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -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',