From ea69703b9d72e71b517703eb69ba5bb9b4d88dca Mon Sep 17 00:00:00 2001 From: Vishal Kumar Date: Mon, 4 May 2026 17:30:07 +0530 Subject: [PATCH 01/12] Fix param types, add examples --- frappe_openapi/api/__init__.py | 0 frappe_openapi/api/examples.py | 293 ++++++++++++++++ .../frappe_openapi/generate_api_docs.py | 329 ++++++++++++++++-- 3 files changed, 594 insertions(+), 28 deletions(-) create mode 100644 frappe_openapi/api/__init__.py create mode 100644 frappe_openapi/api/examples.py 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..b93bbfd --- /dev/null +++ b/frappe_openapi/api/examples.py @@ -0,0 +1,293 @@ +# 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 + + +# --------------------------------------------------------------------------- +# 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" + } + """ + 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 + } + ] + } + """ + # 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(allow_guest=True, 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" + } + """ + frappe.only_for("System Manager") + return {"success": True, "name": "ITEM-NEW"} + + +# --------------------------------------------------------------------------- +# 4. Update item – PUT showing optional partial update +# --------------------------------------------------------------------------- +@frappe.whitelist(allow_guest=True, 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"] + } + """ + frappe.only_for("System Manager") + return {"success": True, "name": item_name, "updated_fields": []} + + +# --------------------------------------------------------------------------- +# 5. Delete item – DELETE showing boolean response +# --------------------------------------------------------------------------- +@frappe.whitelist(allow_guest=True, 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 + } + """ + 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" + } + """ + 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": {} + } + """ + return { + "success": True, + "resource_id": resource_id, + "owner": frappe.session.user, + "data": {}, + "metadata": {} if include_metadata else None, + } diff --git a/frappe_openapi/frappe_openapi/generate_api_docs.py b/frappe_openapi/frappe_openapi/generate_api_docs.py index dc4fbbd..13b12a4 100644 --- a/frappe_openapi/frappe_openapi/generate_api_docs.py +++ b/frappe_openapi/frappe_openapi/generate_api_docs.py @@ -54,26 +54,258 @@ def extract_returns_from_docstring(docstring): return returns_block.strip() +_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 {"type": "string", "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 non_none: + schema = get_openapi_type(non_none[0]) + 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): + right = annotation.right + right_is_none = (isinstance(right, ast.Constant) and right.value is None) or ( + isinstance(right, ast.Name) and right.id == "None" + ) + schema = get_openapi_type(annotation.left) + if right_is_none: + schema["nullable"] = True + return schema + + 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. + """ + 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 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"} + + # Docstring can refine required status only when no default is available from signature + if arg.annotation is None and "required" in doc_info and i >= n_required: + is_required = doc_info["required"] + + 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): @@ -98,24 +330,66 @@ 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} + if func["returns_example"] is not None: + response_content["example"] = func["returns_example"] + + # 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 + request_body: dict | None = { + "required": True, + "content": { + "application/x-www-form-urlencoded": {"schema": rb_schema} + }, + } + 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": []}] needs_auth = True @@ -130,10 +404,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)] From 049af975ce4d21130521e4a3c17121e385abc855 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Date: Mon, 4 May 2026 17:49:40 +0530 Subject: [PATCH 02/12] Address Copilot review feedback - Fix response schema/example mismatch by wrapping examples in {"message": ...} - Fix requestBody required flag to be based on actual required fields, not always True - Fix security requirements to use OR logic instead of AND logic - Improve Union type handling with proper oneOf/anyOf schemas for complex unions - Improve PEP 604 union handling to support nested unions properly - Remove docstring override of signature-based required/optional parameter logic - Gate demo endpoints behind developer mode or config flag for production security All fixes address the specific issues identified in the GitHub Copilot review. --- frappe_openapi/api/examples.py | 24 ++++++ .../frappe_openapi/generate_api_docs.py | 82 ++++++++++++++----- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/frappe_openapi/api/examples.py b/frappe_openapi/api/examples.py index b93bbfd..bda9ddf 100644 --- a/frappe_openapi/api/examples.py +++ b/frappe_openapi/api/examples.py @@ -26,6 +26,23 @@ 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 # --------------------------------------------------------------------------- @@ -42,6 +59,7 @@ def demo_health_check() -> dict[str, Any]: "version": "1.0.0" } """ + _check_demo_enabled() return { "status": "ok", "timestamp": frappe.utils.now(), @@ -93,6 +111,7 @@ def demo_list_items( ] } """ + _check_demo_enabled() # Demo implementation — not executed in production return {"total": 0, "page": page, "page_size": page_size, "items": []} @@ -139,6 +158,7 @@ def demo_create_item( "created_at": "2026-01-01T00:00:00Z" } """ + _check_demo_enabled() frappe.only_for("System Manager") return {"success": True, "name": "ITEM-NEW"} @@ -175,6 +195,7 @@ def demo_update_item( "updated_fields": ["title", "price"] } """ + _check_demo_enabled() frappe.only_for("System Manager") return {"success": True, "name": item_name, "updated_fields": []} @@ -204,6 +225,7 @@ def demo_delete_item( "permanent": false } """ + _check_demo_enabled() frappe.only_for("System Manager") return {"success": True, "deleted": True, "permanent": permanent} @@ -250,6 +272,7 @@ def demo_upload_file( "content_type": "image/webp" } """ + _check_demo_enabled() return {"success": True, "file_url": file_url} @@ -284,6 +307,7 @@ def demo_authenticated( "metadata": {} } """ + _check_demo_enabled() return { "success": True, "resource_id": resource_id, diff --git a/frappe_openapi/frappe_openapi/generate_api_docs.py b/frappe_openapi/frappe_openapi/generate_api_docs.py index 13b12a4..67387bb 100644 --- a/frappe_openapi/frappe_openapi/generate_api_docs.py +++ b/frappe_openapi/frappe_openapi/generate_api_docs.py @@ -124,25 +124,60 @@ def get_openapi_type(annotation): and not (isinstance(n, ast.Name) and n.id == "None") ] has_none = len(non_none) < len(slice_node.elts) - if non_none: + 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): - right = annotation.right - right_is_none = (isinstance(right, ast.Constant) and right.value is None) or ( - isinstance(right, ast.Name) and right.id == "None" - ) - schema = get_openapi_type(annotation.left) - if right_is_none: - schema["nullable"] = True - return schema + # 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())) @@ -280,9 +315,10 @@ def parse_functions_from_file(file_path): else: type_schema = {"type": "string"} - # Docstring can refine required status only when no default is available from signature - if arg.annotation is None and "required" in doc_info and i >= n_required: - is_required = doc_info["required"] + # Only use docstring required info when signature doesn't provide defaults + if "required" in doc_info and i >= n_required and arg.annotation is None: + # Only override if signature doesn't already make it optional + pass # Keep signature-derived status as authoritative params.append( { @@ -333,7 +369,7 @@ def generate_openapi_static(app_name): response_schema = build_response_schema(func["return_annotation"], func["returns_example"]) response_content: dict = {"schema": response_schema} if func["returns_example"] is not None: - response_content["example"] = func["returns_example"] + response_content["example"] = {"message": func["returns_example"]} # For non-GET methods, send params as form-encoded request body if method in ("post", "put", "patch", "delete"): @@ -350,12 +386,18 @@ def generate_openapi_static(app_name): rb_schema: dict = {"type": "object", "properties": rb_properties} if rb_required: rb_schema["required"] = rb_required - request_body: dict | None = { - "required": True, - "content": { - "application/x-www-form-urlencoded": {"schema": rb_schema} - }, - } + + # 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 = [] @@ -391,7 +433,7 @@ def generate_openapi_static(app_name): 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: From 50382d417ff8c8368c41207b1b734ffe7910f642 Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Tue, 5 May 2026 12:32:35 +0530 Subject: [PATCH 03/12] feat(generate_api_docs): improve OpenAPI spec generation and type parsing - add _PLACEHOLDER_TYPE_MAP and _parse_typed_block to handle annotations in docstring Returns blocks - update extract_returns_from_docstring to detect placeholders and return __placeholder_schema__ sentinel - update build_response_schema to convert __placeholder_schema__ into proper OpenAPI object schema with typed properties - fix example generation to produce typed defaults from placeholder schemas instead of serialising raw sentinel - add full Union / X|Y|None handling in get_openapi_type for Python 3.10+ union syntax - parse multi-line parameter descriptions in parse_docstring_args - only add requestBody when parameters exist; set required flag only when required params are present - delete stale spec JSON files for apps that are disabled in OpenAPI Settings --- .../frappe_openapi/generate_api_docs.py | 83 +- frappe_openapi/www/docs.html | 1041 ++++++++++++++++- 2 files changed, 1061 insertions(+), 63 deletions(-) diff --git a/frappe_openapi/frappe_openapi/generate_api_docs.py b/frappe_openapi/frappe_openapi/generate_api_docs.py index 67387bb..80b32b3 100644 --- a/frappe_openapi/frappe_openapi/generate_api_docs.py +++ b/frappe_openapi/frappe_openapi/generate_api_docs.py @@ -37,21 +37,65 @@ 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: - return None - returns_block = re.sub(r"^\s*\w+\s*:\s*", "", match.group(1)) - brace_match = re.search(r"(\{.*\}|\[.*\])", returns_block, re.DOTALL) + # 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() + brace_match = re.search(r"(\{[^{}]*\}|\[[^\[\]]*\])", returns_block, re.DOTALL) if brace_match: block = brace_match.group(1) + # 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 = { @@ -248,7 +292,18 @@ def build_response_schema(return_annotation, 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): @@ -258,7 +313,7 @@ def build_response_schema(return_annotation, example): else: inner = {"type": "string"} - # When we have a dict example, derive properties from its keys/value types + # 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", @@ -368,8 +423,20 @@ def generate_openapi_static(app_name): for method in func["methods"]: response_schema = build_response_schema(func["return_annotation"], func["returns_example"]) response_content: dict = {"schema": response_schema} - if func["returns_example"] is not None: - response_content["example"] = {"message": func["returns_example"]} + 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"): diff --git a/frappe_openapi/www/docs.html b/frappe_openapi/www/docs.html index c694964..c2310d6 100644 --- a/frappe_openapi/www/docs.html +++ b/frappe_openapi/www/docs.html @@ -2,118 +2,1049 @@ {% block page_content %} + + + - + - - - -
-
-
-
+
+ +
+ + + + +
+ +
+ + + + + +
+ +
+ +
+
+
+
+
{% endblock %} \ No newline at end of file From e13b09474ab0bf69bedbf15e457542a54d581a24 Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Tue, 5 May 2026 12:45:55 +0530 Subject: [PATCH 04/12] Fix API method filter accent colors --- frappe_openapi/www/docs.html | 73 ++++++++++++++---------------------- 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/frappe_openapi/www/docs.html b/frappe_openapi/www/docs.html index c2310d6..a589397 100644 --- a/frappe_openapi/www/docs.html +++ b/frappe_openapi/www/docs.html @@ -16,13 +16,11 @@ crossorigin="anonymous" referrerpolicy="no-referrer"> - +
@@ -727,7 +727,6 @@
-
@@ -743,7 +742,6 @@
-
@@ -751,12 +749,10 @@
-
-
@@ -783,7 +779,6 @@ let searchDebounce = null; let countObserver = null; - // ── localStorage persistence ──────────────────────────────────────── function getSavedApp() { try { const saved = localStorage.getItem(LS_KEY); @@ -794,7 +789,6 @@ try { localStorage.setItem(LS_KEY, name); } catch (e) { } } - // ── Helpers ───────────────────────────────────────────────────────── function countOps(spec) { let n = 0; for (const item of Object.values(spec.paths || {})) @@ -814,7 +808,7 @@ return counts; } - // ── Inject count badges via MutationObserver ───────────────────────── + // Inject count badges into tag section headers via MutationObserver function startCountInjector(tagCounts) { if (countObserver) countObserver.disconnect(); @@ -840,7 +834,6 @@ inject(); } - // ── Render Swagger UI ──────────────────────────────────────────────── function renderSwagger(spec, isFiltered) { document.getElementById('swagger-ui').innerHTML = ''; const tagCounts = buildTagCounts(spec); @@ -855,19 +848,16 @@ plugins: [SwaggerUIBundle.plugins.DownloadUrl], layout: 'BaseLayout', requestInterceptor: req => { req.headers['X-Frappe-CSRF-Token'] = null; return req; }, - // Expand all matching sections when a filter is active docExpansion: isFiltered ? 'list' : 'none', persistAuthorization: true, }); startCountInjector(tagCounts); } - // ── Search scoring ─────────────────────────────────────────────────── - // Returns a numeric score: higher = more relevant. 0 = no match. - // Priority: path/function-name > summary > tag > param name > description + // Score an operation against a query string. Higher = more relevant, 0 = no match. + // Priority: path/function-name > summary > tag > param name > description function scoreOp(q, path, op) { const pathLc = path.toLowerCase(); - // The function name is the last URL segment (e.g. "add_manual_payment") const fnName = pathLc.split('/').pop() || ''; const summary = (op.summary || '').toLowerCase(); const desc = (op.description || '').toLowerCase(); @@ -877,30 +867,24 @@ let score = 0; - // ① Path / function name — highest priority if (fnName === q) score += 100; else if (fnName.startsWith(q)) score += 88; else if (fnName.includes(q)) score += 72; else if (pathLc.includes(q)) score += 60; - // ② Summary if (summary.startsWith(q)) score += 55; else if (summary.includes(q)) score += 42; - // ③ Tags if (tags.some(t => t === q)) score += 48; else if (tags.some(t => t.includes(q))) score += 35; - // ④ Parameter names if (paramText.includes(q)) score += 28; - // ⑤ Description — lowest if (desc.includes(q)) score += 15; return score; } - // ── Build filtered + ranked spec ───────────────────────────────────── function buildFilteredSpec(query, methods) { if (!fullSpec) return null; @@ -913,7 +897,6 @@ const matchedTags = new Set(); let matchCount = 0; - // Collect [path, maxScore, filteredPathItem] const scoredEntries = []; for (const [path, pathItem] of Object.entries(fullSpec.paths || {})) { @@ -923,13 +906,11 @@ for (const [method, op] of Object.entries(pathItem)) { if (!HTTP_METHODS.includes(method)) continue; - // Method filter if (filterMethods && !methods.has(method)) continue; - // Text filter + scoring if (filterQuery) { const s = scoreOp(q, path, op); - if (s === 0) continue; // no match + if (s === 0) continue; maxScore = Math.max(maxScore, s); } @@ -946,7 +927,6 @@ } } - // Sort by score descending only when a text query is active if (filterQuery) { scoredEntries.sort((a, b) => b[1] - a[1]); } @@ -955,7 +935,6 @@ scoredEntries.map(([p, , item]) => [p, item]) ); - // Stats badge const info = document.getElementById('search-results-info'); if (isFiltered) { info.textContent = `${matchCount} of ${totalOps} matched`; @@ -965,7 +944,7 @@ info.classList.remove('is-filtered'); } - // Strip empty tag sections when filtering + // When filtering, hide tag sections that have no matched operations const filteredTags = isFiltered ? (fullSpec.tags || []).filter(t => matchedTags.has(t.name)) : (fullSpec.tags || []); @@ -973,17 +952,14 @@ return { ...fullSpec, paths: filteredPaths, tags: filteredTags }; } - // ── Apply current filters and re-render ────────────────────────────── function applyFilters() { const q = document.getElementById('api-search').value; const isFiltered = q.trim().length > 0 || activeMethods.size > 0; const filtered = buildFilteredSpec(q, activeMethods); if (filtered) renderSwagger(filtered, isFiltered); - // Show/hide ✕ clear button document.getElementById('clear-search').style.display = q.length ? 'block' : 'none'; } - // ── Load spec for an app ───────────────────────────────────────────── function loadSwagger(appName) { saveApp(appName); document.title = `${appTitles[appName] || appName} | API Docs`; @@ -1001,7 +977,7 @@ }); } - // ── Events ──────────────────────────────────────────────────────────── + // Events document.getElementById('api-search').addEventListener('input', function () { clearTimeout(searchDebounce); searchDebounce = setTimeout(applyFilters, 200); @@ -1035,7 +1011,6 @@ loadSwagger(this.value); }); - // ── Initial load ────────────────────────────────────────────────────── const initialApp = getSavedApp(); document.getElementById('app-select').value = initialApp; if (apps.length) loadSwagger(initialApp); From 3d7c5c8b851e63d3ac468b66fa06cecdda2e7f7b Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 15:28:06 +0530 Subject: [PATCH 07/12] Bump app version to v0.1.0 --- frappe_openapi/__init__.py | 2 +- frappe_openapi/hooks.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe_openapi/__init__.py b/frappe_openapi/__init__.py index f102a9c..3dc1f76 100644 --- a/frappe_openapi/__init__.py +++ b/frappe_openapi/__init__.py @@ -1 +1 @@ -__version__ = "0.0.1" +__version__ = "0.1.0" diff --git a/frappe_openapi/hooks.py b/frappe_openapi/hooks.py index 6a0b1b8..6853dbe 100644 --- a/frappe_openapi/hooks.py +++ b/frappe_openapi/hooks.py @@ -4,6 +4,7 @@ app_description = "Frappe app to generate and visualize whitelisted APIs" app_email = "frappe@rtcamp.com" app_license = "agpl-3.0" +app_version = "0.1.0" # Apps # ------------------ From f03ff732c940f1938bae87b70507411b40716c98 Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 15:39:56 +0530 Subject: [PATCH 08/12] Rmeove version from hooks.py --- frappe_openapi/hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe_openapi/hooks.py b/frappe_openapi/hooks.py index 6853dbe..6a0b1b8 100644 --- a/frappe_openapi/hooks.py +++ b/frappe_openapi/hooks.py @@ -4,7 +4,6 @@ app_description = "Frappe app to generate and visualize whitelisted APIs" app_email = "frappe@rtcamp.com" app_license = "agpl-3.0" -app_version = "0.1.0" # Apps # ------------------ From a0a6ff07ffd3406bf3daee312f11a6302ed5027e Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 15:59:48 +0530 Subject: [PATCH 09/12] Update Readme file --- README.md | 51 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f834827..10f7516 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# API Explorer - -A beautiful Frappe app to **generate** and **visualize** whitelisted APIs with interactive Swagger UI documentation. +
+

