apiforge-analyzer 1.3.7 → 1.3.8
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.
- package/README.md +66 -2
- package/dist/cjs/cli.js +762 -0
- package/dist/cjs/detect.js +48 -0
- package/dist/cjs/frameworks/python-introspect.js +555 -0
- package/dist/cjs/frameworks/python.js +209 -0
- package/dist/cjs/index.js +16 -1
- package/dist/esm/cli.js +764 -2
- package/dist/esm/detect.js +47 -1
- package/dist/esm/frameworks/python-introspect.js +552 -0
- package/dist/esm/frameworks/python.js +171 -0
- package/dist/esm/index.js +15 -1
- package/dist/types/detect.d.ts +12 -1
- package/dist/types/frameworks/python-introspect.d.ts +18 -0
- package/dist/types/frameworks/python.d.ts +52 -0
- package/dist/types/index.d.ts +11 -1
- package/package.json +1 -1
package/dist/esm/detect.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
/**
|
|
4
4
|
* Frameworks analyze() can actually dispatch to. Detection can identify more
|
|
@@ -13,6 +13,9 @@ export const SUPPORTED_FRAMEWORKS = new Set([
|
|
|
13
13
|
'nextjs-app',
|
|
14
14
|
'vite',
|
|
15
15
|
'react-router',
|
|
16
|
+
'fastapi',
|
|
17
|
+
'flask',
|
|
18
|
+
'django',
|
|
16
19
|
]);
|
|
17
20
|
const cwd = () => process.cwd();
|
|
18
21
|
function fileExists(rel) {
|
|
@@ -98,5 +101,48 @@ export function detectFramework(app) {
|
|
|
98
101
|
return mk('vite', 0.60);
|
|
99
102
|
if (tryResolve('react-router-dom'))
|
|
100
103
|
return mk('react-router', 0.55);
|
|
104
|
+
// ── Python projects (checked last so no JS project's detection changes) ───
|
|
105
|
+
const python = detectPythonFramework();
|
|
106
|
+
if (python)
|
|
107
|
+
return mk(python, 0.65);
|
|
101
108
|
return mk('unknown', 0);
|
|
102
109
|
}
|
|
110
|
+
const PYTHON_DEPENDENCY_FILES = ['requirements.txt', 'requirements/base.txt', 'pyproject.toml', 'Pipfile', 'setup.py', 'setup.cfg'];
|
|
111
|
+
/**
|
|
112
|
+
* Detect a Python web framework from manage.py / dependency files.
|
|
113
|
+
* Python apps are never passed to analyze() as an object, so this is file-based only.
|
|
114
|
+
*/
|
|
115
|
+
export function detectPythonFramework(dir = cwd()) {
|
|
116
|
+
if (existsSync(join(dir, 'manage.py')))
|
|
117
|
+
return 'django';
|
|
118
|
+
const deps = PYTHON_DEPENDENCY_FILES
|
|
119
|
+
.map(file => {
|
|
120
|
+
try {
|
|
121
|
+
return readTextFile(join(dir, file)).toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.join('\n');
|
|
128
|
+
if (/\bfastapi\b/.test(deps))
|
|
129
|
+
return 'fastapi';
|
|
130
|
+
if (/\bdjango\b/.test(deps))
|
|
131
|
+
return 'django';
|
|
132
|
+
if (/\bflask\b/.test(deps))
|
|
133
|
+
return 'flask';
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read a text file honouring a UTF-16 BOM. Windows PowerShell 5.1's
|
|
138
|
+
* `pip freeze > requirements.txt` writes UTF-16 LE, which read as UTF-8
|
|
139
|
+
* interleaves NUL bytes and hides every package name.
|
|
140
|
+
*/
|
|
141
|
+
export function readTextFile(path) {
|
|
142
|
+
const buf = readFileSync(path);
|
|
143
|
+
if (buf[0] === 0xff && buf[1] === 0xfe)
|
|
144
|
+
return buf.subarray(2).toString('utf16le');
|
|
145
|
+
if (buf[0] === 0xfe && buf[1] === 0xff)
|
|
146
|
+
return Buffer.from(buf.subarray(2)).swap16().toString('utf16le');
|
|
147
|
+
return buf.toString('utf-8');
|
|
148
|
+
}
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python side of the FastAPI / Flask / Django analyzers (see python.ts).
|
|
3
|
+
*
|
|
4
|
+
* Reads the framework's live route table — the Python equivalent of walking
|
|
5
|
+
* app._router.stack in express.ts. Runs in one of two modes:
|
|
6
|
+
* - analyze(): piped to the user's interpreter on stdin; imports the app and
|
|
7
|
+
* prints raw Route objects as one JSON line prefixed with ROUTES_MARKER.
|
|
8
|
+
* - CLI: exec'd by the snippet the CLI injects into the user's entry file
|
|
9
|
+
* (APIFORGE_APP set), inside their own running server; writes the routes
|
|
10
|
+
* to APIFORGE_STATUS_FILE, like the Node snippet's collection mode.
|
|
11
|
+
* Enrichment and upload stay in TS either way.
|
|
12
|
+
*
|
|
13
|
+
* Kept as an embedded string (not a .py file) so the existing tsc-only build
|
|
14
|
+
* ships it without any copy step. String.raw keeps regex backslashes intact —
|
|
15
|
+
* the Python below must not contain backticks or a dollar-brace sequence.
|
|
16
|
+
*/
|
|
17
|
+
export const ROUTES_MARKER = '__APIFORGE_ROUTES__';
|
|
18
|
+
export const PYTHON_INTROSPECT_SCRIPT = String.raw `
|
|
19
|
+
import importlib, inspect, json, os, re, sys
|
|
20
|
+
|
|
21
|
+
MARKER = "__APIFORGE_ROUTES__"
|
|
22
|
+
FRAMEWORK_MODULES = {"flask", "werkzeug", "django", "rest_framework", "fastapi", "starlette"}
|
|
23
|
+
BODY_METHODS = {"POST", "PUT", "PATCH"}
|
|
24
|
+
HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
|
|
25
|
+
|
|
26
|
+
# Decorators that register routes or configure DRF/Django rather than guard the handler.
|
|
27
|
+
IGNORED_DECORATORS = {
|
|
28
|
+
"route", "get", "post", "put", "patch", "delete", "head", "options", "websocket",
|
|
29
|
+
"api_view", "action", "permission_classes", "authentication_classes", "throttle_classes",
|
|
30
|
+
"parser_classes", "renderer_classes", "schema", "require_http_methods", "require_GET",
|
|
31
|
+
"require_POST", "require_safe", "staticmethod", "classmethod", "property", "wraps",
|
|
32
|
+
"method_decorator",
|
|
33
|
+
}
|
|
34
|
+
AUTH_HINTS = (
|
|
35
|
+
"auth", "login_required", "permission_required", "jwt_required", "token_required",
|
|
36
|
+
"staff_member_required", "user_passes_test", "verifytoken", "protect",
|
|
37
|
+
)
|
|
38
|
+
STATUS_TEXT = {
|
|
39
|
+
400: "Invalid request input", 401: "Authentication required or invalid credentials",
|
|
40
|
+
403: "Forbidden — insufficient permissions", 404: "Resource not found",
|
|
41
|
+
409: "Conflict — resource already exists", 422: "Unprocessable entity",
|
|
42
|
+
429: "Too many requests — rate limit exceeded", 500: "Unexpected server error",
|
|
43
|
+
503: "Service unavailable",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# ── Handler source scanning (Flask/Django declare none of this) ─────────────
|
|
47
|
+
NAME = r"""['"]([\w\-.]+)['"]"""
|
|
48
|
+
ACCESS = r"(?:\.get(?:list)?\(|\[)\s*"
|
|
49
|
+
BODY_SOURCE = r"request\.(?:json|form|data|POST|get_json\([^)]*\))"
|
|
50
|
+
QUERY_RE = re.compile(r"request\.(?:args|GET|query_params)" + ACCESS + NAME)
|
|
51
|
+
BODY_RE = re.compile(BODY_SOURCE + ACCESS + NAME)
|
|
52
|
+
BODY_VAR_RE = re.compile(r"^\s*(\w+)\s*(?::[^=\n]+)?=\s*(?:" + BODY_SOURCE + r"|json\.loads\(\s*request\.body)", re.M)
|
|
53
|
+
HEADER_RE = re.compile(r"request\.headers" + ACCESS + NAME)
|
|
54
|
+
META_HEADER_RE = re.compile(r"""request\.META(?:\.get\(|\[)\s*['"]HTTP_(\w+)['"]""")
|
|
55
|
+
DECORATOR_RE = re.compile(r"^\s*@([\w.]+)", re.M)
|
|
56
|
+
STATUS_RES = [
|
|
57
|
+
re.compile(r"\babort\(\s*(\d{3})"),
|
|
58
|
+
re.compile(r"\bstatus(?:_code)?\s*=\s*(\d{3})\b"),
|
|
59
|
+
re.compile(r"\bHTTP_(\d{3})_"),
|
|
60
|
+
re.compile(r"^\s*return\b[^\n]*,\s*(\d{3})\s*(?:#[^\n]*)?$", re.M),
|
|
61
|
+
]
|
|
62
|
+
STATUS_NAMES = {
|
|
63
|
+
"get_object_or_404": 404, "Http404": 404, "HttpResponseNotFound": 404,
|
|
64
|
+
"HttpResponseBadRequest": 400, "HttpResponseForbidden": 403,
|
|
65
|
+
"HttpResponseNotAllowed": 405, "HttpResponseServerError": 500,
|
|
66
|
+
}
|
|
67
|
+
DECLARED_METHODS_RE = re.compile(r"@require_http_methods\(\s*\[([^\]]*)\]")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unique(items):
|
|
71
|
+
seen = []
|
|
72
|
+
for item in items:
|
|
73
|
+
if item and item not in seen:
|
|
74
|
+
seen.append(item)
|
|
75
|
+
return seen
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_user_code(fn):
|
|
79
|
+
module = getattr(fn, "__module__", None) or ""
|
|
80
|
+
return bool(module) and module.split(".")[0] not in FRAMEWORK_MODULES
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def unwrap(fn):
|
|
84
|
+
try:
|
|
85
|
+
return inspect.unwrap(fn)
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
return fn
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_source(fn):
|
|
91
|
+
fn = unwrap(fn)
|
|
92
|
+
if fn is None or not is_user_code(fn):
|
|
93
|
+
return ""
|
|
94
|
+
try:
|
|
95
|
+
return inspect.getsource(fn)
|
|
96
|
+
except (OSError, TypeError):
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def docstring(fn):
|
|
101
|
+
fn = unwrap(fn)
|
|
102
|
+
if fn is None or not is_user_code(fn):
|
|
103
|
+
return "", ""
|
|
104
|
+
first, _, rest = (inspect.getdoc(fn) or "").strip().partition("\n")
|
|
105
|
+
return first.strip(), rest.strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def declared_methods(source):
|
|
109
|
+
match = DECLARED_METHODS_RE.search(source)
|
|
110
|
+
if match:
|
|
111
|
+
return unique(m.upper() for m in re.findall(r"""['"](\w+)['"]""", match.group(1)))
|
|
112
|
+
if "@require_POST" in source:
|
|
113
|
+
return ["POST"]
|
|
114
|
+
if "@require_GET" in source or "@require_safe" in source:
|
|
115
|
+
return ["GET"]
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan_source(source):
|
|
120
|
+
body = list(BODY_RE.findall(source))
|
|
121
|
+
for var in sorted(set(BODY_VAR_RE.findall(source))):
|
|
122
|
+
body += re.findall(r"\b" + re.escape(var) + ACCESS + NAME, source)
|
|
123
|
+
headers = list(HEADER_RE.findall(source))
|
|
124
|
+
headers += ["-".join(p.capitalize() for p in raw.split("_")) for raw in META_HEADER_RE.findall(source)]
|
|
125
|
+
statuses = [int(code) for pattern in STATUS_RES for code in pattern.findall(source)]
|
|
126
|
+
statuses += [code for name, code in STATUS_NAMES.items() if re.search(r"\b" + name + r"\b", source)]
|
|
127
|
+
header = source.split("def ", 1)[0]
|
|
128
|
+
decorators = [n.rsplit(".", 1)[-1] for n in DECORATOR_RE.findall(header)]
|
|
129
|
+
return {
|
|
130
|
+
"query": unique(QUERY_RE.findall(source)),
|
|
131
|
+
"body": unique(body),
|
|
132
|
+
"headers": unique(headers),
|
|
133
|
+
"statuses": sorted({s for s in statuses if 100 <= s < 600}),
|
|
134
|
+
"decorators": unique(n for n in decorators if n not in IGNORED_DECORATORS),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def looks_like_auth(names):
|
|
139
|
+
return any(hint in name.lower() for name in names for hint in AUTH_HINTS)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def path_parameter(name, type_="string", fmt=None):
|
|
143
|
+
schema = {"type": type_}
|
|
144
|
+
if fmt:
|
|
145
|
+
schema["format"] = fmt
|
|
146
|
+
return {"name": name, "in": "path", "required": True, "type": type_, "schema": schema,
|
|
147
|
+
"description": "Required path parameter: " + name + "."}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_route(framework, method, path, handler, path_params, operation_id,
|
|
151
|
+
middleware=(), auth=False, request_body=None):
|
|
152
|
+
"""Route for frameworks that don't describe their own params (Flask, Django)."""
|
|
153
|
+
scan = scan_source(get_source(handler))
|
|
154
|
+
summary, description = docstring(handler)
|
|
155
|
+
names = unique(list(middleware) + scan["decorators"])
|
|
156
|
+
auth_required = bool(auth or looks_like_auth(names))
|
|
157
|
+
takes_body = method in BODY_METHODS
|
|
158
|
+
body_fields = scan["body"] if takes_body else []
|
|
159
|
+
if takes_body and request_body:
|
|
160
|
+
schema = request_body["content"]["application/json"]["schema"]
|
|
161
|
+
body_fields = unique(list(schema.get("properties", {})) + body_fields)
|
|
162
|
+
route = {
|
|
163
|
+
"method": method, "path": path, "routeKey": method + " " + path, "tag": "",
|
|
164
|
+
"operationId": operation_id, "summary": summary, "description": description,
|
|
165
|
+
"middleware": names, "authRequired": auth_required, "hasAuth": auth_required,
|
|
166
|
+
"pathParams": [p["name"] for p in path_params], "queryParams": scan["query"],
|
|
167
|
+
"requestBodyFields": body_fields, "headerParams": scan["headers"],
|
|
168
|
+
"responseStatuses": scan["statuses"], "parameters": list(path_params),
|
|
169
|
+
"linkedPaths": [], "framework": framework,
|
|
170
|
+
}
|
|
171
|
+
if takes_body and request_body:
|
|
172
|
+
route["requestBody"] = request_body
|
|
173
|
+
return route
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── FastAPI: the app's own OpenAPI document carries the real Pydantic schemas ─
|
|
177
|
+
FASTAPI_PARAM = re.compile(r"\{(\w+)(?::\w+)?\}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def deref(node, spec, seen=()):
|
|
181
|
+
"""Inline local $refs so the backend sees schema properties directly."""
|
|
182
|
+
if isinstance(node, list):
|
|
183
|
+
return [deref(item, spec, seen) for item in node]
|
|
184
|
+
if not isinstance(node, dict):
|
|
185
|
+
return node
|
|
186
|
+
ref = node.get("$ref")
|
|
187
|
+
if isinstance(ref, str) and ref.startswith("#/"):
|
|
188
|
+
if ref in seen:
|
|
189
|
+
return {"type": "object", "description": "Recursive reference to " + ref.rsplit("/", 1)[-1] + "."}
|
|
190
|
+
target = spec
|
|
191
|
+
for part in ref[2:].split("/"):
|
|
192
|
+
target = target.get(part, {}) if isinstance(target, dict) else {}
|
|
193
|
+
merged = dict(target)
|
|
194
|
+
merged.update({k: v for k, v in node.items() if k != "$ref"})
|
|
195
|
+
return deref(merged, spec, seen + (ref,))
|
|
196
|
+
return {key: deref(value, spec, seen) for key, value in node.items()}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def schema_type(schema):
|
|
200
|
+
if schema.get("type"):
|
|
201
|
+
return schema["type"]
|
|
202
|
+
for option in schema.get("anyOf") or schema.get("oneOf") or []:
|
|
203
|
+
if option.get("type") and option["type"] != "null":
|
|
204
|
+
return option["type"]
|
|
205
|
+
return "string"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def analyze_fastapi(app):
|
|
209
|
+
spec = app.openapi()
|
|
210
|
+
live_routes = {}
|
|
211
|
+
for live in getattr(app, "routes", []):
|
|
212
|
+
path_format = getattr(live, "path_format", None) or getattr(live, "path", None)
|
|
213
|
+
for method in getattr(live, "methods", None) or ():
|
|
214
|
+
live_routes[(path_format, method.upper())] = live
|
|
215
|
+
|
|
216
|
+
global_auth = bool(spec.get("security"))
|
|
217
|
+
routes = []
|
|
218
|
+
for raw_path, item in (spec.get("paths") or {}).items():
|
|
219
|
+
path = FASTAPI_PARAM.sub(r":\1", raw_path)
|
|
220
|
+
shared = deref(item.get("parameters") or [], spec)
|
|
221
|
+
for method, operation in item.items():
|
|
222
|
+
method = method.upper()
|
|
223
|
+
if method not in HTTP_METHODS or not isinstance(operation, dict):
|
|
224
|
+
continue
|
|
225
|
+
operation = deref(operation, spec)
|
|
226
|
+
live = live_routes.get((raw_path, method))
|
|
227
|
+
routes.append(fastapi_route(path, method, operation, shared, live, global_auth))
|
|
228
|
+
|
|
229
|
+
# Sub-applications attached with app.mount() have their own OpenAPI document.
|
|
230
|
+
for live in getattr(app, "routes", []):
|
|
231
|
+
sub_app = getattr(live, "app", None)
|
|
232
|
+
if type(live).__name__ == "Mount" and hasattr(sub_app, "openapi"):
|
|
233
|
+
prefix = (getattr(live, "path", "") or "").rstrip("/")
|
|
234
|
+
for route in analyze_fastapi(sub_app):
|
|
235
|
+
route["path"] = prefix + route["path"]
|
|
236
|
+
route["routeKey"] = route["method"] + " " + route["path"]
|
|
237
|
+
routes.append(route)
|
|
238
|
+
return routes
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def fastapi_route(path, method, operation, shared, live, global_auth):
|
|
242
|
+
parameters = []
|
|
243
|
+
for p in shared + (operation.get("parameters") or []):
|
|
244
|
+
schema = p.get("schema") or {}
|
|
245
|
+
location = p.get("in", "query")
|
|
246
|
+
parameters.append({
|
|
247
|
+
"name": p["name"], "in": location, "required": bool(p.get("required", location == "path")),
|
|
248
|
+
"type": schema_type(schema), "schema": schema,
|
|
249
|
+
"description": p.get("description") or location.capitalize() + " parameter: " + p["name"] + ".",
|
|
250
|
+
})
|
|
251
|
+
responses = dict(operation.get("responses") or {})
|
|
252
|
+
request_body = operation.get("requestBody")
|
|
253
|
+
|
|
254
|
+
handler = getattr(live, "endpoint", None)
|
|
255
|
+
scan = scan_source(get_source(handler))
|
|
256
|
+
# HTTPException(status_code=...) raised inside the handler never reaches OpenAPI.
|
|
257
|
+
for code in scan["statuses"]:
|
|
258
|
+
responses.setdefault(str(code), {"description": STATUS_TEXT.get(code, "HTTP " + str(code))})
|
|
259
|
+
|
|
260
|
+
middleware = []
|
|
261
|
+
for dependency in getattr(getattr(live, "dependant", None), "dependencies", None) or []:
|
|
262
|
+
call = getattr(dependency, "call", None)
|
|
263
|
+
if call is not None:
|
|
264
|
+
middleware.append(getattr(call, "__name__", None) or type(call).__name__)
|
|
265
|
+
middleware = unique(middleware)
|
|
266
|
+
auth = bool(operation["security"]) if "security" in operation else global_auth
|
|
267
|
+
auth = auth or looks_like_auth(middleware)
|
|
268
|
+
|
|
269
|
+
operation_id = operation.get("operationId", "")
|
|
270
|
+
if live is not None:
|
|
271
|
+
name = getattr(live, "name", "") or ""
|
|
272
|
+
methods = getattr(live, "methods", None) or ()
|
|
273
|
+
operation_id = getattr(live, "operation_id", None) or (name if len(methods) <= 1 else name + "_" + method.lower())
|
|
274
|
+
|
|
275
|
+
body_fields = []
|
|
276
|
+
for media in ((request_body or {}).get("content") or {}).values():
|
|
277
|
+
body_fields = list(((media or {}).get("schema") or {}).get("properties") or {})
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
route = {
|
|
281
|
+
"method": method, "path": path, "routeKey": method + " " + path,
|
|
282
|
+
"tag": (operation.get("tags") or [""])[0], "operationId": operation_id,
|
|
283
|
+
"summary": operation.get("summary", ""), "description": operation.get("description", ""),
|
|
284
|
+
"middleware": middleware, "authRequired": auth, "hasAuth": auth,
|
|
285
|
+
"pathParams": [p["name"] for p in parameters if p["in"] == "path"],
|
|
286
|
+
"queryParams": [p["name"] for p in parameters if p["in"] == "query"],
|
|
287
|
+
"headerParams": unique([p["name"] for p in parameters if p["in"] == "header"] + scan["headers"]),
|
|
288
|
+
"requestBodyFields": body_fields,
|
|
289
|
+
"responseStatuses": sorted(int(code) for code in responses if code.isdigit()),
|
|
290
|
+
"parameters": parameters, "linkedPaths": [], "framework": "fastapi",
|
|
291
|
+
}
|
|
292
|
+
if operation.get("tags"):
|
|
293
|
+
route["tags"] = operation["tags"]
|
|
294
|
+
if request_body:
|
|
295
|
+
route["requestBody"] = request_body
|
|
296
|
+
if responses:
|
|
297
|
+
route["responses"] = responses
|
|
298
|
+
return route
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Flask: app.url_map is the table Flask itself dispatches from ────────────
|
|
302
|
+
FLASK_PARAM = re.compile(r"<(?:(\w+)(?:\([^)]*\))?:)?(\w+)>")
|
|
303
|
+
FLASK_TYPES = {"int": ("integer", None), "float": ("number", None), "uuid": ("string", "uuid")}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def analyze_flask(app):
|
|
307
|
+
routes = []
|
|
308
|
+
for rule in sorted(app.url_map.iter_rules(), key=lambda r: (r.rule, r.endpoint)):
|
|
309
|
+
if rule.endpoint == "static" or rule.endpoint.endswith(".static"):
|
|
310
|
+
continue
|
|
311
|
+
params = []
|
|
312
|
+
|
|
313
|
+
def replace(match, params=params):
|
|
314
|
+
type_, fmt = FLASK_TYPES.get(match.group(1) or "string", ("string", None))
|
|
315
|
+
params.append(path_parameter(match.group(2), type_, fmt))
|
|
316
|
+
return ":" + match.group(2)
|
|
317
|
+
|
|
318
|
+
path = FLASK_PARAM.sub(replace, rule.rule)
|
|
319
|
+
view = app.view_functions.get(rule.endpoint)
|
|
320
|
+
view_class = getattr(view, "view_class", None)
|
|
321
|
+
class_decorators = [getattr(d, "__name__", "") for d in getattr(view_class, "decorators", None) or []]
|
|
322
|
+
methods = sorted(set(rule.methods or ()) - {"HEAD", "OPTIONS"})
|
|
323
|
+
name = rule.endpoint.rsplit(".", 1)[-1]
|
|
324
|
+
for method in methods:
|
|
325
|
+
# MethodView: document the per-method handler, not the generic dispatcher.
|
|
326
|
+
handler = getattr(view_class, method.lower(), None) if view_class else None
|
|
327
|
+
routes.append(build_route(
|
|
328
|
+
"flask", method, path, handler or view, params,
|
|
329
|
+
name if len(methods) == 1 else name + "_" + method.lower(),
|
|
330
|
+
middleware=class_decorators,
|
|
331
|
+
))
|
|
332
|
+
return routes
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── Django / DRF: walk the root URLconf resolver ──────────────────────────────
|
|
336
|
+
# Admin, DRF's browsable-API login and the debug toolbar aren't part of the user's API.
|
|
337
|
+
DJANGO_SKIP_NAMESPACES = {"admin", "rest_framework", "djdt"}
|
|
338
|
+
DJANGO_PARAM = re.compile(r"<(?:(\w+):)?(\w+)>")
|
|
339
|
+
DJANGO_TYPES = {"int": ("integer", None), "uuid": ("string", "uuid")}
|
|
340
|
+
AUTH_MIXINS = {"LoginRequiredMixin", "PermissionRequiredMixin", "UserPassesTestMixin"}
|
|
341
|
+
SERIALIZER_TYPES = {
|
|
342
|
+
"BooleanField": "boolean", "IntegerField": "integer", "FloatField": "number",
|
|
343
|
+
"DecimalField": "number", "ListField": "array", "ManyRelatedField": "array",
|
|
344
|
+
"ListSerializer": "array", "DictField": "object", "JSONField": "object", "Serializer": "object",
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def analyze_django():
|
|
349
|
+
import django
|
|
350
|
+
from django.apps import apps
|
|
351
|
+
if not apps.ready:
|
|
352
|
+
django.setup()
|
|
353
|
+
from django.urls import get_resolver
|
|
354
|
+
|
|
355
|
+
routes = []
|
|
356
|
+
for path, params, pattern in walk_urls(get_resolver().url_patterns, "", []):
|
|
357
|
+
routes.extend(django_routes(path, params, pattern))
|
|
358
|
+
return routes
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def walk_urls(patterns, prefix, params):
|
|
362
|
+
from django.urls import URLPattern, URLResolver
|
|
363
|
+
for pattern in patterns:
|
|
364
|
+
piece, piece_params = convert_django_pattern(pattern.pattern)
|
|
365
|
+
if piece is None:
|
|
366
|
+
continue
|
|
367
|
+
if isinstance(pattern, URLResolver):
|
|
368
|
+
if {pattern.namespace, pattern.app_name} & DJANGO_SKIP_NAMESPACES:
|
|
369
|
+
continue
|
|
370
|
+
yield from walk_urls(pattern.url_patterns, prefix + piece, params + piece_params)
|
|
371
|
+
elif isinstance(pattern, URLPattern):
|
|
372
|
+
yield "/" + re.sub(r"/{2,}", "/", (prefix + piece).lstrip("/")), params + piece_params, pattern
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def convert_django_pattern(pattern):
|
|
376
|
+
from django.urls.resolvers import RegexPattern, RoutePattern
|
|
377
|
+
raw = str(pattern)
|
|
378
|
+
if isinstance(pattern, RoutePattern):
|
|
379
|
+
params = []
|
|
380
|
+
|
|
381
|
+
def replace(match):
|
|
382
|
+
type_, fmt = DJANGO_TYPES.get(match.group(1) or "str", ("string", None))
|
|
383
|
+
params.append(path_parameter(match.group(2), type_, fmt))
|
|
384
|
+
return ":" + match.group(2)
|
|
385
|
+
|
|
386
|
+
return DJANGO_PARAM.sub(replace, raw), params
|
|
387
|
+
if isinstance(pattern, RegexPattern):
|
|
388
|
+
# DRF's format_suffix_patterns duplicates every route as '.json' etc.
|
|
389
|
+
if "(?P<format>" in raw:
|
|
390
|
+
return None, []
|
|
391
|
+
return regex_to_path(raw)
|
|
392
|
+
return raw, [] # LocalePrefixPattern: the language prefix
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def regex_to_path(regex):
|
|
396
|
+
"""'^users/(?P<pk>[^/.]+)/$' -> ('users/:pk/', [pk])."""
|
|
397
|
+
regex = re.sub(r"^\^|(?:\$|\\Z)$", "", regex)
|
|
398
|
+
out, params, i = [], [], 0
|
|
399
|
+
while i < len(regex):
|
|
400
|
+
if regex.startswith("(?P<", i):
|
|
401
|
+
name_end = regex.index(">", i)
|
|
402
|
+
name = regex[i + 4:name_end]
|
|
403
|
+
depth, j = 1, name_end + 1
|
|
404
|
+
while j < len(regex) and depth:
|
|
405
|
+
if regex[j] == "\\":
|
|
406
|
+
j += 2
|
|
407
|
+
continue
|
|
408
|
+
depth += {"(": 1, ")": -1}.get(regex[j], 0)
|
|
409
|
+
j += 1
|
|
410
|
+
out.append(":" + name)
|
|
411
|
+
params.append(path_parameter(name))
|
|
412
|
+
i = j
|
|
413
|
+
else:
|
|
414
|
+
out.append(regex[i])
|
|
415
|
+
i += 1
|
|
416
|
+
return re.sub(r"\\(.)", r"\1", "".join(out).replace("/?", "/")), params
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def django_routes(path, params, pattern):
|
|
420
|
+
callback = pattern.callback
|
|
421
|
+
view_class = getattr(callback, "view_class", None) or getattr(callback, "cls", None)
|
|
422
|
+
actions = getattr(callback, "actions", None) # DRF ViewSet: {'get': 'list', ...}
|
|
423
|
+
base_name = pattern.name or getattr(callback, "__name__", "view")
|
|
424
|
+
|
|
425
|
+
if actions and view_class:
|
|
426
|
+
handlers = {m.upper(): (getattr(view_class, a, None), base_name + "_" + a) for m, a in actions.items()}
|
|
427
|
+
elif view_class:
|
|
428
|
+
methods = [m for m in view_class.http_method_names if m not in ("head", "options") and hasattr(view_class, m)]
|
|
429
|
+
handlers = {m.upper(): (getattr(view_class, m), base_name if len(methods) == 1 else base_name + "_" + m)
|
|
430
|
+
for m in methods}
|
|
431
|
+
else:
|
|
432
|
+
methods = declared_methods(get_source(callback)) or ["GET"]
|
|
433
|
+
handlers = {m: (callback, base_name if len(methods) == 1 else base_name + "_" + m.lower()) for m in methods}
|
|
434
|
+
|
|
435
|
+
middleware, auth = [], False
|
|
436
|
+
if view_class is not None:
|
|
437
|
+
for permission in getattr(view_class, "permission_classes", None) or []:
|
|
438
|
+
pname = getattr(permission, "__name__", None) or type(permission).__name__
|
|
439
|
+
if pname != "AllowAny":
|
|
440
|
+
middleware.append(pname)
|
|
441
|
+
middleware += [b.__name__ for b in view_class.__mro__ if b.__name__ in AUTH_MIXINS]
|
|
442
|
+
auth = bool(middleware)
|
|
443
|
+
request_body = serializer_body(view_class)
|
|
444
|
+
|
|
445
|
+
return [
|
|
446
|
+
build_route("django", method, path, user_function(handler), params, operation_id,
|
|
447
|
+
middleware=middleware, auth=auth, request_body=request_body)
|
|
448
|
+
for method, (handler, operation_id) in sorted(handlers.items())
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def serializer_body(view_class):
|
|
453
|
+
serializer_class = getattr(view_class, "serializer_class", None)
|
|
454
|
+
if serializer_class is None:
|
|
455
|
+
return None
|
|
456
|
+
try:
|
|
457
|
+
fields = serializer_class().fields
|
|
458
|
+
except Exception: # serializers that need context/instance to build their fields
|
|
459
|
+
return None
|
|
460
|
+
properties, required = {}, []
|
|
461
|
+
for name, field in fields.items():
|
|
462
|
+
if getattr(field, "read_only", False):
|
|
463
|
+
continue
|
|
464
|
+
type_ = next((SERIALIZER_TYPES[b.__name__] for b in type(field).__mro__ if b.__name__ in SERIALIZER_TYPES), "string")
|
|
465
|
+
prop = {"type": type_}
|
|
466
|
+
if getattr(field, "help_text", None):
|
|
467
|
+
prop["description"] = str(field.help_text)
|
|
468
|
+
properties[name] = prop
|
|
469
|
+
if getattr(field, "required", False):
|
|
470
|
+
required.append(name)
|
|
471
|
+
if not properties:
|
|
472
|
+
return None
|
|
473
|
+
schema = {"type": "object", "properties": properties}
|
|
474
|
+
if required:
|
|
475
|
+
schema["required"] = required
|
|
476
|
+
return {"required": True, "content": {"application/json": {"schema": schema}}}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def user_function(fn):
|
|
480
|
+
"""DRF's @api_view wraps the user's function in a generated handler; find the original."""
|
|
481
|
+
if fn is None or is_user_code(unwrap(fn)):
|
|
482
|
+
return fn
|
|
483
|
+
for cell in getattr(fn, "__closure__", None) or ():
|
|
484
|
+
try:
|
|
485
|
+
value = cell.cell_contents
|
|
486
|
+
except ValueError:
|
|
487
|
+
continue
|
|
488
|
+
if callable(value) and is_user_code(value):
|
|
489
|
+
return value
|
|
490
|
+
return fn
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ── Entry ─────────────────────────────────────────────────────────────────────
|
|
494
|
+
def load_app(target, factory):
|
|
495
|
+
module_name, _, attr_path = target.partition(":")
|
|
496
|
+
app = importlib.import_module(module_name)
|
|
497
|
+
for attr in (attr_path or "app").split("."):
|
|
498
|
+
app = getattr(app, attr)
|
|
499
|
+
return app() if factory else app
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def collect(framework, app):
|
|
503
|
+
if framework == "django":
|
|
504
|
+
return analyze_django()
|
|
505
|
+
return analyze_fastapi(app) if framework == "fastapi" else analyze_flask(app)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def main():
|
|
509
|
+
opts = json.loads(os.environ["APIFORGE_PY_OPTIONS"])
|
|
510
|
+
sys.path.insert(0, os.path.abspath(opts.get("appDir") or "."))
|
|
511
|
+
real_stdout = sys.stdout
|
|
512
|
+
sys.stdout = sys.stderr # prints from the user's app must not corrupt the JSON line
|
|
513
|
+
|
|
514
|
+
framework = opts["framework"]
|
|
515
|
+
app = None
|
|
516
|
+
if framework == "django":
|
|
517
|
+
if opts.get("djangoSettings"):
|
|
518
|
+
os.environ["DJANGO_SETTINGS_MODULE"] = opts["djangoSettings"]
|
|
519
|
+
if opts.get("app"):
|
|
520
|
+
load_app(opts["app"], False) # e.g. mysite.wsgi:application, which runs django.setup()
|
|
521
|
+
else:
|
|
522
|
+
app = load_app(opts["app"], opts.get("factory"))
|
|
523
|
+
routes = collect(framework, app)
|
|
524
|
+
|
|
525
|
+
real_stdout.write(MARKER + json.dumps(routes, default=str) + "\n")
|
|
526
|
+
real_stdout.flush()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def report_from_server(app):
|
|
530
|
+
"""Injected mode: the user's own start command loaded the app and the CLI's
|
|
531
|
+
snippet exec'd this script with it. Writes the status file the CLI polls;
|
|
532
|
+
never raises, so a failure here can't take the user's server down."""
|
|
533
|
+
status_file = os.environ["APIFORGE_STATUS_FILE"]
|
|
534
|
+
try:
|
|
535
|
+
# Django: the snippet runs in manage.py before the command (and so before
|
|
536
|
+
# main() sets this); analyze_django() then runs django.setup() itself.
|
|
537
|
+
if os.environ.get("APIFORGE_DJANGO_SETTINGS"):
|
|
538
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.environ["APIFORGE_DJANGO_SETTINGS"])
|
|
539
|
+
payload = {"status": "collected", "routes": collect(os.environ["APIFORGE_FRAMEWORK"], app)}
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
payload = {"status": "error", "error": type(exc).__name__ + ": " + str(exc)}
|
|
542
|
+
tmp = status_file + ".tmp"
|
|
543
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
544
|
+
json.dump(payload, fh, default=str)
|
|
545
|
+
os.replace(tmp, status_file)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if "APIFORGE_APP" in globals():
|
|
549
|
+
report_from_server(APIFORGE_APP)
|
|
550
|
+
else:
|
|
551
|
+
main()
|
|
552
|
+
`;
|