mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
Merge f6c474fa9b into 5ebeb34feb
This commit is contained in:
commit
ae70e74d24
@ -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
|
||||
|
||||
68
api/libs/flask_restx_compat.py
Normal file
68
api/libs/flask_restx_compat.py
Normal 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
|
||||
@ -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 |
|
||||
|
||||
33
api/tests/unit_tests/controllers/test_swagger.py
Normal file
33
api/tests/unit_tests/controllers/test_swagger.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user