From 13315b9f40bd082f6153ab76c33a82092ac0b677 Mon Sep 17 00:00:00 2001 From: Nycholas de Oliveira e Oliveira Date: Sat, 25 Mar 2023 16:31:20 -0300 Subject: [PATCH] Unified API Browser when using modular server (#391) The expected behavior is that when using the modular way, the API Browser merges in one, instead of having one API Browser for each. Now, the server is aware of the `SERVER_NAME` Flask configuration, it is being used by API Browser to request the correct server, besides that, the API Browser is able to call servers in different domains. For that configuration, the `JSONRPCSite` generates the `path` and `base_url` variables from `SERVER_NAME`, `APPLICATION_ROOT`, and `PREFERRED_URL_SCHEME`. It is the first step to providing a Browse Schema to improve documentation and examples from API (JSON-RPC methods). Resolves: #388 See: #378, #377, #376, #374, #373, and #370 --- .github/workflows/on_update.yml | 6 + .github/workflows/pre_release.yml | 16 ++- .github/workflows/release.yml | 2 + bin/docker-compose-it.sh | 2 +- bin/docker-compose-test.sh | 2 +- docker-compose.it.yml | 6 +- docker-compose.test.yml | 2 +- .../{multiple.py => multiplesite.py} | 0 src/flask_jsonrpc/app.py | 78 ++++++++---- src/flask_jsonrpc/contrib/browse/__init__.py | 87 ++++++++----- .../browse/static/js/apps/browse/services.js | 5 +- .../contrib/browse/templates/layout.html | 2 +- src/flask_jsonrpc/site.py | 21 +++- tests/contrib/test_browse.py | 115 +++++++++++++++--- tests/test_app.py | 25 ++++ tests/test_apps/app/__init__.py | 9 +- tests/test_apps/async_app/__init__.py | 9 +- tests/test_apps/pytest.local.ini | 2 +- tests/test_apps/test_app.py | 82 ++++++++++++- tests/test_async_app.py | 17 +++ tests/test_async_client.py | 8 ++ tests/test_client.py | 8 ++ tox.ini | 2 +- 23 files changed, 416 insertions(+), 90 deletions(-) rename examples/multiplesite/{multiple.py => multiplesite.py} (100%) diff --git a/.github/workflows/on_update.yml b/.github/workflows/on_update.yml index 02d02196..6acbcf63 100644 --- a/.github/workflows/on_update.yml +++ b/.github/workflows/on_update.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -57,6 +59,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -80,6 +84,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index e984a502..8c84300f 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -53,6 +55,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -83,6 +87,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -99,7 +105,7 @@ jobs: bandit -r src/ - name: Check dependencies for known security vulnerabilities with Safety run: | - safety check + safety check -i 52495 -i 51457 test: name: Test @@ -116,6 +122,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -153,6 +161,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -188,6 +198,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -223,6 +235,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d0917a4..c8b45fb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/bin/docker-compose-it.sh b/bin/docker-compose-it.sh index 61af8ccf..6c1b60b6 100755 --- a/bin/docker-compose-it.sh +++ b/bin/docker-compose-it.sh @@ -5,7 +5,7 @@ DOCKER_COMPOSE_FILE_PATH=../${DOCKER_COMPOSE_FILE_NAME} [ -f ${DOCKER_COMPOSE_FILE_PATH} ] || DOCKER_COMPOSE_FILE_PATH=${DOCKER_COMPOSE_FILE_NAME} docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci_it build --build-arg VERSION=$(date +%s) -docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci_it up -d +docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci_it --compatibility up -d DOCKER_WAIT_FOR_SUT=$(docker wait ci_it_sut_1) docker logs ci_it_sut_1 diff --git a/bin/docker-compose-test.sh b/bin/docker-compose-test.sh index 6d4a179a..80a6f84d 100755 --- a/bin/docker-compose-test.sh +++ b/bin/docker-compose-test.sh @@ -5,7 +5,7 @@ DOCKER_COMPOSE_FILE_PATH=../${DOCKER_COMPOSE_FILE_NAME} [ -f ${DOCKER_COMPOSE_FILE_PATH} ] || DOCKER_COMPOSE_FILE_PATH=${DOCKER_COMPOSE_FILE_NAME} docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci build --build-arg VERSION=$(date +%s) -docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci up -d +docker-compose -f ${DOCKER_COMPOSE_FILE_PATH} -p ci --compatibility up -d DOCKER_WAIT_FOR_PY36=$(docker wait ci_python3.6_1) docker logs ci_python3.6_1 diff --git a/docker-compose.it.yml b/docker-compose.it.yml index 996718a0..2487e46f 100644 --- a/docker-compose.it.yml +++ b/docker-compose.it.yml @@ -11,7 +11,7 @@ services: - SITE_PORT=5000 - WEB_URL=http://async_app:5000 - API_URL=http://async_app:5000/api - - BROWSABLE_API_URL=http://async_app:5000/browse + - BROWSABLE_API_URL=http://async_app:5000/api/browse user: ${UID:-0}:${GID:-0} depends_on: - async_app @@ -24,6 +24,7 @@ services: - FLASK_ASYNC=1 environment: - FLASK_ENV=TESTING + - FLASK_SERVER_NAME=async_app:5000 user: ${UID:-0}:${GID:-0} command: > python async_app.py @@ -40,7 +41,7 @@ services: - SITE_PORT=5000 - WEB_URL=http://app:5000 - API_URL=http://app:5000/api - - BROWSABLE_API_URL=http://app:5000/browse + - BROWSABLE_API_URL=http://app:5000/api/browse user: ${UID:-0}:${GID:-0} depends_on: - app @@ -51,6 +52,7 @@ services: dockerfile: Dockerfile.local environment: - FLASK_ENV=TESTING + - FLASK_SERVER_NAME=app:5000 user: ${UID:-0}:${GID:-0} command: > python app.py diff --git a/docker-compose.test.yml b/docker-compose.test.yml index ab04659c..85e881e5 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -13,7 +13,7 @@ services: pylint src/ tests/ && mypy --install-types --non-interactive src/ && bandit -r src/ && - safety check && + safety check -i 51457 && pytest" python3.9: diff --git a/examples/multiplesite/multiple.py b/examples/multiplesite/multiplesite.py similarity index 100% rename from examples/multiplesite/multiple.py rename to examples/multiplesite/multiplesite.py diff --git a/src/flask_jsonrpc/app.py b/src/flask_jsonrpc/app.py index 2fb9cdb7..4be8b880 100644 --- a/src/flask_jsonrpc/app.py +++ b/src/flask_jsonrpc/app.py @@ -25,13 +25,14 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import typing as t +from urllib.parse import urlsplit from flask import Flask from .globals import default_jsonrpc_site, default_jsonrpc_site_api from .helpers import urn from .wrappers import JSONRPCDecoratorMixin -from .contrib.browse import create_browse +from .contrib.browse import JSONRPCBrowse if t.TYPE_CHECKING: from .site import JSONRPCSite @@ -49,10 +50,11 @@ def __init__( enable_web_browsable_api: bool = False, ) -> None: self.app = app - self.service_url = service_url + self.path = service_url + self.base_url: t.Optional[str] = None self.jsonrpc_site = jsonrpc_site() self.jsonrpc_site_api = jsonrpc_site_api - self.browse_url = self._make_browse_url(service_url) + self.jsonrpc_browse: t.Optional[JSONRPCBrowse] = None self.enable_web_browsable_api = enable_web_browsable_api if app: self.init_app(app) @@ -63,44 +65,70 @@ def get_jsonrpc_site(self) -> 'JSONRPCSite': def get_jsonrpc_site_api(self) -> t.Type['JSONRPCView']: return self.jsonrpc_site_api - def _make_browse_url(self, service_url: str) -> str: - return ''.join([service_url, '/browse']) if not service_url.endswith('/') else ''.join([service_url, 'browse']) + def _make_jsonrpc_browse_url(self, path: str) -> str: + return ''.join([path.rstrip('/'), '/browse']) def init_app(self, app: Flask) -> None: + http_host = app.config.get('SERVER_NAME') + app_root = app.config['APPLICATION_ROOT'] + url_scheme = app.config['PREFERRED_URL_SCHEME'] + url = urlsplit(self.path) + + self.path = f"{app_root.rstrip('/')}{url.path}" + self.base_url = ( + f"{url.scheme or url_scheme}://{url.netloc or http_host}/{self.path.lstrip('/')}" if http_host else None + ) + + self.get_jsonrpc_site().set_path(self.path) + self.get_jsonrpc_site().set_base_url(self.base_url) + app.add_url_rule( - self.service_url, + self.path, view_func=self.get_jsonrpc_site_api().as_view( - urn('app', app.name, self.service_url), jsonrpc_site=self.get_jsonrpc_site() + urn('app', app.name, self.path), jsonrpc_site=self.get_jsonrpc_site() ), ) - self.register_browse(app, self) + + if app.config['DEBUG'] or self.enable_web_browsable_api: + self.init_browse_app(app) 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 + self, + app: Flask, + jsonrpc_app: 'JSONRPCBlueprint', + url_prefix: t.Optional[str] = None, + enable_web_browsable_api: bool = False, ) -> None: - service_url = ''.join([self.service_url, url_prefix]) if url_prefix else self.service_url + path = ''.join([self.path, '/', url_prefix.lstrip('/')]) if url_prefix else self.path + path_url = urlsplit(path) + + url = urlsplit(self.base_url or path) + base_url = f"{url.scheme}://{url.netloc}/{url.path.lstrip('/')}" if self.base_url else None + + jsonrpc_app.get_jsonrpc_site().set_path(path_url.path) + jsonrpc_app.get_jsonrpc_site().set_base_url(base_url) + app.add_url_rule( - service_url, + path, view_func=jsonrpc_app.get_jsonrpc_site_api().as_view( - urn('blueprint', app.name, jsonrpc_app.name, service_url), jsonrpc_site=jsonrpc_app.get_jsonrpc_site() + urn('blueprint', app.name, jsonrpc_app.name, path), jsonrpc_site=jsonrpc_app.get_jsonrpc_site() ), ) - if enable_web_browsable_api: - self.register_browse(app, jsonrpc_app, url_prefix=url_prefix) + if app.config['DEBUG'] or enable_web_browsable_api: + self.register_browse(jsonrpc_app) - def register_browse( - self, app: Flask, jsonrpc_app: t.Union['JSONRPC', 'JSONRPCBlueprint'], url_prefix: t.Optional[str] = None - ) -> None: - browse_url = ''.join([self.service_url, url_prefix, '/browse']) if url_prefix else self.browse_url - if app.config['DEBUG'] or self.enable_web_browsable_api: - app.register_blueprint( - create_browse(urn('browse', app.name, browse_url), jsonrpc_app.get_jsonrpc_site()), - url_prefix=browse_url, - ) - app.add_url_rule( - browse_url + '/static/', 'urn:browse.static', view_func=app.send_static_file + def init_browse_app(self, app: Flask, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None: + browse_url = self._make_jsonrpc_browse_url(path or self.path) + self.jsonrpc_browse = JSONRPCBrowse(app, url_prefix=browse_url, base_url=base_url or self.base_url) + self.jsonrpc_browse.register_jsonrpc_site(self.get_jsonrpc_site()) + + def register_browse(self, jsonrpc_app: t.Union['JSONRPC', 'JSONRPCBlueprint']) -> None: + if not self.jsonrpc_browse: + raise RuntimeError( + 'You need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' ) + self.jsonrpc_browse.register_jsonrpc_site(jsonrpc_app.get_jsonrpc_site()) diff --git a/src/flask_jsonrpc/contrib/browse/__init__.py b/src/flask_jsonrpc/contrib/browse/__init__.py index c9f15ccc..eda01bc8 100644 --- a/src/flask_jsonrpc/contrib/browse/__init__.py +++ b/src/flask_jsonrpc/contrib/browse/__init__.py @@ -25,52 +25,81 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import typing as t +from itertools import chain from flask import Blueprint, jsonify, request, render_template +from flask_jsonrpc.helpers import urn + if t.TYPE_CHECKING: + from flask import Flask from flask import typing as ft - from flask_jsonrpc.site import JSONRPCSite + from flask_jsonrpc.site import JSONRPCSite, ServiceProcedureDescribe + + +class JSONRPCBrowse: + def __init__( + self, app: t.Optional['Flask'] = None, url_prefix: str = '/api/browse', base_url: t.Optional[str] = None + ) -> None: + self.app = app + self.url_prefix = url_prefix + self.base_url = base_url + self.jsonrpc_sites: t.Set['JSONRPCSite'] = set() + if app: + self.init_app(app) + def _service_desc_procedures(self) -> t.Dict[str, 'ServiceProcedureDescribe']: + service_procs = list(chain(*[site.describe()['procs'] for site in self.jsonrpc_sites])) + return {proc['name']: proc for proc in service_procs} -def create_browse(name: str, jsonrpc_site: 'JSONRPCSite') -> Blueprint: - browse = Blueprint(name, __name__, template_folder='templates', static_folder='static') + def init_app(self, app: 'Flask') -> None: + name = urn('browse', app.name, self.url_prefix) + browse = Blueprint(name, __name__, template_folder='templates', static_folder='static') + browse.add_url_rule('/', view_func=self.vf_index) + browse.add_url_rule('/packages.json', view_func=self.vf_json_packages) + browse.add_url_rule('/.json', view_func=self.vf_json_method) + browse.add_url_rule('/partials/dashboard.html', view_func=self.vf_partials_dashboard) + browse.add_url_rule('/partials/response_object.html', view_func=self.vf_partials_response_object) - # pylint: disable=W0612 - @browse.route('/') - def index() -> str: - url_prefix = request.script_root + request.path - url_prefix = url_prefix.rstrip('/') - service_url = url_prefix.replace('/browse', '') - return render_template('browse/index.html', service_url=service_url, url_prefix=url_prefix) + app.register_blueprint(browse, url_prefix=self.url_prefix) + app.add_url_rule( + f'{self.url_prefix}/static/', 'urn:browse.static', view_func=app.send_static_file + ) - # pylint: disable=W0612 - @browse.route('/packages.json') - def json_packages() -> 'ft.ResponseReturnValue': - jsonrpc_describe = jsonrpc_site.describe() - packages = sorted(jsonrpc_describe['procs'], key=lambda proc: proc['name']) + def register_jsonrpc_site(self, jsonrpc_site: 'JSONRPCSite') -> None: + self.jsonrpc_sites.add(jsonrpc_site) + + def vf_index(self) -> str: + server_urls = {} + service_describes = [site.describe() for site in self.jsonrpc_sites] + for service_describe in service_describes: + server_urls.update( + { + name: service_describe['servers'][0]['url'] + for name in [proc['name'] for proc in service_describe['procs']] + } + ) + url_prefix = f"{request.script_root}{request.path.rstrip('/')}" + return render_template('browse/index.html', url_prefix=url_prefix, server_urls=server_urls) + + def vf_json_packages(self) -> 'ft.ResponseReturnValue': + service_procedures = self._service_desc_procedures() + packages = sorted(service_procedures.values(), key=lambda proc: proc['name']) packages_tree: t.Dict[str, t.Any] = {} for package in packages: package_name = package['name'].split('.')[0] packages_tree.setdefault(package_name, []).append(package) return jsonify(packages_tree) - # pylint: disable=W0612 - @browse.route('/.json') - def json_method(method_name: str) -> 'ft.ResponseReturnValue': - jsonrpc_describe = jsonrpc_site.describe() - method = [method for method in jsonrpc_describe['procs'] if method['name'] == method_name][0] - return jsonify(method) + def vf_json_method(self, method_name: str) -> 'ft.ResponseReturnValue': + service_procedures = self._service_desc_procedures() + if method_name not in service_procedures: + return jsonify({'message': 'Not found'}), 404 + return jsonify(service_procedures[method_name]) - # pylint: disable=W0612 - @browse.route('/partials/dashboard.html') - def partials_dashboard() -> str: + def vf_partials_dashboard(self) -> str: return render_template('browse/partials/dashboard.html') - # pylint: disable=W0612 - @browse.route('/partials/response_object.html') - def partials_response_object() -> str: + def vf_partials_response_object(self) -> str: return render_template('browse/partials/response_object.html') - - return browse diff --git a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js index 9b0b2872..e3e06d92 100644 --- a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js +++ b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js @@ -3,7 +3,7 @@ angular.module('browse.service', ['ngResource']) .constant('urlPrefix', _URL_PREFIX) - .constant('serviceUrl', _SERVICE_URL) + .constant('serverUrls', _SERVER_URLS) .constant('responseExample', { 'status': 200, 'headers': { @@ -73,7 +73,7 @@ } }; }]) - .factory('RPC', ['$http', 'serviceUrl', 'UUID', function($http, serviceUrl, UUID) { + .factory('RPC', ['$http', '$location', 'serverUrls', 'UUID', function($http, $location, serverUrls, UUID) { return { getValue: function(param) { if (param.type === 'Object') { @@ -117,6 +117,7 @@ return payload; }, callWithPayload: function(data, options) { + var serviceUrl = serverUrls[data.method]; var options = options || {method: 'POST', url: serviceUrl}; options.data = data; return $http(options); diff --git a/src/flask_jsonrpc/contrib/browse/templates/layout.html b/src/flask_jsonrpc/contrib/browse/templates/layout.html index 10656ef0..1a19cbbf 100644 --- a/src/flask_jsonrpc/contrib/browse/templates/layout.html +++ b/src/flask_jsonrpc/contrib/browse/templates/layout.html @@ -27,7 +27,7 @@ {% block templates_js %}{% endblock %} diff --git a/src/flask_jsonrpc/site.py b/src/flask_jsonrpc/site.py index 502a047b..29ef2f5e 100644 --- a/src/flask_jsonrpc/site.py +++ b/src/flask_jsonrpc/site.py @@ -26,6 +26,7 @@ # POSSIBILITY OF SUCH DAMAGE. import typing as t from uuid import UUID, uuid4 +from urllib.parse import urlsplit from flask import json, request, current_app @@ -67,6 +68,7 @@ class ServiceDescribe(TypedDict): version: str name: str summary: t.Optional[str] + servers: t.List[t.Dict[str, str]] procs: t.List[ServiceProcedureDescribe] # pytype: disable=invalid-annotation @@ -77,13 +79,19 @@ class ServiceDescribe(TypedDict): class JSONRPCSite: - def __init__(self) -> None: + def __init__(self, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None: + self.path = path + self.base_url = base_url self.view_funcs: t.Dict[str, t.Callable[..., t.Any]] = {} self.uuid: UUID = uuid4() self.name: str = 'Flask-JSONRPC' self.version: str = JSONRPC_VERSION_DEFAULT self.register(JSONRCP_DESCRIBE_METHOD_NAME, self.describe) + def server_url(self) -> str: + url = urlsplit(self.base_url or self.path) + return f"{url.scheme!r}://{url.netloc!r}/{(self.path or '').lstrip('/')}" if self.base_url else str(url.path) + @property def is_json(self) -> bool: """Check if the mimetype indicates JSON data, either @@ -96,6 +104,12 @@ def is_json(self) -> bool: mt.startswith('application/') and mt.endswith('+json') ) + def set_path(self, path: str) -> None: + self.path = path + + def set_base_url(self, base_url: t.Optional[str]) -> None: + self.base_url = base_url + def register(self, name: str, view_func: t.Callable[..., t.Any]) -> None: self.view_funcs[name] = view_func @@ -286,6 +300,11 @@ def service_desc(self) -> ServiceDescribe: version=self.version, name=self.name, summary=self.__doc__, + servers=[ + { + 'url': self.server_url(), + } + ], procs=[self.procedure_desc(k) for k in self.view_funcs if k != JSONRCP_DESCRIBE_METHOD_NAME], ) diff --git a/tests/contrib/test_browse.py b/tests/contrib/test_browse.py index 3e870bc2..545deafd 100644 --- a/tests/contrib/test_browse.py +++ b/tests/contrib/test_browse.py @@ -27,6 +27,7 @@ from flask import Flask from flask_jsonrpc import JSONRPC, JSONRPCBlueprint +from flask_jsonrpc.contrib.browse import JSONRPCBrowse def test_browse_create(): @@ -81,6 +82,7 @@ def fn3(s: str) -> str: rv = client.get('/api/browse/') assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert b'/api' in rv.data assert rv.status_code == 200 rv = client.get('/api/browse/packages.json') @@ -121,6 +123,9 @@ def fn3(s: str) -> str: } assert rv.status_code == 200 + rv = client.get('/api/browse/app.not_found.json') + assert rv.status_code == 404 + rv = client.get('/api/browse/partials/dashboard.html') assert b'Welcome to web browsable API' in rv.data assert rv.status_code == 200 @@ -134,6 +139,27 @@ def fn3(s: str) -> str: assert rv.status_code == 200 +def test_jsonrpc_browse(): + app = Flask('test_browse', instance_relative_config=True) + jsonrpc_browse = JSONRPCBrowse() + jsonrpc_browse.init_app(app) + + with app.test_client() as client: + rv = client.get('/api/browse/packages.json') + assert rv.json == {} + + rv = client.get('/api/browse/App.index.json') + assert rv.status_code == 404 + + rv = client.get('/api/browse/') + assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert rv.status_code == 200 + + rv = client.get('/api/browse/static/js/main.js') + assert b'App' in rv.data + assert rv.status_code == 200 + + def test_browse_create_without_register_app(): app = Flask('test_browse', instance_relative_config=True) jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) @@ -162,6 +188,7 @@ def fn1(s: str) -> str: rv = client.get('/api/browse/') assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert b'/api' in rv.data assert rv.status_code == 200 rv = client.get('/api/browse/static/js/main.js') @@ -218,12 +245,27 @@ def fn2(s: str) -> str: rv = client.get('/api/v1/browse/') assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert b'/api/v1' in rv.data + assert b'/api/v2' not in rv.data assert rv.status_code == 200 rv = client.get('/api/v1/browse/static/js/main.js') assert b'App' in rv.data assert rv.status_code == 200 + rv = client.get('/api/v1/browse/app.fn3.json') + assert rv.json == { + 'name': 'app.fn3', + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + } + assert rv.status_code == 200 + + rv = client.get('/api/v1/browse/app.fn1.json') + assert rv.status_code == 404 + rv = client.get('/api/v2/browse/packages.json') assert rv.json == { 'app': [ @@ -245,8 +287,24 @@ def fn2(s: str) -> str: } assert rv.status_code == 200 + rv = client.get('/api/v2/browse/app.fn1.json') + assert rv.json == { + 'name': 'app.fn1', + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + } + assert rv.status_code == 200 + + rv = client.get('/api/v2/browse/app.fn3.json') + assert rv.status_code == 404 + rv = client.get('/api/v2/browse/') assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert b'/api/v2/browse' in rv.data + assert b'/api/v2' in rv.data + assert b'/api/v1/browse/static/' in rv.data assert rv.status_code == 200 rv = client.get('/api/v2/browse/static/js/main.js') @@ -293,7 +351,7 @@ def fn1_b3(s: str) -> str: jsonrpc.register_blueprint(app, jsonrpc_api_3, url_prefix='/b3') with app.test_client() as client: - rv = client.get('/api/b1/browse/packages.json') + rv = client.get('/api/browse/packages.json') assert rv.json == { 'blue1': [ { @@ -303,20 +361,7 @@ def fn1_b3(s: str) -> str: 'return': {'type': 'String'}, 'summary': None, } - ] - } - assert rv.status_code == 200 - - rv = client.get('/api/b1/browse/') - assert b'Flask JSON-RPC | Web Browsable API' in rv.data - assert rv.status_code == 200 - - rv = client.get('/api/b1/browse/static/js/main.js') - assert b'App' in rv.data - assert rv.status_code == 200 - - rv = client.get('/api/b2/browse/packages.json') - assert rv.json == { + ], 'blue2': [ { 'name': 'blue2.fn1', @@ -339,17 +384,49 @@ def fn1_b3(s: str) -> str: 'return': {'type': 'String'}, 'summary': None, }, - ] + ], } assert rv.status_code == 200 - rv = client.get('/api/b2/browse/') + rv = client.get('/api/browse/') assert b'Flask JSON-RPC | Web Browsable API' in rv.data + assert b'/api/b1' in rv.data + assert b'/api/b2' in rv.data + assert b'/api/b3' not in rv.data assert rv.status_code == 200 - rv = client.get('/api/b2/browse/static/js/main.js') + rv = client.get('/api/browse/static/js/main.js') assert b'App' in rv.data assert rv.status_code == 200 - rv = client.get('/api/b3/browse/packages.json') + rv = client.get('/api/b1/browse/packages.json') + assert rv.status_code == 404 + + rv = client.get('/api/b2/browse/packages.json') + assert rv.status_code == 404 + + rv = client.get('/api/b3/browse') + assert rv.status_code == 404 + + rv = client.get('/api/browse/blue2.fn1.json') + assert rv.status_code == 200 + assert rv.json == { + 'name': 'blue2.fn1', + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + } + + rv = client.get('/api/browse/blue2.fn2.json') + assert rv.status_code == 200 + assert rv.json == { + 'name': 'blue2.fn2', + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + } + + rv = client.get('/api/browse/blue3.fn3.json') assert rv.status_code == 404 diff --git a/tests/test_app.py b/tests/test_app.py index 76166b05..6fb3c69b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -137,6 +137,22 @@ def fn4(s: str) -> str: assert rv.status_code == 200 +def test_app_create_with_server_name(): + app = Flask('test_app', instance_relative_config=True) + app.config.update({'SERVER_NAME': 'domain:80'}) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + + # pylint: disable=W0612 + @jsonrpc.method('app.index') + def index() -> str: + return 'Welcome to Flask JSON-RPC' + + with app.test_client() as client: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Welcome to Flask JSON-RPC'} + assert rv.status_code == 200 + + def test_app_create_without_register_app(): app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) @@ -154,6 +170,15 @@ def fn1(s: str) -> str: assert rv.status_code == 200 +def test_app_create_without_register_browse(): + jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) + + with pytest.raises( + RuntimeError, match='You need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' + ): + jsonrpc.register_browse(jsonrpc) + + def test_app_create_with_method_without_annotation(): with pytest.raises(ValueError, match='no type annotations present to: app.fn1'): app = Flask('test_app', instance_relative_config=True) diff --git a/tests/test_apps/app/__init__.py b/tests/test_apps/app/__init__.py index 9ff8d046..78914ff1 100644 --- a/tests/test_apps/app/__init__.py +++ b/tests/test_apps/app/__init__.py @@ -80,7 +80,7 @@ def wrapped(*args, **kwargs): return wrapped -def create_app(test_config=None): # noqa: C901 pylint: disable=W0612 +def create_app(test_config: t.Dict[str, t.Any] = None): # noqa: C901 pylint: disable=W0612 """Create and configure an instance of the Flask application.""" flask_app = Flask('apptest', instance_relative_config=True) if test_config: @@ -148,6 +148,11 @@ def return_headers(s: str) -> t.Tuple[str, t.Dict[str, t.Any]]: def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str, t.Any]]: return f'Status Code and Headers {s}', 400, {'X-JSONRPC': '1'} + # pylint: disable=W0612 + @jsonrpc.method('jsonrpc.not_validate', validate=False) + def not_validate(s='Oops!'): + return f'Not validate: {s}' + class_app = App() jsonrpc.register(class_app.index, name='classapp.index') jsonrpc.register(class_app.greeting) @@ -161,5 +166,5 @@ def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str, t.An if __name__ == '__main__': - app = create_app() + app = create_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) app.run(host='0.0.0.0') diff --git a/tests/test_apps/async_app/__init__.py b/tests/test_apps/async_app/__init__.py index 6b6ceff2..8ab0c882 100644 --- a/tests/test_apps/async_app/__init__.py +++ b/tests/test_apps/async_app/__init__.py @@ -80,7 +80,7 @@ async def wrapped(*args, **kwargs): return wrapped -def create_async_app(test_config=None): # noqa: C901 pylint: disable=W0612 +def create_async_app(test_config: t.Dict[str, t.Any] = None): # noqa: C901 pylint: disable=W0612 """Create and configure an instance of the Flask application.""" flask_app = Flask('apptest', instance_relative_config=True) if test_config: @@ -158,6 +158,11 @@ async def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str await asyncio.sleep(0) return f'Status Code and Headers {s}', 400, {'X-JSONRPC': '1'} + # pylint: disable=W0612 + @jsonrpc.method('jsonrpc.not_validate', validate=False) + def not_validate(s='Oops!'): + return f'Not validate: {s}' + class_app = App() jsonrpc.register(class_app.index, name='classapp.index') jsonrpc.register(class_app.greeting) @@ -171,5 +176,5 @@ async def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str if __name__ == '__main__': - app = create_async_app() + app = create_async_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) app.run(host='0.0.0.0') diff --git a/tests/test_apps/pytest.local.ini b/tests/test_apps/pytest.local.ini index 932ecc2e..6f840503 100644 --- a/tests/test_apps/pytest.local.ini +++ b/tests/test_apps/pytest.local.ini @@ -15,4 +15,4 @@ env = SITE_PORT=5000 WEB_URL=http://localhost:5000 API_URL=http://localhost:5000/api - BROWSABLE_API_URL=http://localhost:5000/browse + BROWSABLE_API_URL=http://localhost:5000/api/browse diff --git a/tests/test_apps/test_app.py b/tests/test_apps/test_app.py index fa05ba7d..3e03e788 100644 --- a/tests/test_apps/test_app.py +++ b/tests/test_apps/test_app.py @@ -24,7 +24,7 @@ # 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. -# pylint: disable=duplicate-code +# pylint: disable=duplicate-code,too-many-public-methods import json from .conftest import APITestCase @@ -328,6 +328,39 @@ def test_notify(self): self.assertEqual('', rv.text) self.assertEqual(204, rv.status_code) + def test_not_allow_notify(self): + rv = self.requests.post(self.api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify'}) + self.assertEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'}, rv.json()) + self.assertEqual(200, rv.status_code) + + rv = self.requests.post( + self.api_url, + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']}, + ) + self.assertEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'}, rv.json()) + self.assertEqual(200, rv.status_code) + + rv = self.requests.post( + self.api_url, json={'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']} + ) + self.assertEqual( + { + 'error': { + 'code': -32600, + 'data': { + 'message': "The method 'jsonrpc.not_allow_notify' doesn't allow Notification Request " + "object (without an 'id' member)" + }, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + 'id': None, + 'jsonrpc': '2.0', + }, + rv.json(), + ) + self.assertEqual(400, rv.status_code) + def test_fails(self): rv = self.requests.post( self.api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]} @@ -419,6 +452,14 @@ def test_return_status_code_and_headers(self): self.assertEqual(400, rv.status_code) self.assertEqual('1', rv.headers['X-JSONRPC']) + def test_not_validate_method(self): + rv = self.requests.post( + self.api_url, + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_validate', 'params': ['OK']}, + ) + self.assertEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not validate: OK'}, rv.json()) + self.assertEqual(200, rv.status_code) + def test_with_rcp_batch(self): rv = self.requests.post(self.api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) self.assertEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'}, rv.json()) @@ -525,34 +566,48 @@ def test_system_describe(self): self.assertEqual('1.0', json_data['result']['sdversion']) self.assertIsNone(json_data['result']['summary']) self.assertEqual('2.0', json_data['result']['version']) + self.assertIsNotNone(json_data['result']['servers']) + self.maxDiff = None self.assertEqual( [ { 'name': 'jsonrpc.greeting', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'name', 'type': 'String'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'jsonrpc.echo', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'jsonrpc.notify', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': '_string', 'type': 'String'}], 'return': {'type': 'Null'}, 'summary': None, }, + { + 'name': 'jsonrpc.not_allow_notify', + 'options': {'notification': False, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + }, { 'name': 'jsonrpc.fails', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'n', 'type': 'Number'}], 'return': {'type': 'Number'}, 'summary': None, }, { 'name': 'jsonrpc.strangeEcho', + 'options': {'notification': True, 'validate': True}, 'params': [ {'name': 'string', 'type': 'String'}, {'name': 'omg', 'type': 'Object'}, @@ -565,66 +620,91 @@ def test_system_describe(self): }, { 'name': 'jsonrpc.sum', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], 'return': {'type': 'Number'}, 'summary': None, }, { 'name': 'jsonrpc.decorators', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'string', 'type': 'String'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'jsonrpc.returnStatusCode', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 's', 'type': 'String'}], 'return': {'type': 'Array'}, 'summary': None, }, { 'name': 'jsonrpc.returnHeaders', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 's', 'type': 'String'}], 'return': {'type': 'Array'}, 'summary': None, }, { 'name': 'jsonrpc.returnStatusCodeAndHeaders', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 's', 'type': 'String'}], 'return': {'type': 'Array'}, 'summary': None, }, + { + 'name': 'jsonrpc.not_validate', + 'options': {'notification': True, 'validate': False}, + 'params': [], + 'return': {'type': 'Null'}, + 'summary': None, + }, { 'name': 'classapp.index', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'name', 'type': 'String'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'greeting', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'name', 'type': 'String'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'hello', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'name', 'type': 'String'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'echo', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'return': {'type': 'String'}, 'summary': None, }, { 'name': 'notify', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': '_string', 'type': 'String'}], 'return': {'type': 'Null'}, 'summary': None, }, + { + 'name': 'not_allow_notify', + 'options': {'notification': False, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'return': {'type': 'String'}, + 'summary': None, + }, { 'name': 'fails', + 'options': {'notification': True, 'validate': True}, 'params': [{'name': 'n', 'type': 'Number'}], 'return': {'type': 'Number'}, 'summary': None, diff --git a/tests/test_async_app.py b/tests/test_async_app.py index 4ac19e4a..b6b73482 100644 --- a/tests/test_async_app.py +++ b/tests/test_async_app.py @@ -147,6 +147,23 @@ def fn4(s: str) -> str: assert rv.status_code == 200 +@pyminversion +def test_app_create_with_server_name(): + app = Flask('test_app', instance_relative_config=True) + app.config.update({'SERVER_NAME': 'domain:80'}) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + + # pylint: disable=W0612 + @jsonrpc.method('app.index') + def index() -> str: + return 'Welcome to Flask JSON-RPC' + + with app.test_client() as client: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Welcome to Flask JSON-RPC'} + assert rv.status_code == 200 + + @pyminversion def test_app_create_without_register_app(): app = Flask('test_app', instance_relative_config=True) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index b88e212f..fa7c00cc 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -487,6 +487,7 @@ def test_app_system_describe(async_client): assert rv.json['result']['sdversion'] == '1.0' assert rv.json['result']['summary'] is None assert rv.json['result']['version'] == '2.0' + assert rv.json['result']['servers'] is not None assert rv.json['result']['procs'] == [ { 'name': 'jsonrpc.greeting', @@ -571,6 +572,13 @@ def test_app_system_describe(async_client): 'return': {'type': 'Array'}, 'summary': None, }, + { + 'name': 'jsonrpc.not_validate', + 'options': {'notification': True, 'validate': False}, + 'params': [], + 'return': {'type': 'Null'}, + 'summary': None, + }, { 'name': 'classapp.index', 'options': {'notification': True, 'validate': True}, diff --git a/tests/test_client.py b/tests/test_client.py index 459c48b4..5f9ffb98 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -473,6 +473,7 @@ def test_app_system_describe(client): assert rv.json['result']['sdversion'] == '1.0' assert rv.json['result']['summary'] is None assert rv.json['result']['version'] == '2.0' + assert rv.json['result']['servers'] is not None assert rv.json['result']['procs'] == [ { 'name': 'jsonrpc.greeting', @@ -557,6 +558,13 @@ def test_app_system_describe(client): 'return': {'type': 'Array'}, 'summary': None, }, + { + 'name': 'jsonrpc.not_validate', + 'options': {'notification': True, 'validate': False}, + 'params': [], + 'return': {'type': 'Null'}, + 'summary': None, + }, { 'name': 'classapp.index', 'options': {'notification': True, 'validate': True}, diff --git a/tox.ini b/tox.ini index 4053c075..70151607 100644 --- a/tox.ini +++ b/tox.ini @@ -36,7 +36,7 @@ deps = bandit==1.7.4 safety==2.3.5 commands = - safety check + safety check -i 51457 bandit -r src/ [testenv:docs]