fix(swagger): Apply the inline-nested-dicts patch to HTTP Swagger endpoints

Fixes #35955 .

The patch introduced in #35477 should be applied to HTTP endpoints as well
This commit is contained in:
chariri 2026-05-09 11:16:43 +09:00
parent 140ad6ba4e
commit 1ac48952dd
No known key found for this signature in database
GPG Key ID: 23A554A36F7FF2FD
4 changed files with 107 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

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