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

Frappe OpenAPI

+ A Frappe app to generate and visualize whitelisted APIs with interactive Swagger UI documentation. +
+
+
+image +
-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. --- -Screenshot 2025-07-25 at 10 09 30 AM +## 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 %} + + + - + - - - -
-
-
-
+
+
+ + + + + +
+
+ + + + +
+
+
+
+
+
+
{% endblock %} \ No newline at end of file