|
| 1 | +"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend. |
| 2 | +
|
| 3 | +This helper intentionally avoids `app_factory.create_app()`. The normal backend |
| 4 | +startup eagerly initializes database, Redis, Celery, and storage extensions, |
| 5 | +which is unnecessary when the goal is only to serialize the Flask-RESTX |
| 6 | +`/swagger.json` documents. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import argparse |
| 12 | +import json |
| 13 | +import logging |
| 14 | +import os |
| 15 | +import sys |
| 16 | +from dataclasses import dataclass |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +from flask import Flask |
| 20 | +from flask_restx.swagger import Swagger |
| 21 | + |
| 22 | +logger = logging.getLogger(__name__) |
| 23 | + |
| 24 | +API_ROOT = Path(__file__).resolve().parents[1] |
| 25 | +if str(API_ROOT) not in sys.path: |
| 26 | + sys.path.insert(0, str(API_ROOT)) |
| 27 | + |
| 28 | + |
| 29 | +@dataclass(frozen=True) |
| 30 | +class SpecTarget: |
| 31 | + route: str |
| 32 | + filename: str |
| 33 | + |
| 34 | + |
| 35 | +SPEC_TARGETS: tuple[SpecTarget, ...] = ( |
| 36 | + SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"), |
| 37 | + SpecTarget(route="/api/swagger.json", filename="web-swagger.json"), |
| 38 | + SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"), |
| 39 | +) |
| 40 | + |
| 41 | +_ORIGINAL_REGISTER_MODEL = Swagger.register_model |
| 42 | +_ORIGINAL_REGISTER_FIELD = Swagger.register_field |
| 43 | + |
| 44 | + |
| 45 | +def _apply_runtime_defaults() -> None: |
| 46 | + """Force the small config surface required for Swagger generation.""" |
| 47 | + |
| 48 | + os.environ.setdefault("SECRET_KEY", "spec-export") |
| 49 | + os.environ.setdefault("STORAGE_TYPE", "local") |
| 50 | + os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage") |
| 51 | + os.environ.setdefault("SWAGGER_UI_ENABLED", "true") |
| 52 | + |
| 53 | + from configs import dify_config |
| 54 | + |
| 55 | + dify_config.SECRET_KEY = os.environ["SECRET_KEY"] |
| 56 | + dify_config.STORAGE_TYPE = "local" |
| 57 | + dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"] |
| 58 | + dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true" |
| 59 | + |
| 60 | + |
| 61 | +def _patch_swagger_for_inline_nested_dicts() -> None: |
| 62 | + """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps. |
| 63 | +
|
| 64 | + Some existing controllers use `fields.Nested({...})` with a raw field mapping |
| 65 | + instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous |
| 66 | + dicts during schema registration, so this helper upgrades them into temporary |
| 67 | + named models at export time. |
| 68 | + """ |
| 69 | + |
| 70 | + if getattr(Swagger, "_dify_inline_nested_dict_patch", False): |
| 71 | + return |
| 72 | + |
| 73 | + def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: |
| 74 | + anonymous_models = getattr(self, "_anonymous_inline_models", None) |
| 75 | + if anonymous_models is None: |
| 76 | + anonymous_models = {} |
| 77 | + self._anonymous_inline_models = anonymous_models |
| 78 | + |
| 79 | + anonymous_name = anonymous_models.get(id(nested_fields)) |
| 80 | + if anonymous_name is None: |
| 81 | + anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}" |
| 82 | + anonymous_models[id(nested_fields)] = anonymous_name |
| 83 | + self.api.model(anonymous_name, nested_fields) |
| 84 | + |
| 85 | + return self.api.models[anonymous_name] |
| 86 | + |
| 87 | + def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: |
| 88 | + if isinstance(model, dict): |
| 89 | + model = get_or_create_inline_model(self, model) |
| 90 | + |
| 91 | + return _ORIGINAL_REGISTER_MODEL(self, model) |
| 92 | + |
| 93 | + def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: |
| 94 | + nested = getattr(field, "nested", None) |
| 95 | + if isinstance(nested, dict): |
| 96 | + field.model = get_or_create_inline_model(self, nested) # type: ignore |
| 97 | + |
| 98 | + _ORIGINAL_REGISTER_FIELD(self, field) |
| 99 | + |
| 100 | + Swagger.register_model = register_model_with_inline_dict_support |
| 101 | + Swagger.register_field = register_field_with_inline_dict_support |
| 102 | + Swagger._dify_inline_nested_dict_patch = True |
| 103 | + |
| 104 | + |
| 105 | +def create_spec_app() -> Flask: |
| 106 | + """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" |
| 107 | + |
| 108 | + _apply_runtime_defaults() |
| 109 | + _patch_swagger_for_inline_nested_dicts() |
| 110 | + |
| 111 | + app = Flask(__name__) |
| 112 | + |
| 113 | + from controllers.console import bp as console_bp |
| 114 | + from controllers.service_api import bp as service_api_bp |
| 115 | + from controllers.web import bp as web_bp |
| 116 | + |
| 117 | + app.register_blueprint(console_bp) |
| 118 | + app.register_blueprint(web_bp) |
| 119 | + app.register_blueprint(service_api_bp) |
| 120 | + |
| 121 | + return app |
| 122 | + |
| 123 | + |
| 124 | +def generate_specs(output_dir: Path) -> list[Path]: |
| 125 | + """Write all Swagger specs to `output_dir` and return the written paths.""" |
| 126 | + |
| 127 | + output_dir.mkdir(parents=True, exist_ok=True) |
| 128 | + |
| 129 | + app = create_spec_app() |
| 130 | + client = app.test_client() |
| 131 | + |
| 132 | + written_paths: list[Path] = [] |
| 133 | + for target in SPEC_TARGETS: |
| 134 | + response = client.get(target.route) |
| 135 | + if response.status_code != 200: |
| 136 | + raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}") |
| 137 | + |
| 138 | + payload = response.get_json() |
| 139 | + if not isinstance(payload, dict): |
| 140 | + raise RuntimeError(f"unexpected response payload for {target.route}") |
| 141 | + |
| 142 | + output_path = output_dir / target.filename |
| 143 | + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 144 | + written_paths.append(output_path) |
| 145 | + |
| 146 | + return written_paths |
| 147 | + |
| 148 | + |
| 149 | +def parse_args() -> argparse.Namespace: |
| 150 | + parser = argparse.ArgumentParser(description=__doc__) |
| 151 | + parser.add_argument( |
| 152 | + "-o", |
| 153 | + "--output-dir", |
| 154 | + type=Path, |
| 155 | + default=Path("openapi"), |
| 156 | + help="Directory where the Swagger JSON files will be written.", |
| 157 | + ) |
| 158 | + return parser.parse_args() |
| 159 | + |
| 160 | + |
| 161 | +def main() -> int: |
| 162 | + args = parse_args() |
| 163 | + written_paths = generate_specs(args.output_dir) |
| 164 | + |
| 165 | + for path in written_paths: |
| 166 | + logger.debug(path) |
| 167 | + |
| 168 | + return 0 |
| 169 | + |
| 170 | + |
| 171 | +if __name__ == "__main__": |
| 172 | + raise SystemExit(main()) |
0 commit comments