This commit is contained in:
chariri 2026-05-09 04:55:49 +00:00 committed by GitHub
commit ae70e74d24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 439 additions and 50 deletions

View File

@ -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__)

View File

@ -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

View File

@ -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

View File

@ -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)<br> |
##### [POST] /console/api/init
**Validate initialization password.**
###### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)<br> |
##### [GET] /console/api/ping
**Health check endpoint for connection testing.**
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | OK | **application/json**: [PingResponse](#pingresponse)<br> |
##### [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)<br> |
##### [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)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [SetupResponse](#setupresponse)<br> |
##### [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)<br> |
---
##### Schemas
###### ErrorSchema
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
###### InitStatusResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | string, <br>**Available values:** "finished", "not_started" | Initialization status<br>*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, <br>**Available values:** "finished", "not_started" | Setup step status<br>*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)<br> |
##### [POST] /console/api/init
**Validate initialization password.**
###### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)<br> |
##### [GET] /console/api/ping
**Health check endpoint for connection testing.**
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | OK | **application/json**: [PingResponse](#pingresponse)<br> |
##### [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)<br> |
##### [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)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [SetupResponse](#setupresponse)<br> |
##### [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)<br> |
---
##### Schemas
###### ErrorSchema
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
###### InitStatusResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | string, <br>**Available values:** "finished", "not_started" | Initialization status<br>*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, <br>**Available values:** "finished", "not_started" | Setup step status<br>*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 |

View File

@ -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