fastapi-docs-plus 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_docs_plus/__init__.py +9 -0
- fastapi_docs_plus/ai_fill.py +239 -0
- fastapi_docs_plus/config.py +123 -0
- fastapi_docs_plus/docs_plus.py +171 -0
- fastapi_docs_plus/i18n.py +61 -0
- fastapi_docs_plus/pre_request.py +145 -0
- fastapi_docs_plus/schema_utils.py +174 -0
- fastapi_docs_plus/static/adapter.js +197 -0
- fastapi_docs_plus/static/docs-plus.css +122 -0
- fastapi_docs_plus/static/docs-plus.js +628 -0
- fastapi_docs_plus/static/docs.html +19 -0
- fastapi_docs_plus-1.0.0.dist-info/METADATA +271 -0
- fastapi_docs_plus-1.0.0.dist-info/RECORD +16 -0
- fastapi_docs_plus-1.0.0.dist-info/WHEEL +5 -0
- fastapi_docs_plus-1.0.0.dist-info/licenses/LICENSE +21 -0
- fastapi_docs_plus-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from openai import AsyncOpenAI
|
|
11
|
+
from jsonschema import Draft202012Validator
|
|
12
|
+
|
|
13
|
+
from .config import DocsPlusConfig
|
|
14
|
+
from .i18n import Language, language_instruction, translate
|
|
15
|
+
|
|
16
|
+
_LOCATIONS = ("path", "query", "header", "cookie")
|
|
17
|
+
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$")
|
|
18
|
+
|
|
19
|
+
SYSTEM_PROMPT = """You generate realistic API request data from an OpenAPI operation specification.
|
|
20
|
+
|
|
21
|
+
Output rules:
|
|
22
|
+
1. Return one JSON object with only these keys: path, query, header, cookie, body.
|
|
23
|
+
2. path/query/header/cookie map declared parameter names to scalar values; use {} for empty locations. Never invent parameters.
|
|
24
|
+
3. body must strictly match requestBody.schema; use null when there is no requestBody.
|
|
25
|
+
4. Obey enum, const, pattern, format, minimum/maximum, minLength/maxLength, minItems, required and all other schema constraints.
|
|
26
|
+
5. Use description / examples / businessHint for business meaning, but write free-form natural-language values such as remark, comments, name, address and keyword in the selected output language, even when descriptions, examples or businessHint are Chinese or use another language.
|
|
27
|
+
6. Never translate JSON keys, enum/const values, protocol identifiers, or fixed business codes/values. Language selection must not violate schema constraints, including country-specific phone formats.
|
|
28
|
+
7. Use ISO 8601 timestamps, reasonable monetary amounts and realistic, correctly formatted IDs.
|
|
29
|
+
8. Do not use placeholders such as "string", "foo", "test", 0 or empty strings unless the schema requires them. Include optional fields when useful.
|
|
30
|
+
9. Do not output explanations, comments or Markdown fences.
|
|
31
|
+
10. When generation history is provided, make new values clearly different from each previous result while obeying the schema and the selected output language. These language rules also apply to correction retries."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AIFillError(RuntimeError):
|
|
35
|
+
"""Raised when AI parameter generation fails for a known reason.
|
|
36
|
+
|
|
37
|
+
The error carries a localizable message key so callers can present
|
|
38
|
+
user-facing messages in the appropriate language.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message_key: str, **params: Any) -> None:
|
|
42
|
+
self.message_key = message_key
|
|
43
|
+
self.params = params
|
|
44
|
+
super().__init__(self.localize())
|
|
45
|
+
|
|
46
|
+
def localize(self, language: Language = "en") -> str:
|
|
47
|
+
"""Return the error message localized to *language*."""
|
|
48
|
+
params = {
|
|
49
|
+
key: value.localize(language) if isinstance(value, AIFillError) else value
|
|
50
|
+
for key, value in self.params.items()
|
|
51
|
+
}
|
|
52
|
+
return translate(self.message_key, language, **params)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ Cache
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _OpCache:
|
|
59
|
+
"""Per-operation queue of cached generation results.
|
|
60
|
+
|
|
61
|
+
The cursor advances monotonically then wraps via modulo so that
|
|
62
|
+
evicting the oldest entry does not disturb the round-robin order.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
schema_fingerprint: str
|
|
66
|
+
items: deque[dict] = field(default_factory=deque)
|
|
67
|
+
cursor: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_CACHES: dict[tuple[Language, str], _OpCache] = {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _op_key(path: str, method: str) -> str:
|
|
74
|
+
return f"{method.upper()} {path}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fingerprint(op_spec: dict) -> str:
|
|
78
|
+
return hashlib.sha256(json.dumps(op_spec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def next_fill(path: str, method: str, language: Language = "en") -> dict | None:
|
|
82
|
+
"""Retrieve the next cached result for a given operation in round-robin order.
|
|
83
|
+
|
|
84
|
+
Returns ``None`` when the cache is empty.
|
|
85
|
+
"""
|
|
86
|
+
entry = _CACHES.get((language, _op_key(path, method)))
|
|
87
|
+
if entry is None or not entry.items:
|
|
88
|
+
return None
|
|
89
|
+
index = entry.cursor % len(entry.items)
|
|
90
|
+
entry.cursor += 1
|
|
91
|
+
return {**entry.items[index], "cacheIndex": index, "cacheCount": len(entry.items)}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cache_counts(language: Language = "en") -> dict[str, int]:
|
|
95
|
+
"""Return a mapping of ``"METHOD path"`` to cached-item count for *language*."""
|
|
96
|
+
return {
|
|
97
|
+
key: len(entry.items)
|
|
98
|
+
for (entry_language, key), entry in _CACHES.items()
|
|
99
|
+
if entry_language == language and entry.items
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ Generation
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_client(config: DocsPlusConfig):
|
|
107
|
+
if not config.llm_api_key:
|
|
108
|
+
raise AIFillError("missing_key")
|
|
109
|
+
return AsyncOpenAI(api_key=config.llm_api_key, base_url=config.llm_base_url)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalize(data: Any) -> dict:
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
raise AIFillError("non_object")
|
|
115
|
+
result: dict[str, Any] = {}
|
|
116
|
+
for key in _LOCATIONS:
|
|
117
|
+
value = data.get(key)
|
|
118
|
+
result[key] = value if isinstance(value, dict) else {}
|
|
119
|
+
result["body"] = data.get("body")
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _prune(data: dict, op_spec: dict) -> dict:
|
|
124
|
+
"""Remove parameters the model invented that are not declared in the schema."""
|
|
125
|
+
declared: dict[str, set[str]] = {location: set() for location in _LOCATIONS}
|
|
126
|
+
for param in op_spec.get("parameters", []):
|
|
127
|
+
location = param.get("in")
|
|
128
|
+
if location in declared and param.get("name"):
|
|
129
|
+
declared[location].add(param["name"])
|
|
130
|
+
for location in _LOCATIONS:
|
|
131
|
+
data[location] = {k: v for k, v in data[location].items() if k in declared[location]}
|
|
132
|
+
if not op_spec.get("requestBody"):
|
|
133
|
+
data["body"] = None
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate(data: dict, op_spec: dict) -> str | AIFillError | None:
|
|
138
|
+
body_schema = (op_spec.get("requestBody") or {}).get("schema")
|
|
139
|
+
if not body_schema:
|
|
140
|
+
return None
|
|
141
|
+
if data.get("body") is None:
|
|
142
|
+
return AIFillError("body_required") if op_spec["requestBody"].get("required") else None
|
|
143
|
+
errors = sorted(Draft202012Validator(body_schema).iter_errors(data["body"]), key=lambda e: list(e.path))
|
|
144
|
+
if not errors:
|
|
145
|
+
return None
|
|
146
|
+
return "; ".join(f"{'/'.join(map(str, e.path)) or '<root>'}: {e.message}" for e in errors[:5])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _complete(client, config: DocsPlusConfig, messages: list[dict], temperature: float) -> dict:
|
|
150
|
+
response = await client.chat.completions.create(
|
|
151
|
+
model=config.llm_model,
|
|
152
|
+
messages=messages,
|
|
153
|
+
temperature=temperature,
|
|
154
|
+
response_format={"type": "json_object"},
|
|
155
|
+
timeout=config.llm_timeout,
|
|
156
|
+
)
|
|
157
|
+
if not response.choices:
|
|
158
|
+
raise AIFillError("empty_response")
|
|
159
|
+
raw = (response.choices[0].message.content or "").strip()
|
|
160
|
+
if not raw:
|
|
161
|
+
raise AIFillError("empty_response")
|
|
162
|
+
try:
|
|
163
|
+
return _normalize(json.loads(_FENCE.sub("", raw)))
|
|
164
|
+
except json.JSONDecodeError as exc:
|
|
165
|
+
raise AIFillError("invalid_json") from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def generate_and_cache(
|
|
169
|
+
op_spec: dict, config: DocsPlusConfig, *, path: str, method: str, language: Language = "en"
|
|
170
|
+
) -> int:
|
|
171
|
+
"""Generate parameter values via LLM and cache them on validation success.
|
|
172
|
+
|
|
173
|
+
Only results that pass ``jsonschema`` validation against the
|
|
174
|
+
operation's ``requestBody`` are added to the in-memory cache.
|
|
175
|
+
Returns the current number of cached items for this operation.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
op_spec: Self-contained operation spec from
|
|
179
|
+
:func:`build_operation_spec`.
|
|
180
|
+
config: Global ``DocsPlusConfig``.
|
|
181
|
+
path: URL path for cache keying.
|
|
182
|
+
method: HTTP method for cache keying.
|
|
183
|
+
language: Output language for generated values.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Number of cached items after insertion.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
AIFillError: On configuration issues, schema size overflow,
|
|
190
|
+
empty LLM responses, or repeated validation failure.
|
|
191
|
+
"""
|
|
192
|
+
key = (language, _op_key(path, method))
|
|
193
|
+
fingerprint = _fingerprint(op_spec)
|
|
194
|
+
entry = _CACHES.get(key)
|
|
195
|
+
if entry is None or entry.schema_fingerprint != fingerprint:
|
|
196
|
+
# Schema changed — discard old cache (including history used as context)
|
|
197
|
+
entry = _OpCache(schema_fingerprint=fingerprint)
|
|
198
|
+
_CACHES[key] = entry
|
|
199
|
+
|
|
200
|
+
payload = json.dumps(op_spec, ensure_ascii=False, sort_keys=True)
|
|
201
|
+
history = list(entry.items)
|
|
202
|
+
if history:
|
|
203
|
+
compact = json.dumps(history, ensure_ascii=False, separators=(",", ":"))
|
|
204
|
+
payload += f"\n\nGeneration history (make new values clearly different):\n{compact}"
|
|
205
|
+
if len(payload) > config.max_schema_chars:
|
|
206
|
+
raise AIFillError("schema_too_large", size=len(payload), limit=config.max_schema_chars)
|
|
207
|
+
|
|
208
|
+
# More history → more diversity needed
|
|
209
|
+
temperature = min(1.0, config.llm_temperature + 0.1 * min(len(history), 5))
|
|
210
|
+
instruction = language_instruction(language)
|
|
211
|
+
messages: list[dict] = [
|
|
212
|
+
{"role": "system", "content": f"{SYSTEM_PROMPT}\n\n{instruction}"},
|
|
213
|
+
{"role": "user", "content": payload},
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
client = _build_client(config)
|
|
217
|
+
data = _prune(await _complete(client, config, messages, temperature), op_spec)
|
|
218
|
+
|
|
219
|
+
problem = _validate(data, op_spec)
|
|
220
|
+
if problem:
|
|
221
|
+
messages = [
|
|
222
|
+
*messages,
|
|
223
|
+
{"role": "assistant", "content": json.dumps(data, ensure_ascii=False)},
|
|
224
|
+
{
|
|
225
|
+
"role": "user",
|
|
226
|
+
"content": f"The previous output failed schema validation: {problem}\n"
|
|
227
|
+
f"Correct it and return the complete JSON object. {instruction}",
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
retried = _prune(await _complete(client, config, messages, temperature), op_spec)
|
|
231
|
+
second_problem = _validate(retried, op_spec)
|
|
232
|
+
if second_problem:
|
|
233
|
+
raise AIFillError("validation_failed", problem=second_problem)
|
|
234
|
+
data = retried
|
|
235
|
+
|
|
236
|
+
entry.items.append(data)
|
|
237
|
+
while len(entry.items) > max(1, config.ai_cache_max_size):
|
|
238
|
+
entry.items.popleft()
|
|
239
|
+
return len(entry.items)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _env(*names: str, default: str | None = None) -> str | None:
|
|
8
|
+
"""Read the first non-empty environment variable from *names."""
|
|
9
|
+
for name in names:
|
|
10
|
+
value = os.getenv(name)
|
|
11
|
+
if value:
|
|
12
|
+
return value
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DEFAULT_SWAGGER_UI_PARAMETERS: dict = {
|
|
17
|
+
"persistAuthorization": True,
|
|
18
|
+
"displayRequestDuration": True,
|
|
19
|
+
"docExpansion": "list",
|
|
20
|
+
"showExtensions": True,
|
|
21
|
+
"showCommonExtensions": True,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DocsPlusConfig:
|
|
27
|
+
"""Configuration for fastapi-docs-plus.
|
|
28
|
+
|
|
29
|
+
Controls Swagger UI rendering, AI parameter generation, and
|
|
30
|
+
pre-request hook behaviour.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
docs_url: Path for the documentation page (default ``"/docs"``).
|
|
34
|
+
api_prefix: Prefix for internal API endpoints and static assets
|
|
35
|
+
(default ``"/_docs"``).
|
|
36
|
+
openapi_url: URL where Swagger UI fetches the OpenAPI spec
|
|
37
|
+
(default ``"/openapi.json"``).
|
|
38
|
+
swagger_ui_version: Swagger UI version used to build CDN URLs
|
|
39
|
+
(default ``"5.17.14"``).
|
|
40
|
+
swagger_ui_js_url: Override for the Swagger UI JS bundle URL.
|
|
41
|
+
When ``None`` the CDN URL is used.
|
|
42
|
+
swagger_ui_css_url: Override for the Swagger UI CSS URL.
|
|
43
|
+
When ``None`` the CDN URL is used.
|
|
44
|
+
swagger_ui_js_integrity: Subresource Integrity hash for the JS
|
|
45
|
+
bundle. When set, an ``integrity`` attribute is emitted.
|
|
46
|
+
swagger_ui_css_integrity: Subresource Integrity hash for the
|
|
47
|
+
CSS. When set, an ``integrity`` attribute is emitted.
|
|
48
|
+
swagger_ui_parameters: Extra parameters passed to the
|
|
49
|
+
``SwaggerUIBundle`` constructor. Merged key-wise over the
|
|
50
|
+
defaults, so only the keys you provide are overridden and
|
|
51
|
+
the rest keep their default values. Defaults enable
|
|
52
|
+
``persistAuthorization``, ``displayRequestDuration``,
|
|
53
|
+
``docExpansion="list"``, ``showExtensions`` and
|
|
54
|
+
``showCommonExtensions``.
|
|
55
|
+
identities: List of identity labels shown in the top-bar
|
|
56
|
+
dropdown. An empty list hides the dropdown entirely.
|
|
57
|
+
llm_model: LLM model identifier (default ``"gpt-4o-mini"``).
|
|
58
|
+
llm_base_url: Base URL for the OpenAI-compatible API. Reads
|
|
59
|
+
``DOCS_PLUS_LLM_BASE_URL`` or ``OPENAI_BASE_URL``.
|
|
60
|
+
llm_api_key: API key for the LLM service. Reads
|
|
61
|
+
``DOCS_PLUS_LLM_API_KEY`` or ``OPENAI_API_KEY``.
|
|
62
|
+
llm_temperature: Temperature for LLM calls. Automatically
|
|
63
|
+
increased by ``0.1`` per cached history entry, capped at
|
|
64
|
+
``1.0`` (default ``0.3``).
|
|
65
|
+
llm_timeout: Timeout in seconds for each LLM call
|
|
66
|
+
(default ``60.0``).
|
|
67
|
+
max_schema_depth: Maximum depth for inlining ``$ref`` schemas
|
|
68
|
+
(default ``4``).
|
|
69
|
+
max_schema_chars: Maximum total characters for the schema plus
|
|
70
|
+
cached history. Beyond this the request is rejected without
|
|
71
|
+
calling the LLM (default ``60_000``).
|
|
72
|
+
ai_cache_max_size: Maximum number of validated AI-generated
|
|
73
|
+
results kept per operation in the in-memory cache. Oldest
|
|
74
|
+
entries are evicted first (default ``5``).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
docs_url: str = "/docs"
|
|
78
|
+
api_prefix: str = "/_docs"
|
|
79
|
+
openapi_url: str = "/openapi.json"
|
|
80
|
+
|
|
81
|
+
swagger_ui_version: str = "5.17.14"
|
|
82
|
+
swagger_ui_js_url: str | None = None
|
|
83
|
+
swagger_ui_css_url: str | None = None
|
|
84
|
+
swagger_ui_js_integrity: str | None = None
|
|
85
|
+
swagger_ui_css_integrity: str | None = None
|
|
86
|
+
swagger_ui_parameters: dict = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
identities: list[str] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
llm_model: str = field(default_factory=lambda: _env("DOCS_PLUS_LLM_MODEL", default="gpt-4o-mini"))
|
|
91
|
+
llm_base_url: str | None = field(default_factory=lambda: _env("DOCS_PLUS_LLM_BASE_URL", "OPENAI_BASE_URL"))
|
|
92
|
+
llm_api_key: str | None = field(default_factory=lambda: _env("DOCS_PLUS_LLM_API_KEY", "OPENAI_API_KEY"))
|
|
93
|
+
llm_temperature: float = 0.3
|
|
94
|
+
llm_timeout: float = 60.0
|
|
95
|
+
|
|
96
|
+
max_schema_depth: int = 4
|
|
97
|
+
max_schema_chars: int = 60_000
|
|
98
|
+
|
|
99
|
+
ai_cache_max_size: int = 5
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
self.swagger_ui_parameters = {
|
|
103
|
+
**_DEFAULT_SWAGGER_UI_PARAMETERS,
|
|
104
|
+
**(self.swagger_ui_parameters or {}),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def ai_enabled(self) -> bool:
|
|
109
|
+
return bool(self.llm_api_key)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def static_url(self) -> str:
|
|
113
|
+
return f"{self.api_prefix}/static"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def resolved_js_url(self) -> str:
|
|
117
|
+
base = f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{self.swagger_ui_version}"
|
|
118
|
+
return self.swagger_ui_js_url or f"{base}/swagger-ui-bundle.js"
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def resolved_css_url(self) -> str:
|
|
122
|
+
base = f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{self.swagger_ui_version}"
|
|
123
|
+
return self.swagger_ui_css_url or f"{base}/swagger-ui.css"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from html import escape
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException
|
|
9
|
+
from fastapi.responses import HTMLResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
from openai import OpenAIError
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from .ai_fill import AIFillError, cache_counts, generate_and_cache, next_fill
|
|
15
|
+
from .config import DocsPlusConfig
|
|
16
|
+
from .i18n import Language, translate
|
|
17
|
+
from .pre_request import PreRequestHook, build_context, run_hooks
|
|
18
|
+
from .schema_utils import build_operation_spec
|
|
19
|
+
|
|
20
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _asset_tag(kind: str, url: str, integrity: str | None) -> str:
|
|
24
|
+
attrs = f'integrity="{escape(integrity, quote=True)}" crossorigin="anonymous" ' if integrity else ""
|
|
25
|
+
if kind == "css":
|
|
26
|
+
return f'<link rel="stylesheet" {attrs}href="{escape(url, quote=True)}" />'
|
|
27
|
+
return f'<script {attrs}src="{escape(url, quote=True)}"></script>'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _OpTarget(BaseModel):
|
|
31
|
+
path: str
|
|
32
|
+
method: str
|
|
33
|
+
language: Language = "en"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _PreRequestPayload(BaseModel):
|
|
37
|
+
method: str
|
|
38
|
+
url: str
|
|
39
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
40
|
+
env: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DocsPlus:
|
|
44
|
+
"""Enhanced interactive API docs with AI parameter filling and Python-side pre-request hooks.
|
|
45
|
+
|
|
46
|
+
Usage::
|
|
47
|
+
|
|
48
|
+
docs = DocsPlus(app, DocsPlusConfig(...))
|
|
49
|
+
|
|
50
|
+
@docs.pre_request
|
|
51
|
+
async def inject_auth(ctx: PreRequestContext) -> None:
|
|
52
|
+
ctx.headers["Authorization"] = f"Bearer {token}"
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, app: FastAPI, config: DocsPlusConfig | None = None) -> None:
|
|
56
|
+
self.app = app
|
|
57
|
+
self.config = config or DocsPlusConfig()
|
|
58
|
+
self._hooks: list[PreRequestHook] = []
|
|
59
|
+
self._register_routes()
|
|
60
|
+
|
|
61
|
+
def pre_request(self, hook: PreRequestHook) -> PreRequestHook:
|
|
62
|
+
"""Register a pre-request hook.
|
|
63
|
+
|
|
64
|
+
Multiple hooks execute in registration order and share the same
|
|
65
|
+
:class:`PreRequestContext`.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
hook: A synchronous or asynchronous callable that receives a
|
|
69
|
+
:class:`PreRequestContext`.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The same callable (allows use as a decorator).
|
|
73
|
+
"""
|
|
74
|
+
self._hooks.append(hook)
|
|
75
|
+
return hook
|
|
76
|
+
|
|
77
|
+
def _register_routes(self) -> None:
|
|
78
|
+
app, config = self.app, self.config
|
|
79
|
+
app.mount(config.static_url, StaticFiles(directory=STATIC_DIR), name="docs_plus_static")
|
|
80
|
+
|
|
81
|
+
@app.get(config.docs_url, include_in_schema=False)
|
|
82
|
+
async def docs_page() -> HTMLResponse:
|
|
83
|
+
return HTMLResponse(self._render_page())
|
|
84
|
+
|
|
85
|
+
@app.post(f"{config.api_prefix}/api/ai/generate", include_in_schema=False)
|
|
86
|
+
async def ai_generate(payload: _OpTarget) -> dict:
|
|
87
|
+
try:
|
|
88
|
+
op_spec = build_operation_spec(
|
|
89
|
+
app.openapi(), payload.path, payload.method, max_depth=config.max_schema_depth
|
|
90
|
+
)
|
|
91
|
+
except KeyError as exc:
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=404,
|
|
94
|
+
detail=translate(
|
|
95
|
+
"operation_not_found", payload.language,
|
|
96
|
+
method=payload.method.upper(), path=payload.path,
|
|
97
|
+
),
|
|
98
|
+
) from exc
|
|
99
|
+
try:
|
|
100
|
+
count = await generate_and_cache(
|
|
101
|
+
op_spec, config, path=payload.path, method=payload.method,
|
|
102
|
+
language=payload.language,
|
|
103
|
+
)
|
|
104
|
+
except AIFillError as exc:
|
|
105
|
+
raise HTTPException(status_code=503, detail=exc.localize(payload.language)) from exc
|
|
106
|
+
except OpenAIError as exc:
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=503, detail=translate("service_error", payload.language)
|
|
109
|
+
) from exc
|
|
110
|
+
return {"count": count}
|
|
111
|
+
|
|
112
|
+
@app.post(f"{config.api_prefix}/api/ai/fill", include_in_schema=False)
|
|
113
|
+
async def ai_fill(payload: _OpTarget) -> dict:
|
|
114
|
+
result = next_fill(payload.path, payload.method, payload.language)
|
|
115
|
+
if result is None:
|
|
116
|
+
raise HTTPException(status_code=409, detail=translate("empty_cache", payload.language))
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
@app.get(f"{config.api_prefix}/api/ai/cache", include_in_schema=False)
|
|
120
|
+
async def ai_cache(language: Language = "en") -> dict:
|
|
121
|
+
return {"counts": cache_counts(language)}
|
|
122
|
+
|
|
123
|
+
@app.post(f"{config.api_prefix}/api/pre-request", include_in_schema=False)
|
|
124
|
+
async def pre_request(payload: _PreRequestPayload) -> dict:
|
|
125
|
+
ctx = build_context(
|
|
126
|
+
app,
|
|
127
|
+
method=payload.method,
|
|
128
|
+
url=payload.url,
|
|
129
|
+
headers=payload.headers,
|
|
130
|
+
env=payload.env,
|
|
131
|
+
)
|
|
132
|
+
await run_hooks(self._hooks, ctx)
|
|
133
|
+
return {
|
|
134
|
+
"headers": {k: str(v) for k, v in ctx.headers.items() if v is not None},
|
|
135
|
+
"query": {k: str(v) for k, v in ctx.query.items() if v is not None},
|
|
136
|
+
"operationId": ctx.operation_id,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def _render_page(self) -> str:
|
|
140
|
+
config = self.config
|
|
141
|
+
front_config = {
|
|
142
|
+
"openapiUrl": config.openapi_url,
|
|
143
|
+
"apiPrefix": config.api_prefix,
|
|
144
|
+
"identities": config.identities,
|
|
145
|
+
"aiEnabled": config.ai_enabled,
|
|
146
|
+
"hasPreRequestHook": bool(self._hooks),
|
|
147
|
+
"swaggerUiParameters": config.swagger_ui_parameters,
|
|
148
|
+
}
|
|
149
|
+
template = (STATIC_DIR / "docs.html").read_text(encoding="utf-8")
|
|
150
|
+
return (
|
|
151
|
+
template.replace("__STATIC_URL__", escape(config.static_url, quote=True))
|
|
152
|
+
.replace("__SWAGGER_CSS_TAG__", _asset_tag("css", config.resolved_css_url, config.swagger_ui_css_integrity))
|
|
153
|
+
.replace("__SWAGGER_JS_TAG__", _asset_tag("js", config.resolved_js_url, config.swagger_ui_js_integrity))
|
|
154
|
+
.replace("__CONFIG__", json.dumps(front_config, ensure_ascii=False).replace("</", "<\\/"))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def setup_docs_plus(app: FastAPI, config: DocsPlusConfig | None = None) -> DocsPlus:
|
|
159
|
+
"""Create and register a :class:`DocsPlus` instance on the given FastAPI application.
|
|
160
|
+
|
|
161
|
+
This is a convenience wrapper around ``DocsPlus(app, config)``.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
app: The FastAPI application instance.
|
|
165
|
+
config: Optional configuration. Falls back to defaults.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The :class:`DocsPlus` instance (use it to register hooks via
|
|
169
|
+
:meth:`DocsPlus.pre_request`).
|
|
170
|
+
"""
|
|
171
|
+
return DocsPlus(app, config)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
Language = Literal["en", "zh"]
|
|
6
|
+
|
|
7
|
+
_MESSAGES: dict[Language, dict[str, str]] = {
|
|
8
|
+
"en": {
|
|
9
|
+
"missing_key": "LLM credentials are not configured. Set DOCS_PLUS_LLM_API_KEY or OPENAI_API_KEY.",
|
|
10
|
+
"empty_response": "The model returned empty content.",
|
|
11
|
+
"invalid_json": "The model returned invalid JSON.",
|
|
12
|
+
"non_object": "The model did not return a JSON object.",
|
|
13
|
+
"schema_too_large": "The operation schema and history are too large ({size} characters); the limit is {limit}.",
|
|
14
|
+
"validation_failed": "Both attempts failed schema validation; nothing was cached: {problem}",
|
|
15
|
+
"body_required": "requestBody is required, but body is null.",
|
|
16
|
+
"empty_cache": "No AI-generated data is cached for this operation. Click AI Generate first.",
|
|
17
|
+
"operation_not_found": "Operation not found in OpenAPI: {method} {path}",
|
|
18
|
+
"service_error": "The AI service is unavailable. Please check the service configuration or try again later.",
|
|
19
|
+
},
|
|
20
|
+
"zh": {
|
|
21
|
+
"missing_key": "未配置 LLM 凭据,请设置环境变量 DOCS_PLUS_LLM_API_KEY 或 OPENAI_API_KEY。",
|
|
22
|
+
"empty_response": "模型返回了空内容。",
|
|
23
|
+
"invalid_json": "模型返回的内容不是合法 JSON。",
|
|
24
|
+
"non_object": "模型返回的不是 JSON 对象。",
|
|
25
|
+
"schema_too_large": "该接口的 schema 和历史过大({size} 字符),已超出上限 {limit}。",
|
|
26
|
+
"validation_failed": "两次生成均未通过 schema 校验,未缓存:{problem}",
|
|
27
|
+
"body_required": "requestBody 是必填的,但 body 为 null。",
|
|
28
|
+
"empty_cache": "该接口暂无 AI 生成缓存,请先点击「AI 生成」。",
|
|
29
|
+
"operation_not_found": "OpenAPI 中不存在操作:{method} {path}",
|
|
30
|
+
"service_error": "AI 服务暂不可用,请检查服务配置或稍后重试。",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def translate(message_key: str, language: Language = "en", **params: Any) -> str:
|
|
36
|
+
"""Look up a localized message and format it with the given parameters.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
message_key: Key into the message dictionary.
|
|
40
|
+
language: Target language (``"en"`` or ``"zh"``).
|
|
41
|
+
**params: Format-string parameters injected via ``str.format``.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The formatted localized string.
|
|
45
|
+
"""
|
|
46
|
+
return _MESSAGES[language][message_key].format(**params)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def language_instruction(language: Language = "en") -> str:
|
|
50
|
+
"""Return a system-level language instruction for the LLM prompt.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
language: Target language (``"en"`` or ``"zh"``).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A short instruction string such as ``"Output language: English."``.
|
|
57
|
+
"""
|
|
58
|
+
return {
|
|
59
|
+
"en": "Output language: English.",
|
|
60
|
+
"zh": "Output language: Simplified Chinese.",
|
|
61
|
+
}[language]
|