Skip to content

Commit

Permalink
Unified API Browser when using modular server
Browse files Browse the repository at this point in the history
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
  • Loading branch information
nycholas committed Mar 25, 2023
1 parent 2c6afcd commit e37ba8f
Show file tree
Hide file tree
Showing 19 changed files with 305 additions and 86 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/codeql_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.ref_name }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/on_update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/pre_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion bin/docker-compose-it.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion bin/docker-compose-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
File renamed without changes.
78 changes: 53 additions & 25 deletions src/flask_jsonrpc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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/<path:filename>', '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())
87 changes: 58 additions & 29 deletions src/flask_jsonrpc/contrib/browse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('/<method_name>.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/<path:filename>', '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('/<method_name>.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
Loading

0 comments on commit e37ba8f

Please sign in to comment.