From e1d0addf41169082da1a684405ca039ebd22705a Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 23 Apr 2026 12:42:04 +0900 Subject: [PATCH] chore: add script to generate openapi v2 json and add in README #35474 (#35477) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/README.md | 8 + api/dev/generate_swagger_specs.py | 172 ++++++++++++++++++ .../commands/test_generate_swagger_specs.py | 37 ++++ 3 files changed, 217 insertions(+) create mode 100644 api/dev/generate_swagger_specs.py create mode 100644 api/tests/unit_tests/commands/test_generate_swagger_specs.py diff --git a/api/README.md b/api/README.md index 00562f3f78..a075bc0fa9 100644 --- a/api/README.md +++ b/api/README.md @@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a uv run ruff format ./ # Format code uv run basedpyright . # Type checking ``` + +## Generate TS stub + +``` +uv run dev/generate_swagger_specs.py --output-dir openapi +``` + +use https://jsontotable.org/openapi-to-typescript to convert to typescript diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py new file mode 100644 index 0000000000..7e9688bfb4 --- /dev/null +++ b/api/dev/generate_swagger_specs.py @@ -0,0 +1,172 @@ +"""Generate Flask-RESTX Swagger 2.0 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. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +from flask import Flask +from flask_restx.swagger import Swagger + +logger = logging.getLogger(__name__) + +API_ROOT = Path(__file__).resolve().parents[1] +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + + +@dataclass(frozen=True) +class SpecTarget: + route: str + filename: str + + +SPEC_TARGETS: tuple[SpecTarget, ...] = ( + SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"), + SpecTarget(route="/api/swagger.json", filename="web-swagger.json"), + SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"), +) + +_ORIGINAL_REGISTER_MODEL = Swagger.register_model +_ORIGINAL_REGISTER_FIELD = Swagger.register_field + + +def _apply_runtime_defaults() -> None: + """Force the small config surface required for Swagger generation.""" + + os.environ.setdefault("SECRET_KEY", "spec-export") + os.environ.setdefault("STORAGE_TYPE", "local") + os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage") + os.environ.setdefault("SWAGGER_UI_ENABLED", "true") + + from configs import dify_config + + dify_config.SECRET_KEY = os.environ["SECRET_KEY"] + dify_config.STORAGE_TYPE = "local" + dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"] + 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._anonymous_inline_models = anonymous_models + + 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 + + _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() + + app = Flask(__name__) + + 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 + + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + return app + + +def generate_specs(output_dir: Path) -> list[Path]: + """Write all Swagger specs to `output_dir` and return the written paths.""" + + output_dir.mkdir(parents=True, exist_ok=True) + + app = create_spec_app() + client = app.test_client() + + written_paths: list[Path] = [] + for target in SPEC_TARGETS: + response = client.get(target.route) + if response.status_code != 200: + raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}") + + payload = response.get_json() + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected response payload for {target.route}") + + output_path = output_dir / target.filename + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + written_paths.append(output_path) + + return written_paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", + "--output-dir", + type=Path, + default=Path("openapi"), + help="Directory where the Swagger JSON files will be written.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + written_paths = generate_specs(args.output_dir) + + for path in written_paths: + logger.debug(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py new file mode 100644 index 0000000000..e77e875081 --- /dev/null +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -0,0 +1,37 @@ +"""Unit tests for the standalone Swagger export helper.""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def _load_generate_swagger_specs_module(): + api_dir = Path(__file__).resolve().parents[3] + script_path = api_dir / "dev" / "generate_swagger_specs.py" + + spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path) + assert spec + assert spec.loader + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +def test_generate_specs_writes_console_web_and_service_swagger_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", + ] + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["swagger"] == "2.0" + assert "paths" in payload