diff --git a/api/dev/generate_fastopenapi_specs.py b/api/dev/generate_fastopenapi_specs.py
new file mode 100644
index 0000000000..3f8a638500
--- /dev/null
+++ b/api/dev/generate_fastopenapi_specs.py
@@ -0,0 +1,95 @@
+"""Generate FastOpenAPI OpenAPI 3.0 specs without booting the full backend."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+API_ROOT = Path(__file__).resolve().parents[1]
+if str(API_ROOT) not in sys.path:
+ sys.path.insert(0, str(API_ROOT))
+
+from dev.generate_swagger_specs import _apply_runtime_defaults, _drop_null_values, _sort_swagger_arrays
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class FastOpenApiSpecTarget:
+ route: str
+ filename: str
+
+
+FASTOPENAPI_SPEC_TARGETS: tuple[FastOpenApiSpecTarget, ...] = (
+ FastOpenApiSpecTarget(route="/fastopenapi/openapi.json", filename="fastopenapi-console-openapi.json"),
+)
+
+
+def create_fastopenapi_spec_app():
+ """Build a minimal Flask app that only mounts FastOpenAPI docs routes."""
+
+ _apply_runtime_defaults()
+
+ from app_factory import create_flask_app_with_configs
+ from extensions import ext_fastopenapi
+
+ app = create_flask_app_with_configs()
+ ext_fastopenapi.init_app(app)
+ return app
+
+
+def generate_fastopenapi_specs(output_dir: Path) -> list[Path]:
+ """Write FastOpenAPI specs to `output_dir` and return the written paths."""
+
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ app = create_fastopenapi_spec_app()
+ client = app.test_client()
+
+ written_paths: list[Path] = []
+ for target in FASTOPENAPI_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}")
+ payload = _drop_null_values(payload)
+ payload = _sort_swagger_arrays(payload)
+
+ 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 OpenAPI JSON files will be written.",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ written_paths = generate_fastopenapi_specs(args.output_dir)
+
+ for path in written_paths:
+ logger.debug(path)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py
index 196abbd5f4..67b948cb7e 100644
--- a/api/dev/generate_swagger_markdown_docs.py
+++ b/api/dev/generate_swagger_markdown_docs.py
@@ -1,8 +1,8 @@
-"""Generate Swagger JSON specs and Markdown API docs.
+"""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
-Swagger output early.
+OpenAPI output early.
"""
from __future__ import annotations
@@ -12,49 +12,112 @@ import logging
import shutil
import subprocess
import sys
+import tempfile
from pathlib import Path
API_ROOT = Path(__file__).resolve().parents[1]
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
+from dev.generate_fastopenapi_specs import FASTOPENAPI_SPEC_TARGETS, generate_fastopenapi_specs
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"
+STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md"
-def generate_markdown_docs(swagger_dir: Path, markdown_dir: Path, *, keep_swagger_json: bool = False) -> list[Path]:
- """Generate Swagger JSON files, convert each one to Markdown, and return Markdown paths."""
+def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None:
+ subprocess.run(
+ [
+ "npx",
+ "--yes",
+ SWAGGER_MARKDOWN_PACKAGE,
+ "-i",
+ str(spec_path),
+ "-o",
+ str(markdown_path),
+ ],
+ check=True,
+ )
+
+
+def _demote_markdown_headings(markdown: str, *, levels: int = 1) -> str:
+ """Nest generated Markdown under another Markdown section."""
+
+ heading_prefix = "#" * levels
+ lines = []
+ for line in markdown.splitlines():
+ if line.startswith("#"):
+ lines.append(f"{heading_prefix}{line}")
+ else:
+ lines.append(line)
+ return "\n".join(lines).strip()
+
+
+def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdown_path: Path) -> None:
+ """Append FastOpenAPI console docs to the existing console API Markdown."""
+
+ console_markdown = console_markdown_path.read_text(encoding="utf-8").rstrip()
+ fastopenapi_markdown = _demote_markdown_headings(
+ fastopenapi_markdown_path.read_text(encoding="utf-8"),
+ levels=2,
+ )
+ console_markdown_path.write_text(
+ "\n\n".join(
+ [
+ console_markdown,
+ "## FastOpenAPI Preview (OpenAPI 3.0)",
+ fastopenapi_markdown,
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+
+def generate_markdown_docs(
+ swagger_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}
+ fastopenapi_paths_by_name = {path.name: path for path in fastopenapi_paths}
markdown_dir.mkdir(parents=True, exist_ok=True)
written_paths: list[Path] = []
try:
- for target in SPEC_TARGETS:
- swagger_path = swagger_paths_by_name[target.filename]
- markdown_path = markdown_dir / f"{swagger_path.stem}.md"
- subprocess.run(
- [
- "npx",
- "--yes",
- SWAGGER_MARKDOWN_PACKAGE,
- "-i",
- str(swagger_path),
- "-o",
- str(markdown_path),
- ],
- check=True,
- )
- written_paths.append(markdown_path)
+ with tempfile.TemporaryDirectory(prefix="dify-api-docs-") as temp_dir:
+ 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)
+ written_paths.append(markdown_path)
+
+ for target in FASTOPENAPI_SPEC_TARGETS:
+ fastopenapi_path = fastopenapi_paths_by_name[target.filename]
+ 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"
+ _append_fastopenapi_markdown(console_markdown_path, markdown_path)
+
+ (markdown_dir / STALE_COMBINED_MARKDOWN_FILENAME).unlink(missing_ok=True)
finally:
if not keep_swagger_json:
if swagger_dir == markdown_dir or markdown_dir.is_relative_to(swagger_dir):
- for path in swagger_paths:
+ for path in spec_paths:
path.unlink(missing_ok=True)
else:
shutil.rmtree(swagger_dir, ignore_errors=True)
@@ -68,18 +131,18 @@ def parse_args() -> argparse.Namespace:
"--swagger-dir",
type=Path,
default=Path("openapi"),
- help="Directory where Swagger JSON files will be written.",
+ help="Directory where intermediate JSON spec files will be written.",
)
parser.add_argument(
"--markdown-dir",
type=Path,
default=Path("openapi/markdown"),
- help="Directory where Markdown API docs will be written.",
+ help="Directory where split Markdown API docs will be written.",
)
parser.add_argument(
"--keep-swagger-json",
action="store_true",
- help="Keep intermediate Swagger JSON files after Markdown generation.",
+ help="Keep intermediate JSON spec files after Markdown generation.",
)
return parser.parse_args()
diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md
index bb694d77a6..1e1d7148ea 100644
--- a/api/openapi/markdown/console-swagger.md
+++ b/api/openapi/markdown/console-swagger.md
@@ -13277,7 +13277,7 @@ Default value types for form inputs.
| app | | | No |
| app_id | string | | Yes |
| can_trial | | | No |
-| categories | [ string ] | | No |
+| category | | | No |
| copyright | | | No |
| custom_disclaimer | | | No |
| description | | | No |
@@ -14598,3 +14598,169 @@ Workflow tool configuration
| mentioned_user_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No |
| mentioned_user_id | string | | No |
| reply_id | string | | No |
+
+## FastOpenAPI Preview (OpenAPI 3.0)
+
+### Dify API (FastOpenAPI PoC)
+FastOpenAPI proof of concept for Dify API
+
+#### Version: 1.0
+
+---
+
+##### [GET] /console/api/init
+**Get initialization validation status.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [InitStatusResponse](#initstatusresponse)
|
+
+##### [POST] /console/api/init
+**Validate initialization password.**
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)
|
+
+##### [GET] /console/api/ping
+**Health check endpoint for connection testing.**
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [PingResponse](#pingresponse)
|
+
+##### [GET] /console/api/setup
+**Get system setup status.
+
+ NOTE: This endpoint is unauthenticated by design.
+
+ During first-time bootstrap there is no admin account yet, so frontend initialization must be
+ able to query setup progress before any login flow exists.
+
+ Only bootstrap-safe status information should be returned by this endpoint.
+ **
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [SetupStatusResponse](#setupstatusresponse)
|
+
+##### [POST] /console/api/setup
+**Initialize system setup with admin account.
+
+ NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
+ Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
+ and init-password validation rather than user session authentication.
+ **
+
+###### Request Body
+
+| Required | Schema |
+| -------- | ------ |
+| Yes | **application/json**: [SetupRequestPayload](#setuprequestpayload)
|
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 201 | Created | **application/json**: [SetupResponse](#setupresponse)
|
+
+##### [GET] /console/api/version
+**Check for application version updates.**
+
+###### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| current_version | query | | Yes | string |
+
+###### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | OK | **application/json**: [VersionResponse](#versionresponse)
|
+
+---
+##### Schemas
+
+###### ErrorSchema
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
+
+###### InitStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| status | string,
**Available values:** "finished", "not_started" | Initialization status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### InitValidatePayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| password | string | Initialization password | Yes |
+
+###### InitValidateResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Operation result | Yes |
+
+###### PingResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Health check result | Yes |
+
+###### SetupRequestPayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| email | string | Admin email address | Yes |
+| language | | Admin language | No |
+| name | string | Admin name (max 30 characters) | Yes |
+| password | string | Admin password | Yes |
+
+###### SetupResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| result | string | Setup result | Yes |
+
+###### SetupStatusResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| setup_at | | Setup completion time (ISO format) | No |
+| step | string,
**Available values:** "finished", "not_started" | Setup step status
*Enum:* `"finished"`, `"not_started"` | Yes |
+
+###### VersionFeatures
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_replace_logo | boolean | Whether logo replacement is supported | Yes |
+| model_load_balancing_enabled | boolean | Whether model load balancing is enabled | Yes |
+
+###### VersionResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| can_auto_update | boolean | Whether auto-update is supported | Yes |
+| features | [VersionFeatures](#versionfeatures) | Feature flags and capabilities | Yes |
+| release_date | string | Release date of latest version | Yes |
+| release_notes | string | Release notes for latest version | Yes |
+| version | string | Latest version number | Yes |
diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py
new file mode 100644
index 0000000000..a8673e56bc
--- /dev/null
+++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py
@@ -0,0 +1,67 @@
+"""Unit tests for the Markdown API docs generator."""
+
+import importlib.util
+import sys
+from pathlib import Path
+
+
+def _load_generate_swagger_markdown_docs_module():
+ api_dir = Path(__file__).resolve().parents[3]
+ script_path = api_dir / "dev" / "generate_swagger_markdown_docs.py"
+
+ spec = importlib.util.spec_from_file_location("generate_swagger_markdown_docs", 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_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"
+ markdown_dir = tmp_path / "markdown"
+ stale_combined_doc = markdown_dir / "api-reference.md"
+ markdown_dir.mkdir()
+ stale_combined_doc.write_text("stale", encoding="utf-8")
+
+ def write_specs(output_dir: Path) -> list[Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ paths = []
+ for target in module.SPEC_TARGETS:
+ path = output_dir / target.filename
+ path.write_text("{}", encoding="utf-8")
+ paths.append(path)
+ return paths
+
+ def write_fastopenapi_specs(output_dir: Path) -> list[Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ path = output_dir / module.FASTOPENAPI_SPEC_TARGETS[0].filename
+ path.write_text("{}", encoding="utf-8")
+ return [path]
+
+ def convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None:
+ markdown_path.write_text(f"# {spec_path.stem}\n\n## Routes\n", encoding="utf-8")
+
+ monkeypatch.setattr(module, "generate_specs", write_specs)
+ 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)
+
+ assert [path.name for path in written_paths] == [
+ "console-swagger.md",
+ "web-swagger.md",
+ "service-swagger.md",
+ ]
+ assert not stale_combined_doc.exists()
+ assert not list(swagger_dir.glob("*.json"))
+
+ console_markdown = (markdown_dir / "console-swagger.md").read_text(encoding="utf-8")
+ assert "## FastOpenAPI Preview (OpenAPI 3.0)" 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")