chore: update to openapi v3 by change dep (#37316)

Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Asuka Minato
2026-06-12 07:52:19 +00:00
committed by GitHub
co-authored by Stephen Zhou Stephen Zhou autofix-ci[bot]
parent 9c25fa1c96
commit 6c0cce4b7f
67 changed files with 7054 additions and 8673 deletions
+10 -40
View File
@@ -1,9 +1,9 @@
"""Helpers for registering Pydantic models with Flask-RESTX namespaces.
Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not
promote Pydantic's nested `$defs` into top-level Swagger `definitions`.
promote Pydantic's nested `$defs` into top-level OpenAPI component schemas.
These helpers keep that translation centralized so models registered through
`register_schema_models` emit resolvable Swagger 2.0 references.
`register_schema_models` emit resolvable OpenAPI 3 references.
"""
from collections.abc import Iterable, Mapping
@@ -14,7 +14,7 @@ from flask import request
from flask_restx import Namespace
from pydantic import BaseModel, TypeAdapter
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}"
QueryParamDoc = TypedDict(
@@ -48,7 +48,6 @@ class QueryArgs(Protocol):
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
schema = _swagger_2_compatible_schema(schema)
nested_definitions = schema.get("$defs")
schema_to_register = dict(schema)
if isinstance(nested_definitions, dict):
@@ -71,41 +70,12 @@ def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode
_register_json_schema(
namespace,
model.__name__,
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode),
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode),
)
def _swagger_2_compatible_schema(value: Any) -> Any:
if isinstance(value, list):
return [_swagger_2_compatible_schema(item) for item in value]
if not isinstance(value, dict):
return value
converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()}
any_of = value.get("anyOf")
if not isinstance(any_of, list):
return converted
non_null_candidates = [
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
]
has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of)
if not has_null_candidate or len(non_null_candidates) != 1:
return converted
non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0]))
if not isinstance(non_null_schema, dict):
return converted
converted.pop("anyOf", None)
converted.update(non_null_schema)
converted["x-nullable"] = True
return converted
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
"""Register a BaseModel and its nested component schemas for OpenAPI documentation."""
_register_schema_model(namespace, model, mode="validation")
@@ -146,7 +116,7 @@ def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None:
_register_json_schema(
namespace,
model.__name__,
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
)
@@ -155,10 +125,10 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
`Namespace.expect()` treats Pydantic schema models as request bodies, so GET
endpoints should keep runtime validation on the Pydantic model and feed this
derived mapping to `Namespace.doc(params=...)` for Swagger documentation.
derived mapping to `Namespace.doc(params=...)` for OpenAPI documentation.
"""
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
properties = schema.get("properties", {})
if not isinstance(properties, Mapping):
return {}
@@ -203,7 +173,7 @@ def query_params_from_request[ModelT: BaseModel](
def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dict[str, Any]) -> None:
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0).get("properties", {})
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0).get("properties", {})
if not isinstance(properties, Mapping):
return
@@ -297,7 +267,7 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str
__all__ = [
"DEFAULT_REF_TEMPLATE_SWAGGER_2_0",
"DEFAULT_REF_TEMPLATE_OPENAPI_3_0",
"get_or_create_model",
"query_params_from_model",
"query_params_from_request",
@@ -2,6 +2,7 @@ from flask import request
from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import login_required
@@ -17,7 +18,7 @@ class AdvancedPromptTemplateQuery(BaseModel):
console_ns.schema_model(
AdvancedPromptTemplateQuery.__name__,
AdvancedPromptTemplateQuery.model_json_schema(ref_template="#/definitions/{model}"),
AdvancedPromptTemplateQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
)
@@ -7,6 +7,7 @@ from libs.login import login_required
from models import Account
from services.billing_service import BillingService
from ...common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
from .. import console_ns
from ..wraps import (
account_initialization_required,
@@ -23,7 +24,7 @@ class ComplianceDownloadQuery(BaseModel):
console_ns.schema_model(
ComplianceDownloadQuery.__name__,
ComplianceDownloadQuery.model_json_schema(ref_template="#/definitions/{model}"),
ComplianceDownloadQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
)
+2 -2
View File
@@ -14,7 +14,7 @@ from models.api_based_extension import APIBasedExtension
from services.api_based_extension_service import APIBasedExtensionService
from services.code_based_extension_service import CodeBasedExtensionService
from ..common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_models
from ..common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_models
from . import console_ns
from .wraps import account_initialization_required, setup_required, with_current_tenant_id
@@ -63,7 +63,7 @@ class APIBasedExtensionResponse(ResponseModel):
register_schema_models(console_ns, APIBasedExtensionPayload, CodeBasedExtensionResponse, APIBasedExtensionResponse)
console_ns.schema_model(
"APIBasedExtensionListResponse",
TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Generate FastOpenAPI OpenAPI 3.0 specs without booting the full backend."""
"""Generate FastOpenAPI OpenAPI 3.1 specs without booting the full backend."""
from __future__ import annotations
+32 -27
View File
@@ -1,7 +1,7 @@
"""Generate OpenAPI JSON specs and split Markdown API docs.
The Markdown step uses `swagger-markdown`, the same converter family as the
Swagger Markdown UI, so CI and local regeneration catch converter-incompatible
legacy Markdown UI, so CI and local regeneration catch converter-incompatible
OpenAPI output early.
"""
@@ -25,19 +25,21 @@ from dev.generate_swagger_specs import SPEC_TARGETS, generate_specs
logger = logging.getLogger(__name__)
SWAGGER_MARKDOWN_PACKAGE = "swagger-markdown@3.0.0"
CONSOLE_SWAGGER_FILENAME = "console-swagger.json"
CONSOLE_OPENAPI_FILENAME = "console-openapi.json"
STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md"
def _definition_ref_name(schema: object) -> str | None:
def _schema_ref_name(schema: object) -> str | None:
if not isinstance(schema, dict):
return None
ref = schema.get("$ref")
if not isinstance(ref, str) or not ref.startswith("#/definitions/"):
if not isinstance(ref, str):
return None
return ref.removeprefix("#/definitions/")
if ref.startswith("#/components/schemas/"):
return ref.removeprefix("#/components/schemas/")
return None
def _markdown_anchor(name: str) -> str:
@@ -48,7 +50,7 @@ def _schema_markdown_type(schema: object) -> str:
if not isinstance(schema, dict):
return ""
ref_name = _definition_ref_name(schema)
ref_name = _schema_ref_name(schema)
if ref_name is not None:
return f"[{ref_name}](#{_markdown_anchor(ref_name)})"
@@ -111,15 +113,16 @@ def _has_union_schema(schema: object) -> bool:
def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str:
"""Fill Swagger Markdown table cells that `swagger-markdown` leaves blank for union schemas."""
"""Fill Markdown table cells that `swagger-markdown` leaves blank for union schemas."""
spec = json.loads(spec_path.read_text(encoding="utf-8"))
definitions = spec.get("definitions")
if not isinstance(definitions, dict):
components = spec.get("components")
schemas = components.get("schemas") if isinstance(components, dict) else None
if not isinstance(schemas, dict):
return markdown
for definition_name, schema in definitions.items():
if not isinstance(definition_name, str) or not isinstance(schema, dict):
for schema_name, schema in schemas.items():
if not isinstance(schema_name, str) or not isinstance(schema, dict):
continue
properties = schema.get("properties")
@@ -128,7 +131,7 @@ def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str:
if isinstance(property_name, str) and _has_union_schema(property_schema):
markdown = _replace_schema_table_type(
markdown,
definition_name,
schema_name,
property_name,
_schema_markdown_type(property_schema),
)
@@ -139,14 +142,14 @@ def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str:
markdown = _replace_schema_table_type(
markdown,
definition_name,
definition_name,
schema_name,
schema_name,
_schema_markdown_type(schema),
)
for variant in union_variants:
variant_name = _definition_ref_name(variant)
variant_schema = definitions.get(variant_name) if variant_name is not None else None
variant_name = _schema_ref_name(variant)
variant_schema = schemas.get(variant_name) if variant_name is not None else None
if not isinstance(variant_name, str) or not isinstance(variant_schema, dict):
continue
properties = variant_schema.get("properties")
@@ -229,7 +232,7 @@ def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdo
"\n\n".join(
[
console_markdown,
"## FastOpenAPI Preview (OpenAPI 3.0)",
"## FastOpenAPI Preview (OpenAPI 3.1)",
fastopenapi_markdown,
]
)
@@ -239,17 +242,17 @@ def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdo
def generate_markdown_docs(
swagger_dir: Path,
openapi_dir: Path,
markdown_dir: Path,
*,
keep_swagger_json: bool = False,
) -> list[Path]:
"""Generate intermediate specs, convert them to split Markdown API docs, and return Markdown paths."""
swagger_paths = generate_specs(swagger_dir)
fastopenapi_paths = generate_fastopenapi_specs(swagger_dir)
spec_paths = [*swagger_paths, *fastopenapi_paths]
swagger_paths_by_name = {path.name: path for path in swagger_paths}
openapi_paths = generate_specs(openapi_dir)
fastopenapi_paths = generate_fastopenapi_specs(openapi_dir)
spec_paths = [*openapi_paths, *fastopenapi_paths]
openapi_paths_by_name = {path.name: path for path in openapi_paths}
fastopenapi_paths_by_name = {path.name: path for path in fastopenapi_paths}
markdown_dir.mkdir(parents=True, exist_ok=True)
@@ -260,9 +263,9 @@ def generate_markdown_docs(
temp_markdown_dir = Path(temp_dir)
for target in SPEC_TARGETS:
swagger_path = swagger_paths_by_name[target.filename]
markdown_path = markdown_dir / f"{swagger_path.stem}.md"
_convert_spec_to_markdown(swagger_path, markdown_path)
openapi_path = openapi_paths_by_name[target.filename]
markdown_path = markdown_dir / f"{openapi_path.stem}.md"
_convert_spec_to_markdown(openapi_path, markdown_path)
written_paths.append(markdown_path)
for target in FASTOPENAPI_SPEC_TARGETS: # type: ignore
@@ -270,7 +273,7 @@ def generate_markdown_docs(
markdown_path = temp_markdown_dir / f"{fastopenapi_path.stem}.md"
_convert_spec_to_markdown(fastopenapi_path, markdown_path)
console_markdown_path = markdown_dir / f"{Path(CONSOLE_SWAGGER_FILENAME).stem}.md"
console_markdown_path = markdown_dir / f"{Path(CONSOLE_OPENAPI_FILENAME).stem}.md"
_append_fastopenapi_markdown(console_markdown_path, markdown_path)
(markdown_dir / STALE_COMBINED_MARKDOWN_FILENAME).unlink(missing_ok=True)
@@ -286,6 +289,8 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--swagger-dir",
"--openapi-dir",
dest="openapi_dir",
type=Path,
default=Path("openapi"),
help="Directory where intermediate JSON spec files will be written.",
@@ -307,7 +312,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
args = parse_args()
written_paths = generate_markdown_docs(
args.swagger_dir,
args.openapi_dir,
args.markdown_dir,
keep_swagger_json=args.keep_swagger_json,
)
+174 -21
View File
@@ -1,9 +1,9 @@
"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend.
"""Generate Flask-RESTX OpenAPI 3 specs without booting the full backend.
This helper intentionally avoids `app_factory.create_app()`. The normal backend
startup eagerly initializes database, Redis, Celery, and storage extensions,
which is unnecessary when the goal is only to serialize the Flask-RESTX
`/swagger.json` documents.
`/openapi.json` documents.
"""
from __future__ import annotations
@@ -42,10 +42,10 @@ class RestxApi(Protocol):
SPEC_TARGETS: tuple[SpecTarget, ...] = (
SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json", namespace="console"),
SpecTarget(route="/api/swagger.json", filename="web-swagger.json", namespace="web"),
SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"),
SpecTarget(route="/openapi/v1/swagger.json", filename="openapi-swagger.json", namespace="openapi"),
SpecTarget(route="/console/api/openapi.json", filename="console-openapi.json", namespace="console"),
SpecTarget(route="/api/openapi.json", filename="web-openapi.json", namespace="web"),
SpecTarget(route="/v1/openapi.json", filename="service-openapi.json", namespace="service"),
SpecTarget(route="/openapi/v1/openapi.json", filename="openapi-openapi.json", namespace="openapi"),
)
@@ -126,7 +126,7 @@ def _inline_model_signature(nested_fields: dict[object, object]) -> object:
def _inline_model_name(nested_fields: dict[object, object]) -> str:
"""Return a stable Swagger model name for an anonymous inline field map."""
"""Return a stable OpenAPI 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]
@@ -134,7 +134,7 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str:
def apply_runtime_defaults() -> None:
"""Force the small config surface required for Swagger generation."""
"""Force the small config surface required for OpenAPI generation."""
os.environ.setdefault("SECRET_KEY", "spec-export")
os.environ.setdefault("STORAGE_TYPE", "local")
@@ -150,7 +150,7 @@ def apply_runtime_defaults() -> None:
def create_spec_app() -> Flask:
"""Build a minimal Flask app that only mounts the Swagger-producing blueprints."""
"""Build a minimal Flask app that only mounts the OpenAPI-producing blueprints."""
apply_runtime_defaults()
@@ -182,7 +182,7 @@ def create_spec_app() -> Flask:
def _registered_models(namespace: str) -> dict[str, object]:
"""Return the Flask-RESTX models registered for a Swagger namespace."""
"""Return the Flask-RESTX models registered for an OpenAPI namespace."""
if namespace == "console":
from controllers.console import console_ns
@@ -213,7 +213,7 @@ def _registered_models(namespace: str) -> dict[str, object]:
models.update(api.models)
return models
raise ValueError(f"unknown Swagger namespace: {namespace}")
raise ValueError(f"unknown OpenAPI namespace: {namespace}")
def _materialize_inline_model_definitions(api: RestxApi) -> None:
@@ -289,7 +289,7 @@ def drop_null_values(value: object) -> object:
def sort_openapi_arrays(value: object, *, parent_key: str | None = None) -> object:
"""Sort order-insensitive Swagger arrays so generated Markdown is stable."""
"""Sort order-insensitive OpenAPI arrays so generated Markdown is stable."""
if isinstance(value, dict):
return {key: sort_openapi_arrays(item, parent_key=key) for key, item in value.items()}
@@ -313,23 +313,174 @@ def sort_openapi_arrays(value: object, *, parent_key: str | None = None) -> obje
return sorted_items
def _merge_registered_definitions(payload: dict[str, object], namespace: str) -> dict[str, object]:
"""Include registered but route-indirect models in the exported Swagger definitions."""
def _replace_legacy_refs(value: object) -> object:
if isinstance(value, dict):
replaced: dict[object, object] = {}
for key, item in value.items():
if key == "$ref" and isinstance(item, str) and item.startswith("#/definitions/"):
replaced[key] = item.replace("#/definitions/", "#/components/schemas/", 1)
else:
replaced[key] = _replace_legacy_refs(item)
return replaced
if isinstance(value, list):
return [_replace_legacy_refs(item) for item in value]
return value
definitions = payload.setdefault("definitions", {})
if not isinstance(definitions, dict):
raise RuntimeError("unexpected Swagger definitions payload")
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
def _resolve_component_schema(payload: dict[str, object], schema: object) -> dict[str, object] | None:
if not isinstance(schema, dict):
return None
ref = schema.get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
name = ref.removeprefix("#/components/schemas/")
components = payload.get("components")
if not isinstance(components, dict):
return None
schemas = components.get("schemas")
if not isinstance(schemas, dict):
return None
resolved = schemas.get(name)
return resolved if isinstance(resolved, dict) else None
return schema
def _request_body_schema(request_body: object) -> object | None:
if not isinstance(request_body, dict):
return None
content = request_body.get("content")
if not isinstance(content, dict):
return None
media_type = content.get("application/json")
if not isinstance(media_type, dict):
return None
return media_type.get("schema")
def _query_parameters_from_schema(schema: dict[str, object]) -> list[dict[str, object]]:
properties = schema.get("properties")
if not isinstance(properties, dict):
return []
required = schema.get("required")
required_names = set(required) if isinstance(required, list) else set()
parameters: list[dict[str, object]] = []
for name, property_schema in sorted(properties.items()):
if not isinstance(name, str) or not isinstance(property_schema, dict):
continue
schema_copy = dict(property_schema)
description = schema_copy.get("description")
parameter: dict[str, object] = {
"name": name,
"in": "query",
"required": name in required_names,
"schema": schema_copy,
}
if isinstance(description, str):
parameter["description"] = description
parameters.append(parameter)
return parameters
def _move_get_request_bodies_to_query_parameters(payload: dict[str, object]) -> dict[str, object]:
"""Represent GET request bodies as query parameters in exported specs."""
paths = payload.get("paths")
if not isinstance(paths, dict):
return payload
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
operation = path_item.get("get")
if not isinstance(operation, dict) or "requestBody" not in operation:
continue
schema = _resolve_component_schema(payload, _request_body_schema(operation.get("requestBody")))
existing_parameters = operation.get("parameters")
parameters = list(existing_parameters) if isinstance(existing_parameters, list) else []
existing_query_names = {
parameter.get("name")
for parameter in parameters
if isinstance(parameter, dict) and parameter.get("in") == "query"
}
if schema is not None:
for parameter in _query_parameters_from_schema(schema):
if parameter["name"] not in existing_query_names:
parameters.append(parameter)
if parameters:
operation["parameters"] = parameters
operation.pop("requestBody", None)
return payload
def _deduplicate_operation_ids(payload: dict[str, object]) -> dict[str, object]:
"""Make operationId values unique while preserving already-unique IDs."""
paths = payload.get("paths")
if not isinstance(paths, dict):
return payload
operations_by_id: dict[str, list[tuple[str, str, dict[str, object]]]] = {}
for path, path_item in paths.items():
if not isinstance(path, str) or not isinstance(path_item, dict):
continue
for method, operation in path_item.items():
if method not in HTTP_METHODS or not isinstance(operation, dict):
continue
operation_id = operation.get("operationId")
if isinstance(operation_id, str):
operations_by_id.setdefault(operation_id, []).append((method, path, operation))
for operation_id, operations in operations_by_id.items():
if len(operations) < 2:
continue
for method, path, operation in operations:
digest = hashlib.sha1(f"{method}:{path}".encode()).hexdigest()[:8]
operation["operationId"] = f"{operation_id}_{digest}"
return payload
def _component_schemas(payload: dict[str, object]) -> dict[str, object]:
components = payload.setdefault("components", {})
if not isinstance(components, dict):
raise RuntimeError("unexpected OpenAPI components payload")
schemas = components.setdefault("schemas", {})
if not isinstance(schemas, dict):
raise RuntimeError("unexpected OpenAPI component schemas payload")
return schemas
def _merge_registered_schemas(payload: dict[str, object], namespace: str) -> dict[str, object]:
"""Include registered but route-indirect models in exported OpenAPI schemas."""
schemas = _component_schemas(payload)
for name, model in _registered_models(namespace).items():
schema = getattr(model, "__schema__", None)
if isinstance(schema, dict):
definitions.setdefault(name, schema)
schemas.setdefault(name, _replace_legacy_refs(schema))
payload.pop("definitions", None)
payload = _replace_legacy_refs(payload) # type: ignore[assignment]
return payload
def generate_specs(output_dir: Path) -> list[Path]:
"""Write all Swagger specs to `output_dir` and return the written paths."""
"""Write all OpenAPI specs to `output_dir` and return the written paths."""
output_dir.mkdir(parents=True, exist_ok=True)
@@ -345,7 +496,9 @@ def generate_specs(output_dir: Path) -> list[Path]:
payload = response.get_json()
if not isinstance(payload, dict):
raise RuntimeError(f"unexpected response payload for {target.route}")
payload = _merge_registered_definitions(payload, target.namespace)
payload = _merge_registered_schemas(payload, target.namespace)
payload = _move_get_request_bodies_to_query_parameters(payload)
payload = _deduplicate_operation_ids(payload)
payload = drop_null_values(payload)
payload = sort_openapi_arrays(payload)
@@ -363,7 +516,7 @@ def parse_args() -> argparse.Namespace:
"--output-dir",
type=Path,
default=Path("openapi"),
help="Directory where the Swagger JSON files will be written.",
help="Directory where the OpenAPI JSON files will be written.",
)
return parser.parse_args()
+1 -1
View File
@@ -26,7 +26,7 @@ def init_app(app: DifyApp) -> None:
docs_url=docs_url,
redoc_url=redoc_url,
openapi_url=openapi_url,
openapi_version="3.0.0",
openapi_version="3.1.0",
title="Dify API (FastOpenAPI PoC)",
version="1.0",
description="FastOpenAPI proof of concept for Dify API",
+5 -5
View File
@@ -1,8 +1,8 @@
"""Compatibility helpers for Dify's Flask-RESTX Swagger integration.
"""Compatibility helpers for Dify's Flask-RESTX OpenAPI 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
models. Keep the behavior centralized so live OpenAPI endpoints and offline
spec export fail or succeed in the same way.
"""
@@ -91,7 +91,7 @@ def _inline_model_signature(nested_fields: dict[object, object]) -> object:
def _inline_model_name(nested_fields: dict[object, object]) -> str:
"""Return a stable Swagger model name for an anonymous inline field map."""
"""Return a stable OpenAPI 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]
@@ -99,11 +99,11 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str:
def patch_swagger_for_inline_nested_dicts() -> None:
"""Allow Swagger generation to handle legacy inline Flask-RESTX field dicts.
"""Allow OpenAPI 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
but Flask-RESTX registration expects a named model. Convert those
anonymous mappings into temporary named models during docs generation.
"""
File diff suppressed because it is too large Load Diff
@@ -3,523 +3,485 @@ User-scoped programmatic API (bearer auth)
## Version: 1.0
### Security
**Bearer**
| apiKey | *API Key* |
| ------ | --------- |
| Description | Type: Bearer {your-api-key} |
| In | header |
| Name | Authorization |
### Available authorizations
#### Bearer (API Key Authentication)
Type: Bearer {your-api-key}
**Name:** Authorization
**In:** header
---
## openapi
User-scoped operations
### /_health
#### GET
##### Responses
### [GET] /_health
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Health check | [HealthResponse](#healthresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Health check | **application/json**: [HealthResponse](#healthresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /_version
#### GET
##### Responses
### [GET] /_version
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Server version | [ServerVersionResponse](#serverversionresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Server version | **application/json**: [ServerVersionResponse](#serverversionresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /account
#### GET
##### Responses
### [GET] /account
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Account info | [AccountResponse](#accountresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Account info | **application/json**: [AccountResponse](#accountresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /account/sessions
#### GET
##### Parameters
### [GET] /account/sessions
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer |
| page | query | | No | integer |
| limit | query | | No | integer, <br>**Default:** 100 |
| page | query | | No | integer, <br>**Default:** 1 |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session list | [SessionListResponse](#sessionlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Session list | **application/json**: [SessionListResponse](#sessionlistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /account/sessions/self
#### DELETE
##### Responses
### [DELETE] /account/sessions/self
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Session revoked | **application/json**: [RevokeResponse](#revokeresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /account/sessions/{session_id}
#### DELETE
##### Parameters
### [DELETE] /account/sessions/{session_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| session_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Session revoked | **application/json**: [RevokeResponse](#revokeresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps
#### GET
##### Parameters
### [GET] /apps
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer |
| limit | query | | No | integer, <br>**Default:** 20 |
| mode | query | | No | string |
| name | query | | No | string |
| page | query | | No | integer |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | | No | string |
| workspace_id | query | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | App list | **application/json**: [AppListResponse](#applistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/check-dependencies
#### GET
##### Parameters
### [GET] /apps/{app_id}/check-dependencies
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/describe
#### GET
##### Parameters
### [GET] /apps/{app_id}/describe
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| fields | query | | No | string |
| app_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | App description | **application/json**: [AppDescribeResponse](#appdescriberesponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/export
#### GET
##### Parameters
### [GET] /apps/{app_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| include_secret | query | Include encrypted secret values in the exported DSL | No | boolean |
| workflow_id | query | Export a specific workflow version instead of the current draft | No | string |
| app_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/files/upload
#### POST
##### Description
| 200 | Export successful | **application/json**: [AppDslExportResponse](#appdslexportresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /apps/{app_id}/files/upload
Upload a file to use as an input variable when running the app
##### Parameters
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File uploaded successfully | [FileResponse](#fileresponse) |
| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)<br> |
| 400 | Bad request — no file or filename missing | |
| 401 | Unauthorized — invalid or expired bearer token | |
| 413 | File too large | |
| 415 | Unsupported file type or blocked extension | |
| default | Error | [ErrorBody](#errorbody) |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/form/human_input/{form_token}
#### GET
##### Parameters
### [GET] /apps/{app_id}/form/human_input/{form_token}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| form_token | path | | Yes | string |
##### Responses
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Form definition |
#### POST
##### Parameters
### [POST] /apps/{app_id}/form/human_input/{form_token}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| form_token | path | | Yes | string |
| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) |
##### Responses
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Form submitted | **application/json**: [FormSubmitResponse](#formsubmitresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/run
#### POST
##### Parameters
### [POST] /apps/{app_id}/run
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| payload | body | | Yes | [AppRunRequest](#apprunrequest) |
##### Responses
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AppRunRequest](#apprunrequest)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | |
| 422 | Validation error | [ErrorBody](#errorbody) |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
### /apps/{app_id}/tasks/{task_id}/events
#### GET
##### Parameters
### [GET] /apps/{app_id}/tasks/{task_id}/events
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | SSE event stream |
### /apps/{app_id}/tasks/{task_id}/stop
#### POST
##### Parameters
### [POST] /apps/{app_id}/tasks/{task_id}/stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /oauth/device/approve
### [POST] /oauth/device/approve
#### Request Body
#### POST
##### Parameters
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [DeviceMutateRequest](#devicemutaterequest)<br> |
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Approved | [DeviceMutateResponse](#devicemutateresponse) |
| 200 | Approved | **application/json**: [DeviceMutateResponse](#devicemutateresponse)<br> |
### /oauth/device/code
### [POST] /oauth/device/code
#### Request Body
#### POST
##### Parameters
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [DeviceCodeRequest](#devicecoderequest)<br> |
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [DeviceCodeRequest](#devicecoderequest) |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Device code created | [DeviceCodeResponse](#devicecoderesponse) |
| 200 | Device code created | **application/json**: [DeviceCodeResponse](#devicecoderesponse)<br> |
### /oauth/device/deny
### [POST] /oauth/device/deny
#### Request Body
#### POST
##### Parameters
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [DeviceMutateRequest](#devicemutaterequest)<br> |
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Denied | [DeviceMutateResponse](#devicemutateresponse) |
| 200 | Denied | **application/json**: [DeviceMutateResponse](#devicemutateresponse)<br> |
### /oauth/device/lookup
#### GET
##### Parameters
### [GET] /oauth/device/lookup
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| user_code | query | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Device lookup result | [DeviceLookupResponse](#devicelookupresponse) |
| 200 | Device lookup result | **application/json**: [DeviceLookupResponse](#devicelookupresponse)<br> |
### /oauth/device/token
### [POST] /oauth/device/token
#### Request Body
#### POST
##### Parameters
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [DevicePollRequest](#devicepollrequest)<br> |
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [DevicePollRequest](#devicepollrequest) |
##### Responses
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /permitted-external-apps
#### GET
##### Parameters
### [GET] /permitted-external-apps
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer |
| limit | query | | No | integer, <br>**Default:** 20 |
| mode | query | | No | string |
| name | query | | No | string |
| page | query | | No | integer |
| page | query | | No | integer, <br>**Default:** 1 |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Permitted external apps list | **application/json**: [PermittedExternalAppsListResponse](#permittedexternalappslistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces
#### GET
##### Responses
### [GET] /workspaces
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Workspace list | **application/json**: [WorkspaceListResponse](#workspacelistresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}
#### GET
##### Parameters
### [GET] /workspaces/{workspace_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}/apps/imports
#### POST
##### Parameters
### [POST] /workspaces/{workspace_id}/apps/imports
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [AppDslImportPayload](#appdslimportpayload) |
##### Responses
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AppDslImportPayload](#appdslimportpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import completed | [Import](#import) |
| 202 | Import pending confirmation | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Import completed | **application/json**: [Import](#import)<br> |
| 202 | Import pending confirmation | **application/json**: [Import](#import)<br> |
| 400 | Import failed | **application/json**: [Import](#import)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
#### POST
##### Parameters
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| import_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import confirmed | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Import confirmed | **application/json**: [Import](#import)<br> |
| 400 | Import failed | **application/json**: [Import](#import)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}/members
### [GET] /workspaces/{workspace_id}/members
#### Parameters
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer, <br>**Default:** 20 |
| page | query | | No | integer, <br>**Default:** 1 |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member list | **application/json**: [MemberListResponse](#memberlistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}/members
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [MemberInvitePayload](#memberinvitepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 201 | Member invited | **application/json**: [MemberInviteResponse](#memberinviteresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members/{member_id}
#### DELETE
##### Parameters
### [DELETE] /workspaces/{workspace_id}/members/{member_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| member_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Member removed | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}/members/{member_id}/role
#### PUT
##### Parameters
### [PUT] /workspaces/{workspace_id}/members/{member_id}/role
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| member_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) |
##### Responses
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [MemberRoleUpdatePayload](#memberroleupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Role updated | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### /workspaces/{workspace_id}/switch
#### POST
##### Parameters
### [POST] /workspaces/{workspace_id}/switch
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
##### Responses
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
---
### Models
### Schemas
#### AccountPayload
@@ -538,7 +500,7 @@ Upload a file to use as an input variable when running the app
| subject_email | string | | No |
| subject_issuer | string | | No |
| subject_type | string | | Yes |
| workspaces | [ [WorkspacePayload](#workspacepayload) ] | | No |
| workspaces | [ [WorkspacePayload](#workspacepayload) ], <br>**Default:** | | No |
#### AppDescribeInfo
@@ -551,7 +513,7 @@ Upload a file to use as an input variable when running the app
| mode | string | | Yes |
| name | string | | Yes |
| service_api_enabled | boolean | | Yes |
| tags | [ [TagItem](#tagitem) ] | | No |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
#### AppDescribeQuery
@@ -600,7 +562,7 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| mode | string | Import mode: yaml-content or yaml-url<br>*Enum:* `"yaml-content"`, `"yaml-url"` | Yes |
| mode | string, <br>**Available values:** "yaml-content", "yaml-url" | Import mode: yaml-content or yaml-url<br>*Enum:* `"yaml-content"`, `"yaml-url"` | Yes |
| name | string | Override the app name from the DSL | No |
| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
@@ -614,7 +576,7 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ] | | No |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
#### AppListQuery
@@ -622,10 +584,10 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer | | No |
| limit | integer, <br>**Default:** 20 | | No |
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | string | | No |
| workspace_id | string | | Yes |
@@ -648,7 +610,7 @@ mode is a closed enum.
| id | string | | Yes |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ] | | No |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
| workspace_id | string | | No |
| workspace_name | string | | No |
@@ -663,7 +625,7 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_generate_name | boolean | | No |
| auto_generate_name | boolean, <br>**Default:** true | | No |
| conversation_id | string | | No |
| files | [ object ] | | No |
| inputs | object | | Yes |
@@ -745,7 +707,7 @@ future server adds a code. Formatter tests pin emitted values to the enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| loc | [ ] | | No |
| loc | [ ], <br>**Default:** | | No |
| msg | string | | Yes |
| type | string | | Yes |
@@ -808,7 +770,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| ---- | ---- | ----------- | -------- |
| app_id | string | | No |
| app_mode | string | | No |
| current_dsl_version | string | | No |
| current_dsl_version | string, <br>**Default:** 0.6.0 | | No |
| error | string | | No |
| id | string | | Yes |
| imported_dsl_version | string | | No |
@@ -837,14 +799,14 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | No |
| result | string, <br>**Default:** success | | No |
#### MemberInvitePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
| role | string, <br>**Available values:** "admin", "normal" | *Enum:* `"admin"`, `"normal"` | Yes |
#### MemberInviteResponse
@@ -853,7 +815,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| email | string | | Yes |
| invite_url | string | | Yes |
| member_id | string | | Yes |
| result | string | | No |
| result | string, <br>**Default:** success | | No |
| role | string | | Yes |
| tenant_id | string | | Yes |
@@ -863,8 +825,8 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer | | No |
| page | integer | | No |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
#### MemberListResponse
@@ -891,13 +853,13 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
| role | string, <br>**Available values:** "admin", "normal" | *Enum:* `"admin"`, `"normal"` | Yes |
#### MessageMetadata
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| retriever_resources | [ object ] | | No |
| retriever_resources | [ object ], <br>**Default:** | | No |
| usage | [UsageInfo](#usageinfo) | | No |
#### OpenApiErrorCode
@@ -919,10 +881,10 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer | | No |
| limit | integer, <br>**Default:** 20 | | No |
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer | | No |
| page | integer, <br>**Default:** 1 | | No |
#### PermittedExternalAppsListResponse
@@ -954,7 +916,7 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| edition | string | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes |
| edition | string, <br>**Available values:** "CLOUD", "SELF_HOSTED" | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes |
| version | string | | Yes |
#### SessionListQuery
@@ -963,8 +925,8 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer | | No |
| page | integer | | No |
| limit | integer, <br>**Default:** 100 | | No |
| page | integer, <br>**Default:** 1 | | No |
#### SessionListResponse
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -60,6 +60,7 @@ exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"]
[tool.uv.sources]
dify-agent = { path = "../dify-agent", editable = true }
flask-restx = { git = "https://github.com/asukaminato0721/flask-restx", rev = "27758e26f8f740d7525d5039c51a9e524b6e2b68" }
dify-vdb-alibabacloud-mysql = { workspace = true }
dify-vdb-analyticdb = { workspace = true }
dify-vdb-baidu = { workspace = true }
@@ -22,7 +22,7 @@ def _load_generate_swagger_markdown_docs_module():
def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_console(tmp_path, monkeypatch):
module = _load_generate_swagger_markdown_docs_module()
swagger_dir = tmp_path / "openapi"
openapi_dir = tmp_path / "openapi"
markdown_dir = tmp_path / "markdown"
stale_combined_doc = markdown_dir / "api-reference.md"
markdown_dir.mkdir()
@@ -50,23 +50,23 @@ def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_con
monkeypatch.setattr(module, "generate_fastopenapi_specs", write_fastopenapi_specs)
monkeypatch.setattr(module, "_convert_spec_to_markdown", convert_spec_to_markdown)
written_paths = module.generate_markdown_docs(swagger_dir, markdown_dir)
written_paths = module.generate_markdown_docs(openapi_dir, markdown_dir)
assert [path.name for path in written_paths] == [
"console-swagger.md",
"web-swagger.md",
"service-swagger.md",
"openapi-swagger.md",
"console-openapi.md",
"web-openapi.md",
"service-openapi.md",
"openapi-openapi.md",
]
assert not stale_combined_doc.exists()
assert not list(swagger_dir.glob("*.json"))
assert not list(openapi_dir.glob("*.json"))
console_markdown = (markdown_dir / "console-swagger.md").read_text(encoding="utf-8")
assert "## FastOpenAPI Preview (OpenAPI 3.0)" in console_markdown
console_markdown = (markdown_dir / "console-openapi.md").read_text(encoding="utf-8")
assert "## FastOpenAPI Preview (OpenAPI 3.1)" in console_markdown
assert "### fastopenapi-console-openapi" in console_markdown
assert "#### Routes" in console_markdown
assert "FastOpenAPI Preview" not in (markdown_dir / "web-swagger.md").read_text(encoding="utf-8")
assert "FastOpenAPI Preview" not in (markdown_dir / "service-swagger.md").read_text(encoding="utf-8")
assert "FastOpenAPI Preview" not in (markdown_dir / "web-openapi.md").read_text(encoding="utf-8")
assert "FastOpenAPI Preview" not in (markdown_dir / "service-openapi.md").read_text(encoding="utf-8")
def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagger_dir(tmp_path, monkeypatch):
@@ -107,39 +107,41 @@ def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagg
def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-swagger.json"
spec_path = tmp_path / "console-openapi.json"
spec_path.write_text(
json.dumps(
{
"definitions": {
"FormInputConfig": {
"oneOf": [
{"$ref": "#/definitions/ParagraphInputConfig"},
{"$ref": "#/definitions/SelectInputConfig"},
{"$ref": "#/definitions/FileInputConfig"},
],
},
"ParagraphInputConfig": {
"properties": {
"default": {
"anyOf": [
{"$ref": "#/definitions/StringSource"},
{"type": "null"},
],
"components": {
"schemas": {
"FormInputConfig": {
"oneOf": [
{"$ref": "#/components/schemas/ParagraphInputConfig"},
{"$ref": "#/components/schemas/SelectInputConfig"},
{"$ref": "#/components/schemas/FileInputConfig"},
],
},
"ParagraphInputConfig": {
"properties": {
"default": {
"anyOf": [
{"$ref": "#/components/schemas/StringSource"},
{"type": "null"},
],
},
"output_variable_name": {"type": "string"},
},
"output_variable_name": {"type": "string"},
},
},
"SelectInputConfig": {
"properties": {
"option_source": {"$ref": "#/definitions/StringListSource"},
"SelectInputConfig": {
"properties": {
"option_source": {"$ref": "#/components/schemas/StringListSource"},
},
},
},
"FileInputConfig": {
"properties": {
"allowed_file_types": {
"type": "array",
"items": {"$ref": "#/definitions/FileType"},
"FileInputConfig": {
"properties": {
"allowed_file_types": {
"type": "array",
"items": {"$ref": "#/components/schemas/FileType"},
},
},
},
},
@@ -188,24 +190,26 @@ def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path
assert "| allowed_file_types | [ [FileType](#filetype) ] | | No |" in patched
def test_patch_union_schema_markdown_fills_regular_definition_union_property(tmp_path):
def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "service-swagger.json"
spec_path = tmp_path / "service-openapi.json"
spec_path.write_text(
json.dumps(
{
"definitions": {
"DocumentMetadataResponse": {
"properties": {
"id": {"type": "string"},
"value": {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "number"},
{"type": "boolean"},
{"type": "null"},
],
"components": {
"schemas": {
"DocumentMetadataResponse": {
"properties": {
"id": {"type": "string"},
"value": {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "number"},
{"type": "boolean"},
{"type": "null"},
],
},
},
},
},
@@ -227,9 +231,9 @@ def test_patch_union_schema_markdown_fills_regular_definition_union_property(tmp
assert "| value | string<br>integer<br>number<br>boolean | | No |" in patched
def test_patch_union_schema_markdown_ignores_specs_without_definitions(tmp_path):
def test_patch_union_schema_markdown_ignores_specs_without_schemas(tmp_path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-swagger.json"
spec_path = tmp_path / "console-openapi.json"
spec_path.write_text("{}", encoding="utf-8")
assert module._patch_union_schema_markdown("unchanged", spec_path) == "unchanged"
@@ -237,27 +241,29 @@ def test_patch_union_schema_markdown_ignores_specs_without_definitions(tmp_path)
def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-swagger.json"
spec_path = tmp_path / "console-openapi.json"
spec_path.write_text(
json.dumps(
{
"definitions": {
"NotAMapping": [],
"BrokenUnion": {
"oneOf": [
{},
{"$ref": "#/definitions/Missing"},
{"$ref": "#/definitions/NoPropertyMapping"},
],
"components": {
"schemas": {
"NotAMapping": [],
"BrokenUnion": {
"oneOf": [
{},
{"$ref": "#/components/schemas/Missing"},
{"$ref": "#/components/schemas/NoPropertyMapping"},
],
},
"NoPropertyMapping": {"properties": []},
},
"NoPropertyMapping": {"properties": []},
}
}
),
encoding="utf-8",
)
assert module._definition_ref_name(None) is None
assert module._schema_ref_name(None) is None
assert module._schema_markdown_type(None) == ""
assert module._schema_markdown_type({"anyOf": [{"type": "null"}]}) == ""
assert module._replace_schema_table_type("unchanged", "Definition", "field", "") == "unchanged"
@@ -280,24 +286,26 @@ def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path):
def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monkeypatch):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-swagger.json"
output_path = tmp_path / "console-swagger.md"
spec_path = tmp_path / "console-openapi.json"
output_path = tmp_path / "console-openapi.md"
spec_path.write_text(
json.dumps(
{
"definitions": {
"FormInputConfig": {
"oneOf": [
{"$ref": "#/definitions/ParagraphInputConfig"},
],
},
"ParagraphInputConfig": {
"properties": {
"default": {
"anyOf": [
{"$ref": "#/definitions/StringSource"},
{"type": "null"},
],
"components": {
"schemas": {
"FormInputConfig": {
"oneOf": [
{"$ref": "#/components/schemas/ParagraphInputConfig"},
],
},
"ParagraphInputConfig": {
"properties": {
"default": {
"anyOf": [
{"$ref": "#/components/schemas/StringSource"},
{"type": "null"},
],
},
},
},
},
@@ -1,4 +1,4 @@
"""Unit tests for the standalone Swagger export helper."""
"""Unit tests for the standalone OpenAPI export helper."""
import importlib.util
import json
@@ -30,42 +30,82 @@ def _load_generate_swagger_specs_module():
return module
def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path):
def _operation_ids(payload):
methods = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
for path_item in payload["paths"].values():
for method, operation in path_item.items():
if method in methods and isinstance(operation, dict) and "operationId" in operation:
yield operation["operationId"]
def _get_operations(payload):
for path_item in payload["paths"].values():
operation = path_item.get("get")
if isinstance(operation, dict):
yield operation
def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
assert [path.name for path in written_paths] == [
"console-swagger.json",
"web-swagger.json",
"service-swagger.json",
"openapi-swagger.json",
"console-openapi.json",
"web-openapi.json",
"service-openapi.json",
"openapi-openapi.json",
]
for path in written_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["swagger"] == "2.0"
assert payload["openapi"].startswith("3.")
assert "paths" in payload
def test_generate_specs_writes_swagger_with_resolvable_references_and_no_nulls(tmp_path):
def test_generate_specs_writes_openapi_with_resolvable_references_and_no_nulls(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
for path in written_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
definitions = payload["definitions"]
schemas = payload["components"]["schemas"]
refs = {
item["$ref"].removeprefix("#/definitions/")
item["$ref"].removeprefix("#/components/schemas/")
for item in _walk_values(payload)
if isinstance(item, dict) and isinstance(item.get("$ref"), str)
if isinstance(item, dict)
and isinstance(item.get("$ref"), str)
and item["$ref"].startswith("#/components/schemas/")
}
assert refs <= set(definitions)
assert refs <= set(schemas)
assert all(value is not None for value in _walk_values(payload))
def test_generate_specs_writes_unique_operation_ids(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
for path in written_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
operation_ids = list(_operation_ids(payload))
assert len(operation_ids) == len(set(operation_ids))
def test_generate_specs_moves_get_request_bodies_to_query_parameters(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
for path in written_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
assert all("requestBody" not in operation for operation in _get_operations(payload))
def test_generate_specs_is_idempotent(tmp_path):
module = _load_generate_swagger_specs_module()
@@ -78,9 +78,9 @@ def mock_console_ns():
def test_default_ref_template_value():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}"
assert DEFAULT_REF_TEMPLATE_OPENAPI_3_0 == "#/components/schemas/{model}"
def test_register_schema_model_calls_namespace_schema_model():
@@ -100,7 +100,7 @@ def test_register_schema_model_calls_namespace_schema_model():
def test_register_schema_model_passes_schema_from_pydantic():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model
namespace = MagicMock(spec=Namespace)
@@ -108,24 +108,24 @@ def test_register_schema_model_passes_schema_from_pydantic():
schema = namespace.schema_model.call_args.args[1]
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
assert schema == expected_schema
def test_register_schema_model_promotes_nested_pydantic_definitions():
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model
namespace = MagicMock(spec=Namespace)
register_schema_model(namespace, ParentModel)
called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list}
parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
assert set(called_schemas) == {"ParentModel", "ChildModel"}
assert "$defs" not in called_schemas["ParentModel"]
assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/definitions/ChildModel"
assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/components/schemas/ChildModel"
assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"]
@@ -179,7 +179,7 @@ def test_register_response_schema_model_uses_serialized_field_names():
assert "internal_name" not in schema["properties"]
def test_register_schema_model_flattens_simple_nullable_any_of_for_swagger_2():
def test_register_schema_model_preserves_openapi_nullable_unions():
from controllers.common.schema import register_schema_model
namespace = MagicMock(spec=Namespace)
@@ -189,14 +189,9 @@ def test_register_schema_model_flattens_simple_nullable_any_of_for_swagger_2():
called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list}
properties = called_schemas["NullableSchemaModel"]["properties"]
assert properties["name"]["type"] == "string"
assert properties["name"]["x-nullable"] is True
assert "anyOf" not in properties["name"]
assert properties["tags"]["type"] == "array"
assert properties["tags"]["items"] == {"type": "string"}
assert properties["tags"]["x-nullable"] is True
assert properties["owner"]["$ref"] == "#/definitions/UserModel"
assert properties["owner"]["x-nullable"] is True
assert properties["name"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
assert properties["tags"]["anyOf"] == [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}]
assert properties["owner"]["anyOf"] == [{"$ref": "#/components/schemas/UserModel"}, {"type": "null"}]
assert "anyOf" in properties["ambiguous"]
@@ -1,20 +1,20 @@
"""Swagger JSON rendering tests for Flask-RESTX API blueprints."""
"""OpenAPI JSON rendering tests for Flask-RESTX API blueprints."""
import pytest
from flask import Flask
def _definition_refs(value: object) -> set[str]:
def _schema_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/"))
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
refs.add(ref.removeprefix("#/components/schemas/"))
for item in value.values():
refs.update(_definition_refs(item))
refs.update(_schema_refs(item))
elif isinstance(value, list):
for item in value:
refs.update(_definition_refs(item))
refs.update(_schema_refs(item))
return refs
@@ -31,6 +31,18 @@ def _parameters_by_name(operation: dict[str, object]) -> dict[str, dict[str, obj
return result
def _multipart_form_schema(operation: dict[str, object]) -> dict[str, object]:
request_body = operation.get("requestBody")
assert isinstance(request_body, dict)
content = request_body.get("content")
assert isinstance(content, dict)
multipart = content.get("multipart/form-data")
assert isinstance(multipart, dict)
schema = multipart.get("schema")
assert isinstance(schema, dict)
return schema
@pytest.mark.parametrize(
("first_kwargs", "second_kwargs"),
[
@@ -53,7 +65,7 @@ def test_inline_model_name_includes_list_constraints(
assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model)
def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch):
def test_openapi_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
@@ -70,17 +82,17 @@ def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch):
client = app.test_client()
for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"):
for route in ("/console/api/openapi.json", "/api/openapi.json", "/v1/openapi.json"):
response = client.get(route)
assert response.status_code == 200
payload = response.get_json()
assert payload["swagger"] == "2.0"
assert payload["openapi"].startswith("3.")
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 "schemas" in payload["components"]
assert isinstance(payload["components"]["schemas"], dict)
missing_refs = _schema_refs(payload) - set(payload["components"]["schemas"])
assert not missing_refs
assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True
@@ -96,17 +108,17 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch:
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
app.register_blueprint(service_api_bp)
payload = app.test_client().get("/v1/swagger.json").get_json()
payload = app.test_client().get("/v1/openapi.json").get_json()
paths = payload["paths"]
create_operation = paths["/datasets/{dataset_id}/document/create-by-file"]["post"]
create_params = _parameters_by_name(create_operation)
assert create_operation["consumes"] == ["multipart/form-data"]
assert create_params["file"]["in"] == "formData"
assert create_params["file"]["type"] == "file"
assert create_params["file"]["required"] is True
assert create_params["data"]["in"] == "formData"
assert create_params["data"]["type"] == "string"
create_schema = _multipart_form_schema(create_operation)
create_properties = create_schema["properties"]
assert isinstance(create_properties, dict)
assert create_properties["file"] == {"type": "string", "format": "binary"}
assert create_properties["data"] == {"type": "string"}
assert create_schema["required"] == ["file"]
assert create_operation["requestBody"]["required"] is True
for path in (
"/datasets/{dataset_id}/documents/{document_id}",
@@ -114,13 +126,13 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch:
"/datasets/{dataset_id}/documents/{document_id}/update_by_file",
):
update_operation = paths[path]["patch" if path.endswith("{document_id}") else "post"]
update_params = _parameters_by_name(update_operation)
assert update_operation["consumes"] == ["multipart/form-data"]
assert update_params["file"]["in"] == "formData"
assert update_params["file"]["type"] == "file"
assert update_params["file"]["required"] is False
assert update_params["data"]["in"] == "formData"
assert update_params["data"]["type"] == "string"
update_schema = _multipart_form_schema(update_operation)
update_properties = update_schema["properties"]
assert isinstance(update_properties, dict)
assert update_properties["file"] == {"type": "string", "format": "binary"}
assert update_properties["data"] == {"type": "string"}
assert "required" not in update_schema
assert update_operation["requestBody"]["required"] is False
def test_service_document_list_documents_query_params_render(monkeypatch: pytest.MonkeyPatch):
@@ -134,7 +146,7 @@ def test_service_document_list_documents_query_params_render(monkeypatch: pytest
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
app.register_blueprint(service_api_bp)
payload = app.test_client().get("/v1/swagger.json").get_json()
payload = app.test_client().get("/v1/openapi.json").get_json()
operation = payload["paths"]["/datasets/{dataset_id}/documents"]["get"]
params = _parameters_by_name(operation)
@@ -153,7 +165,7 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
app.register_blueprint(console_bp)
payload = app.test_client().get("/console/api/swagger.json").get_json()
payload = app.test_client().get("/console/api/openapi.json").get_json()
operation = payload["paths"]["/account/avatar"]["get"]
params = _parameters_by_name(operation)
Generated
+3 -7
View File
@@ -1630,7 +1630,7 @@ requires-dist = [
{ name = "flask-login", specifier = "==0.6.3" },
{ name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" },
{ name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" },
{ name = "flask-restx", specifier = ">=1.3.2,<2.0.0" },
{ name = "flask-restx", git = "https://github.com/asukaminato0721/flask-restx?rev=27758e26f8f740d7525d5039c51a9e524b6e2b68" },
{ name = "gevent", specifier = ">=26.4.0,<26.5.0" },
{ name = "gevent-websocket", specifier = "==0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
@@ -2570,8 +2570,8 @@ wheels = [
[[package]]
name = "flask-restx"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
version = "1.3.3.dev0"
source = { git = "https://github.com/asukaminato0721/flask-restx?rev=27758e26f8f740d7525d5039c51a9e524b6e2b68#27758e26f8f740d7525d5039c51a9e524b6e2b68" }
dependencies = [
{ name = "aniso8601" },
{ name = "flask" },
@@ -2580,10 +2580,6 @@ dependencies = [
{ name = "referencing" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/89/9b9ca58cbb8e9ec46f4a510ba93878e0c88d518bf03c350e3b1b7ad85cbe/flask-restx-1.3.2.tar.gz", hash = "sha256:0ae13d77e7d7e4dce513970cfa9db45364aef210e99022de26d2b73eb4dbced5", size = 2814719, upload-time = "2025-09-23T20:34:25.21Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/3f/b82cd8e733a355db1abb8297afbf59ec972c00ef90bf8d4eed287958b204/flask_restx-1.3.2-py2.py3-none-any.whl", hash = "sha256:6e035496e8223668044fc45bf769e526352fd648d9e159bd631d94fd645a687b", size = 2799859, upload-time = "2025-09-23T20:34:23.055Z" },
]
[[package]]
name = "flask-sqlalchemy"