Skip to content

Commit

Permalink
Add new param to allow/disallow JSON-RPC Notification (#385)
Browse files Browse the repository at this point in the history
A Notification is a Request object without an "id" member. A Request
object that is a Notification signifies the Client's lack of interest
in the corresponding Response object, and as such, no Response object
needs to be returned to the client. The Server MUST NOT reply to a
Notification, including those that are within a batch request.

The new parameter allows defining whether the method will support the
Notification Response object, the default is true. The use of this
parameter is:

```
@jsonrpc.method('App.method', notification: bool = True)
```

When `notification` parameter is false, the method itself doesn't allow
the Notification Response object, in the other word, if the client calls
the method with a Notification Request object the server will respond
with an Error object with code = -32600 and message = Invalid Request.

Resolves: #369
  • Loading branch information
nycholas authored Mar 24, 2023
1 parent d67100c commit 2c6afcd
Show file tree
Hide file tree
Showing 18 changed files with 401 additions and 48 deletions.
2 changes: 2 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!--
## The seven rules of a great Git commit message
:: Keep in mind: This has all been said before.
Expand Down Expand Up @@ -43,3 +44,4 @@ See also: #456, #789
```
More details: https://chris.beams.io/posts/git-commit/.
-->
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,4 @@ exclude-protected=_asdict,_fields,_replace,_source,_make

# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=Exception
overgeneral-exceptions=builtins.Exception
5 changes: 5 additions & 0 deletions examples/minimal/minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ def notify(_string: Optional[str] = None) -> None:
pass


@jsonrpc.method('App.notNotify', notification=False)
def not_notify(string: str) -> str:
return f'Not allow notification: {string}'


@jsonrpc.method('App.fails')
def fails(_string: Optional[str] = None) -> NoReturn:
raise ValueError('example of fail')
Expand Down
6 changes: 2 additions & 4 deletions src/flask_jsonrpc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,8 @@ def init_app(self, app: Flask) -> None:
)
self.register_browse(app, self)

def register(
self, view_func: t.Callable[..., t.Any], name: t.Optional[str] = None, validate: bool = True, **options: t.Any
) -> None:
self.register_view_function(view_func, name, validate, **options)
def register(self, view_func: t.Callable[..., t.Any], name: t.Optional[str] = None, **options: t.Any) -> None:
self.register_view_function(view_func, name, **options)

def register_blueprint(
self, app: Flask, jsonrpc_app: 'JSONRPCBlueprint', url_prefix: str, enable_web_browsable_api: bool = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
$scope.showFakeIntro = true;
$scope.showContentLoaded = true;
$scope.showToolbar = false;
$scope.showToolbarNotifyButton = true;
$scope.breadcrumbs = breadcrumbs('Dashboard');
$scope.response = responseExample;
$scope.response_object = responseObjectExample;
$scope.responseObject = responseObjectExample;

$scope.$on('App:displayFakeIntro', function(event, display) {
$scope.showFakeIntro = display;
Expand All @@ -36,6 +37,10 @@
$scope.showToolbar = display;
});

$scope.$on('App:displayToolbarNotifyButton', function(event, display) {
$scope.showToolbarNotifyButton = display;
});

$scope.showSpinner = function() {
return PendingRequests.isPending();
};
Expand Down Expand Up @@ -67,11 +72,17 @@
}, 750);
});

$scope.goToDashboard = function() {
$scope.$emit('App:displayToolbar', false);
$scope.$emit('App:breadcrumb', 'Dashboard');
$location.path('/');
};

$scope.showTooltip = function(module) {
return Handlebars.template('menu-module-tooltip', module);
};

$scope.showReponseObject = function(module) {
$scope.showResponseObject = function(module) {
return $location.path(module.name);
};
}]);
Expand All @@ -98,7 +109,7 @@
};

$scope.hitEnter = function(evt) {
if (angular.equals(evt.keyCode,13) && !(angular.equals($scope.name,null) || angular.equals($scope.name,''))) {
if (angular.equals(evt.keyCode, 13) && !(angular.equals($scope.name, null) || angular.equals($scope.name, ''))) {
$scope.ok();
}
};
Expand All @@ -112,25 +123,26 @@
$scope.module = module;
$scope.$emit('App:displayToolbar', true);
$scope.$emit('App:breadcrumb', module.name);
$scope.$emit('App:displayToolbarNotifyButton', module.options.notification);

var RPCCall = function(module) {
var payload = RPC.payload(module);
$scope.request_object = payload;
$scope.requestObject = payload;
$scope.response = undefined;
$scope.response_object = undefined;
RPC.callWithPayload(payload).success(function(response_object, status, headers, config) { // success
var headers_pretty = headers();
headers_pretty.data = config.data;
$scope.responseObject = undefined;
RPC.callWithPayload(payload).success(function(responseObject, status, headers, config) { // success
var headersPretty = headers();
headersPretty.data = config.data;

$scope.response = {status: status, headers: headers_pretty, config: config};
$scope.response_object = response_object;
$scope.response = {status: status, headers: headersPretty, config: config};
$scope.responseObject = responseObject;
$scope.$emit('App:displayContentLoaded', false);
}).error(function(response_object, status, headers, config) { // error
var headers_pretty = headers();
headers_pretty.data = config.data;
}).error(function(responseObject, status, headers, config) { // error
var headersPretty = headers();
headersPretty.data = config.data;

$scope.response = {status_code: status, headers: headers_pretty, config: config};
$scope.response_object = response_object;
$scope.response = {statusCode: status, headers: headersPretty, config: config};
$scope.responseObject = responseObject;
$scope.$emit('App:displayContentLoaded', false);
});
},
Expand Down
14 changes: 7 additions & 7 deletions src/flask_jsonrpc/contrib/browse/templates/browse/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,22 @@
<div class="row">
<div id="logo-section">
<div id="logo-loading-box"><div id="logo-loading" ng-show="showSpinner()"></div></div>
<a id="logo-link" class="title" href="#/">Web browsable API</a>
<a id="logo-link" class="title" href="#/" ng-click="goToDashboard()">Web browsable API</a>
</div>
</div>
<div id="box-subscribe" class="row box-subscribe">
<a href="https://github.com/cenobites/flask-jsonrpc" class="btn btn-red btn-subscribe"><i class="icon icon-github icon-large"></i> Fork A Repo</a>
</div>
<div id="scrollable-sections">
<ul class="nav nav-tabs nav-stacked">
<li><a id="logo-link" href="#/" ng-class="{active:routeIs('/')}"><i class="icon icon-home"></i> Dashboard</a></li>
<li><a id="logo-link" href="#/" ng-click="goToDashboard()" ng-class="{active:routeIs('/')}"><i class="icon icon-home"></i> Dashboard</a></li>
{% raw %}
<li class="dropdown" ng-repeat="(package_name, modules) in packages">
<a data-toggle="collapse" data-target="#{{package_name}}"><i class="icon icon-folder-open"></i> {{package_name}}</a>
<div id="{{package_name}}" class="accordion-body collapse" ng-class="{in:$first}">
<li class="dropdown" ng-repeat="(packageName, modules) in packages">
<a data-toggle="collapse" data-target="#{{packageName}}"><i class="icon icon-folder-open"></i> {{packageName}}</a>
<div id="{{packageName}}" class="accordion-body collapse" ng-class="{in:$first}">
<ul class="dropdown-submenu">
<li ng-repeat="module in modules">
<a ng-click="showReponseObject(module)" ng-class="{active:routeIs(module.name)}" class="disableable" data-placement="bottom" data-toggle="tooltip" tooltip="{{showTooltip(module)}}"><i class="icon icon-caret-right"></i> {{module.name}}</a>
<a ng-click="showResponseObject(module)" ng-class="{active:routeIs(module.name)}" class="disableable" data-placement="bottom" data-toggle="tooltip" tooltip="{{showTooltip(module)}}"><i class="icon icon-caret-right"></i> {{module.name}}</a>
</li>
</ul>
</div>
Expand All @@ -72,7 +72,7 @@ <h4 class="blue"><button ng-click="resend()" class="btn btn-blue">Resend</button
<li>
<h4 class="blue"><button ng-click="changeParameters()" class="btn btn-green">Change parameters</button></h4>
</li>
<li>
<li ng-show="showToolbarNotifyButton">
<h4 class="green"><button ng-click="notify()" class="btn btn-gold">Notify</button></h4>
</li>
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
</div>
<p><b>Response:</b></p>
<div class="response-info">
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.status_code }}</b></div>
<span ng-bind-html="response_object|json|prettyprint"></span></pre>
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.statusCode }}</b></div>
<span ng-bind-html="responseObject|json|prettyprint"></span></pre>
</div>
</div>
{% endraw %}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ <h5><b>Summary:</b> <span ng-if="!module.summary">None</span><span style="white-
<label class="control-label" for="course">{{param.name}} -> {{param.type}}: </label><input type="text" class="form-control" name="{{param.name}}" id="{{param.name}}" ng-model="param.value" ng-keyup="hitEnter($event)" required>
<span class="help-block"></span>
</span>
<label class="control-label" for="notify">Is a notify?</label><input type="checkbox" class="form-control" name="notify" id="notify" ng-model="module.notify" />
<span ng-show="module.options.notification">
<label class="control-label" for="notify">Is a notify?</label><input type="checkbox" class="form-control" name="notify" id="notify" ng-model="module.notify" />
</span>
</div>
</ng-form>
</div>
Expand All @@ -36,12 +38,12 @@ <h5><b>Summary:</b> <span ng-if="!module.summary">None</span><span style="white-
<p><b>Request:</b></p>
<div class="request-info" style="clear: both">
<pre class="prettyprint"><b>{{ response.config.method }}</b> {{ response.config.url }}
<span ng-bind-html="request_object|json|prettyprint"></span></pre>
<span ng-bind-html="requestObject|json|prettyprint"></span></pre>
</div>
<p><b>Response:</b></p>
<div class="response-info">
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.status_code }}{{ response.status }}</b>
<span ng-bind-html="response_object|json|prettyprint"></span></div></pre>
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.statusCode }}{{ response.status }}</b>
<span ng-bind-html="responseObject|json|prettyprint"></span></div></pre>
</div>
</span>
</div>
Expand Down
51 changes: 51 additions & 0 deletions src/flask_jsonrpc/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright (c) 2023-2023, Cenobit Technologies, Inc. http://cenobit.es/
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of the Cenobit Technologies nor the names of
# its contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import typing as t

DEFAULTS = {
'DEFAULT_JSONRPC_METHOD': {
'VALIDATE': True,
'NOTIFICATION': True,
},
}


class JSONRPCSettings:
def __init__(self, defaults: t.Optional[t.Dict[str, t.Any]] = None):
self.defaults = defaults or DEFAULTS

def __getattr__(self, attr: str) -> t.Any:
if attr not in self.defaults:
raise AttributeError(f"Invalid setting: '{attr}'")

val = self.defaults[attr]

setattr(self, attr, val)
return val


settings = JSONRPCSettings(DEFAULTS)
20 changes: 17 additions & 3 deletions src/flask_jsonrpc/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from werkzeug.datastructures import Headers

from .helpers import get, from_python_type
from .settings import settings
from .exceptions import (
ParseError,
ServerError,
Expand All @@ -52,6 +53,7 @@
'ServiceProcedureDescribe',
{
'name': str,
'options': t.Dict[str, t.Any],
'summary': t.Optional[str],
'params': t.List[t.Dict[str, str]],
'return': t.Dict[str, str],
Expand Down Expand Up @@ -173,9 +175,20 @@ def dispatch(
self, req_json: t.Dict[str, t.Any]
) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]:
params = req_json.get('params', {})
view_func = self.view_funcs.get(req_json['method'])
method_name = req_json['method']
view_func = self.view_funcs.get(method_name)
validate = getattr(view_func, 'jsonrpc_validate', settings.DEFAULT_JSONRPC_METHOD['VALIDATE'])
notification = getattr(view_func, 'jsonrpc_notification', settings.DEFAULT_JSONRPC_METHOD['NOTIFICATION'])
if not view_func:
raise MethodNotFoundError(data={'message': f"Method not found: {req_json['method']}"})
raise MethodNotFoundError(data={'message': f"Method not found: {method_name}"})

if self.is_notification_request(req_json) and not notification:
raise InvalidRequestError(
data={
'message': f"The method '{method_name}' doesn't allow Notification "
"Request object (without an 'id' member)"
}
)

try:
if isinstance(params, (tuple, set, list)):
Expand All @@ -193,7 +206,7 @@ def dispatch(
# TODO: Improve the checker to return type
view_fun_annotations = t.get_type_hints(view_func)
view_fun_return: t.Optional[t.Any] = view_fun_annotations.pop('return', None)
if resp_view is not None and view_fun_return is None:
if validate and resp_view is not None and view_fun_return is None:
resp_view_qn = qualified_name(resp_view)
view_fun_return_qn = qualified_name(view_fun_return)
raise TypeError(f'return type of {resp_view_qn} must be a type; got {view_fun_return_qn} instead')
Expand Down Expand Up @@ -258,6 +271,7 @@ def procedure_desc(self, key: str) -> ServiceProcedureDescribe: # pytype: disab
return {
'name': getattr(view_func, 'jsonrpc_method_name', key),
'summary': getattr(view_func, '__doc__', None),
'options': getattr(view_func, 'jsonrpc_options', {}),
'params': [
{'name': k, 'type': self.python_type_name(t)}
for k, t in getattr(view_func, 'jsonrpc_method_params', {}).items()
Expand Down
25 changes: 19 additions & 6 deletions src/flask_jsonrpc/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@

from typeguard import typechecked

from .settings import settings

if t.TYPE_CHECKING:
from .site import JSONRPCSite
from .views import JSONRPCView


class JSONRPCDecoratorMixin:
def _method_options(self, options: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
default_options = {
'validate': settings.DEFAULT_JSONRPC_METHOD['VALIDATE'],
'notification': settings.DEFAULT_JSONRPC_METHOD['NOTIFICATION'],
}
return {**default_options, **options}

def _method_has_parameters(self, fn: t.Callable[..., t.Any]) -> bool:
fn_signature = signature(fn)
return bool(fn_signature.parameters)
Expand Down Expand Up @@ -68,26 +77,30 @@ def get_jsonrpc_site_api(self) -> t.Type['JSONRPCView']:
raise NotImplementedError

def register_view_function(
self, view_func: t.Callable[..., t.Any], name: t.Optional[str] = None, validate: bool = True, **options: t.Any
self, view_func: t.Callable[..., t.Any], name: t.Optional[str] = None, **options: t.Dict[str, t.Any]
) -> t.Callable[..., t.Any]:
fn = self._get_function(view_func)
fn_options = self._method_options(options)
fn_annotations = t.get_type_hints(fn)
method_name = getattr(fn, '__name__', '<noname>') if not name else name
view_func_wrapped = typechecked(view_func) if validate else view_func
view_func_wrapped = typechecked(view_func) if fn_options['validate'] else view_func
setattr(view_func_wrapped, 'jsonrpc_method_name', method_name) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_method_sig', fn_annotations) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_method_return', fn_annotations.pop('return', None)) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_method_params', fn_annotations) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_validate', validate) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_options', options) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_validate', fn_options['validate']) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_notification', fn_options['notification']) # noqa: B010
setattr(view_func_wrapped, 'jsonrpc_options', fn_options) # noqa: B010
self.get_jsonrpc_site().register(method_name, view_func_wrapped)
return view_func_wrapped

def method(self, name: t.Optional[str] = None, validate: bool = True, **options: t.Any) -> t.Callable[..., t.Any]:
def method(self, name: t.Optional[str] = None, **options: t.Dict[str, t.Any]) -> t.Callable[..., t.Any]:
validate = options.get('validate', settings.DEFAULT_JSONRPC_METHOD['VALIDATE'])

def decorator(fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
method_name = getattr(fn, '__name__', '<noname>') if not name else name
if validate and not self._validate(fn):
raise ValueError(f'no type annotations present to: {method_name}')
return self.register_view_function(fn, name, validate, **options)
return self.register_view_function(fn, name, **options)

return decorator
Loading

0 comments on commit 2c6afcd

Please sign in to comment.