diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py
index 9122f3ab24..254310cd2a 100644
--- a/api/dev/generate_swagger_specs.py
+++ b/api/dev/generate_swagger_specs.py
@@ -20,7 +20,6 @@ from pathlib import Path
from typing import Protocol, TypeGuard
from flask import Flask
-from flask_restx.swagger import Swagger
logger = logging.getLogger(__name__)
@@ -48,9 +47,6 @@ SPEC_TARGETS: tuple[SpecTarget, ...] = (
SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"),
)
-_ORIGINAL_REGISTER_MODEL = Swagger.register_model
-_ORIGINAL_REGISTER_FIELD = Swagger.register_field
-
def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]:
"""Return whether a nested field map is an anonymous inline mapping."""
@@ -152,56 +148,14 @@ def apply_runtime_defaults() -> None:
dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true"
-def _patch_swagger_for_inline_nested_dicts() -> None:
- """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps.
-
- Some existing controllers use `fields.Nested({...})` with a raw field mapping
- instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous
- dicts during schema registration, so this helper upgrades them into temporary
- named models at export time.
- """
-
- if getattr(Swagger, "_dify_inline_nested_dict_patch", False):
- return
-
- def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object:
- anonymous_models = getattr(self, "_anonymous_inline_models", None)
- if anonymous_models is None:
- anonymous_models = {}
- self.__dict__["_anonymous_inline_models"] = anonymous_models
-
- anonymous_name = anonymous_models.get(id(nested_fields))
- if anonymous_name is None:
- anonymous_name = _inline_model_name(nested_fields)
- anonymous_models[id(nested_fields)] = anonymous_name
- if anonymous_name not in self.api.models:
- self.api.model(anonymous_name, nested_fields)
-
- return self.api.models[anonymous_name]
-
- def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]:
- if _is_inline_field_map(model):
- model = get_or_create_inline_model(self, model)
-
- return _ORIGINAL_REGISTER_MODEL(self, model)
-
- def register_field_with_inline_dict_support(self: Swagger, field: object) -> None:
- nested = getattr(field, "nested", None)
- if _is_inline_field_map(nested):
- field.model = get_or_create_inline_model(self, nested) # type: ignore
-
- _ORIGINAL_REGISTER_FIELD(self, field)
-
- Swagger.register_model = register_model_with_inline_dict_support
- Swagger.register_field = register_field_with_inline_dict_support
- Swagger._dify_inline_nested_dict_patch = True
-
-
def create_spec_app() -> Flask:
"""Build a minimal Flask app that only mounts the Swagger-producing blueprints."""
apply_runtime_defaults()
- _patch_swagger_for_inline_nested_dicts()
+
+ from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts
+
+ patch_swagger_for_inline_nested_dicts()
app = Flask(__name__)
diff --git a/api/libs/external_api.py b/api/libs/external_api.py
index f907d17750..64eb99a42b 100644
--- a/api/libs/external_api.py
+++ b/api/libs/external_api.py
@@ -9,6 +9,7 @@ from werkzeug.http import HTTP_STATUS_CODES
from configs import dify_config
from core.errors.error import AppInvokeQuotaExceededError
+from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts
from libs.token import build_force_logout_cookie_headers
@@ -120,6 +121,7 @@ class ExternalApi(Api):
}
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
+ patch_swagger_for_inline_nested_dicts()
kwargs.setdefault("authorizations", self._authorizations)
kwargs.setdefault("security", "Bearer")
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py
new file mode 100644
index 0000000000..7c91c01b46
--- /dev/null
+++ b/api/libs/flask_restx_compat.py
@@ -0,0 +1,68 @@
+"""Compatibility helpers for Dify's Flask-RESTX Swagger integration.
+
+These helpers are temporary bridges for legacy Flask-RESTX field contracts
+while controllers migrate their request and response documentation to Pydantic
+models. Keep the behavior centralized so live Swagger endpoints and offline
+spec export fail or succeed in the same way.
+"""
+
+from flask import current_app
+from flask_restx.swagger import Swagger
+
+
+def patch_swagger_for_inline_nested_dicts() -> None:
+ """Allow Swagger generation to handle legacy inline Flask-RESTX field dicts.
+
+ Some existing controllers use raw field mappings in `fields.Nested({...})`
+ or directly in `@namespace.response(...)`. Runtime marshalling accepts that,
+ but Flask-RESTX Swagger registration expects a named model. Convert those
+ anonymous mappings into temporary named models during docs generation.
+ """
+
+ if getattr(Swagger, "_dify_inline_nested_dict_patch", False):
+ return
+
+ original_register_model = Swagger.register_model
+ original_register_field = Swagger.register_field
+ original_as_dict = Swagger.as_dict
+
+ def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object:
+ anonymous_models = getattr(self, "_anonymous_inline_models", None)
+ if anonymous_models is None:
+ anonymous_models = {}
+ self._anonymous_inline_models = anonymous_models # type: ignore[missing-attribute]
+
+ anonymous_name = anonymous_models.get(id(nested_fields))
+ if anonymous_name is None:
+ anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}"
+ anonymous_models[id(nested_fields)] = anonymous_name
+ self.api.model(anonymous_name, nested_fields)
+
+ return self.api.models[anonymous_name]
+
+ def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]:
+ if isinstance(model, dict):
+ model = get_or_create_inline_model(self, model)
+
+ return original_register_model(self, model)
+
+ def register_field_with_inline_dict_support(self: Swagger, field: object) -> None:
+ nested = getattr(field, "nested", None)
+ if isinstance(nested, dict):
+ field.model = get_or_create_inline_model(self, nested) # type: ignore[attr-defined]
+
+ original_register_field(self, field)
+
+ def as_dict_with_inline_dict_support(self: Swagger):
+ # Temporary set RESTX_INCLUDE_ALL_MODELS = false to prevent "length changed while iterating" error
+ include_all_models = current_app.config.get("RESTX_INCLUDE_ALL_MODELS", False)
+ current_app.config["RESTX_INCLUDE_ALL_MODELS"] = False
+ try:
+ return original_as_dict(self)
+ finally:
+ current_app.config["RESTX_INCLUDE_ALL_MODELS"] = include_all_models
+
+ Swagger.register_model = register_model_with_inline_dict_support
+ Swagger.register_field = register_field_with_inline_dict_support
+ Swagger.as_dict = as_dict_with_inline_dict_support
+ Swagger._dify_inline_nested_dict_patch = True
diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md
index a69cecd83c..910193326c 100644
--- a/api/openapi/markdown/console-swagger.md
+++ b/api/openapi/markdown/console-swagger.md
@@ -14764,3 +14764,335 @@ FastOpenAPI proof of concept for Dify API
| release_date | string | Release date of latest version | Yes |
| release_notes | string | Release notes for latest version | Yes |
| version | string | Latest version number | Yes |
+
+## FastOpenAPI Preview (OpenAPI 3.0)
+
+### Dify API (FastOpenAPI PoC)
+FastOpenAPI proof of concept for Dify API
+
+#### Version: 1.0
+
+---
+
+##### [GET] /console/api/init
+**Get initialization validation status.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [InitStatusResponse](#initstatusresponse)
|
+
+##### [POST] /console/api/init
+**Validate initialization password.**
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)
|
+
+##### [GET] /console/api/ping
+**Health check endpoint for connection testing.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [PingResponse](#pingresponse)
|
+
+##### [GET] /console/api/setup
+**Get system setup status.
+
+ NOTE: This endpoint is unauthenticated by design.
+
+ During first-time bootstrap there is no admin account yet, so frontend initialization must be
+ able to query setup progress before any login flow exists.
+
+ Only bootstrap-safe status information should be returned by this endpoint.
+ **
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [SetupStatusResponse](#setupstatusresponse)
|
+
+##### [POST] /console/api/setup
+**Initialize system setup with admin account.
+
+ NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
+ Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
+ and init-password validation rather than user session authentication.
+ **
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [SetupRequestPayload](#setuprequestpayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [SetupResponse](#setupresponse)
|
+
+##### [GET] /console/api/version
+**Check for application version updates.**
+
+###### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| current_version | query | | Yes | string |
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [VersionResponse](#versionresponse)
|
+
+---
+##### Schemas
+
+###### ErrorSchema
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
+
+###### InitStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| status | string,
**Available values:** "finished", "not_started" | Initialization status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### InitValidatePayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| password | string | Initialization password | Yes |
+
+###### InitValidateResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Operation result | Yes |
+
+###### PingResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Health check result | Yes |
+
+###### SetupRequestPayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| email | string | Admin email address | Yes |
+| language | | Admin language | No |
+| name | string | Admin name (max 30 characters) | Yes |
+| password | string | Admin password | Yes |
+
+###### SetupResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Setup result | Yes |
+
+###### SetupStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| setup_at | | Setup completion time (ISO format) | No |
+| step | string,
**Available values:** "finished", "not_started" | Setup step status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### VersionFeatures
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_replace_logo | boolean | Whether logo replacement is supported | Yes |
+| model_load_balancing_enabled | boolean | Whether model load balancing is enabled | Yes |
+
+###### VersionResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_auto_update | boolean | Whether auto-update is supported | Yes |
+| features | [VersionFeatures](#versionfeatures) | Feature flags and capabilities | Yes |
+| release_date | string | Release date of latest version | Yes |
+| release_notes | string | Release notes for latest version | Yes |
+| version | string | Latest version number | Yes |
+
+## FastOpenAPI Preview (OpenAPI 3.0)
+
+### Dify API (FastOpenAPI PoC)
+FastOpenAPI proof of concept for Dify API
+
+#### Version: 1.0
+
+---
+
+##### [GET] /console/api/init
+**Get initialization validation status.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [InitStatusResponse](#initstatusresponse)
|
+
+##### [POST] /console/api/init
+**Validate initialization password.**
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)
|
+
+##### [GET] /console/api/ping
+**Health check endpoint for connection testing.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [PingResponse](#pingresponse)
|
+
+##### [GET] /console/api/setup
+**Get system setup status.
+
+ NOTE: This endpoint is unauthenticated by design.
+
+ During first-time bootstrap there is no admin account yet, so frontend initialization must be
+ able to query setup progress before any login flow exists.
+
+ Only bootstrap-safe status information should be returned by this endpoint.
+ **
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [SetupStatusResponse](#setupstatusresponse)
|
+
+##### [POST] /console/api/setup
+**Initialize system setup with admin account.
+
+ NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
+ Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
+ and init-password validation rather than user session authentication.
+ **
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [SetupRequestPayload](#setuprequestpayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [SetupResponse](#setupresponse)
|
+
+##### [GET] /console/api/version
+**Check for application version updates.**
+
+###### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| current_version | query | | Yes | string |
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [VersionResponse](#versionresponse)
|
+
+---
+##### Schemas
+
+###### ErrorSchema
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
+
+###### InitStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| status | string,
**Available values:** "finished", "not_started" | Initialization status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### InitValidatePayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| password | string | Initialization password | Yes |
+
+###### InitValidateResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Operation result | Yes |
+
+###### PingResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Health check result | Yes |
+
+###### SetupRequestPayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| email | string | Admin email address | Yes |
+| language | | Admin language | No |
+| name | string | Admin name (max 30 characters) | Yes |
+| password | string | Admin password | Yes |
+
+###### SetupResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Setup result | Yes |
+
+###### SetupStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| setup_at | | Setup completion time (ISO format) | No |
+| step | string,
**Available values:** "finished", "not_started" | Setup step status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### VersionFeatures
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_replace_logo | boolean | Whether logo replacement is supported | Yes |
+| model_load_balancing_enabled | boolean | Whether model load balancing is enabled | Yes |
+
+###### VersionResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_auto_update | boolean | Whether auto-update is supported | Yes |
+| features | [VersionFeatures](#versionfeatures) | Feature flags and capabilities | Yes |
+| release_date | string | Release date of latest version | Yes |
+| release_notes | string | Release notes for latest version | Yes |
+| version | string | Latest version number | Yes |
diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py
new file mode 100644
index 0000000000..fc8af9deb8
--- /dev/null
+++ b/api/tests/unit_tests/controllers/test_swagger.py
@@ -0,0 +1,33 @@
+"""Swagger JSON rendering tests for Flask-RESTX API blueprints."""
+
+import pytest
+from flask import Flask
+
+
+def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch):
+ from configs import dify_config
+ from controllers.console import bp as console_bp
+ from controllers.service_api import bp as service_api_bp
+ from controllers.web import bp as web_bp
+
+ monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
+
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ app.config["RESTX_INCLUDE_ALL_MODELS"] = True
+ app.register_blueprint(console_bp)
+ app.register_blueprint(web_bp)
+ app.register_blueprint(service_api_bp)
+
+ client = app.test_client()
+
+ for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"):
+ response = client.get(route)
+
+ assert response.status_code == 200
+ payload = response.get_json()
+ assert payload["swagger"] == "2.0"
+ assert "paths" in payload
+ assert "definitions" in payload
+
+ assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True