diff --git a/README.md b/README.md
index f834827..ad365f9 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,44 @@
-# API Explorer
+
+
+

+
+
Frappe OpenAPI
+ A Frappe app to
generate and
visualize whitelisted APIs with interactive Swagger UI documentation.
+
+
+
+

+
-A beautiful Frappe app to **generate** and **visualize** whitelisted APIs with interactive Swagger UI documentation.
+## Features
+
+- Automatically generates OpenAPI (Swagger) JSON files for all installed Frappe apps.
+- Responsive Swagger UI portal with sidebar app selection.
+- Displays API endpoints, parameters, sample requests, and responses.
---
-
+## Installation
+Run the following commands inside your bench directory:
-## Features
+```bash
+bench get-app https://github.com/rtCamp/frappe-openapi.git
+bench install-app frappe_openapi
+bench migrate
+bench restart
+```
-- Automatically generates OpenAPI (Swagger) JSON files for all installed Frappe apps.
-- Responsive Swagger UI portal with sidebar app selection.
-- Displays API endpoints, parameters, sample requests, and responses.
+> For a detailed installation guide and advanced configuration, visit the [Wiki](https://github.com/rtCamp/frappe-openapi/wiki).
---
## How to Use
-1. **Install the app** in your Frappe site.
-2. **Run `bench migrate`** to apply changes and update your site.
-3. **Run the OpenAPI generator**.
-4. **Access the API Explorer UI** at `/docs` or `/redoc`.
-5. **Select an app** from the sidebar to view its API documentation.
-6. **Try out endpoints** directly from the Swagger UI.
+1. **Install the app** using the steps above.
+2. **Access the API Explorer UI** at `/docs`.
+3. **Select an app** from the sidebar to view its API documentation.
+4. **Try out endpoints** directly from the Swagger UI.
---
@@ -30,10 +46,10 @@ A beautiful Frappe app to **generate** and **visualize** whitelisted APIs with i
To ensure your APIs are documented correctly:
-- **Whitelist your function** using `@frappe.whitelist()` or in `hooks.py`.
-- **Add a detailed Python docstring** to your function, including:
- - **Description** of what the endpoint does.
- - **Sample Response** format (JSON).
+- **Whitelist your function** using `@frappe.whitelist()`.
+- **Add a Python docstring** that includes:
+ - A **description** of what the endpoint does.
+ - A **sample response** in JSON format.
**Example:**
@@ -53,13 +69,14 @@ def create_customer(name, email):
```
**Guidelines:**
+
- Always describe the endpoint's purpose.
- Include clear sample response blocks.
- Keep examples concise and relevant.
-- These doc comments are parsed and shown in the API Explorer UI.
+- These docstrings are parsed and shown in the API Explorer UI.
---
## License
-agpl-3.0
+[AGPL-3.0](LICENSE)
diff --git a/frappe_openapi/__init__.py b/frappe_openapi/__init__.py
index 5becc17..5c4105c 100644
--- a/frappe_openapi/__init__.py
+++ b/frappe_openapi/__init__.py
@@ -1 +1 @@
-__version__ = "1.0.0"
+__version__ = "1.0.1"
diff --git a/frappe_openapi/api/__init__.py b/frappe_openapi/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/frappe_openapi/api/examples.py b/frappe_openapi/api/examples.py
new file mode 100644
index 0000000..c10c999
--- /dev/null
+++ b/frappe_openapi/api/examples.py
@@ -0,0 +1,317 @@
+# Copyright (c) 2025, rtCamp and contributors
+# For license information, please see license.txt
+#
+# ---------------------------------------------------------------------------
+# SAMPLE API ENDPOINTS
+# ---------------------------------------------------------------------------
+# These functions exist purely to demonstrate every feature of the OpenAPI
+# spec generator. Each one is decorated with @frappe.whitelist so the
+# parser picks it up, and each docstring exercises a different aspect of the
+# comment format:
+#
+# 1. demo_health_check – minimal GET, guest-accessible, no parameters
+# 2. demo_list_items – GET with multiple typed query params, pagination
+# 3. demo_create_item – POST with required + optional body params, rich
+# Returns example that drives the response schema
+# 4. demo_update_item – PUT showing path-style name in params, optional fields
+# 5. demo_delete_item – DELETE showing boolean response
+# 6. demo_upload_file – POST with binary / mixed types
+# 7. demo_authenticated – endpoint that requires auth (no allow_guest)
+# ---------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from typing import Any
+
+import frappe
+
+
+def _check_demo_enabled():
+ """Check if demo endpoints should be enabled.
+
+ Demo endpoints are only enabled in:
+ 1. Developer mode
+ 2. When frappe_openapi_enable_demos site config is True
+ """
+ if frappe.conf.developer_mode:
+ return True
+
+ if frappe.conf.get("frappe_openapi_enable_demos"):
+ return True
+
+ frappe.throw("Demo endpoints are disabled in production. Enable developer mode or set frappe_openapi_enable_demos=1 in site_config.json",
+ frappe.PermissionError)
+
+
+# ---------------------------------------------------------------------------
+# 1. Health check – minimal, guest, GET
+# ---------------------------------------------------------------------------
+@frappe.whitelist(allow_guest=True, methods=["GET"])
+def demo_health_check() -> dict[str, Any]:
+ """Return a simple liveness probe for the API.
+
+ No parameters required. Always returns HTTP 200 with status information.
+
+ Returns:
+ dict: {
+ "status": "ok",
+ "timestamp": "2026-01-01T00:00:00Z",
+ "version": "1.0.0"
+ }
+ """
+ _check_demo_enabled()
+ return {
+ "status": "ok",
+ "timestamp": frappe.utils.now(),
+ "version": frappe.get_attr("frappe_openapi.__version__") or "1.0.0",
+ }
+
+
+# ---------------------------------------------------------------------------
+# 2. List items – GET with typed query params and pagination
+# ---------------------------------------------------------------------------
+@frappe.whitelist(allow_guest=True, methods=["GET"])
+def demo_list_items(
+ page: int = 1,
+ page_size: int = 20,
+ search: str | None = None,
+ category: str | None = None,
+ is_active: bool = True,
+ min_price: float | None = None,
+ max_price: float | None = None,
+) -> dict[str, Any]:
+ """List catalogue items with optional filtering and pagination.
+
+ Supports full-text search, category filtering, active/inactive toggle and
+ price-range constraints. Results are paginated; use ``page`` and
+ ``page_size`` to navigate.
+
+ Args:
+ page (int, optional): Page number, 1-based (default: 1).
+ page_size (int, optional): Number of results per page, max 100 (default: 20).
+ search (str, optional): Free-text search term applied to name and description.
+ category (str, optional): Filter by category slug, e.g. ``"electronics"``.
+ is_active (bool, optional): When True only active items are returned (default: True).
+ min_price (float, optional): Lower bound for item price (inclusive).
+ max_price (float, optional): Upper bound for item price (inclusive).
+
+ Returns:
+ dict: {
+ "total": 42,
+ "page": 1,
+ "page_size": 20,
+ "items": [
+ {
+ "name": "ITEM-00001",
+ "title": "Demo Widget",
+ "category": "electronics",
+ "price": 9.99,
+ "is_active": true
+ }
+ ]
+ }
+ """
+ _check_demo_enabled()
+ # Demo implementation — not executed in production
+ return {"total": 0, "page": page, "page_size": page_size, "items": []}
+
+
+# ---------------------------------------------------------------------------
+# 3. Create item – POST with required + optional params, rich response example
+# ---------------------------------------------------------------------------
+@frappe.whitelist(methods=["POST"])
+def demo_create_item(
+ title: str,
+ category: str,
+ price: float,
+ description: str | None = None,
+ is_active: bool = True,
+ tags: list | None = None,
+ metadata: dict | None = None,
+) -> dict[str, Any]:
+ """Create a new catalogue item.
+
+ Creates a new item in the catalogue and returns its generated identifier.
+ The ``title``, ``category`` and ``price`` fields are mandatory; all other
+ fields are optional.
+
+ Args:
+ title (str): Human-readable item title (max 140 characters).
+ category (str): Category slug the item belongs to, e.g. ``"electronics"``.
+ price (float): Retail price in the site's default currency (must be >= 0).
+ description (str, optional): Long-form description supporting Markdown.
+ is_active (bool, optional): Whether the item should be immediately visible
+ (default: True).
+ tags (list, optional): List of tag strings for search and filtering,
+ e.g. ``["new", "sale"]``.
+ metadata (dict, optional): Arbitrary key-value pairs for custom attributes,
+ e.g. ``{"weight_kg": 0.5, "sku": "WDG-001"}``.
+
+ Returns:
+ dict: {
+ "success": true,
+ "name": "ITEM-00042",
+ "title": "Demo Widget",
+ "category": "electronics",
+ "price": 9.99,
+ "is_active": true,
+ "created_at": "2026-01-01T00:00:00Z"
+ }
+ """
+ _check_demo_enabled()
+ frappe.only_for("System Manager")
+ return {"success": True, "name": "ITEM-NEW"}
+
+
+# ---------------------------------------------------------------------------
+# 4. Update item – PUT showing optional partial update
+# ---------------------------------------------------------------------------
+@frappe.whitelist(methods=["PUT"])
+def demo_update_item(
+ item_name: str,
+ title: str | None = None,
+ price: float | None = None,
+ description: str | None = None,
+ is_active: bool | None = None,
+ tags: list | None = None,
+) -> dict[str, Any]:
+ """Update one or more fields on an existing catalogue item.
+
+ Only the fields that are explicitly provided will be updated; omitted fields
+ are left unchanged (PATCH semantics over HTTP PUT).
+
+ Args:
+ item_name (str): The document name of the item to update, e.g. ``"ITEM-00042"``.
+ title (str, optional): New item title.
+ price (float, optional): New retail price (must be >= 0 if provided).
+ description (str, optional): Replacement description text.
+ is_active (bool, optional): Toggle visibility.
+ tags (list, optional): Replacement tag list; pass an empty list to clear all tags.
+
+ Returns:
+ dict: {
+ "success": true,
+ "name": "ITEM-00042",
+ "updated_fields": ["title", "price"]
+ }
+ """
+ _check_demo_enabled()
+ frappe.only_for("System Manager")
+ return {"success": True, "name": item_name, "updated_fields": []}
+
+
+# ---------------------------------------------------------------------------
+# 5. Delete item – DELETE showing boolean response
+# ---------------------------------------------------------------------------
+@frappe.whitelist(methods=["DELETE"])
+def demo_delete_item(
+ item_name: str,
+ permanent: bool = False,
+) -> dict[str, Any]:
+ """Delete or archive a catalogue item.
+
+ By default the item is soft-deleted (archived) so it can be restored later.
+ Pass ``permanent=True`` to hard-delete the record; this action is irreversible.
+
+ Args:
+ item_name (str): The document name of the item to remove, e.g. ``"ITEM-00042"``.
+ permanent (bool, optional): When True the record is permanently deleted
+ instead of archived (default: False).
+
+ Returns:
+ dict: {
+ "success": true,
+ "deleted": true,
+ "permanent": false
+ }
+ """
+ _check_demo_enabled()
+ frappe.only_for("System Manager")
+ return {"success": True, "deleted": True, "permanent": permanent}
+
+
+# ---------------------------------------------------------------------------
+# 6. Upload file – POST with binary / mixed types
+# ---------------------------------------------------------------------------
+@frappe.whitelist(allow_guest=True, methods=["POST"])
+def demo_upload_file(
+ file_url: str,
+ file_name: str | None = None,
+ folder: str = "Home",
+ is_private: bool = False,
+ optimize: bool = True,
+ max_width: int | None = None,
+ quality: int = 85,
+) -> dict[str, Any]:
+ """Download a remote file and attach it to the file manager.
+
+ Fetches the file from ``file_url``, optionally re-encodes images for size
+ optimisation, and stores the result in the Frappe File doctype.
+
+ Args:
+ file_url (str): Publicly accessible URL of the file to download.
+ file_name (str, optional): Override the stored file name. Defaults to the
+ last segment of ``file_url``.
+ folder (str, optional): Destination folder in the file manager (default: ``"Home"``).
+ is_private (bool, optional): Store as a private file visible only to the
+ uploader (default: False).
+ optimize (bool, optional): Re-encode JPEG/WebP images for smaller file size
+ (default: True).
+ max_width (int, optional): Resize image so its width does not exceed this value
+ in pixels (aspect ratio preserved). No resizing when omitted.
+ quality (int, optional): JPEG/WebP encoding quality 1–100 (default: 85).
+ Only applies when ``optimize`` is True.
+
+ Returns:
+ dict: {
+ "success": true,
+ "file_url": "/files/my-image.webp",
+ "file_name": "my-image.webp",
+ "file_size": 204800,
+ "is_private": false,
+ "content_type": "image/webp"
+ }
+ """
+ _check_demo_enabled()
+ return {"success": True, "file_url": file_url}
+
+
+# ---------------------------------------------------------------------------
+# 7. Authenticated endpoint – requires Bearer token / API key
+# ---------------------------------------------------------------------------
+@frappe.whitelist(methods=["GET"])
+def demo_authenticated(
+ resource_id: str,
+ include_metadata: bool = False,
+) -> dict[str, Any]:
+ """Fetch a private resource that requires authentication.
+
+ This endpoint does **not** set ``allow_guest=True``, so the OpenAPI spec
+ will include a ``security`` requirement showing both ``TokenAuth`` (API
+ key/secret) and ``bearerAuth`` (OAuth2 token) schemes.
+
+ Args:
+ resource_id (str): Unique identifier of the private resource.
+ include_metadata (bool, optional): When True extra metadata is included
+ in the response (default: False).
+
+ Returns:
+ dict: {
+ "success": true,
+ "resource_id": "RES-00001",
+ "owner": "user@example.com",
+ "data": {
+ "value": "secret content",
+ "created_at": "2026-01-01T00:00:00Z"
+ },
+ "metadata": {}
+ }
+ """
+ _check_demo_enabled()
+ return {
+ "success": True,
+ "resource_id": resource_id,
+ "owner": frappe.session.user,
+ "data": {},
+ "metadata": {} if include_metadata else {},
+ }
diff --git a/frappe_openapi/frappe_openapi/generate_api_docs.py b/frappe_openapi/frappe_openapi/generate_api_docs.py
index 5a4663d..7fd9ec5 100644
--- a/frappe_openapi/frappe_openapi/generate_api_docs.py
+++ b/frappe_openapi/frappe_openapi/generate_api_docs.py
@@ -41,43 +41,377 @@ def get_decorator_info(decorator_list):
return methods or DEFAULT_METHODS, allow_guest
+_PLACEHOLDER_TYPE_MAP = {
+ "float": {"type": "number"},
+ "number": {"type": "number"},
+ "int": {"type": "integer"},
+ "integer": {"type": "integer"},
+ "str": {"type": "string"},
+ "string": {"type": "string"},
+ "bool": {"type": "boolean"},
+ "boolean": {"type": "boolean"},
+ "list": {"type": "array", "items": {}},
+ "array": {"type": "array", "items": {}},
+ "dict": {"type": "object"},
+ "object": {"type": "object"},
+}
+
+
+def _parse_typed_block(block):
+ """Parse a dict block containing placeholders like ``{"key": }``.
+
+ Returns a dict of ``{key: openapi_schema}`` tagged with ``__placeholder_schema__``
+ so that ``build_response_schema`` can recognise it, or ``None`` if no
+ ```` patterns are found.
+ """
+ pairs = re.findall(r'"(\w+)"\s*:\s*<(\w+)>', block)
+ if not pairs:
+ return None
+ schemas = {key: dict(_PLACEHOLDER_TYPE_MAP.get(typ.lower(), {"type": "string"})) for key, typ in pairs}
+ return {"__placeholder_schema__": schemas}
+
+
def extract_returns_from_docstring(docstring):
if not docstring:
return None
- match = re.search(r"Returns?:\s*(.*)", docstring, re.DOTALL | re.IGNORECASE)
+ # Stop at the next top-level section header (e.g. "Raises:", "Example:")
+ match = re.search(
+ r"Returns?:\s*\n(.*?)(?:\n[ \t]*\n|\n[ \t]*[A-Z]\w*:|\Z)",
+ docstring,
+ re.DOTALL | re.IGNORECASE,
+ )
if not match:
+ # Fallback: grab everything after "Returns:"
+ match = re.search(r"Returns?:\s*(.*)", docstring, re.DOTALL | re.IGNORECASE)
+ if not match:
+ return None
+ returns_block = match.group(1).strip()
+
+ def _extract_balanced(text, open_ch, close_ch):
+ """Return the first balanced open_ch...close_ch substring, or None."""
+ start = text.find(open_ch)
+ if start == -1:
+ return None
+ depth = 0
+ for i, ch in enumerate(text[start:], start):
+ if ch == open_ch:
+ depth += 1
+ elif ch == close_ch:
+ depth -= 1
+ if depth == 0:
+ return text[start : i + 1]
return None
- returns_block = re.sub(r"^\s*\w+\s*:\s*", "", match.group(1))
- brace_match = re.search(r"(\{.*\}|\[.*\])", returns_block, re.DOTALL)
+
+ raw_block = _extract_balanced(returns_block, "{", "}") or _extract_balanced(returns_block, "[", "]")
+ brace_match = raw_block is not None
if brace_match:
- block = brace_match.group(1)
+ block = raw_block
+ # Handle placeholder blocks first
+ if re.search(r"<\w+>", block):
+ return _parse_typed_block(block)
try:
return json.loads(block.replace("'", '"'))
except Exception:
return block
- return returns_block.strip()
+ return returns_block.strip() or None
+
+
+_PYTHON_TO_OPENAPI = {
+ "str": "string",
+ "string": "string",
+ "int": "integer",
+ "integer": "integer",
+ "float": "number",
+ "number": "number",
+ "bool": "boolean",
+ "boolean": "boolean",
+ "bytes": "string",
+ "list": "array",
+ "dict": "object",
+}
+
+
+def get_openapi_type(annotation):
+ """Convert an AST annotation node to an OpenAPI schema dict."""
+ if annotation is None:
+ return {"type": "string"}
+
+ if isinstance(annotation, ast.Name):
+ name = annotation.id
+ if name in _PYTHON_TO_OPENAPI:
+ openapi_type = _PYTHON_TO_OPENAPI[name]
+ if openapi_type == "array":
+ return {"type": "array", "items": {}}
+ return {"type": openapi_type}
+ return {"type": "string"}
+
+ if isinstance(annotation, ast.Constant):
+ if annotation.value is None:
+ return {"nullable": True}
+ return {"type": "string"}
+
+ if isinstance(annotation, ast.Subscript):
+ outer = annotation.value
+ outer_name = None
+ if isinstance(outer, ast.Name):
+ outer_name = outer.id
+ elif isinstance(outer, ast.Attribute):
+ outer_name = outer.attr
+
+ slice_node = annotation.slice
+ # Python < 3.9 wraps slice in ast.Index
+ if hasattr(ast, "Index") and isinstance(slice_node, ast.Index):
+ slice_node = slice_node.value # type: ignore[attr-defined]
+
+ if outer_name == "Optional":
+ inner = get_openapi_type(slice_node)
+ inner["nullable"] = True
+ return inner
+
+ if outer_name in ("List", "list", "Sequence", "Iterable", "FrozenSet", "Set"):
+ items = get_openapi_type(slice_node) if not isinstance(slice_node, ast.Tuple) else {}
+ return {"type": "array", "items": items}
+
+ if outer_name in ("Dict", "dict", "Mapping", "DefaultDict"):
+ return {"type": "object"}
+
+ if outer_name in ("Tuple", "tuple"):
+ return {"type": "array", "items": {}}
+
+ if outer_name == "Union":
+ if isinstance(slice_node, ast.Tuple):
+ non_none = [
+ n
+ for n in slice_node.elts
+ if not (isinstance(n, ast.Constant) and n.value is None)
+ and not (isinstance(n, ast.Name) and n.id == "None")
+ ]
+ has_none = len(non_none) < len(slice_node.elts)
+ if len(non_none) == 1:
+ # Simple Union[X, None] -> X with nullable
+ schema = get_openapi_type(non_none[0])
+ if has_none:
+ schema["nullable"] = True
+ return schema
+ elif len(non_none) > 1:
+ # Complex union -> oneOf with multiple schemas
+ one_of = [get_openapi_type(n) for n in non_none]
+ schema = {"oneOf": one_of}
+ if has_none:
+ schema["nullable"] = True
+ return schema
+ return {"type": "string"}
+
+ return {"type": "string"}
+
+ # Python 3.10+ union syntax: X | Y | None
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
+ # Collect all union members by walking the BitOr chain
+ union_members = []
+
+ def collect_union_members(node):
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
+ collect_union_members(node.left)
+ collect_union_members(node.right)
+ else:
+ union_members.append(node)
+
+ collect_union_members(annotation)
+
+ # Separate None from other types
+ non_none = [
+ n
+ for n in union_members
+ if not (isinstance(n, ast.Constant) and n.value is None)
+ and not (isinstance(n, ast.Name) and n.id == "None")
+ ]
+ has_none = len(non_none) < len(union_members)
+
+ if len(non_none) == 1:
+ # Simple X | None -> X with nullable
+ schema = get_openapi_type(non_none[0])
+ if has_none:
+ schema["nullable"] = True
+ return schema
+ elif len(non_none) > 1:
+ # Complex union -> oneOf
+ one_of = [get_openapi_type(n) for n in non_none]
+ schema = {"oneOf": one_of}
+ if has_none:
+ schema["nullable"] = True
+ return schema
+
+ return {"type": "string"}
+
+ if isinstance(annotation, ast.Attribute):
+ return get_openapi_type(ast.Name(id=annotation.attr, ctx=ast.Load()))
+
+ return {"type": "string"}
+
+
+def parse_docstring_args(docstring):
+ """Parse a Google-style Args section from a docstring.
+
+ Returns a dict of {param_name: {"type_schema": {...}, "required": bool, "description": str}}.
+ Handles multi-line parameter descriptions (continuation lines).
+ """
+ if not docstring:
+ return {}
+ args_match = re.search(
+ r"Args?:\s*\n(.*?)(?:\n[ \t]*\n|\n[ \t]*[A-Z]\w*:|\Z)",
+ docstring,
+ re.DOTALL | re.IGNORECASE,
+ )
+ if not args_match:
+ return {}
+ block = args_match.group(1)
+ result = {}
+ param_re = re.compile(r"^(\s{2,})(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)", re.MULTILINE)
+
+ lines = block.split("\n")
+ current_param = None
+ current_type_info = ""
+ current_desc_lines: list[str] = []
+ current_indent = ""
+
+ def _flush():
+ if not current_param:
+ return
+ description = " ".join(filter(None, [l.strip() for l in current_desc_lines]))
+ parts = [p.strip().lower() for p in current_type_info.split(",")]
+ required = "optional" not in parts
+ type_name = parts[0] if parts and parts[0] else "string"
+ openapi_type = _PYTHON_TO_OPENAPI.get(type_name, "string")
+ schema: dict = {"type": openapi_type}
+ if openapi_type == "array":
+ schema["items"] = {}
+ result[current_param] = {"type_schema": schema, "required": required, "description": description}
+
+ for line in lines:
+ m = param_re.match(line)
+ if m:
+ _flush()
+ current_indent = m.group(1)
+ current_param = m.group(2)
+ current_type_info = m.group(3) or ""
+ current_desc_lines = [m.group(4).strip()]
+ elif current_param and line.strip():
+ # Continuation line — must be indented deeper than the param name
+ stripped = line.lstrip()
+ line_indent = line[: len(line) - len(stripped)]
+ if len(line_indent) > len(current_indent):
+ current_desc_lines.append(stripped)
+
+ _flush()
+ return result
+
+
+def build_response_schema(return_annotation, example):
+ """Build an OpenAPI response schema from a return type annotation and/or docstring example.
+
+ Frappe always wraps the return value under a ``message`` key, so the outer schema
+ is ``{message: }``. When a JSON example is available its keys are used to
+ populate ``properties`` for a richer object schema.
+
+ When the docstring Returns block used ```` placeholders (e.g. ``{"total": }``)
+ ``example`` will be a ``{"__placeholder_schema__": {key: schema, ...}}`` dict produced
+ by :func:`_parse_typed_block`; this is converted directly to an object schema.
+ """
+ # ── Placeholder schema from annotations in docstring ──────────
+ if isinstance(example, dict) and "__placeholder_schema__" in example:
+ properties = example["__placeholder_schema__"]
+ inner: dict = {"type": "object", "properties": properties}
+ return {"type": "object", "properties": {"message": inner}}
+
+ # ── Normal path ───────────────────────────────────────────────────────
+ if return_annotation is not None:
+ inner = get_openapi_type(return_annotation)
+ elif isinstance(example, list):
+ inner = {"type": "array", "items": {}}
+ elif isinstance(example, dict):
+ inner = {"type": "object"}
+ else:
+ inner = {"type": "string"}
+
+ # When we have a real dict example, derive properties from its keys/value types
+ if isinstance(example, dict) and inner.get("type") == "object":
+ _type_map = {
+ bool: "boolean",
+ int: "integer",
+ float: "number",
+ str: "string",
+ list: "array",
+ dict: "object",
+ }
+ properties = {}
+ for key, value in example.items():
+ prop_type = _type_map.get(type(value), "string")
+ prop: dict = {"type": prop_type}
+ if prop_type == "array":
+ prop["items"] = {}
+ properties[key] = prop
+ if properties:
+ inner = {"type": "object", "properties": properties}
+
+ return {"type": "object", "properties": {"message": inner}}
def parse_functions_from_file(file_path):
with open(file_path, encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=file_path)
- return [
- {
- "name": node.name,
- "params": [arg.arg for arg in node.args.args if arg.arg not in ("self", "cls")],
- "doc": ast.get_docstring(node) or "",
- "methods": (methods := get_decorator_info(node.decorator_list))[0],
- "allow_guest": methods[1],
- "returns_example": extract_returns_from_docstring(ast.get_docstring(node) or ""),
- }
- for node in ast.walk(tree)
- if isinstance(node, ast.FunctionDef)
- and "whitelist"
- in {
+
+ result = []
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.FunctionDef):
+ continue
+ deco_names = {
getattr(getattr(deco, "func", deco), "attr", getattr(getattr(deco, "func", deco), "id", None))
for deco in node.decorator_list
}
- ]
+ if "whitelist" not in deco_names:
+ continue
+
+ methods, allow_guest = get_decorator_info(node.decorator_list)
+ docstring = ast.get_docstring(node) or ""
+ docstring_args = parse_docstring_args(docstring)
+
+ filtered_args = [arg for arg in node.args.args if arg.arg not in ("self", "cls")]
+ n_required = len(filtered_args) - len(node.args.defaults)
+
+ params = []
+ for i, arg in enumerate(filtered_args):
+ is_required = i < n_required
+ doc_info = docstring_args.get(arg.arg, {})
+
+ if arg.annotation is not None:
+ type_schema = get_openapi_type(arg.annotation)
+ elif doc_info.get("type_schema"):
+ type_schema = doc_info["type_schema"]
+ else:
+ type_schema = {"type": "string"}
+
+ params.append(
+ {
+ "name": arg.arg,
+ "required": is_required,
+ "type_schema": type_schema,
+ "description": doc_info.get("description", ""),
+ }
+ )
+
+ result.append(
+ {
+ "name": node.name,
+ "params": params,
+ "doc": docstring,
+ "methods": methods,
+ "allow_guest": allow_guest,
+ "return_annotation": node.returns,
+ "returns_example": extract_returns_from_docstring(docstring),
+ }
+ )
+
+ return result
def generate_openapi_static(app_name):
@@ -102,26 +436,88 @@ def generate_openapi_static(app_name):
path = f"/api/method/{app_name}.{module_path}.{func['name']}"
tags_set.add(parent_module)
for method in func["methods"]:
- op = {
- "summary": func["doc"].split("\n")[0] if func["doc"] else "",
- "parameters": [
- {"name": p, "in": "query", "required": True, "schema": {"type": "string"}}
- for p in func["params"]
- ],
+ response_schema = build_response_schema(func["return_annotation"], func["returns_example"])
+ response_content: dict = {"schema": response_schema}
+ example_val = func["returns_example"]
+ if isinstance(example_val, dict) and "__placeholder_schema__" in example_val:
+ # Build a human-readable example from the placeholder types
+ _example_defaults = {
+ "number": 0.0,
+ "integer": 0,
+ "string": "",
+ "boolean": False,
+ "array": [],
+ "object": {},
+ }
+ inner_example = {
+ k: _example_defaults.get(v.get("type", "string"), "")
+ for k, v in example_val["__placeholder_schema__"].items()
+ }
+ response_content["example"] = {"message": inner_example}
+ elif example_val is not None:
+ response_content["example"] = {"message": example_val}
+
+ # For non-GET methods, send params as form-encoded request body
+ if method in ("post", "put", "patch", "delete"):
+ param_objects = []
+ rb_properties: dict = {}
+ rb_required: list[str] = []
+ for p in func["params"]:
+ prop: dict = {**p["type_schema"]}
+ if p["description"]:
+ prop["description"] = p["description"]
+ rb_properties[p["name"]] = prop
+ if p["required"]:
+ rb_required.append(p["name"])
+ rb_schema: dict = {"type": "object", "properties": rb_properties}
+ if rb_required:
+ rb_schema["required"] = rb_required
+
+ # Only mark requestBody as required if there are required fields
+ has_required_params = bool(rb_required)
+ if rb_properties: # Only add requestBody if there are parameters
+ request_body: dict | None = {
+ "required": has_required_params,
+ "content": {"application/x-www-form-urlencoded": {"schema": rb_schema}},
+ }
+ else:
+ request_body = None
+ else:
+ # GET — use query parameters
+ param_objects = []
+ for p in func["params"]:
+ param = {
+ "name": p["name"],
+ "in": "query",
+ "required": p["required"],
+ "schema": p["type_schema"],
+ }
+ if p["description"]:
+ param["description"] = p["description"]
+ param_objects.append(param)
+ request_body = None
+
+ doc_lines = func["doc"].splitlines() if func["doc"] else []
+ summary = doc_lines[0].strip() if doc_lines else ""
+ description = "\n".join(doc_lines[1:]).strip() if len(doc_lines) > 1 else ""
+
+ op: dict = {
+ "summary": summary,
+ "parameters": param_objects,
"responses": {
"200": {
"description": "Success",
- "content": {
- "application/json": {
- "example": func["returns_example"] or {"status": "success", "data": {}}
- }
- },
+ "content": {"application/json": response_content},
}
},
"tags": [parent_module],
}
+ if description:
+ op["description"] = description
+ if request_body is not None:
+ op["requestBody"] = request_body
if not func["allow_guest"]:
- op["security"] = [{"TokenAuth": [], "bearerAuth": []}]
+ op["security"] = [{"TokenAuth": []}, {"bearerAuth": []}]
needs_auth = True
openapi["paths"].setdefault(path, {})[method] = op
if needs_auth:
@@ -134,10 +530,9 @@ def generate_openapi_static(app_name):
},
"bearerAuth": {
"type": "http",
- "in": "header",
- "name": "Authorization",
"scheme": "bearer",
- "description": "Enter access token.",
+ "bearerFormat": "JWT",
+ "description": "Enter OAuth2 bearer access token.",
},
}
openapi["tags"] = [{"name": tag} for tag in sorted(tags_set)]
diff --git a/frappe_openapi/www/docs.html b/frappe_openapi/www/docs.html
index c694964..9ba4dfd 100644
--- a/frappe_openapi/www/docs.html
+++ b/frappe_openapi/www/docs.html
@@ -1,119 +1,1018 @@
{% extends "templates/web.html" %}
+{% block head_include %}
+
+{% endblock %}
+
{% block page_content %}
+
+
+
-
+
-
-