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]