mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
fix(swagger): Apply the inline-nested-dicts patch to HTTP Swagger endpoints (#35952)
This commit is contained in:
parent
65c36a51ef
commit
1efd365b62
@ -29,18 +29,39 @@ STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md"
|
||||
|
||||
|
||||
def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"npx",
|
||||
"--yes",
|
||||
SWAGGER_MARKDOWN_PACKAGE,
|
||||
"-i",
|
||||
str(spec_path),
|
||||
"-o",
|
||||
str(markdown_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
markdown_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=f"{markdown_path.stem}-", dir=markdown_path.parent) as temp_dir:
|
||||
temp_markdown_path = Path(temp_dir) / markdown_path.name
|
||||
result = subprocess.run(
|
||||
[
|
||||
"npx",
|
||||
"--yes",
|
||||
SWAGGER_MARKDOWN_PACKAGE,
|
||||
"-i",
|
||||
str(spec_path),
|
||||
"-o",
|
||||
str(temp_markdown_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode,
|
||||
result.args,
|
||||
output=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
if not temp_markdown_path.exists():
|
||||
converter_output = "\n".join(item for item in (result.stdout, result.stderr) if item).strip()
|
||||
raise RuntimeError(f"swagger-markdown did not write {markdown_path}: {converter_output}")
|
||||
|
||||
converted_markdown = temp_markdown_path.read_text(encoding="utf-8")
|
||||
if not converted_markdown.strip():
|
||||
raise RuntimeError(f"swagger-markdown wrote an empty document for {markdown_path}")
|
||||
|
||||
markdown_path.write_text(converted_markdown, encoding="utf-8")
|
||||
|
||||
|
||||
def _demote_markdown_headings(markdown: str, *, levels: int = 1) -> str:
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
149
api/libs/flask_restx_compat.py
Normal file
149
api/libs/flask_restx_compat.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import TypeGuard
|
||||
|
||||
from flask import current_app
|
||||
from flask_restx import fields
|
||||
from flask_restx.model import Model, OrderedModel, instance
|
||||
from flask_restx.swagger import Swagger
|
||||
|
||||
|
||||
def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]:
|
||||
"""Return whether a nested field map is an anonymous inline mapping."""
|
||||
|
||||
return isinstance(value, dict) and not isinstance(value, (Model, OrderedModel))
|
||||
|
||||
|
||||
def _jsonable_schema_value(value: object) -> object:
|
||||
"""Return a deterministic JSON-serializable representation for schema fingerprints."""
|
||||
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
return value
|
||||
if isinstance(value, list | tuple):
|
||||
return [_jsonable_schema_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable_schema_value(item) for key, item in value.items()}
|
||||
value_type = type(value)
|
||||
return f"<{value_type.__module__}.{value_type.__qualname__}>"
|
||||
|
||||
|
||||
def _field_signature(field: object) -> object:
|
||||
"""Build a stable signature for a Flask-RESTX field object."""
|
||||
|
||||
field_instance = instance(field)
|
||||
signature: dict[str, object] = {
|
||||
"class": f"{field_instance.__class__.__module__}.{field_instance.__class__.__qualname__}"
|
||||
}
|
||||
|
||||
if isinstance(field_instance, fields.Nested):
|
||||
nested = getattr(field_instance, "nested", None)
|
||||
if _is_inline_field_map(nested):
|
||||
signature["nested"] = _inline_model_signature(nested)
|
||||
else:
|
||||
signature["nested"] = getattr(
|
||||
nested,
|
||||
"name",
|
||||
f"<{type(nested).__module__}.{type(nested).__qualname__}>",
|
||||
)
|
||||
elif hasattr(field_instance, "container"):
|
||||
signature["container"] = _field_signature(field_instance.container)
|
||||
else:
|
||||
schema = getattr(field_instance, "__schema__", None)
|
||||
if isinstance(schema, dict):
|
||||
signature["schema"] = _jsonable_schema_value(schema)
|
||||
|
||||
for attr_name in (
|
||||
"attribute",
|
||||
"default",
|
||||
"description",
|
||||
"example",
|
||||
"max",
|
||||
"max_items",
|
||||
"min",
|
||||
"min_items",
|
||||
"nullable",
|
||||
"readonly",
|
||||
"required",
|
||||
"title",
|
||||
"unique",
|
||||
):
|
||||
if hasattr(field_instance, attr_name):
|
||||
signature[attr_name] = _jsonable_schema_value(getattr(field_instance, attr_name))
|
||||
|
||||
return signature
|
||||
|
||||
|
||||
def _inline_model_signature(nested_fields: dict[object, object]) -> object:
|
||||
"""Build a stable signature for an anonymous inline model."""
|
||||
|
||||
return [
|
||||
(str(field_name), _field_signature(field))
|
||||
for field_name, field in sorted(nested_fields.items(), key=lambda item: str(item[0]))
|
||||
]
|
||||
|
||||
|
||||
def _inline_model_name(nested_fields: dict[object, object]) -> str:
|
||||
"""Return a stable Swagger model name for an anonymous inline field map."""
|
||||
|
||||
signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12]
|
||||
return f"_AnonymousInlineModel_{digest}"
|
||||
|
||||
|
||||
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_name = _inline_model_name(nested_fields)
|
||||
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[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
|
||||
72
api/tests/unit_tests/controllers/test_swagger.py
Normal file
72
api/tests/unit_tests/controllers/test_swagger.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Swagger JSON rendering tests for Flask-RESTX API blueprints."""
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def _definition_refs(value: object) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
if isinstance(value, dict):
|
||||
ref = value.get("$ref")
|
||||
if isinstance(ref, str) and ref.startswith("#/definitions/"):
|
||||
refs.add(ref.removeprefix("#/definitions/"))
|
||||
for item in value.values():
|
||||
refs.update(_definition_refs(item))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
refs.update(_definition_refs(item))
|
||||
return refs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first_kwargs", "second_kwargs"),
|
||||
[
|
||||
({"min_items": 1}, {"min_items": 2}),
|
||||
({"max_items": 1}, {"max_items": 2}),
|
||||
({"unique": True}, {"unique": False}),
|
||||
],
|
||||
)
|
||||
def test_inline_model_name_includes_list_constraints(
|
||||
first_kwargs: dict[str, object],
|
||||
second_kwargs: dict[str, object],
|
||||
):
|
||||
from flask_restx import fields
|
||||
|
||||
from libs.flask_restx_compat import _inline_model_name
|
||||
|
||||
first_inline_model: dict[object, object] = {"items": fields.List(fields.String, **first_kwargs)}
|
||||
second_inline_model: dict[object, object] = {"items": fields.List(fields.String, **second_kwargs)}
|
||||
|
||||
assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model)
|
||||
|
||||
|
||||
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 isinstance(payload["definitions"], dict)
|
||||
missing_refs = _definition_refs(payload) - set(payload["definitions"])
|
||||
assert not sorted(ref for ref in missing_refs if ref.startswith("_AnonymousInlineModel"))
|
||||
|
||||
assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True
|
||||
Loading…
Reference in New Issue
Block a user