Frappe OpenAPI

+ A Frappe app to generate and visualize whitelisted APIs with interactive Swagger UI documentation. +
+
+
+ image-removebg-preview +
--- -Screenshot 2025-07-25 at 10 09 30 AM - - ## Features @@ -15,14 +17,27 @@ A beautiful Frappe app to **generate** and **visualize** whitelisted APIs with i --- +## Installation + +Run the following commands inside your bench directory: + +```bash +bench get-app https://github.com/rtCamp/frappe-openapi.git +bench install-app frappe_openapi +bench migrate +bench restart +``` + +> 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 +45,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:** @@ -56,10 +71,10 @@ def create_customer(name, email): - 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) From 5581a2ab29ea1b2f7fd997b3657cc9cdc4fbb61d Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 16:04:55 +0530 Subject: [PATCH 10/12] Fix logo size in reame file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 10f7516..23d2046 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- image-removebg-preview + image-removebg-preview
--- From 08a83aa63a55b5d5cdde295116d82cb64e42b3e5 Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 16:14:46 +0530 Subject: [PATCH 11/12] Add UI image in readme.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 23d2046..de41fab 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@
+
+ logo +

Frappe OpenAPI

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

- image-removebg-preview +image
- --- ## Features @@ -68,6 +70,7 @@ def create_customer(name, email): ``` **Guidelines:** + - Always describe the endpoint's purpose. - Include clear sample response blocks. - Keep examples concise and relevant. From 1f930653606e1446e06dd9165b7451068411e4a8 Mon Sep 17 00:00:00 2001 From: siddhantsingh1230 Date: Wed, 6 May 2026 16:15:33 +0530 Subject: [PATCH 12/12] Remove unwanted line charcters from readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index de41fab..ad365f9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@
image
---- ## Features