Merge pull request #22300 from ankush/api_v2
feat!: API versioning and API v2 (beta)
This commit is contained in:
commit
563639235e
33 changed files with 1056 additions and 460 deletions
|
|
@ -169,7 +169,7 @@ lang = local("lang")
|
|||
|
||||
# This if block is never executed when running the code. It is only used for
|
||||
# telling static code analyzer where to find dynamically defined attributes.
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from werkzeug.wrappers import Request
|
||||
|
||||
from frappe.database.mariadb.database import MariaDBDatabase
|
||||
|
|
@ -488,9 +488,12 @@ def msgprint(
|
|||
def _raise_exception():
|
||||
if raise_exception:
|
||||
if inspect.isclass(raise_exception) and issubclass(raise_exception, Exception):
|
||||
raise raise_exception(msg)
|
||||
exc = raise_exception(msg)
|
||||
else:
|
||||
raise ValidationError(msg)
|
||||
exc = ValidationError(msg)
|
||||
if out.__frappe_exc_id:
|
||||
exc.__frappe_exc_id = out.__frappe_exc_id
|
||||
raise exc
|
||||
|
||||
if flags.mute_messages:
|
||||
_raise_exception()
|
||||
|
|
@ -527,6 +530,7 @@ def msgprint(
|
|||
|
||||
if raise_exception:
|
||||
out.raise_exception = 1
|
||||
out.__frappe_exc_id = generate_hash()
|
||||
|
||||
if primary_action:
|
||||
out.primary_action = primary_action
|
||||
|
|
@ -534,11 +538,7 @@ def msgprint(
|
|||
if wide:
|
||||
out.wide = wide
|
||||
|
||||
message_log.append(json.dumps(out))
|
||||
|
||||
if raise_exception and hasattr(raise_exception, "__name__"):
|
||||
local.response["exc_type"] = raise_exception.__name__
|
||||
|
||||
message_log.append(out)
|
||||
_raise_exception()
|
||||
|
||||
|
||||
|
|
|
|||
306
frappe/api.py
306
frappe/api.py
|
|
@ -1,306 +0,0 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import frappe
|
||||
import frappe.client
|
||||
import frappe.handler
|
||||
from frappe import _
|
||||
from frappe.utils.data import sbool
|
||||
from frappe.utils.response import build_response
|
||||
|
||||
|
||||
def handle():
|
||||
"""
|
||||
Handler for `/api` methods
|
||||
|
||||
### Examples:
|
||||
|
||||
`/api/method/{methodname}` will call a whitelisted method
|
||||
|
||||
`/api/resource/{doctype}` will query a table
|
||||
examples:
|
||||
- `?fields=["name", "owner"]`
|
||||
- `?filters=[["Task", "name", "like", "%005"]]`
|
||||
- `?limit_start=0`
|
||||
- `?limit_page_length=20`
|
||||
|
||||
`/api/resource/{doctype}/{name}` will point to a resource
|
||||
`GET` will return doclist
|
||||
`POST` will insert
|
||||
`PUT` will update
|
||||
`DELETE` will delete
|
||||
|
||||
`/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method
|
||||
"""
|
||||
|
||||
parts = frappe.request.path[1:].split("/", 3)
|
||||
call = doctype = name = None
|
||||
|
||||
if len(parts) > 1:
|
||||
call = parts[1]
|
||||
|
||||
if len(parts) > 2:
|
||||
doctype = parts[2]
|
||||
|
||||
if len(parts) > 3:
|
||||
name = parts[3]
|
||||
|
||||
return _RESTAPIHandler(call, doctype, name).get_response()
|
||||
|
||||
|
||||
class _RESTAPIHandler:
|
||||
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
|
||||
self.call = call
|
||||
self.doctype = doctype
|
||||
self.name = name
|
||||
|
||||
def get_response(self):
|
||||
"""Prepare and get response based on URL and form body.
|
||||
|
||||
Note: most methods of this class directly operate on the response local.
|
||||
"""
|
||||
match self.call:
|
||||
case "method":
|
||||
return self.handle_method()
|
||||
case "resource":
|
||||
self.handle_resource()
|
||||
case _:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
return build_response("json")
|
||||
|
||||
def handle_method(self):
|
||||
frappe.local.form_dict.cmd = self.doctype
|
||||
return frappe.handler.handle()
|
||||
|
||||
def handle_resource(self):
|
||||
if self.doctype and self.name:
|
||||
self.handle_document_resource()
|
||||
elif self.doctype:
|
||||
self.handle_doctype_resource()
|
||||
else:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
def handle_document_resource(self):
|
||||
if "run_method" in frappe.local.form_dict:
|
||||
self.execute_doc_method()
|
||||
return
|
||||
|
||||
match frappe.local.request.method:
|
||||
case "GET":
|
||||
self.get_doc()
|
||||
case "PUT":
|
||||
self.update_doc()
|
||||
case "DELETE":
|
||||
self.delete_doc()
|
||||
case _:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
def handle_doctype_resource(self):
|
||||
match frappe.local.request.method:
|
||||
case "GET":
|
||||
self.get_doc_list()
|
||||
case "POST":
|
||||
self.create_doc()
|
||||
case _:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
def execute_doc_method(self):
|
||||
method = frappe.local.form_dict.pop("run_method")
|
||||
doc = frappe.get_doc(self.doctype, self.name)
|
||||
doc.is_whitelisted(method)
|
||||
|
||||
if frappe.local.request.method == "GET":
|
||||
if not doc.has_permission("read"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
|
||||
|
||||
elif frappe.local.request.method == "POST":
|
||||
if not doc.has_permission("write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
|
||||
frappe.db.commit()
|
||||
|
||||
def get_doc(self):
|
||||
doc = frappe.get_doc(self.doctype, self.name)
|
||||
if not doc.has_permission("read"):
|
||||
raise frappe.PermissionError
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
frappe.local.response.update({"data": doc})
|
||||
|
||||
def update_doc(self):
|
||||
data = get_request_form_data()
|
||||
|
||||
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
|
||||
|
||||
if "flags" in data:
|
||||
del data["flags"]
|
||||
|
||||
# Not checking permissions here because it's checked in doc.save
|
||||
doc.update(data)
|
||||
|
||||
frappe.local.response.update({"data": doc.save().as_dict()})
|
||||
|
||||
# check for child table doctype
|
||||
if doc.get("parenttype"):
|
||||
frappe.get_doc(doc.parenttype, doc.parent).save()
|
||||
frappe.db.commit()
|
||||
|
||||
def delete_doc(self):
|
||||
# Not checking permissions here because it's checked in delete_doc
|
||||
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
|
||||
frappe.local.response.http_status_code = 202
|
||||
frappe.local.response.message = "ok"
|
||||
frappe.db.commit()
|
||||
|
||||
def get_doc_list(self):
|
||||
if frappe.local.form_dict.get("fields"):
|
||||
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
|
||||
|
||||
# set limit of records for frappe.get_list
|
||||
frappe.local.form_dict.setdefault(
|
||||
"limit_page_length",
|
||||
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
|
||||
)
|
||||
|
||||
# convert strings to native types - only as_dict and debug accept bool
|
||||
for param in ["as_dict", "debug"]:
|
||||
param_val = frappe.local.form_dict.get(param)
|
||||
if param_val is not None:
|
||||
frappe.local.form_dict[param] = sbool(param_val)
|
||||
|
||||
# evaluate frappe.get_list
|
||||
data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict)
|
||||
|
||||
# set frappe.get_list result to response
|
||||
frappe.local.response.update({"data": data})
|
||||
|
||||
def create_doc(self):
|
||||
data = get_request_form_data()
|
||||
data.update({"doctype": self.doctype})
|
||||
|
||||
# insert document from request data
|
||||
doc = frappe.get_doc(data).insert()
|
||||
|
||||
# set response data
|
||||
frappe.local.response.update({"data": doc.as_dict()})
|
||||
|
||||
# commit for POST requests
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def get_request_form_data():
|
||||
if frappe.local.form_dict.data is None:
|
||||
data = frappe.safe_decode(frappe.local.request.get_data())
|
||||
else:
|
||||
data = frappe.local.form_dict.data
|
||||
|
||||
try:
|
||||
return frappe.parse_json(data)
|
||||
except ValueError:
|
||||
return frappe.local.form_dict
|
||||
|
||||
|
||||
def validate_auth():
|
||||
"""
|
||||
Authenticate and sets user for the request.
|
||||
"""
|
||||
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
|
||||
|
||||
if len(authorization_header) == 2:
|
||||
validate_oauth(authorization_header)
|
||||
validate_auth_via_api_keys(authorization_header)
|
||||
|
||||
validate_auth_via_hooks()
|
||||
|
||||
|
||||
def validate_oauth(authorization_header):
|
||||
"""
|
||||
Authenticate request using OAuth and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
from frappe.integrations.oauth2 import get_oauth_server
|
||||
from frappe.oauth import get_url_delimiter
|
||||
|
||||
form_dict = frappe.local.form_dict
|
||||
token = authorization_header[1]
|
||||
req = frappe.request
|
||||
parsed_url = urlparse(req.url)
|
||||
access_token = {"access_token": token}
|
||||
uri = (
|
||||
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
|
||||
)
|
||||
http_method = req.method
|
||||
headers = req.headers
|
||||
body = req.get_data()
|
||||
if req.content_type and "multipart/form-data" in req.content_type:
|
||||
body = None
|
||||
|
||||
try:
|
||||
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
|
||||
get_url_delimiter()
|
||||
)
|
||||
valid, oauthlib_request = get_oauth_server().verify_request(
|
||||
uri, http_method, body, headers, required_scopes
|
||||
)
|
||||
if valid:
|
||||
frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
|
||||
frappe.local.form_dict = form_dict
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def validate_auth_via_api_keys(authorization_header):
|
||||
"""
|
||||
Authenticate request using API keys and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
try:
|
||||
auth_type, auth_token = authorization_header
|
||||
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
|
||||
if auth_type.lower() == "basic":
|
||||
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
elif auth_type.lower() == "token":
|
||||
api_key, api_secret = auth_token.split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
except binascii.Error:
|
||||
frappe.throw(
|
||||
_("Failed to decode token, please provide a valid base64-encoded token."),
|
||||
frappe.InvalidAuthorizationToken,
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
|
||||
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
|
||||
doctype = frappe_authorization_source or "User"
|
||||
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
|
||||
form_dict = frappe.local.form_dict
|
||||
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
|
||||
if api_secret == doc_secret:
|
||||
if doctype == "User":
|
||||
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
|
||||
else:
|
||||
user = frappe.db.get_value(doctype, doc, "user")
|
||||
if frappe.local.login_manager.user in ("", "Guest"):
|
||||
frappe.set_user(user)
|
||||
frappe.local.form_dict = form_dict
|
||||
|
||||
|
||||
def validate_auth_via_hooks():
|
||||
for auth_hook in frappe.get_hooks("auth_hooks", []):
|
||||
frappe.get_attr(auth_hook)()
|
||||
80
frappe/api/__init__.py
Normal file
80
frappe/api/__init__.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from enum import Enum
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.routing import Map, Submount
|
||||
from werkzeug.wrappers import Request, Response
|
||||
|
||||
import frappe
|
||||
import frappe.client
|
||||
from frappe import _
|
||||
from frappe.utils.response import build_response
|
||||
|
||||
|
||||
class ApiVersion(str, Enum):
|
||||
V1 = "v1"
|
||||
V2 = "v2"
|
||||
|
||||
|
||||
def handle(request: Request):
|
||||
"""
|
||||
Entry point for `/api` methods.
|
||||
|
||||
APIs are versioned using second part of path.
|
||||
v1 -> `/api/v1/*`
|
||||
v2 -> `/api/v2/*`
|
||||
|
||||
Different versions have different specification but broadly following things are supported:
|
||||
|
||||
- `/api/method/{methodname}` will call a whitelisted method
|
||||
- `/api/resource/{doctype}` will query a table
|
||||
examples:
|
||||
- `?fields=["name", "owner"]`
|
||||
- `?filters=[["Task", "name", "like", "%005"]]`
|
||||
- `?limit_start=0`
|
||||
- `?limit_page_length=20`
|
||||
- `/api/resource/{doctype}/{name}` will point to a resource
|
||||
`GET` will return document
|
||||
`POST` will insert
|
||||
`PUT` will update
|
||||
`DELETE` will delete
|
||||
"""
|
||||
|
||||
try:
|
||||
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
|
||||
except NotFound: # Wrap 404 - backward compatiblity
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
data = endpoint(**arguments)
|
||||
if isinstance(data, Response):
|
||||
return data
|
||||
|
||||
if data is not None:
|
||||
frappe.response["data"] = data
|
||||
return build_response("json")
|
||||
|
||||
|
||||
# Merge all API version routing rules
|
||||
from frappe.api.v1 import url_rules as v1_rules
|
||||
from frappe.api.v2 import url_rules as v2_rules
|
||||
|
||||
API_URL_MAP = Map(
|
||||
[
|
||||
# V1 routes
|
||||
Submount("/api", v1_rules),
|
||||
Submount(f"/api/{ApiVersion.V1.value}", v1_rules),
|
||||
Submount(f"/api/{ApiVersion.V2.value}", v2_rules),
|
||||
],
|
||||
strict_slashes=False, # Allows skipping trailing slashes
|
||||
merge_slashes=False,
|
||||
)
|
||||
|
||||
|
||||
def get_api_version() -> ApiVersion | None:
|
||||
if not frappe.request:
|
||||
return
|
||||
|
||||
if frappe.request.path.startswith(f"/api/{ApiVersion.V2.value}"):
|
||||
return ApiVersion.V2
|
||||
return ApiVersion.V1
|
||||
0
frappe/api/utils.py
Normal file
0
frappe/api/utils.py
Normal file
118
frappe/api/v1.py
Normal file
118
frappe/api/v1.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import json
|
||||
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils.data import sbool
|
||||
|
||||
|
||||
def document_list(doctype: str):
|
||||
if frappe.form_dict.get("fields"):
|
||||
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
|
||||
|
||||
# set limit of records for frappe.get_list
|
||||
frappe.form_dict.setdefault(
|
||||
"limit_page_length",
|
||||
frappe.form_dict.limit or frappe.form_dict.limit_page_length or 20,
|
||||
)
|
||||
|
||||
# convert strings to native types - only as_dict and debug accept bool
|
||||
for param in ["as_dict", "debug"]:
|
||||
param_val = frappe.form_dict.get(param)
|
||||
if param_val is not None:
|
||||
frappe.form_dict[param] = sbool(param_val)
|
||||
|
||||
# evaluate frappe.get_list
|
||||
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
|
||||
|
||||
|
||||
def handle_rpc_call(method: str):
|
||||
import frappe.handler
|
||||
|
||||
method = method.split("/")[0] # for backward compatiblity
|
||||
|
||||
frappe.form_dict.cmd = method
|
||||
return frappe.handler.handle()
|
||||
|
||||
|
||||
def create_doc(doctype: str):
|
||||
data = get_request_form_data()
|
||||
data.pop("doctype", None)
|
||||
return frappe.new_doc(doctype, **data).insert()
|
||||
|
||||
|
||||
def update_doc(doctype: str, name: str):
|
||||
data = get_request_form_data()
|
||||
|
||||
doc = frappe.get_doc(doctype, name, for_update=True)
|
||||
if "flags" in data:
|
||||
del data["flags"]
|
||||
|
||||
doc.update(data)
|
||||
doc.save()
|
||||
|
||||
# check for child table doctype
|
||||
if doc.get("parenttype"):
|
||||
frappe.get_doc(doc.parenttype, doc.parent).save()
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
def delete_doc(doctype: str, name: str):
|
||||
# TODO: child doc handling
|
||||
frappe.delete_doc(doctype, name, ignore_missing=False)
|
||||
frappe.response.http_status_code = 202
|
||||
return "ok"
|
||||
|
||||
|
||||
def read_doc(doctype: str, name: str):
|
||||
# Backward compatiblity
|
||||
if "run_method" in frappe.form_dict:
|
||||
return execute_doc_method(doctype, name)
|
||||
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if not doc.has_permission("read"):
|
||||
raise frappe.PermissionError
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
return doc
|
||||
|
||||
|
||||
def execute_doc_method(doctype: str, name: str, method: str | None = None):
|
||||
method = method or frappe.form_dict.pop("run_method")
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.is_whitelisted(method)
|
||||
|
||||
if frappe.request.method == "GET":
|
||||
if not doc.has_permission("read"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
return doc.run_method(method, **frappe.form_dict)
|
||||
|
||||
elif frappe.request.method == "POST":
|
||||
if not doc.has_permission("write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
return doc.run_method(method, **frappe.form_dict)
|
||||
|
||||
|
||||
def get_request_form_data():
|
||||
if frappe.form_dict.data is None:
|
||||
data = frappe.safe_decode(frappe.request.get_data())
|
||||
else:
|
||||
data = frappe.form_dict.data
|
||||
|
||||
try:
|
||||
return frappe.parse_json(data)
|
||||
except ValueError:
|
||||
return frappe.form_dict
|
||||
|
||||
|
||||
url_rules = [
|
||||
Rule("/method/<path:method>", endpoint=handle_rpc_call),
|
||||
Rule("/resource/<doctype>", methods=["GET"], endpoint=document_list),
|
||||
Rule("/resource/<doctype>", methods=["POST"], endpoint=create_doc),
|
||||
Rule("/resource/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
|
||||
Rule("/resource/<doctype>/<path:name>/", methods=["PUT"], endpoint=update_doc),
|
||||
Rule("/resource/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
|
||||
Rule("/resource/<doctype>/<path:name>/", methods=["POST"], endpoint=execute_doc_method),
|
||||
]
|
||||
193
frappe/api/v2.py
Normal file
193
frappe/api/v2.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""REST API v2
|
||||
|
||||
This file defines routes and implementation for REST API.
|
||||
|
||||
Note:
|
||||
- All functions in this file should be treated as "whitelisted" as they are exposed via routes
|
||||
- None of the functions present here should be called from python code, their location and
|
||||
internal implementation can change without treating it as "breaking change".
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
import frappe
|
||||
import frappe.client
|
||||
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, upload_file
|
||||
|
||||
PERMISSION_MAP = {
|
||||
"GET": "read",
|
||||
"POST": "write",
|
||||
}
|
||||
|
||||
|
||||
def handle_rpc_call(method: str, doctype: str | None = None):
|
||||
from frappe.modules.utils import load_doctype_module
|
||||
|
||||
if doctype:
|
||||
# Expand to run actual method from doctype controller
|
||||
module = load_doctype_module(doctype)
|
||||
method = module.__name__ + "." + method
|
||||
|
||||
for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(method, [])):
|
||||
# override using the last hook
|
||||
method = hook
|
||||
break
|
||||
|
||||
# via server script
|
||||
server_script = get_server_script_map().get("_api", {}).get(method)
|
||||
if server_script:
|
||||
return run_server_script(server_script)
|
||||
|
||||
try:
|
||||
method = frappe.get_attr(method)
|
||||
except Exception as e:
|
||||
frappe.throw(_("Failed to get method {0} with {1}").format(method, e))
|
||||
|
||||
is_whitelisted(method)
|
||||
is_valid_http_method(method)
|
||||
|
||||
return frappe.call(method, **frappe.form_dict)
|
||||
|
||||
|
||||
def login():
|
||||
"""Login happens implicitly, this function doesn't do anything."""
|
||||
pass
|
||||
|
||||
|
||||
def logout():
|
||||
frappe.local.login_manager.logout()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def read_doc(doctype: str, name: str):
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission("read")
|
||||
doc.apply_fieldlevel_read_permissions()
|
||||
return doc
|
||||
|
||||
|
||||
def document_list(doctype: str):
|
||||
if frappe.form_dict.get("fields"):
|
||||
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
|
||||
|
||||
# set limit of records for frappe.get_list
|
||||
frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20
|
||||
# evaluate frappe.get_list
|
||||
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
|
||||
|
||||
|
||||
def count(doctype: str) -> int:
|
||||
from frappe.desk.reportview import get_count
|
||||
|
||||
frappe.form_dict.doctype = doctype
|
||||
|
||||
return get_count()
|
||||
|
||||
|
||||
def create_doc(doctype: str):
|
||||
data = frappe.form_dict
|
||||
data.pop("doctype", None)
|
||||
return frappe.new_doc(doctype, **data).insert()
|
||||
|
||||
|
||||
def update_doc(doctype: str, name: str):
|
||||
data = frappe.form_dict
|
||||
|
||||
doc = frappe.get_doc(doctype, name, for_update=True)
|
||||
data.pop("flags", None)
|
||||
doc.update(data)
|
||||
doc.save()
|
||||
|
||||
# check for child table doctype
|
||||
if doc.get("parenttype"):
|
||||
frappe.get_doc(doc.parenttype, doc.parent).save()
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
def delete_doc(doctype: str, name: str):
|
||||
frappe.client.delete_doc(doctype, name)
|
||||
frappe.response.http_status_code = 202
|
||||
return "ok"
|
||||
|
||||
|
||||
def execute_doc_method(doctype: str, name: str, method: str | None = None):
|
||||
"""Get a document from DB and execute method on it.
|
||||
|
||||
Use cases:
|
||||
- Submitting/cancelling document
|
||||
- Triggering some kind of update on a document
|
||||
"""
|
||||
method = method or frappe.form_dict.pop("run_method")
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.is_whitelisted(method)
|
||||
|
||||
doc.check_permission(PERMISSION_MAP[frappe.request.method])
|
||||
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()
|
||||
|
||||
doc.check_permission(PERMISSION_MAP[frappe.request.method])
|
||||
|
||||
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 = [
|
||||
# RPC calls
|
||||
Rule("/method/login", endpoint=login),
|
||||
Rule("/method/logout", endpoint=logout),
|
||||
Rule("/method/ping", endpoint=frappe.ping),
|
||||
Rule("/method/upload_file", endpoint=upload_file),
|
||||
Rule("/method/<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/<doctype>/<method>", endpoint=handle_rpc_call),
|
||||
# Document level APIs
|
||||
Rule("/document/<doctype>", methods=["GET"], endpoint=document_list),
|
||||
Rule("/document/<doctype>", methods=["POST"], endpoint=create_doc),
|
||||
Rule("/document/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
|
||||
Rule("/document/<doctype>/<path:name>/", methods=["PATCH", "PUT"], endpoint=update_doc),
|
||||
Rule("/document/<doctype>/<path:name>/", methods=["DELETE"], endpoint=delete_doc),
|
||||
Rule(
|
||||
"/document/<doctype>/<path:name>/method/<method>/",
|
||||
methods=["GET", "POST"],
|
||||
endpoint=execute_doc_method,
|
||||
),
|
||||
# Collection level APIs
|
||||
Rule("/doctype/<doctype>/meta", methods=["GET"], endpoint=frappe.get_meta),
|
||||
Rule("/doctype/<doctype>/count", methods=["GET"], endpoint=count),
|
||||
]
|
||||
|
|
@ -22,10 +22,11 @@ import frappe.rate_limiter
|
|||
import frappe.recorder
|
||||
import frappe.utils.response
|
||||
from frappe import _
|
||||
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest
|
||||
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth
|
||||
from frappe.middlewares import StaticDataMiddleware
|
||||
from frappe.utils import CallbackManager, cint, get_site_name, sanitize_html
|
||||
from frappe.utils import CallbackManager, cint, get_site_name
|
||||
from frappe.utils.data import escape_html
|
||||
from frappe.utils.deprecations import deprecation_warning
|
||||
from frappe.utils.error import log_error_snapshot
|
||||
from frappe.website.serve import get_response
|
||||
|
||||
|
|
@ -93,16 +94,20 @@ def application(request: Request):
|
|||
|
||||
init_request(request)
|
||||
|
||||
frappe.api.validate_auth()
|
||||
validate_auth()
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
response = Response()
|
||||
|
||||
elif frappe.form_dict.cmd:
|
||||
response = frappe.handler.handle()
|
||||
deprecation_warning(
|
||||
f"{frappe.form_dict.cmd}: Sending `cmd` for RPC calls is deprecated, call REST API instead `/api/method/cmd`"
|
||||
)
|
||||
frappe.handler.handle()
|
||||
response = frappe.utils.response.build_response("json")
|
||||
|
||||
elif request.path.startswith("/api/"):
|
||||
response = frappe.api.handle()
|
||||
response = frappe.api.handle(request)
|
||||
|
||||
elif request.path.startswith("/backups"):
|
||||
response = frappe.utils.response.download_backup(request.path)
|
||||
|
|
|
|||
103
frappe/auth.py
103
frappe/auth.py
|
|
@ -1,6 +1,8 @@
|
|||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See LICENSE
|
||||
from urllib.parse import quote
|
||||
import base64
|
||||
import binascii
|
||||
from urllib.parse import quote, urlencode, urlparse
|
||||
|
||||
import frappe
|
||||
import frappe.database
|
||||
|
|
@ -547,3 +549,102 @@ class LoginAttemptTracker:
|
|||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_auth():
|
||||
"""
|
||||
Authenticate and sets user for the request.
|
||||
"""
|
||||
authorization_header = frappe.get_request_header("Authorization", "").split(" ")
|
||||
|
||||
if len(authorization_header) == 2:
|
||||
validate_oauth(authorization_header)
|
||||
validate_auth_via_api_keys(authorization_header)
|
||||
|
||||
validate_auth_via_hooks()
|
||||
|
||||
|
||||
def validate_oauth(authorization_header):
|
||||
"""
|
||||
Authenticate request using OAuth and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
from frappe.integrations.oauth2 import get_oauth_server
|
||||
from frappe.oauth import get_url_delimiter
|
||||
|
||||
form_dict = frappe.local.form_dict
|
||||
token = authorization_header[1]
|
||||
req = frappe.request
|
||||
parsed_url = urlparse(req.url)
|
||||
access_token = {"access_token": token}
|
||||
uri = (
|
||||
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
|
||||
)
|
||||
http_method = req.method
|
||||
headers = req.headers
|
||||
body = req.get_data()
|
||||
if req.content_type and "multipart/form-data" in req.content_type:
|
||||
body = None
|
||||
|
||||
try:
|
||||
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
|
||||
get_url_delimiter()
|
||||
)
|
||||
valid, oauthlib_request = get_oauth_server().verify_request(
|
||||
uri, http_method, body, headers, required_scopes
|
||||
)
|
||||
if valid:
|
||||
frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
|
||||
frappe.local.form_dict = form_dict
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def validate_auth_via_api_keys(authorization_header):
|
||||
"""
|
||||
Authenticate request using API keys and set session user
|
||||
|
||||
Args:
|
||||
authorization_header (list of str): The 'Authorization' header containing the prefix and token
|
||||
"""
|
||||
|
||||
try:
|
||||
auth_type, auth_token = authorization_header
|
||||
authorization_source = frappe.get_request_header("Frappe-Authorization-Source")
|
||||
if auth_type.lower() == "basic":
|
||||
api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
elif auth_type.lower() == "token":
|
||||
api_key, api_secret = auth_token.split(":")
|
||||
validate_api_key_secret(api_key, api_secret, authorization_source)
|
||||
except binascii.Error:
|
||||
frappe.throw(
|
||||
_("Failed to decode token, please provide a valid base64-encoded token."),
|
||||
frappe.InvalidAuthorizationToken,
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
|
||||
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
|
||||
doctype = frappe_authorization_source or "User"
|
||||
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
|
||||
form_dict = frappe.local.form_dict
|
||||
doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret")
|
||||
if api_secret == doc_secret:
|
||||
if doctype == "User":
|
||||
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
|
||||
else:
|
||||
user = frappe.db.get_value(doctype, doc, "user")
|
||||
if frappe.local.login_manager.user in ("", "Guest"):
|
||||
frappe.set_user(user)
|
||||
frappe.local.form_dict = form_dict
|
||||
|
||||
|
||||
def validate_auth_via_hooks():
|
||||
for auth_hook in frappe.get_hooks("auth_hooks", []):
|
||||
frappe.get_attr(auth_hook)()
|
||||
|
|
|
|||
|
|
@ -295,8 +295,7 @@ def add_home_page(bootinfo, docs):
|
|||
docs.append(page)
|
||||
bootinfo["home_page"] = page.name
|
||||
except (frappe.DoesNotExistError, frappe.PermissionError):
|
||||
if frappe.message_log:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
bootinfo["home_page"] = "Workspaces"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ def get_list(
|
|||
limit_start=None,
|
||||
limit_page_length=20,
|
||||
parent=None,
|
||||
debug=False,
|
||||
as_dict=True,
|
||||
debug: bool = False,
|
||||
as_dict: bool = True,
|
||||
or_filters=None,
|
||||
):
|
||||
"""Returns a list of records by filters, fields, ordering and limit
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class TestImporter(FrappeTestCase):
|
|||
def test_data_import_without_mandatory_values(self):
|
||||
import_file = get_import_file("sample_import_file_without_mandatory")
|
||||
data_import = self.get_importer(doctype_name, import_file)
|
||||
frappe.local.message_log = []
|
||||
frappe.clear_messages()
|
||||
data_import.start_import()
|
||||
data_import.reload()
|
||||
|
||||
|
|
|
|||
|
|
@ -73,11 +73,11 @@ def bulk_restore(docnames):
|
|||
restored.append(d)
|
||||
|
||||
except frappe.DocumentAlreadyRestored:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
invalid.append(d)
|
||||
|
||||
except Exception:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
failed.append(d)
|
||||
frappe.db.rollback()
|
||||
|
||||
|
|
|
|||
|
|
@ -248,8 +248,7 @@ class DocumentNamingSettings(Document):
|
|||
doc = self._fetch_last_doc_if_available()
|
||||
return "\n".join(NamingSeries(series).get_preview(doc=doc))
|
||||
except Exception as e:
|
||||
if frappe.message_log:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
return _("Failed to generate names from the series") + f"\n{str(e)}"
|
||||
|
||||
def _fetch_last_doc_if_available(self):
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ class TestFile(FrappeTestCase):
|
|||
test_file.file_url = frappe.utils.get_url("unknown.jpg")
|
||||
test_file.make_thumbnail(suffix="xs")
|
||||
self.assertEqual(
|
||||
json.loads(frappe.message_log[0]).get("message"),
|
||||
frappe.message_log[0].get("message"),
|
||||
f"File '{frappe.utils.get_url('unknown.jpg')}' not found",
|
||||
)
|
||||
self.assertEqual(test_file.thumbnail_url, None)
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ class TestUser(FrappeTestCase):
|
|||
self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/")
|
||||
update_password(old_password, old_password=new_password)
|
||||
self.assertEqual(
|
||||
json.loads(frappe.message_log[0]).get("message"),
|
||||
frappe.message_log[0].get("message"),
|
||||
"Password reset instructions have been sent to your email",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -549,6 +549,10 @@ class User(Document):
|
|||
# delete user permissions
|
||||
frappe.db.delete("User Permission", {"user": self.name})
|
||||
|
||||
# Delete OAuth data
|
||||
frappe.db.delete("OAuth Authorization Code", {"user": self.name})
|
||||
frappe.db.delete("Token Cache", {"user": self.name})
|
||||
|
||||
def before_rename(self, old_name, new_name, merge=False):
|
||||
frappe.clear_cache(user=old_name)
|
||||
self.validate_rename(old_name, new_name)
|
||||
|
|
@ -775,7 +779,7 @@ def get_timezones():
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_all_roles(arg=None):
|
||||
def get_all_roles():
|
||||
"""return all roles"""
|
||||
active_domains = frappe.get_active_domains()
|
||||
|
||||
|
|
@ -789,7 +793,7 @@ def get_all_roles(arg=None):
|
|||
order_by="name",
|
||||
)
|
||||
|
||||
return [role.get("name") for role in roles]
|
||||
return sorted([role.get("name") for role in roles])
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ def handle_not_exist(fn):
|
|||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except DoesNotExistError:
|
||||
if frappe.message_log:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
return []
|
||||
|
||||
return wrapper
|
||||
|
|
|
|||
|
|
@ -283,8 +283,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True):
|
|||
raise e
|
||||
else:
|
||||
visible_list.remove(module_name)
|
||||
if frappe.message_log:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
|
||||
# set the order
|
||||
set_order(visible_list)
|
||||
|
|
|
|||
|
|
@ -427,8 +427,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di
|
|||
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
|
||||
except Exception as e:
|
||||
if isinstance(e, frappe.DoesNotExistError):
|
||||
if frappe.local.message_log:
|
||||
frappe.local.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
continue
|
||||
linkmeta = link_meta_bundle[0]
|
||||
|
||||
|
|
@ -502,8 +501,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di
|
|||
ret = None
|
||||
|
||||
except frappe.PermissionError:
|
||||
if frappe.local.message_log:
|
||||
frappe.local.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ def get_list():
|
|||
|
||||
@frappe.whitelist()
|
||||
@frappe.read_only()
|
||||
def get_count():
|
||||
def get_count() -> int:
|
||||
args = get_form_params()
|
||||
|
||||
if is_virtual_doctype(args.doctype):
|
||||
|
|
@ -65,7 +65,7 @@ def execute(doctype, *args, **kwargs):
|
|||
|
||||
|
||||
def get_form_params():
|
||||
"""Stringify GET request parameters."""
|
||||
"""parse GET request parameters."""
|
||||
data = frappe._dict(frappe.local.form_dict)
|
||||
clean_params(data)
|
||||
validate_args(data)
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ class EmailAccount(Document):
|
|||
except OSError:
|
||||
if in_receive:
|
||||
# timeout while connecting, see receive.py connect method
|
||||
description = frappe.message_log.pop() if frappe.message_log else "Socket Error"
|
||||
description = frappe.clear_last_message() if frappe.message_log else "Socket Error"
|
||||
if test_internet():
|
||||
self.db_set("no_failed", self.no_failed + 1)
|
||||
if self.no_failed > 2:
|
||||
|
|
@ -496,7 +496,7 @@ class EmailAccount(Document):
|
|||
}
|
||||
)
|
||||
except assign_to.DuplicateToDoError:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
pass
|
||||
else:
|
||||
self.set_failed_attempts_count(self.get_failed_attempts_count() + 1)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from frappe.core.doctype.server_script.server_script_utils import get_server_scr
|
|||
from frappe.monitor import add_data_to_monitor
|
||||
from frappe.utils import cint
|
||||
from frappe.utils.csvutils import build_csv_response
|
||||
from frappe.utils.deprecations import deprecation_warning
|
||||
from frappe.utils.image import optimize_image
|
||||
from frappe.utils.response import build_response
|
||||
|
||||
|
|
@ -56,13 +57,11 @@ def handle():
|
|||
# add the response to `message` label
|
||||
frappe.response["message"] = data
|
||||
|
||||
return build_response("json")
|
||||
|
||||
|
||||
def execute_cmd(cmd, from_async=False):
|
||||
"""execute a request as python module"""
|
||||
for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(cmd, [])):
|
||||
# override using the first hook
|
||||
# override using the last hook
|
||||
cmd = hook
|
||||
break
|
||||
|
||||
|
|
@ -273,6 +272,9 @@ def get_attr(cmd):
|
|||
if "." in cmd:
|
||||
method = frappe.get_attr(cmd)
|
||||
else:
|
||||
deprecation_warning(
|
||||
f"Calling shorthand for {cmd} is deprecated, please specify full path in RPC call."
|
||||
)
|
||||
method = globals()[cmd]
|
||||
frappe.log("method:" + cmd)
|
||||
return method
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ class ConnectedApp(Document):
|
|||
def validate(self):
|
||||
base_url = frappe.utils.get_url()
|
||||
callback_path = (
|
||||
"/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/" + self.name
|
||||
"/api/method/frappe.integrations.doctype.connected_app.connected_app.callback"
|
||||
+ f"?app={self.name}"
|
||||
)
|
||||
self.redirect_uri = urljoin(base_url, callback_path)
|
||||
|
||||
|
|
@ -148,7 +149,7 @@ class ConnectedApp(Document):
|
|||
|
||||
|
||||
@frappe.whitelist(methods=["GET"], allow_guest=True)
|
||||
def callback(code=None, state=None):
|
||||
def callback(code=None, state=None, app=None):
|
||||
"""Handle client's code.
|
||||
|
||||
Called during the oauthorization flow by the remote oAuth2 server to
|
||||
|
|
@ -161,11 +162,7 @@ def callback(code=None, state=None):
|
|||
frappe.local.response["location"] = "/login?" + urlencode({"redirect-to": frappe.request.url})
|
||||
return
|
||||
|
||||
path = frappe.request.path[1:].split("/")
|
||||
if len(path) != 4 or not path[3]:
|
||||
frappe.throw(_("Invalid Parameters."))
|
||||
|
||||
connected_app = frappe.get_doc("Connected App", path[3])
|
||||
connected_app = frappe.get_doc("Connected App", app)
|
||||
token_cache = frappe.get_doc("Token Cache", connected_app.name + "-" + frappe.session.user)
|
||||
|
||||
if state != token_cache.state:
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ class TestConnectedApp(FrappeTestCase):
|
|||
def delete_if_exists(attribute):
|
||||
doc = getattr(self, attribute, None)
|
||||
if doc:
|
||||
doc.delete()
|
||||
doc.delete(force=True)
|
||||
|
||||
delete_if_exists("token_cache")
|
||||
delete_if_exists("connected_app")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from frappe.model.workflow import set_workflow_state_on_action, validate_workflo
|
|||
from frappe.types import DF
|
||||
from frappe.utils import compare, cstr, date_diff, file_lock, flt, get_datetime_str, now
|
||||
from frappe.utils.data import get_absolute_url
|
||||
from frappe.utils.deprecations import deprecated
|
||||
from frappe.utils.global_search import update_global_search
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -139,12 +140,6 @@ class Document(BaseDocument):
|
|||
def is_locked(self):
|
||||
return file_lock.lock_exists(self.get_signature())
|
||||
|
||||
@staticmethod
|
||||
def whitelist(fn):
|
||||
"""Decorator: Whitelist method to be called remotely via REST API."""
|
||||
frappe.whitelist()(fn)
|
||||
return fn
|
||||
|
||||
def load_from_db(self):
|
||||
"""Load document and children from database and create properties
|
||||
from fields"""
|
||||
|
|
@ -1016,19 +1011,16 @@ class Document(BaseDocument):
|
|||
elif alert.event == "Method" and method == alert.method:
|
||||
_evaluate_alert(alert)
|
||||
|
||||
@whitelist.__func__
|
||||
def _submit(self):
|
||||
"""Submit the document. Sets `docstatus` = 1, then saves."""
|
||||
self.docstatus = DocStatus.submitted()
|
||||
return self.save()
|
||||
|
||||
@whitelist.__func__
|
||||
def _cancel(self):
|
||||
"""Cancel the document. Sets `docstatus` = 2, then saves."""
|
||||
self.docstatus = DocStatus.cancelled()
|
||||
return self.save()
|
||||
|
||||
@whitelist.__func__
|
||||
def _rename(
|
||||
self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True
|
||||
):
|
||||
|
|
@ -1038,20 +1030,18 @@ class Document(BaseDocument):
|
|||
self.name = rename_doc(doc=self, new=name, merge=merge, force=force, validate=validate_rename)
|
||||
self.reload()
|
||||
|
||||
@whitelist.__func__
|
||||
@frappe.whitelist()
|
||||
def submit(self):
|
||||
"""Submit the document. Sets `docstatus` = 1, then saves."""
|
||||
return self._submit()
|
||||
|
||||
@whitelist.__func__
|
||||
@frappe.whitelist()
|
||||
def cancel(self):
|
||||
"""Cancel the document. Sets `docstatus` = 2, then saves."""
|
||||
return self._cancel()
|
||||
|
||||
@whitelist.__func__
|
||||
def rename(
|
||||
self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True
|
||||
):
|
||||
@frappe.whitelist()
|
||||
def rename(self, name: str, merge=False, force=False, validate_rename=True):
|
||||
"""Rename the document to `name`. This transforms the current object."""
|
||||
return self._rename(name=name, merge=merge, force=force, validate_rename=validate_rename)
|
||||
|
||||
|
|
@ -1638,8 +1628,8 @@ def execute_action(__doctype, __name, __action, **kwargs):
|
|||
frappe.db.rollback()
|
||||
|
||||
# add a comment (?)
|
||||
if frappe.local.message_log:
|
||||
msg = json.loads(frappe.local.message_log[-1]).get("message")
|
||||
if frappe.message_log:
|
||||
msg = frappe.message_log[-1].get("message")
|
||||
else:
|
||||
msg = "<pre><code>" + frappe.get_traceback() + "</pre></code>"
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ frappe.call = function (opts) {
|
|||
|
||||
let url = opts.url;
|
||||
if (!url) {
|
||||
url = "/api/method/" + args.cmd;
|
||||
let prefix = "/api/method/";
|
||||
if (opts.api_version) {
|
||||
prefix = `/api/${opts.api_version}/method/`;
|
||||
}
|
||||
url = prefix + args.cmd;
|
||||
if (window.cordova) {
|
||||
let host = frappe.request.url;
|
||||
host = host.slice(0, host.length - 1);
|
||||
|
|
@ -116,6 +120,7 @@ frappe.call = function (opts) {
|
|||
// show_spinner: !opts.no_spinner,
|
||||
async: opts.async,
|
||||
silent: opts.silent,
|
||||
api_version: opts.api_version,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
|
@ -444,12 +449,18 @@ frappe.request.cleanup = function (opts, r) {
|
|||
}
|
||||
|
||||
// show messages
|
||||
if (r._server_messages && !opts.silent) {
|
||||
//
|
||||
let messages;
|
||||
if (opts.api_version == "v2") {
|
||||
messages = r.messages;
|
||||
} else if (r._server_messages) {
|
||||
messages = JSON.parse(r._server_messages);
|
||||
}
|
||||
if (messages && !opts.silent) {
|
||||
// show server messages if no handlers exist
|
||||
if (handlers.length === 0) {
|
||||
r._server_messages = JSON.parse(r._server_messages);
|
||||
frappe.hide_msgprint();
|
||||
frappe.msgprint(r._server_messages);
|
||||
frappe.msgprint(messages);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,15 @@ frappe.msgprint = function (msg, title, is_minimizable) {
|
|||
|
||||
if (data.message instanceof Array) {
|
||||
let messages = data.message;
|
||||
const exceptions = messages.map((m) => JSON.parse(m)).filter((m) => m.raise_exception);
|
||||
const exceptions = messages
|
||||
.map((m) => {
|
||||
if (typeof m == "string") {
|
||||
return JSON.parse(m);
|
||||
} else {
|
||||
return m;
|
||||
}
|
||||
})
|
||||
.filter((m) => m.raise_exception);
|
||||
|
||||
// only show exceptions if any exceptions exist
|
||||
if (exceptions.length) {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import json
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
from random import choice
|
||||
from threading import Thread
|
||||
from time import time
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from filetype import guess_mime
|
||||
from semantic_version import Version
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import frappe
|
||||
from frappe.installer import update_site_config
|
||||
from frappe.tests.utils import FrappeTestCase, patch_hooks
|
||||
from frappe.utils import cint, get_site_url, get_test_client
|
||||
from frappe.utils import cint, get_test_client, get_url
|
||||
|
||||
try:
|
||||
_site = frappe.local.site
|
||||
|
|
@ -73,34 +74,55 @@ class ThreadWithReturnValue(Thread):
|
|||
return self._return
|
||||
|
||||
|
||||
resource_key = {
|
||||
"": "resource",
|
||||
"v1": "resource",
|
||||
"v2": "document",
|
||||
}
|
||||
|
||||
|
||||
class FrappeAPITestCase(FrappeTestCase):
|
||||
SITE = frappe.local.site
|
||||
SITE_URL = get_site_url(SITE)
|
||||
RESOURCE_URL = f"{SITE_URL}/api/resource"
|
||||
version = "" # Empty implies v1
|
||||
TEST_CLIENT = get_test_client()
|
||||
|
||||
@property
|
||||
def site_url(self):
|
||||
return get_url()
|
||||
|
||||
def resource_path(self, *parts):
|
||||
return self.get_path(resource_key[self.version], *parts)
|
||||
|
||||
def method_path(self, *method):
|
||||
return self.get_path("method", *method)
|
||||
|
||||
def doctype_path(self, *method):
|
||||
return self.get_path("doctype", *method)
|
||||
|
||||
def get_path(self, *parts):
|
||||
return urljoin(self.site_url, "/".join(("api", self.version, *parts)))
|
||||
|
||||
@cached_property
|
||||
def sid(self) -> str:
|
||||
if not getattr(self, "_sid", None):
|
||||
from frappe.auth import CookieManager, LoginManager
|
||||
from frappe.utils import set_request
|
||||
from frappe.auth import CookieManager, LoginManager
|
||||
from frappe.utils import set_request
|
||||
|
||||
set_request(path="/")
|
||||
frappe.local.cookie_manager = CookieManager()
|
||||
frappe.local.login_manager = LoginManager()
|
||||
frappe.local.login_manager.login_as("Administrator")
|
||||
self._sid = frappe.session.sid
|
||||
|
||||
return self._sid
|
||||
set_request(path="/")
|
||||
frappe.local.cookie_manager = CookieManager()
|
||||
frappe.local.login_manager = LoginManager()
|
||||
frappe.local.login_manager.login_as("Administrator")
|
||||
return frappe.session.sid
|
||||
|
||||
def get(self, path: str, params: dict | None = None, **kwargs) -> TestResponse:
|
||||
return make_request(target=self.TEST_CLIENT.get, args=(path,), kwargs={"data": params, **kwargs})
|
||||
return make_request(target=self.TEST_CLIENT.get, args=(path,), kwargs={"json": params, **kwargs})
|
||||
|
||||
def post(self, path, data, **kwargs) -> TestResponse:
|
||||
return make_request(target=self.TEST_CLIENT.post, args=(path,), kwargs={"data": data, **kwargs})
|
||||
return make_request(target=self.TEST_CLIENT.post, args=(path,), kwargs={"json": data, **kwargs})
|
||||
|
||||
def put(self, path, data, **kwargs) -> TestResponse:
|
||||
return make_request(target=self.TEST_CLIENT.put, args=(path,), kwargs={"data": data, **kwargs})
|
||||
return make_request(target=self.TEST_CLIENT.put, args=(path,), kwargs={"json": data, **kwargs})
|
||||
|
||||
def patch(self, path, data, **kwargs) -> TestResponse:
|
||||
return make_request(target=self.TEST_CLIENT.patch, args=(path,), kwargs={"json": data, **kwargs})
|
||||
|
||||
def delete(self, path, **kwargs) -> TestResponse:
|
||||
return make_request(target=self.TEST_CLIENT.delete, args=(path,), kwargs=kwargs)
|
||||
|
|
@ -113,8 +135,9 @@ class TestResourceAPI(FrappeAPITestCase):
|
|||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
for _ in range(10):
|
||||
for _ in range(20):
|
||||
doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert()
|
||||
cls.GENERATED_DOCUMENTS = []
|
||||
cls.GENERATED_DOCUMENTS.append(doc.name)
|
||||
frappe.db.commit()
|
||||
|
||||
|
|
@ -126,31 +149,31 @@ class TestResourceAPI(FrappeAPITestCase):
|
|||
|
||||
def test_unauthorized_call(self):
|
||||
# test 1: fetch documents without auth
|
||||
response = requests.get(f"{self.RESOURCE_URL}/{self.DOCTYPE}")
|
||||
response = requests.get(self.resource_path(self.DOCTYPE))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_get_list(self):
|
||||
# test 2: fetch documents without params
|
||||
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid})
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertIn("data", response.json)
|
||||
|
||||
def test_get_list_limit(self):
|
||||
# test 3: fetch data with limit
|
||||
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "limit": 2})
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "limit": 2})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json["data"]), 2)
|
||||
|
||||
def test_get_list_dict(self):
|
||||
# test 4: fetch response as (not) dict
|
||||
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": True})
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": True})
|
||||
json = frappe._dict(response.json)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(json.data, list)
|
||||
self.assertIsInstance(json.data[0], dict)
|
||||
|
||||
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": False})
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": False})
|
||||
json = frappe._dict(response.json)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(json.data, list)
|
||||
|
|
@ -158,7 +181,8 @@ class TestResourceAPI(FrappeAPITestCase):
|
|||
|
||||
def test_get_list_debug(self):
|
||||
# test 5: fetch response with debug
|
||||
response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "debug": True})
|
||||
with suppress_stdout():
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "debug": True})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("exc", response.json)
|
||||
self.assertIsInstance(response.json["exc"], str)
|
||||
|
|
@ -167,52 +191,48 @@ class TestResourceAPI(FrappeAPITestCase):
|
|||
def test_get_list_fields(self):
|
||||
# test 6: fetch response with fields
|
||||
response = self.get(
|
||||
f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "fields": '["description"]'}
|
||||
self.resource_path(self.DOCTYPE), {"sid": self.sid, "fields": '["description"]'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
json = frappe._dict(response.json)
|
||||
self.assertIn("description", json.data[0])
|
||||
|
||||
def test_create_document(self):
|
||||
# test 7: POST method on /api/resource to create doc
|
||||
data = {"description": frappe.mock("paragraph"), "sid": self.sid}
|
||||
response = self.post(f"/api/resource/{self.DOCTYPE}", data)
|
||||
response = self.post(self.resource_path(self.DOCTYPE), data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
docname = response.json["data"]["name"]
|
||||
self.assertIsInstance(docname, str)
|
||||
self.GENERATED_DOCUMENTS.append(docname)
|
||||
|
||||
def test_update_document(self):
|
||||
# test 8: PUT method on /api/resource to update doc
|
||||
generated_desc = frappe.mock("paragraph")
|
||||
data = {"description": generated_desc, "sid": self.sid}
|
||||
random_doc = choice(self.GENERATED_DOCUMENTS)
|
||||
desc_before_update = frappe.db.get_value(self.DOCTYPE, random_doc, "description")
|
||||
|
||||
response = self.put(f"/api/resource/{self.DOCTYPE}/{random_doc}", data=data)
|
||||
response = self.put(self.resource_path(self.DOCTYPE, random_doc), data=data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotEqual(response.json["data"]["description"], desc_before_update)
|
||||
self.assertEqual(response.json["data"]["description"], generated_desc)
|
||||
|
||||
response = self.get(self.resource_path(self.DOCTYPE, random_doc))
|
||||
self.assertEqual(response.json["data"]["description"], generated_desc)
|
||||
|
||||
def test_delete_document(self):
|
||||
# test 9: DELETE method on /api/resource
|
||||
doc_to_delete = choice(self.GENERATED_DOCUMENTS)
|
||||
response = self.delete(f"/api/resource/{self.DOCTYPE}/{doc_to_delete}")
|
||||
response = self.delete(self.resource_path(self.DOCTYPE, doc_to_delete))
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertDictEqual(response.json, {"message": "ok"})
|
||||
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
|
||||
self.assertDictEqual(response.json, {"data": "ok"})
|
||||
|
||||
non_existent_doc = frappe.generate_hash(length=12)
|
||||
with suppress_stdout():
|
||||
response = self.delete(f"/api/resource/{self.DOCTYPE}/{non_existent_doc}")
|
||||
response = self.get(self.resource_path(self.DOCTYPE, doc_to_delete))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertDictEqual(response.json, {})
|
||||
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
|
||||
|
||||
def test_run_doc_method(self):
|
||||
# test 10: Run whitelisted method on doc via /api/resource
|
||||
# status_code is 403 if no other tests are run before this - it's not logged in
|
||||
self.post("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
|
||||
response = self.get("/api/resource/Website Theme/Standard", {"run_method": "get_apps"})
|
||||
self.post(self.resource_path("Website Theme", "Standard"), {"run_method": "get_apps"})
|
||||
response = self.get(self.resource_path("Website Theme", "Standard"), {"run_method": "get_apps"})
|
||||
|
||||
self.assertIn(response.status_code, (403, 200))
|
||||
|
||||
if response.status_code == 403:
|
||||
|
|
@ -232,25 +252,16 @@ class TestResourceAPI(FrappeAPITestCase):
|
|||
|
||||
|
||||
class TestMethodAPI(FrappeAPITestCase):
|
||||
METHOD_PATH = "/api/method"
|
||||
|
||||
def setUp(self):
|
||||
if self._testMethodName == "test_auth_cycle":
|
||||
from frappe.core.doctype.user.user import generate_keys
|
||||
|
||||
generate_keys("Administrator")
|
||||
frappe.db.commit()
|
||||
|
||||
def test_ping(self):
|
||||
# test 2: test for /api/method/ping
|
||||
response = self.get(f"{self.METHOD_PATH}/ping")
|
||||
response = self.get(self.method_path("ping"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertEqual(response.json["message"], "pong")
|
||||
|
||||
def test_get_user_info(self):
|
||||
# test 3: test for /api/method/frappe.realtime.get_user_info
|
||||
response = self.get(f"{self.METHOD_PATH}/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("message").get("user"), ("Administrator", "Guest"))
|
||||
|
|
@ -258,10 +269,11 @@ class TestMethodAPI(FrappeAPITestCase):
|
|||
def test_auth_cycle(self):
|
||||
# test 4: Pass authorization token in request
|
||||
global authorization_token
|
||||
generate_admin_keys()
|
||||
user = frappe.get_doc("User", "Administrator")
|
||||
api_key, api_secret = user.api_key, user.get_password("api_secret")
|
||||
authorization_token = f"{api_key}:{api_secret}"
|
||||
response = self.get("/api/method/frappe.auth.get_logged_user")
|
||||
response = self.get(self.method_path("frappe.auth.get_logged_user"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json["message"], "Administrator")
|
||||
|
|
@ -269,18 +281,45 @@ class TestMethodAPI(FrappeAPITestCase):
|
|||
authorization_token = None
|
||||
|
||||
def test_404s(self):
|
||||
response = self.get("/api/rest", {"sid": self.sid})
|
||||
response = self.get(self.get_path("rest"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
response = self.get("/api/resource/User/NonExistent@s.com", {"sid": self.sid})
|
||||
response = self.get(self.resource_path("User", "NonExistent@s.com"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_logs(self):
|
||||
method = "frappe.tests.test_api.test"
|
||||
|
||||
def get_message(resp, msg_type):
|
||||
return frappe.parse_json(frappe.parse_json(frappe.parse_json(resp.json)[msg_type])[0])
|
||||
|
||||
expected_message = "Failed"
|
||||
response = self.get(self.method_path(method), {"sid": self.sid, "message": expected_message})
|
||||
self.assertEqual(get_message(response, "_server_messages").message, expected_message)
|
||||
|
||||
# Cause handled failured
|
||||
with suppress_stdout():
|
||||
response = self.get(
|
||||
self.method_path(method), {"sid": self.sid, "message": expected_message, "fail": True}
|
||||
)
|
||||
self.assertEqual(get_message(response, "_server_messages").message, expected_message)
|
||||
self.assertEqual(response.json["exc_type"], "ValidationError")
|
||||
self.assertIn("Traceback", response.json["exc"])
|
||||
|
||||
# Cause handled failured
|
||||
with suppress_stdout():
|
||||
response = self.get(
|
||||
self.method_path(method),
|
||||
{"sid": self.sid, "message": expected_message, "fail": True, "handled": False},
|
||||
)
|
||||
self.assertNotIn("_server_messages", response.json)
|
||||
self.assertIn("ZeroDivisionError", response.json["exception"]) # WHY?
|
||||
self.assertIn("Traceback", response.json["exc"])
|
||||
|
||||
|
||||
class TestReadOnlyMode(FrappeAPITestCase):
|
||||
"""During migration if read only mode can be enabled.
|
||||
Test if reads work well and writes are blocked"""
|
||||
|
||||
REQ_PATH = "/api/resource/ToDo"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
|
@ -290,13 +329,16 @@ class TestReadOnlyMode(FrappeAPITestCase):
|
|||
update_site_config("maintenance_mode", 1)
|
||||
|
||||
def test_reads(self):
|
||||
response = self.get(self.REQ_PATH, {"sid": self.sid})
|
||||
response = self.get(self.resource_path("ToDo"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertIsInstance(response.json["data"], list)
|
||||
|
||||
def test_blocked_writes(self):
|
||||
response = self.post(self.REQ_PATH, {"description": frappe.mock("paragraph"), "sid": self.sid})
|
||||
with suppress_stdout():
|
||||
response = self.post(
|
||||
self.resource_path("ToDo"), {"description": frappe.mock("paragraph"), "sid": self.sid}
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json["exc_type"], "InReadOnlyMode")
|
||||
|
||||
|
|
@ -368,3 +410,21 @@ class TestResponse(FrappeAPITestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("text/csv", response.headers["content-type"])
|
||||
self.assertGreater(cint(response.headers["content-length"]), 0)
|
||||
|
||||
|
||||
def generate_admin_keys():
|
||||
from frappe.core.doctype.user.user import generate_keys
|
||||
|
||||
generate_keys("Administrator")
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def test(*, fail=False, handled=True, message="Failed"):
|
||||
if fail:
|
||||
if handled:
|
||||
frappe.throw(message)
|
||||
else:
|
||||
1 / 0
|
||||
else:
|
||||
frappe.msgprint(message)
|
||||
|
|
|
|||
297
frappe/tests/test_api_v2.py
Normal file
297
frappe/tests/test_api_v2.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
from random import choice
|
||||
|
||||
import requests
|
||||
|
||||
import frappe
|
||||
from frappe.installer import update_site_config
|
||||
from frappe.tests.test_api import FrappeAPITestCase, suppress_stdout
|
||||
|
||||
authorization_token = None
|
||||
|
||||
|
||||
resource_key = {
|
||||
"": "resource",
|
||||
"v1": "resource",
|
||||
"v2": "document",
|
||||
}
|
||||
|
||||
|
||||
class TestResourceAPIV2(FrappeAPITestCase):
|
||||
version = "v2"
|
||||
DOCTYPE = "ToDo"
|
||||
GENERATED_DOCUMENTS = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
for _ in range(20):
|
||||
doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert()
|
||||
cls.GENERATED_DOCUMENTS = []
|
||||
cls.GENERATED_DOCUMENTS.append(doc.name)
|
||||
frappe.db.commit()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
for name in cls.GENERATED_DOCUMENTS:
|
||||
frappe.delete_doc_if_exists(cls.DOCTYPE, name)
|
||||
frappe.db.commit()
|
||||
|
||||
def test_unauthorized_call(self):
|
||||
# test 1: fetch documents without auth
|
||||
response = requests.get(self.resource_path(self.DOCTYPE))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_get_list(self):
|
||||
# test 2: fetch documents without params
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertIn("data", response.json)
|
||||
|
||||
def test_get_list_limit(self):
|
||||
# test 3: fetch data with limit
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "limit": 2})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.json["data"]), 2)
|
||||
|
||||
def test_get_list_dict(self):
|
||||
# test 4: fetch response as (not) dict
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": True})
|
||||
json = frappe._dict(response.json)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(json.data, list)
|
||||
self.assertIsInstance(json.data[0], dict)
|
||||
|
||||
response = self.get(self.resource_path(self.DOCTYPE), {"sid": self.sid, "as_dict": False})
|
||||
json = frappe._dict(response.json)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(json.data, list)
|
||||
self.assertIsInstance(json.data[0], list)
|
||||
|
||||
def test_get_list_fields(self):
|
||||
# test 6: fetch response with fields
|
||||
response = self.get(
|
||||
self.resource_path(self.DOCTYPE), {"sid": self.sid, "fields": '["description"]'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
json = frappe._dict(response.json)
|
||||
self.assertIn("description", json.data[0])
|
||||
|
||||
def test_create_document(self):
|
||||
data = {"description": frappe.mock("paragraph"), "sid": self.sid}
|
||||
response = self.post(self.resource_path(self.DOCTYPE), data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
docname = response.json["data"]["name"]
|
||||
self.assertIsInstance(docname, str)
|
||||
self.GENERATED_DOCUMENTS.append(docname)
|
||||
|
||||
def test_delete_document(self):
|
||||
doc_to_delete = choice(self.GENERATED_DOCUMENTS)
|
||||
response = self.delete(self.resource_path(self.DOCTYPE, doc_to_delete))
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertDictEqual(response.json, {"data": "ok"})
|
||||
|
||||
response = self.get(self.resource_path(self.DOCTYPE, doc_to_delete))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.GENERATED_DOCUMENTS.remove(doc_to_delete)
|
||||
|
||||
def test_execute_doc_method(self):
|
||||
response = self.get(self.resource_path("Website Theme", "Standard", "method", "get_apps"))
|
||||
self.assertEqual(response.json["data"][0]["name"], "frappe")
|
||||
|
||||
def test_update_document(self):
|
||||
generated_desc = frappe.mock("paragraph")
|
||||
data = {"description": generated_desc, "sid": self.sid}
|
||||
random_doc = choice(self.GENERATED_DOCUMENTS)
|
||||
|
||||
response = self.patch(self.resource_path(self.DOCTYPE, random_doc), data=data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json["data"]["description"], generated_desc)
|
||||
|
||||
response = self.get(self.resource_path(self.DOCTYPE, random_doc))
|
||||
self.assertEqual(response.json["data"]["description"], generated_desc)
|
||||
|
||||
def test_delete_document_non_existing(self):
|
||||
non_existent_doc = frappe.generate_hash(length=12)
|
||||
with suppress_stdout():
|
||||
response = self.delete(self.resource_path(self.DOCTYPE, non_existent_doc))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json["errors"][0]["type"], "DoesNotExistError")
|
||||
# 404s dont return exceptions
|
||||
self.assertFalse(response.json["errors"][0].get("exception"))
|
||||
|
||||
|
||||
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):
|
||||
response = self.get(self.method_path("ping"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertEqual(response.json["data"], "pong")
|
||||
|
||||
def test_get_user_info(self):
|
||||
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):
|
||||
global authorization_token
|
||||
|
||||
generate_admin_keys()
|
||||
user = frappe.get_doc("User", "Administrator")
|
||||
api_key, api_secret = user.api_key, user.get_password("api_secret")
|
||||
authorization_token = f"{api_key}:{api_secret}"
|
||||
response = self.get(self.method_path("frappe.auth.get_logged_user"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json["data"], "Administrator")
|
||||
|
||||
authorization_token = None
|
||||
|
||||
def test_404s(self):
|
||||
response = self.get(self.get_path("rest"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
response = self.get(self.resource_path("User", "NonExistent@s.com"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_shorthand_controller_methods(self):
|
||||
shorthand_response = self.get(self.method_path("User", "get_all_roles"), {"sid": self.sid})
|
||||
self.assertIn("Blogger", shorthand_response.json["data"])
|
||||
|
||||
expanded_response = self.get(
|
||||
self.method_path("frappe.core.doctype.user.user.get_all_roles"), {"sid": self.sid}
|
||||
)
|
||||
self.assertEqual(expanded_response.data, shorthand_response.data)
|
||||
|
||||
def test_logout(self):
|
||||
self.post(self.method_path("logout"), {"sid": self.sid})
|
||||
response = self.get(self.method_path("ping"))
|
||||
self.assertFalse(response.request.cookies["sid"])
|
||||
|
||||
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)
|
||||
|
||||
def test_logs(self):
|
||||
method = "frappe.tests.test_api.test"
|
||||
|
||||
expected_message = "Failed v2"
|
||||
response = self.get(
|
||||
self.method_path(method), {"sid": self.sid, "message": expected_message}
|
||||
).json
|
||||
|
||||
self.assertIsInstance(response["messages"], list)
|
||||
self.assertEqual(response["messages"][0]["message"], expected_message)
|
||||
|
||||
# Cause handled failured
|
||||
with suppress_stdout():
|
||||
response = self.get(
|
||||
self.method_path(method), {"sid": self.sid, "message": expected_message, "fail": True}
|
||||
).json
|
||||
self.assertIsInstance(response["errors"], list)
|
||||
self.assertEqual(response["errors"][0]["message"], expected_message)
|
||||
self.assertEqual(response["errors"][0]["type"], "ValidationError")
|
||||
self.assertIn("Traceback", response["errors"][0]["exception"])
|
||||
|
||||
# Cause handled failured
|
||||
with suppress_stdout():
|
||||
response = self.get(
|
||||
self.method_path(method),
|
||||
{"sid": self.sid, "message": expected_message, "fail": True, "handled": False},
|
||||
).json
|
||||
|
||||
self.assertIsInstance(response["errors"], list)
|
||||
self.assertEqual(response["errors"][0]["type"], "ZeroDivisionError")
|
||||
self.assertIn("Traceback", response["errors"][0]["exception"])
|
||||
|
||||
|
||||
class TestDocTypeAPIV2(FrappeAPITestCase):
|
||||
version = "v2"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.post(self.method_path("login"), {"sid": self.sid})
|
||||
return super().setUp()
|
||||
|
||||
def test_meta(self):
|
||||
response = self.get(self.doctype_path("ToDo", "meta"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json["data"]["name"], "ToDo")
|
||||
|
||||
def test_count(self):
|
||||
response = self.get(self.doctype_path("ToDo", "count"))
|
||||
self.assertIsInstance(response.json["data"], int)
|
||||
|
||||
|
||||
class TestReadOnlyMode(FrappeAPITestCase):
|
||||
"""During migration if read only mode can be enabled.
|
||||
Test if reads work well and writes are blocked"""
|
||||
|
||||
version = "v2"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
update_site_config("allow_reads_during_maintenance", 1)
|
||||
cls.addClassCleanup(update_site_config, "maintenance_mode", 0)
|
||||
update_site_config("maintenance_mode", 1)
|
||||
|
||||
def test_reads(self):
|
||||
response = self.get(self.resource_path("ToDo"), {"sid": self.sid})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json, dict)
|
||||
self.assertIsInstance(response.json["data"], list)
|
||||
|
||||
def test_blocked_writes_v2(self):
|
||||
with suppress_stdout():
|
||||
response = self.post(
|
||||
self.resource_path("ToDo"), {"description": frappe.mock("paragraph"), "sid": self.sid}
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json["errors"][0]["type"], "InReadOnlyMode")
|
||||
|
||||
|
||||
def generate_admin_keys():
|
||||
from frappe.core.doctype.user.user import generate_keys
|
||||
|
||||
generate_keys("Administrator")
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def test(*, fail=False, handled=True, message="Failed"):
|
||||
if fail:
|
||||
if handled:
|
||||
frappe.throw(message)
|
||||
else:
|
||||
1 / 0
|
||||
else:
|
||||
frappe.msgprint(message)
|
||||
|
|
@ -292,7 +292,7 @@ class NestedSet(Document):
|
|||
update_nsm(self)
|
||||
except frappe.DoesNotExistError:
|
||||
if self.flags.on_rollback:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import decimal
|
|||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
import werkzeug.utils
|
||||
|
|
@ -21,7 +22,7 @@ import frappe.sessions
|
|||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.core.doctype.access_log.access_log import make_access_log
|
||||
from frappe.utils import cint, format_timedelta
|
||||
from frappe.utils import format_timedelta
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.core.doctype.file.file import File
|
||||
|
|
@ -29,22 +30,45 @@ if TYPE_CHECKING:
|
|||
|
||||
def report_error(status_code):
|
||||
"""Build error. Show traceback in developer mode"""
|
||||
allow_traceback = frappe.get_system_settings("allow_error_traceback") if frappe.db else False
|
||||
if (
|
||||
allow_traceback
|
||||
and (status_code != 404 or frappe.conf.logging)
|
||||
from frappe.api import ApiVersion, get_api_version
|
||||
|
||||
allow_traceback = (
|
||||
(frappe.get_system_settings("allow_error_traceback") if frappe.db else False)
|
||||
and not frappe.local.flags.disable_traceback
|
||||
):
|
||||
traceback = frappe.utils.get_traceback()
|
||||
if traceback:
|
||||
frappe.errprint(traceback)
|
||||
frappe.local.response.exception = traceback.splitlines()[-1]
|
||||
and (status_code != 404 or frappe.conf.logging)
|
||||
)
|
||||
|
||||
traceback = frappe.utils.get_traceback()
|
||||
exc_type, exc_value, _ = sys.exc_info()
|
||||
|
||||
match get_api_version():
|
||||
case ApiVersion.V1:
|
||||
if allow_traceback:
|
||||
frappe.errprint(traceback)
|
||||
frappe.response.exception = traceback.splitlines()[-1]
|
||||
frappe.response["exc_type"] = exc_type.__name__
|
||||
case ApiVersion.V2:
|
||||
error_log = {"type": exc_type.__name__}
|
||||
if allow_traceback:
|
||||
error_log["exception"] = traceback
|
||||
_link_error_with_message_log(error_log, exc_value, frappe.message_log)
|
||||
frappe.local.response.errors = [error_log]
|
||||
|
||||
response = build_response("json")
|
||||
response.status_code = status_code
|
||||
return response
|
||||
|
||||
|
||||
def _link_error_with_message_log(error_log, exception, message_logs):
|
||||
for message in message_logs:
|
||||
if message.get("__frappe_exc_id") == exception.__frappe_exc_id:
|
||||
error_log.update(message)
|
||||
message_logs.remove(message)
|
||||
error_log.pop("raise_exception", None)
|
||||
error_log.pop("__frappe_exc_id", None)
|
||||
return
|
||||
|
||||
|
||||
def build_response(response_type=None):
|
||||
if "docs" in frappe.local.response and not frappe.local.response.docs:
|
||||
del frappe.local.response["docs"]
|
||||
|
|
@ -99,6 +123,7 @@ def as_raw():
|
|||
|
||||
def as_json():
|
||||
make_logs()
|
||||
|
||||
response = Response()
|
||||
if frappe.local.response.http_status_code:
|
||||
response.status_code = frappe.local.response["http_status_code"]
|
||||
|
|
@ -125,12 +150,22 @@ def as_binary():
|
|||
return response
|
||||
|
||||
|
||||
def make_logs(response=None):
|
||||
def make_logs():
|
||||
"""make strings for msgprint and errprint"""
|
||||
|
||||
from frappe.api import ApiVersion, get_api_version
|
||||
|
||||
match get_api_version():
|
||||
case ApiVersion.V1:
|
||||
_make_logs_v1()
|
||||
case ApiVersion.V2:
|
||||
_make_logs_v2()
|
||||
|
||||
|
||||
def _make_logs_v1():
|
||||
from frappe.utils.error import guess_exception_source
|
||||
|
||||
if not response:
|
||||
response = frappe.local.response
|
||||
response = frappe.local.response
|
||||
|
||||
if frappe.error_log:
|
||||
if source := guess_exception_source(frappe.local.error_log and frappe.local.error_log[0]["exc"]):
|
||||
|
|
@ -138,17 +173,25 @@ def make_logs(response=None):
|
|||
response["exc"] = json.dumps([frappe.utils.cstr(d["exc"]) for d in frappe.local.error_log])
|
||||
|
||||
if frappe.local.message_log:
|
||||
response["_server_messages"] = json.dumps(
|
||||
[frappe.utils.cstr(d) for d in frappe.local.message_log]
|
||||
)
|
||||
response["_server_messages"] = json.dumps([json.dumps(d) for d in frappe.local.message_log])
|
||||
|
||||
if frappe.debug_log and frappe.conf.get("logging") or False:
|
||||
if frappe.debug_log and frappe.conf.get("logging"):
|
||||
response["_debug_messages"] = json.dumps(frappe.local.debug_log)
|
||||
|
||||
if frappe.flags.error_message:
|
||||
response["_error_message"] = frappe.flags.error_message
|
||||
|
||||
|
||||
def _make_logs_v2():
|
||||
response = frappe.local.response
|
||||
|
||||
if frappe.local.message_log:
|
||||
response["messages"] = frappe.local.message_log
|
||||
|
||||
if frappe.debug_log and frappe.conf.get("logging"):
|
||||
response["debug"] = [{"message": m} for m in frappe.local.debug_log]
|
||||
|
||||
|
||||
def json_handler(obj):
|
||||
"""serialize non-serializable data for json"""
|
||||
from collections.abc import Iterable
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from collections.abc import Callable
|
||||
from functools import lru_cache, wraps
|
||||
from inspect import _empty, isclass, signature
|
||||
from types import EllipsisType
|
||||
from types import EllipsisType, NoneType
|
||||
from typing import ForwardRef, TypeVar, Union
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
|
|
|||
|
|
@ -93,8 +93,7 @@ def make_view_log(
|
|||
else:
|
||||
view.insert(ignore_permissions=True)
|
||||
except Exception:
|
||||
if frappe.message_log:
|
||||
frappe.message_log.pop()
|
||||
frappe.clear_last_message()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue