diff --git a/frappe/api/v2.py b/frappe/api/v2.py index 130072d50e..ec184214d4 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -8,11 +8,12 @@ Note: internal implementation can change without treating it as "breaking change". """ import json +from typing import Any from werkzeug.routing import Rule import frappe -from frappe import _, is_whitelisted +from frappe import _, get_newargs, is_whitelisted from frappe.core.doctype.server_script.server_script_utils import get_server_script_map from frappe.handler import is_valid_http_method, run_server_script from frappe.utils.data import sbool @@ -122,8 +123,47 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None): return doc.run_method(method, **frappe.form_dict) +def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): + """run a whitelisted controller method on in-memory document. + + + This is useful for building clients that don't necessarily encode all the business logic but + call server side function on object to validate and modify the doc. + + The doc CAN exists in DB too and can write to DB as well if method is POST. + """ + + if isinstance(document, str): + document = frappe.parse_json(document) + + if kwargs is None: + kwargs = {} + + doc = frappe.get_doc(document) + doc._original_modified = doc.modified + doc.check_if_latest() + + if not doc.has_permission("read"): + raise frappe.PermissionError + + method_obj = getattr(doc, method) + fn = getattr(method_obj, "__func__", method_obj) + is_whitelisted(fn) + is_valid_http_method(fn) + + new_kwargs = get_newargs(fn, kwargs) + response = doc.run_method(method, **new_kwargs) + frappe.response.docs.append(doc) # send modified document and result both. + return response + + url_rules = [ Rule("/method/", endpoint=handle_rpc_call), + Rule( + "/method/run_doc_method", + methods=["GET", "POST"], + endpoint=lambda: frappe.call(run_doc_method, **frappe.form_dict), + ), Rule("/method//", endpoint=handle_rpc_call), Rule("/document/", methods=["GET"], endpoint=document_list), Rule("/document/", methods=["POST"], endpoint=create_doc), diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index db097638fa..a5025a7c98 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -215,7 +215,6 @@ class TestResourceAPI(FrappeAPITestCase): @parameterize("", "v1", "v2") def test_create_document(self): - # test 7: POST method on {self.resource_path} to create doc data = {"description": frappe.mock("paragraph"), "sid": self.sid} response = self.post(self.resource_path(self.DOCTYPE), data) self.assertEqual(response.status_code, 200) @@ -225,7 +224,6 @@ class TestResourceAPI(FrappeAPITestCase): @parameterize("", "v1", "v2") def test_update_document(self): - # test 8: PUT method on {self.resource_path} to update doc generated_desc = frappe.mock("paragraph") data = {"description": generated_desc, "sid": self.sid} random_doc = choice(self.GENERATED_DOCUMENTS) @@ -239,7 +237,6 @@ class TestResourceAPI(FrappeAPITestCase): @parameterize("", "v1", "v2") def test_delete_document(self): - # test 9: DELETE method on {self.resource_path} doc_to_delete = choice(self.GENERATED_DOCUMENTS) response = self.delete(self.resource_path(self.DOCTYPE, doc_to_delete)) self.assertEqual(response.status_code, 202) @@ -331,22 +328,23 @@ class TestResourceAPIV2(FrappeAPITestCase): class TestMethodAPIV2(FrappeAPITestCase): version = "v2" + def setUp(self) -> None: + self.post(self.method_path("login"), {"sid": self.sid}) + return super().setUp() + def test_ping(self): - # test 2: test for /api/method/ping - response = self.get(self.method_path("ping")) + response = self.get(self.method_path("frappe.ping")) self.assertEqual(response.status_code, 200) self.assertIsInstance(response.json, dict) self.assertEqual(response.json["data"], "pong") def test_get_user_info(self): - # test 3: test for /api/method/frappe.realtime.get_user_info response = self.get(self.method_path("frappe.realtime.get_user_info")) self.assertEqual(response.status_code, 200) self.assertIsInstance(response.json, dict) self.assertIn(response.json.get("data").get("user"), ("Administrator", "Guest")) def test_auth_cycle(self): - # test 4: Pass authorization token in request global authorization_token generate_admin_keys() @@ -375,6 +373,33 @@ class TestMethodAPIV2(FrappeAPITestCase): ) self.assertEqual(expanded_response.data, shorthand_response.data) + def test_run_doc_method_in_memory(self): + dns = frappe.get_doc("Document Naming Settings") + + # Check that simple API can be called. + response = self.get( + self.method_path("run_doc_method"), + { + "sid": self.sid, + "document": dns.as_dict(), + "method": "get_transactions_and_prefixes", + }, + ) + self.assertTrue(response.json["data"]) + self.assertGreaterEqual(len(response.json["docs"]), 1) + + # Call with known and unknown arguments, only known should get passed + response = self.get( + self.method_path("run_doc_method"), + { + "sid": self.sid, + "document": dns.as_dict(), + "method": "get_options", + "kwargs": {"doctype": "Webhook", "unknown": "what"}, + }, + ) + self.assertEqual(response.status_code, 200) + class TestReadOnlyMode(FrappeAPITestCase): """During migration if read only mode can be enabled.