Skip to content

Commit e1d0add

Browse files
asukaminato0721autofix-ci[bot]
authored andcommitted
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>
1 parent 34f1ed0 commit e1d0add

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

api/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a
101101
uv run ruff format ./ # Format code
102102
uv run basedpyright . # Type checking
103103
```
104+
105+
## Generate TS stub
106+
107+
```
108+
uv run dev/generate_swagger_specs.py --output-dir openapi
109+
```
110+
111+
use https://jsontotable.org/openapi-to-typescript to convert to typescript

api/dev/generate_swagger_specs.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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())
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Unit tests for the standalone Swagger export helper."""
2+
3+
import importlib.util
4+
import json
5+
import sys
6+
from pathlib import Path
7+
8+
9+
def _load_generate_swagger_specs_module():
10+
api_dir = Path(__file__).resolve().parents[3]
11+
script_path = api_dir / "dev" / "generate_swagger_specs.py"
12+
13+
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
14+
assert spec
15+
assert spec.loader
16+
17+
module = importlib.util.module_from_spec(spec)
18+
sys.modules[spec.name] = module
19+
spec.loader.exec_module(module) # type: ignore[attr-defined]
20+
return module
21+
22+
23+
def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path):
24+
module = _load_generate_swagger_specs_module()
25+
26+
written_paths = module.generate_specs(tmp_path)
27+
28+
assert [path.name for path in written_paths] == [
29+
"console-swagger.json",
30+
"web-swagger.json",
31+
"service-swagger.json",
32+
]
33+
34+
for path in written_paths:
35+
payload = json.loads(path.read_text(encoding="utf-8"))
36+
assert payload["swagger"] == "2.0"
37+
assert "paths" in payload

0 commit comments

Comments
 (0)