Source code for bag.web.burla

"""Powerful URL generation independent of web frameworks.

**Burla** stores a collection of page URL templates, separate from a
collection of API method URL templates.  The only difference between
them is that operations have a request method (GET, POST, PUT etc.).

Burla also facilitates generating documentation about pages and
API operations in the Python server.

No matter what web framework you are using, you are better off
generating URLs with Burla because this makes your application more
independent of web frameworks so you can switch more easily.

Another advantage is that, based on the URL templates, burla generates
URLs in the Python server as well as in the Javascript client.  It
generates a short Javascript library that takes care of this.

Maintenance of your web app becomes easier because you register your
URLs only once (in the Python server code) and then the URLs can be
used in the entire stack.  When you change your URLs, you only do it
in one place.

URL templates (for matching views) stop at the left of the question mark,
but when generating URLs, burla supports both query params
(to the right of the question mark) and fragments (to the right of the #),

Here are a few examples of usage in the Javascript client:

.. code-block:: javascript

    // Let's see a previously registered URL template:
    burla.page('User details')
    "/users/:user_id/details"

    // Provide a map to generate a real URL from the template:
    burla.page('User details', {user_id: 1})
    "/users/1/details"

    // Provide another argument to add a fragment (to the right of the #):
    burla.page('User details', {user_id: 1}, 'tab=aboutme')
    "/users/1/details#tab=aboutme"

    // Extra params (not found in the URL template) go in the query:
    burla.page('User details', {user_id: 1, photos: 'big'}, 'tab=aboutme')
    "/users/1/details?photos=big#tab=aboutme"

You can find integration with the Pyramid web framework in
:py:mod:`bag.web.pyramid.burla`. Please contribute integration into
other frameworks.
"""

from collections import OrderedDict
from json import dumps
from re import compile
from typing import Dict, List
from urllib.parse import urlencode

try:
    from bag.web.pyramid import _
except ImportError:
    _ = str  # and i18n is disabled.


