mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
refactor(web): inject file upload service (#41586)
This commit is contained in:
parent
fc7193cf04
commit
5f4b1f867d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user