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