fastopenapi doc

This commit is contained in:
Asuka Minato 2026-05-08 20:32:07 +09:00
parent 9b0f4111b0
commit e3259a4ec2
4 changed files with 416 additions and 25 deletions

View File

@ -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())

View File

@ -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()

View File

@ -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)<br> |
##### [POST] /console/api/init
**Validate initialization password.**
###### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)<br> |
##### [GET] /console/api/ping
**Health check endpoint for connection testing.**
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | OK | **application/json**: [PingResponse](#pingresponse)<br> |
##### [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)<br> |
##### [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)<br> |
###### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Created | **application/json**: [SetupResponse](#setupresponse)<br> |
##### [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)<br> |
---
##### Schemas
###### ErrorSchema
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes |
###### InitStatusResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | string, <br>**Available values:** "finished", "not_started" | Initialization status<br>*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, <br>**Available values:** "finished", "not_started" | Setup step status<br>*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 |

View File

@ -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")