#####################################################################################
#
# Copyright (c) typedef int GmbH
# SPDX-License-Identifier: EUPL-1.2
#
#####################################################################################
import os
from pprint import pformat
from typing import Any, Dict, Union
import werkzeug
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.routing import MapAdapter, Rule
try:
# removed in werkzeug 2.1.0
from werkzeug.utils import escape
except ImportError:
from markupsafe import escape
from autobahn.wamp.serializer import JsonObjectSerializer
from jinja2 import Environment
from twisted.web import resource
from txaio import make_logger, time_ns
from xbr import FbsRepository
from crossbar.common.checkconfig import InvalidConfigException, check_dict_args
from crossbar.webservice.base import RootResource, RouterWebService
from crossbar.worker.proxy import ProxyController
from crossbar.worker.router import RouterController
__all__ = ("RouterWebServiceCatalog",)
class CatalogResource(resource.Resource):
"""
Twisted Web resource for API FbsRepository Web service.
This resource uses templates loaded into a Jinja2 environment to render HTML pages
with data retrieved from an API FbsRepository archive file or on-chain address.
"""
log = make_logger()
ser = JsonObjectSerializer()
isLeaf = True
def __init__(
self,
jinja_env: Environment,
worker: Union[RouterController, ProxyController],
config: Dict[str, Any],
path: str,
):
"""
:param worker: The router worker controller within this Web service is started.
:param config: The Web service configuration item.
"""
resource.Resource.__init__(self)
# remember all ctor args
self._jinja_env: Environment = jinja_env
self._worker = worker
self._config = config
self._path = path
# setup Werkzeug URL map adapter
# https://werkzeug.palletsprojects.com/en/2.1.x/routing/#werkzeug.routing.Map
adapter_map = werkzeug.routing.Map()
routes = {
"/": "wamp_catalog_home.html",
"table": "wamp_catalog_table.html",
"struct": "wamp_catalog_struct.html",
"enum": "wamp_catalog_enum.html",
"service": "wamp_catalog_service.html",
}
for rpath, route_template in routes.items():
# compute full absolute URL of route to be added - ending in Werkzeug/Routes URL pattern
_rp = []
if path != "/":
_rp.append(path)
if rpath != "/":
_rp.append(rpath)
route_url = os.path.join("/", "/".join(_rp))
route_endpoint = jinja_env.get_template(route_template)
route_rule = Rule(route_url, methods=["GET"], endpoint=route_endpoint)
adapter_map.add(route_rule)
# https://werkzeug.palletsprojects.com/en/2.1.x/routing/#werkzeug.routing.Map.bind
self._map_adapter: MapAdapter = adapter_map.bind("localhost", "/")
# FIXME
self._repo: FbsRepository = FbsRepository("FIXME")
self._repo.load(self._config["filename"])
def render(self, request):
# https://twistedmatrix.com/documents/current/api/twisted.web.resource.Resource.html#render
# The encoded path of the request URI (_not_ (!) including query arguments),
full_path = request.path.decode("utf-8")
# HTTP request method
http_method = request.method.decode()
if http_method not in ["GET"]:
request.setResponseCode(511)
return self._render_error(
'Method not allowed on path "{full_path}" [werkzeug.routing.MapAdapter.match]'.format(
full_path=full_path
),
request,
)
# parse and decode any query parameters
query_args = {}
if request.args:
for key, values in request.args.items():
key = key.decode()
# we only process the first header value per key (!)
value = values[0].decode()
query_args[key] = value
self.log.info("Parsed query parameters: {query_args}", query_args=query_args)
# parse client announced accept-header
client_accept = request.getAllHeaders().get(b"accept", None)
if client_accept:
client_accept = client_accept.decode()
# flag indicating the client wants to get plain JSON results (not rendered HTML)
# client_return_json = client_accept == 'application/json'
# client cookie processing
cookie = request.received_cookies.get(b"session_cookie")
self.log.debug("Session Cookie is ({})".format(cookie))
try:
# werkzeug.routing.MapAdapter
# https://werkzeug.palletsprojects.com/en/2.1.x/routing/#werkzeug.routing.MapAdapter.match
template, kwargs = self._map_adapter.match(full_path, method=http_method, query_args=query_args)
if kwargs:
if query_args:
kwargs.update(query_args)
else:
kwargs = query_args
kwargs["repo"] = self._repo
kwargs["created"] = time_ns()
self.log.info(
'CatalogResource request on path "{full_path}" mapped to template "{template}" using kwargs\n{kwargs}',
full_path=full_path,
template=template,
kwargs=pformat(kwargs),
)
rendered = template.render(**kwargs).encode("utf8")
self.log.info("successfully rendered HTML result: {rendered} bytes", rendered=len(rendered))
request.setResponseCode(200)
return rendered
except NotFound:
self.log.warn('URL "{url}" not found (method={method})', url=full_path, method=http_method)
request.setResponseCode(404)
return self._render_error(
'Path "{full_path}" not found [werkzeug.routing.MapAdapter.match]'.format(full_path=full_path), request
)
except MethodNotAllowed:
self.log.warn('method={method} not allowed on URL "{url}"', url=full_path, method=http_method)
request.setResponseCode(511)
return self._render_error(
'Method not allowed on path "{full_path}" [werkzeug.routing.MapAdapter.match]'.format(
full_path=full_path
),
request,
)
except Exception as e:
self.log.warn(
'error while processing method={method} on URL "{url}": {e}', url=full_path, method=http_method, e=e
)
request.setResponseCode(500)
return self._render_error(
'Unknown error with path "{full_path}" [werkzeug.routing.MapAdapter.match]'.format(
full_path=full_path
),
request,
)
def _render_error(self, message, request, client_return_json=False):
"""
Error renderer, display a basic error message to tell the user that there
was a problem and roughly what the problem was.
:param message: The current error message
:param request: The original HTTP request
:return: HTML formatted error string
"""
if client_return_json:
return self.ser.serialize({"error": message})
else:
return """
<html>
<title>API Error</title>
<body>
<h3 style="color: #f00">Crossbar WAMP Application Page Error</h3>
<pre>{}</pre>
</body>
</html>
""".format(escape(message)).encode("utf8")
[docs]
class RouterWebServiceCatalog(RouterWebService):
"""
WAMP API FbsRepository Web service.
"""
@staticmethod
[docs]
def check(personality, config: Dict[str, Any]):
"""
Checks the configuration item. When errors are found, an
:class:`crossbar.common.checkconfig.InvalidConfigException` is raised.
:param personality: The node personality class.
:param config: The Web service configuration item.
"""
if "type" not in config:
raise InvalidConfigException("missing mandatory attribute 'type' in Web service configuration")
if config["type"] != "catalog":
raise InvalidConfigException('unexpected Web service type "{}"'.format(config["type"]))
check_dict_args(
{
# ID of webservice (must be unique for the web transport)
"id": (False, [str]),
# must be equal to "catalog"
"type": (True, [str]),
# filename (relative to node directory) to FbsRepository file (*.bfbs, *.zip or *.zip.sig)
"filename": (True, [str]),
# path to provide to Werkzeug/Routes (eg "/test" rather than "test")
"path": (False, [str]),
},
config,
"FbsRepository Web service configuration:\n{}".format(pformat(config)),
)
@staticmethod
[docs]
def create(transport, path: str, config: Dict[str, Any]) -> "RouterWebServiceCatalog":
"""
Create a new FbsRepository Web service using a FbsRepository archive file or on-chain address.
:param transport: Web-transport on which to add the web service.
:param path: HTTP path on which to add the web service.
:param config: Web service configuration.
:return: Web service instance.
"""
personality = transport.worker.personality
personality.WEB_SERVICE_CHECKERS["catalog"](personality, config)
_resource = CatalogResource(transport.templates, transport.worker, config, path)
if path == "/":
_resource = RootResource(_resource, {})
return RouterWebServiceCatalog(transport, path, config, _resource)