[docs]class Page: """Class that represents a web page in burla. A page is comprised of a descriptive name, a URL template (from which parameters are discovered) and possibly documentation strings. The instance is able to generate its URL. A URL template looks like this (params are preceded by a colon):: /cities/:city/streets/:street """ def __init__( # noqa self, op_name, url_templ, fn=None, permission=None, section="Miscellaneous", **view_args, ): assert isinstance(op_name, str) assert op_name assert isinstance(url_templ, str) assert url_templ self.name = op_name self.url_templ = url_templ self.fn = fn # In Python source the doc will contain extra indentation doc = fn.__doc__ if fn else None if doc: alist = [] for line in doc.split("\n"): alist.append(line[4:] if line.startswith(" " * 4) else line) doc = "\n".join(alist) self.doc = doc # description of the page or HTTP operation self.permission = permission self.section = section # of the documentation self.view_args = view_args self._discover_params_in_url_templ() def _discover_params_in_url_templ(self): self.params = [] for match in self.PARAM.finditer(self.url_templ): self.params.append(match.group(1)) PARAM = compile(r":([a-z_]+)")
[docs] def url(self, fragment="", **kw): """Given a dictionary, generate an actual URL from the template.""" astr = self.url_templ for param in self.params: key = ":" + param if key in self.url_templ: value = str(kw.pop(param)) astr = astr.replace(key, value) # The remaining params in kw make the query parameters if kw: astr += "?" + urlencode(kw) if fragment: astr += "#" + fragment return astr
[docs] def to_dict(self): """Convert this instance into a dictionary, maybe for JSON output.""" return { "url_templ": self.url_templ, "permission": self.permission, }
is_page = True is_operation = not is_page
[docs]class Operation(Page): """Subclass of Page representing an HTTP operation."""
[docs] def to_dict(self): # noqa adict = super(Operation, self).to_dict() adict["request_method"] = self.view_args.get("request_method") return adict
is_page = False is_operation = not is_page
[docs]class Burla: """Collection of pages and operations. Easily output as JSON. Generates URLs and provides JS code to generate URLs in the client. """ def __init__(self, root="", page_class=Page, op_class=Operation): # noqa self.map = OrderedDict() self.root = root self._page_class = page_class self._op_class = op_class def _add_page(self, op_name, **kw): assert op_name not in self.map, "Already registered: {}".format(op_name) self.map[op_name] = self._page_class(op_name, **kw) def _add_op(self, op_name, **kw): assert op_name not in self.map, "Already registered: {}".format(op_name) self.map[op_name] = self._op_class(op_name, **kw)
[docs] def url(self, name, **kw): """Return only the generated URL.""" return self.map[name].url(**kw)
# def item(self, name, **kw): # """Returns the generated URL and the request_method.""" # op = self.map[name] # return {'url': op.url(**kw), 'request_method': op.request_method}
[docs] def add_op(self, op_name, **kw): """Decorate view handlers to register an operation with Burla.""" def wrapper(view_handler): self._add_op(op_name, fn=view_handler, **kw) return view_handler return wrapper
[docs] def add_page(self, op_name, **kw): """Decorator for view handlers that registers a page with Burla.""" def wrapper(view_handler): self._add_page(op_name, fn=view_handler, **kw) return view_handler return wrapper
[docs] def gen_pages(self): for o in self.map.values(): if o.is_page: yield o
[docs] def gen_ops(self): for o in self.map.values(): if o.is_operation: yield o
[docs] def to_dict(self): """Use this to generate JSON so the client knows the URLs too.""" return { "pages": {o.name: o.to_dict() for o in self.gen_pages()}, "ops": {o.name: o.to_dict() for o in self.gen_ops()}, }
API_TITLE = _("HTTP API Documentation") PAGES_TITLE = _("Site map")
[docs] def gen_documentation( self, pages=False, title: str = "", prefix: str = "", suffix: str = "", ): """Generate documentation in reStructuredText. If ``pages`` is True, the documentation is a site map. But by default the documentation contains HTTP API methods. Sources of information are the 'section', 'name', 'doc' and 'permission' attributes of the registered Operation instances. """ if pages: title = title or self.PAGES_TITLE methods_title = _("Pages") items = self.gen_pages() else: title = title or self.API_TITLE methods_title = _("API methods") items = self.gen_ops() # Organize the operations inside their respective sections first sections: Dict[str, List[Operation]] = {} for op in items: if op.section not in sections: sections[op.section] = [] sections[op.section].append(op) if title: title_line = "=" * len(title) yield title_line yield title yield title_line yield "" if prefix: yield prefix yield "" yield methods_title yield "=" * len(methods_title) for section_name in sorted(sections): if section_name: yield section_name yield "=" * len(section_name) yield "" section = sections[section_name] for op in sorted(section, key=lambda op: op.name): if op.name: yield op.name yield "-" * len(op.name) yield "" if op.url_templ: yield "::\n" method = op.view_args.get("request_method") if method: url_line = method + " " + op.url_templ else: url_line = op.url_templ yield " " + url_line yield "" if op.doc: yield op.doc yield "" if op.permission: yield _( "Requires that the user have the " f'"{op.permission}" permission.' ) yield "" if suffix: yield suffix yield ""
[docs] def get_javascript_code(self): """Return a JS library to generate the application URLs. Return JS code containing the registered operations and pages, plus functions to generate URLs from them. """ return ( BURLA_JS.replace( "PAGES", dumps( {o.name: o.to_dict() for o in self.gen_pages()}, sort_keys=True, ), ) .replace( "OPERATIONS", dumps( {o.name: o.to_dict() for o in self.gen_ops()}, sort_keys=True, ), ) .replace("ROOT", dumps(self.root)) )
BURLA_JS = """"use strict"; // Usage: // import {burla} from "/burla.js"; // const url = burla.page(pageName, params, fragment); // const url = burla.op(operationName, params, fragment); export const burla = { root: ROOT, pages: PAGES, ops: OPERATIONS, urlencode: function (adict) { return Object.keys(adict).map(function (key) { return [key, adict[key]].map(encodeURIComponent).join("="); }).join("&"); }, requiredParameterNames: function (spec) { const matches = spec.url_templ.match(/:\\w+/g); // can be null // Remove the starting colon from each match return matches ? matches.map(s => s.slice(1)) : []; }, _find: function (map, name, params, fragment) { const spec = map[name]; if (!spec) throw new Error(`burla: No item called "${name}".`); const paramNames = this.requiredParameterNames(spec); for (const paramName of paramNames) { if (params[paramName] == null) throw new Error( `burla: "${name}" requires parameter "${paramName}".`); } let s = spec.url_templ; const p = {}; // for after the question mark for (const key in params) { const placeholder = ':' + key; const val = params[key]; if (s.indexOf(placeholder) == -1) { p[key] = val; // accumulate } else { s = s.replace(placeholder, val); } } const strParams = this.urlencode(p); if (strParams) s += '?' + strParams; if (fragment) s += '#' + fragment; return this.root + s; }, page: function (name, params, fragment) { return this._find(this.pages, name, params, fragment); }, op: function (name, params, fragment) { return this._find(this.ops, name, params, fragment); }, pageTemplFragment: function (name) { // Doesn't generate URLs, just returns the fragment of URL template. let o; try { o = this.pages[name]; } catch (ex) { throw new Error(`No page named "${name}"!`); } const parts = o.url_templ.split('#'); return parts.length === 1 ? "" : parts[1]; }, makeEnvelope: function (name, params, fragment) { return { method: this.ops[name].request_method, url: this.op(name, params, fragment), }; } };\n"""