From 5dc5709d5862fc36306c8db07c8d17edb8266cd1 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 23 Jan 2026 19:06:04 +0900 Subject: [PATCH 1/3] refactor: split changes for api/controllers/web/login.py (#29854) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/web/login.py | 69 +++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index e8053acdfd..a824f6d487 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -1,9 +1,11 @@ from flask import make_response, request -from flask_restx import Resource, reqparse +from flask_restx import Resource from jwt import InvalidTokenError +from pydantic import BaseModel, Field, field_validator import services from configs import dify_config +from controllers.common.schema import register_schema_models from controllers.console.auth.error import ( AuthenticationFailedError, EmailCodeError, @@ -18,7 +20,7 @@ from controllers.console.wraps import ( ) from controllers.web import web_ns from controllers.web.wraps import decode_jwt_token -from libs.helper import email +from libs.helper import EmailStr from libs.passport import PassportService from libs.password import valid_password from libs.token import ( @@ -30,10 +32,35 @@ from services.app_service import AppService from services.webapp_auth_service import WebAppAuthService +class LoginPayload(BaseModel): + email: EmailStr + password: str + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return valid_password(value) + + +class EmailCodeLoginSendPayload(BaseModel): + email: EmailStr + language: str | None = None + + +class EmailCodeLoginVerifyPayload(BaseModel): + email: EmailStr + code: str + token: str = Field(min_length=1) + + +register_schema_models(web_ns, LoginPayload, EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload) + + @web_ns.route("/login") class LoginApi(Resource): """Resource for web app email/password login.""" + @web_ns.expect(web_ns.models[LoginPayload.__name__]) @setup_required @only_edition_enterprise @web_ns.doc("web_app_login") @@ -50,15 +77,10 @@ class LoginApi(Resource): @decrypt_password_field def post(self): """Authenticate user and login.""" - parser = ( - reqparse.RequestParser() - .add_argument("email", type=email, required=True, location="json") - .add_argument("password", type=valid_password, required=True, location="json") - ) - args = parser.parse_args() + payload = LoginPayload.model_validate(web_ns.payload or {}) try: - account = WebAppAuthService.authenticate(args["email"], args["password"]) + account = WebAppAuthService.authenticate(payload.email, payload.password) except services.errors.account.AccountLoginError: raise AccountBannedError() except services.errors.account.AccountPasswordError: @@ -145,6 +167,7 @@ class EmailCodeLoginSendEmailApi(Resource): @only_edition_enterprise @web_ns.doc("send_email_code_login") @web_ns.doc(description="Send email verification code for login") + @web_ns.expect(web_ns.models[EmailCodeLoginSendPayload.__name__]) @web_ns.doc( responses={ 200: "Email code sent successfully", @@ -153,19 +176,14 @@ class EmailCodeLoginSendEmailApi(Resource): } ) def post(self): - parser = ( - reqparse.RequestParser() - .add_argument("email", type=email, required=True, location="json") - .add_argument("language", type=str, required=False, location="json") - ) - args = parser.parse_args() + payload = EmailCodeLoginSendPayload.model_validate(web_ns.payload or {}) - if args["language"] is not None and args["language"] == "zh-Hans": + if payload.language == "zh-Hans": language = "zh-Hans" else: language = "en-US" - account = WebAppAuthService.get_user_through_email(args["email"]) + account = WebAppAuthService.get_user_through_email(payload.email) if account is None: raise AuthenticationFailedError() else: @@ -179,6 +197,7 @@ class EmailCodeLoginApi(Resource): @only_edition_enterprise @web_ns.doc("verify_email_code_login") @web_ns.doc(description="Verify email code and complete login") + @web_ns.expect(web_ns.models[EmailCodeLoginVerifyPayload.__name__]) @web_ns.doc( responses={ 200: "Email code verified and login successful", @@ -189,17 +208,11 @@ class EmailCodeLoginApi(Resource): ) @decrypt_code_field def post(self): - parser = ( - reqparse.RequestParser() - .add_argument("email", type=str, required=True, location="json") - .add_argument("code", type=str, required=True, location="json") - .add_argument("token", type=str, required=True, location="json") - ) - args = parser.parse_args() + payload = EmailCodeLoginVerifyPayload.model_validate(web_ns.payload or {}) - user_email = args["email"].lower() + user_email = payload.email.lower() - token_data = WebAppAuthService.get_email_code_login_data(args["token"]) + token_data = WebAppAuthService.get_email_code_login_data(payload.token) if token_data is None: raise InvalidTokenError() @@ -210,10 +223,10 @@ class EmailCodeLoginApi(Resource): if normalized_token_email != user_email: raise InvalidEmailError() - if token_data["code"] != args["code"]: + if token_data["code"] != payload.code: raise EmailCodeError() - WebAppAuthService.revoke_email_code_login_token(args["token"]) + WebAppAuthService.revoke_email_code_login_token(payload.token) account = WebAppAuthService.get_user_through_email(token_email) if not account: raise AuthenticationFailedError() From 6342d196e874d7a4485fb49bf41141d4d44af0b9 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 23 Jan 2026 19:06:21 +0900 Subject: [PATCH 2/3] refactor: split changes for api/controllers/web/workflow.py (#29852) --- api/controllers/web/workflow.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 3cbb07a296..95d8c6d5a5 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -1,8 +1,10 @@ import logging +from typing import Any -from flask_restx import reqparse +from pydantic import BaseModel, Field from werkzeug.exceptions import InternalServerError +from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import ( CompletionRequestError, @@ -27,19 +29,22 @@ from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.errors.llm import InvokeRateLimitError + +class WorkflowRunPayload(BaseModel): + inputs: dict[str, Any] = Field(description="Input variables for the workflow") + files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed by the workflow") + + logger = logging.getLogger(__name__) +register_schema_models(web_ns, WorkflowRunPayload) + @web_ns.route("/workflows/run") class WorkflowRunApi(WebApiResource): @web_ns.doc("Run Workflow") @web_ns.doc(description="Execute a workflow with provided inputs and files.") - @web_ns.doc( - params={ - "inputs": {"description": "Input variables for the workflow", "type": "object", "required": True}, - "files": {"description": "Files to be processed by the workflow", "type": "array", "required": False}, - } - ) + @web_ns.expect(web_ns.models[WorkflowRunPayload.__name__]) @web_ns.doc( responses={ 200: "Success", @@ -58,12 +63,8 @@ class WorkflowRunApi(WebApiResource): if app_mode != AppMode.WORKFLOW: raise NotWorkflowAppError() - parser = ( - reqparse.RequestParser() - .add_argument("inputs", type=dict, required=True, nullable=False, location="json") - .add_argument("files", type=list, required=False, location="json") - ) - args = parser.parse_args() + payload = WorkflowRunPayload.model_validate(web_ns.payload or {}) + args = payload.model_dump(exclude_none=True) try: response = AppGenerateService.generate( From 8d4575530303f22e5eb1d07b182580b42804e9e4 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 23 Jan 2026 21:07:52 +0900 Subject: [PATCH 3/3] feat: init fastopenapi (#30453) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/app_factory.py | 2 + api/controllers/console/ping.py | 28 ++++++------ api/controllers/fastopenapi.py | 3 ++ api/extensions/ext_fastopenapi.py | 43 +++++++++++++++++++ api/pyproject.toml | 1 + api/pyrightconfig.json | 1 + .../console/test_fastopenapi_ping.py | 27 ++++++++++++ api/uv.lock | 19 ++++++++ 8 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 api/controllers/fastopenapi.py create mode 100644 api/extensions/ext_fastopenapi.py create mode 100644 api/tests/unit_tests/controllers/console/test_fastopenapi_ping.py diff --git a/api/app_factory.py b/api/app_factory.py index 1fb01d2e91..07859a3758 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -81,6 +81,7 @@ def initialize_extensions(app: DifyApp): ext_commands, ext_compress, ext_database, + ext_fastopenapi, ext_forward_refs, ext_hosting_provider, ext_import_modules, @@ -128,6 +129,7 @@ def initialize_extensions(app: DifyApp): ext_proxy_fix, ext_blueprints, ext_commands, + ext_fastopenapi, ext_otel, ext_request_logging, ext_session_factory, diff --git a/api/controllers/console/ping.py b/api/controllers/console/ping.py index 25a3d80522..d480af312b 100644 --- a/api/controllers/console/ping.py +++ b/api/controllers/console/ping.py @@ -1,17 +1,17 @@ -from flask_restx import Resource, fields +from pydantic import BaseModel, Field -from . import console_ns +from controllers.fastopenapi import console_router -@console_ns.route("/ping") -class PingApi(Resource): - @console_ns.doc("health_check") - @console_ns.doc(description="Health check endpoint for connection testing") - @console_ns.response( - 200, - "Success", - console_ns.model("PingResponse", {"result": fields.String(description="Health check result", example="pong")}), - ) - def get(self): - """Health check endpoint for connection testing""" - return {"result": "pong"} +class PingResponse(BaseModel): + result: str = Field(description="Health check result", examples=["pong"]) + + +@console_router.get( + "/ping", + response_model=PingResponse, + tags=["console"], +) +def ping() -> PingResponse: + """Health check endpoint for connection testing.""" + return PingResponse(result="pong") diff --git a/api/controllers/fastopenapi.py b/api/controllers/fastopenapi.py new file mode 100644 index 0000000000..c13f22338b --- /dev/null +++ b/api/controllers/fastopenapi.py @@ -0,0 +1,3 @@ +from fastopenapi.routers import FlaskRouter + +console_router = FlaskRouter() diff --git a/api/extensions/ext_fastopenapi.py b/api/extensions/ext_fastopenapi.py new file mode 100644 index 0000000000..0ef1513e11 --- /dev/null +++ b/api/extensions/ext_fastopenapi.py @@ -0,0 +1,43 @@ +from fastopenapi.routers import FlaskRouter +from flask_cors import CORS + +from configs import dify_config +from controllers.fastopenapi import console_router +from dify_app import DifyApp +from extensions.ext_blueprints import AUTHENTICATED_HEADERS, EXPOSED_HEADERS + +DOCS_PREFIX = "/fastopenapi" + + +def init_app(app: DifyApp) -> None: + docs_enabled = dify_config.SWAGGER_UI_ENABLED + docs_url = f"{DOCS_PREFIX}/docs" if docs_enabled else None + redoc_url = f"{DOCS_PREFIX}/redoc" if docs_enabled else None + openapi_url = f"{DOCS_PREFIX}/openapi.json" if docs_enabled else None + + router = FlaskRouter( + app=app, + docs_url=docs_url, + redoc_url=redoc_url, + openapi_url=openapi_url, + openapi_version="3.0.0", + title="Dify API (FastOpenAPI PoC)", + version="1.0", + description="FastOpenAPI proof of concept for Dify API", + ) + + # Ensure route decorators are evaluated. + import controllers.console.ping as ping_module + + _ = ping_module + + router.include_router(console_router, prefix="/console/api") + CORS( + app, + resources={r"/console/api/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}}, + supports_credentials=True, + allow_headers=list(AUTHENTICATED_HEADERS), + methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], + expose_headers=list(EXPOSED_HEADERS), + ) + app.extensions["fastopenapi"] = router diff --git a/api/pyproject.toml b/api/pyproject.toml index d025a92846..141f6f0bb2 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -93,6 +93,7 @@ dependencies = [ "weaviate-client==4.17.0", "apscheduler>=3.11.0", "weave>=0.52.16", + "fastopenapi[flask]>=0.7.0", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json index 6a689b96df..007c49ddb0 100644 --- a/api/pyrightconfig.json +++ b/api/pyrightconfig.json @@ -8,6 +8,7 @@ ], "typeCheckingMode": "strict", "allowedUntypedLibraries": [ + "fastopenapi", "flask_restx", "flask_login", "opentelemetry.instrumentation.celery", diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_ping.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_ping.py new file mode 100644 index 0000000000..fc04ca078b --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_ping.py @@ -0,0 +1,27 @@ +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from extensions import ext_fastopenapi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def test_console_ping_fastopenapi_returns_pong(app: Flask): + ext_fastopenapi.init_app(app) + + client = app.test_client() + response = client.get("/console/api/ping") + + assert response.status_code == 200 + assert response.get_json() == {"result": "pong"} diff --git a/api/uv.lock b/api/uv.lock index 83aa89072c..bdbdc1f4f7 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1382,6 +1382,7 @@ dependencies = [ { name = "celery" }, { name = "charset-normalizer" }, { name = "croniter" }, + { name = "fastopenapi", extra = ["flask"] }, { name = "flask" }, { name = "flask-compress" }, { name = "flask-cors" }, @@ -1580,6 +1581,7 @@ requires-dist = [ { name = "celery", specifier = "~=5.5.2" }, { name = "charset-normalizer", specifier = ">=3.4.4" }, { name = "croniter", specifier = ">=6.0.0" }, + { name = "fastopenapi", extras = ["flask"], specifier = ">=0.7.0" }, { name = "flask", specifier = "~=3.1.2" }, { name = "flask-compress", specifier = ">=1.17,<1.18" }, { name = "flask-cors", specifier = "~=6.0.0" }, @@ -1921,6 +1923,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671 }, ] +[[package]] +name = "fastopenapi" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/02/6ee3ecc1e176bbb8c02cbeee30b11f526167c77ef2f7e741ab7999787ad0/fastopenapi-0.7.0.tar.gz", hash = "sha256:5a671fa663e3d89608e9b39a213595f7ac0bf0caf71f2b6016adf4d8c3e1a50e", size = 17191, upload-time = "2025-04-27T13:38:48.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/ad/1881ed46a0a7d3ce14472425db0ddc7aa45c858b4b14727647805487f10d/fastopenapi-0.7.0-py3-none-any.whl", hash = "sha256:482914301f348270cb231617863cacfadf1841012c5ff7d4255a27077704c7b2", size = 21272, upload-time = "2025-04-27T13:38:46.877Z" }, +] + +[package.optional-dependencies] +flask = [ + { name = "flask" }, +] + [[package]] name = "fastuuid" version = "0.14.0"