gramex.handlers

Handlers set up the micro-services for gramex.services.url.

BaseMixin

Common utilities for all handlers. This is used by BaseHandler and BaseWebSocketHandler.

setup(transform={}, redirect={}, methods=None, auth=None, log=None, set_xsrf=None, error=None, xsrf_cookies=None, cors=None, ratelimit=None, kwargs) classmethod

One-time setup for all request handlers. This is called only when gramex.yaml is parsed / changed.

Source code in gramex\handlers\basehandler.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@classmethod
def setup(
    cls,
    transform={},
    redirect={},
    methods=None,
    auth: Union[None, bool, dict] = None,
    log=None,
    set_xsrf=None,
    error=None,
    xsrf_cookies=None,
    cors: Union[None, bool, dict] = None,
    ratelimit: Optional[dict] = None,
    # If you add any explicit kwargs here, add them to special_keys too.
    **kwargs,
):
    '''
    One-time setup for all request handlers. This is called only when
    gramex.yaml is parsed / changed.
    '''
    cls._on_init_methods = []
    cls._on_finish_methods = []
    cls._set_xsrf = set_xsrf

    cls.kwargs = cls.conf.get('kwargs', AttrDict())

    cls.setup_transform(transform)
    cls.setup_redirect(redirect)
    # Note: call setup_session before setup_auth to ensure that .session is available.
    # This also ensures we override_user before auth
    cls.setup_session(conf.app.get('session'))
    cls.setup_ratelimit(ratelimit, conf.app.get('ratelimit'))
    cls.setup_auth(auth)
    cls.setup_error(error)
    cls.setup_xsrf(xsrf_cookies)
    cls.setup_log()
    cls.setup_httpmethods(methods)
    cls.setup_cors(cors, auth=auth)

    # app.settings.debug enables debugging exceptions using pdb
    if conf.app.settings.get('debug', False):
        cls.log_exception = cls.debug_exception

clear_special_keys(kwargs, args) classmethod

Remove keys handled by BaseHandler that may interfere with setup(). This should be called explicitly in setup() where required.

Source code in gramex\handlers\basehandler.py
102
103
104
105
106
107
108
109
110
111
112
@classmethod
def clear_special_keys(cls, kwargs, *args):
    '''
    Remove keys handled by BaseHandler that may interfere with setup().
    This should be called explicitly in setup() where required.
    '''
    for special_key in cls.special_keys:
        kwargs.pop(special_key, None)
    for special_key in args:
        kwargs.pop(special_key, None)
    return kwargs

get_list(val, key='', eg='', caps=True) classmethod

Split comma-separated values into a set.

Process kwargs that can be a comma-separated string or a list, like BaseMixin’s methods:, cors.origins, cors.methods, cors.headers, ratelimit.keys, etc.

Examples:

>>> get_list('GET, PUT') == {'GET', 'PUT'}
>>> get_list(['GET', ' ,get '], caps=True) == {'GET'}
>>> get_list([' GET ,  PUT', ' ,POST, ']) == {'GET', 'PUT', 'POST'}

Parameters:

Name Type Description Default
val Union[list, tuple, str]

Input to split. If val is not str/list/tuple, raise ValueError

required
key str

url: key to display in error message

''
eg str

Example values to display in error message

''
caps bool

True to convert values to uppercase

True

Returns:

Type Description
set

Unique comma-separated values

Source code in gramex\handlers\basehandler.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def get_list(
    cls, val: Union[list, tuple, str], key: str = '', eg: str = '', caps: bool = True
) -> set:
    '''Split comma-separated values into a set.

    Process kwargs that can be a comma-separated string or a list,
    like BaseMixin's `methods:`, `cors.origins`, `cors.methods`, `cors.headers`,
    `ratelimit.keys`, etc.

    Examples:
        >>> get_list('GET, PUT') == {'GET', 'PUT'}
        >>> get_list(['GET', ' ,get '], caps=True) == {'GET'}
        >>> get_list([' GET ,  PUT', ' ,POST, ']) == {'GET', 'PUT', 'POST'}

    Parameters:
        val: Input to split. If val is not str/list/tuple, raise `ValueError`
        key: `url:` key to display in error message
        eg: Example values to display in error message
        caps: True to convert values to uppercase

    Returns:
        Unique comma-separated values
    '''
    if isinstance(val, (list, tuple)):
        val = ' '.join(val)
    elif not val:
        val = ''
    if not isinstance(val, str):
        err = f'url:{cls.name}.{key}: {val!r} not a string/list'
        err = err + f', e.g. {eg}' if eg else err
        raise ValueError(err)
    if caps:
        val = val.upper()
    # Return de-duplicated list. Avoid set(), use dict to preserve order
    return list(dict.fromkeys(val.replace(',', ' ').split()))

check_http_method()

If method: […] is specified, reject all methods not in the allowed methods set

Source code in gramex\handlers\basehandler.py
158
159
160
161
162
163
164
165
def check_http_method(self):
    '''If method: [...] is specified, reject all methods not in the allowed methods set'''
    if self.request.method not in self._http_methods:
        raise HTTPError(
            METHOD_NOT_ALLOWED,
            f'{self.name}: method {self.request.method} '
            + f'not in allowed methods {self._http_methods}',
        )

check_cors()

For simple CORS requests, send Access-Control-Allow-Origin: . If request needs credentials, allow it.

Source code in gramex\handlers\basehandler.py
188
189
190
191
192
193
194
195
196
def check_cors(self):
    '''
    For simple CORS requests, send Access-Control-Allow-Origin: <origin>.
    If request needs credentials, allow it.'''
    origin, cred = self.cors_origin()
    if origin:
        self.set_header('Access-Control-Allow-Origin', origin)
        if cred:
            self.set_header('Access-Control-Allow-Credentials', 'true')

cors_origin()

Returns the origin to set in Access-Control-Allow-Origin header.

Source code in gramex\handlers\basehandler.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def cors_origin(self):
    '''
    Returns the origin to set in Access-Control-Allow-Origin header.
    '''
    # If CORS is not enabled, it fails
    if not self._cors:
        return None, False
    # Assume credentials are passed if handler requires Auth or Cookie is passed
    cred = self._cors['auth'] or self.request.headers.get('Cookie')
    # If origin: *, then allow all origins
    origin = self.request.headers.get('Origin', '').lower()
    if self._cors['origins'] == set('*'):
        return (origin if cred else '*', cred)
    # If it matches any of the wildcards, return specific origin
    for pattern in self._cors['origins']:
        if fnmatch(origin, pattern.lower()):
            return origin, cred
    # If none of the patterns match, it fails
    return None, cred

setup_default_kwargs() classmethod

Use default config from handlers..* and handlers.BaseHandler. Called directly by gramex.services.url(). NOTE: This updates the kwargs for setup() – so it must be called BEFORE setup() and can’t be merged into setup() or called from setup().

Source code in gramex\handlers\basehandler.py
280
281
282
283
284
285
286
287
288
289
290
@classmethod
def setup_default_kwargs(cls):
    '''
    Use default config from handlers.<Class>.* and handlers.BaseHandler.
    Called directly by gramex.services.url().
    NOTE: This updates the kwargs for setup() -- so it must be called BEFORE setup()
    and can't be merged into setup() or called from setup().
    '''
    c = cls.conf.kwargs
    merge(c, objectpath(conf, 'handlers.' + cls.conf.handler, {}), mode='setdefault')
    merge(c, objectpath(conf, 'handlers.BaseHandler', {}), mode='setdefault')

setup_session(session_conf) classmethod

handler.session returns the session object. It is saved on finish.

Source code in gramex\handlers\basehandler.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
@classmethod
def setup_session(cls, session_conf):
    '''handler.session returns the session object. It is saved on finish.'''
    if session_conf is None:
        return
    cls._session_store = cls._get_store(session_conf)
    cls.session = property(cls.get_session)
    cls._session_expiry = session_conf.get('expiry')
    cls._session_cookie_id = session_conf.get('cookie', 'sid')
    cls._session_cookie = {
        key: session_conf[key]
        for key in ('httponly', 'secure', 'samesite', 'domain')
        if key in session_conf
    }
    # Note: We cannot use path: to specify the Cookie path attribute.
    # session.path is used for the session (JSONStore) file location.
    # So use cookiepath: instead.
    if 'cookiepath' in session_conf:
        cls._session_cookie['path'] = session_conf['cookiepath']
    cls._on_init_methods.append(cls.override_user)
    cls._on_finish_methods.append(cls.set_last_visited)
    # Ensure that session is saved AFTER we set last visited
    cls._on_finish_methods.append(cls.save_session)

setup_ratelimit(ratelimit, ratelimit_app_conf) classmethod

Initialize rate limiting checks

Source code in gramex\handlers\basehandler.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
@classmethod
def setup_ratelimit(
    cls, ratelimit: Union[Dict, List[Dict], None], ratelimit_app_conf: Union[dict, None]
):
    '''Initialize rate limiting checks'''
    if ratelimit is None:
        return
    if not ratelimit_app_conf:
        raise ValueError(f"url:{cls.name}.ratelimit: no app.ratelimit defined")

    # All ratelimit related info is stored in self._ratelimit
    cls._ratelimit = []
    cls._on_init_methods.append(cls.check_ratelimit)
    cls._on_finish_methods.append(cls.update_ratelimit)

    for ratelimit_conf in ratelimit if isinstance(ratelimit, (list, tuple)) else [ratelimit]:
        cls._setup_ratelimit(ratelimit_conf, ratelimit_app_conf)

reset_ratelimit(pool, keys, value=0) classmethod

Reset the rate limit usage for a specific pool.

Examples:

>>> reset_ratelimit('/api', ['2022-01-01', 'x@example.org'])
>>> reset_ratelimit('/api', ['2022-01-01', 'x@example.org'], 10)

Parameters:

Name Type Description Default
pool str

Rate limit pool to use. This is the url’s pattern: unless you specified a kwargs.ratelimit.pool:

required
keys List[Any]

specific instance to reset. If your ratelimit.keys is [daily, user.id], keys might look like ['2022-01-01', 'x@example.org'] to clear for that day/user

required
value int

sets the usage counter to this number (default: 0)

0
Source code in gramex\handlers\basehandler.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
@classmethod
def reset_ratelimit(cls, pool: str, keys: List[Any], value: int = 0) -> bool:
    '''Reset the rate limit usage for a specific pool.

    Examples:
        >>> reset_ratelimit('/api', ['2022-01-01', 'x@example.org'])
        >>> reset_ratelimit('/api', ['2022-01-01', 'x@example.org'], 10)

    Parameters:
        pool: Rate limit pool to use. This is the url's `pattern:` unless you specified a
            `kwargs.ratelimit.pool:`
        keys: specific instance to reset. If your `ratelimit.keys` is `[daily, user.id]`,
            keys might look like `['2022-01-01', 'x@example.org']` to clear for that day/user
        value: sets the usage counter to this number (default: `0`)
    '''
    store = cls._get_store(conf.app.get('ratelimit'))
    key = json.dumps([pool] + keys)
    val = store.load(key, None)
    if val is not None and 'n' in val:
        val['n'] = value
        store.dump(key, val)
    else:
        return False

setup_redirect(redirect) classmethod

Any handler can have a redirect: kwarg that looks like this:

redirect:
    query: next         # If the URL has a ?next=..., redirect to that page next
    header: X-Next      # Else if the header has an X-Next=... redirect to that
    url: ...            # Else redirect to this URL

Only these 3 keys are allowed. All are optional, and checked in the order specified. So, for example:

redirect:
    header: X-Next      # Checks the X-Next header first
    query: next         # If it's missing, uses the ?next=

You can also specify a string for redirect. redirect: ... is the same as redirect: {url: ...}.

When any BaseHandler subclass calls self.save_redirect_page(), it stores the redirect URL in session['_next_url']. The URL is calculated relative to the handler’s URL.

After that, when the subclass calls self.redirect_next(), it redirects to session['_next_url'] and clears the value. (If the _next_url was not stored, we redirect to the home page /.)

Only some handlers implement redirection. But they all implement it in this same consistent way.

Source code in gramex\handlers\basehandler.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
@classmethod
def setup_redirect(cls, redirect):
    '''
    Any handler can have a `redirect:` kwarg that looks like this:

    ```yaml
    redirect:
        query: next         # If the URL has a ?next=..., redirect to that page next
        header: X-Next      # Else if the header has an X-Next=... redirect to that
        url: ...            # Else redirect to this URL
    ```

    Only these 3 keys are allowed. All are optional, and checked in the
    order specified. So, for example:

    ```yaml
    redirect:
        header: X-Next      # Checks the X-Next header first
        query: next         # If it's missing, uses the ?next=
    ```

    You can also specify a string for redirect. `redirect: ...` is the same
    as `redirect: {url: ...}`.

    When any BaseHandler subclass calls `self.save_redirect_page()`, it
    stores the redirect URL in `session['_next_url']`. The URL is
    calculated relative to the handler's URL.

    After that, when the subclass calls `self.redirect_next()`, it
    redirects to `session['_next_url']` and clears the value. (If the
    `_next_url` was not stored, we redirect to the home page `/`.)

    Only some handlers implement redirection. But they all implement it in
    this same consistent way.
    '''
    # Ensure that redirect is a dictionary before proceeding.
    if isinstance(redirect, str):
        redirect = {'url': redirect}
    if not isinstance(redirect, dict):
        app_log.error(f'url:{cls.name}.redirect must be a URL or a dict, not {redirect!r}')
        return

    cls.redirects = []
    add = cls.redirects.append
    for key, value in redirect.items():
        if key == 'query':
            add(lambda h, v=value: h.get_argument(v, None))
        elif key == 'header':
            add(lambda h, v=value: h.request.headers.get(v))
        elif key == 'url':
            add(lambda h, v=value: v)

    # redirect.external=False disallows external URLs
    if not redirect.get('external', False):

        def no_external(method):
            def redirect_method(handler):
                next_uri = method(handler)
                if next_uri is not None:
                    target = urlsplit(next_uri)
                    if not target.scheme and not target.netloc:
                        return next_uri
                    req = handler.request
                    if req.protocol == target.scheme and req.host == target.netloc:
                        return next_uri
                    app_log.error(f'Not redirecting to external url: {next_uri}')

            return redirect_method

        cls.redirects = [no_external(method) for method in cls.redirects]

authorize()

BaseMixin assumes every handler has an authorize() function

Source code in gramex\handlers\basehandler.py
599
600
601
def authorize(self):
    '''BaseMixin assumes every handler has an authorize() function'''
    pass

setup_log() classmethod

Logs access requests to gramex.requests as a CSV file.

Source code in gramex\handlers\basehandler.py
603
604
605
606
607
608
609
610
611
@classmethod
def setup_log(cls):
    '''
    Logs access requests to gramex.requests as a CSV file.
    '''
    logger = logging.getLogger('gramex.requests')
    keys = objectpath(conf, 'log.handlers.requests.keys', [])
    log_info = build_log_info(keys)
    cls.log_request = lambda handler: logger.info(log_info(handler))

setup_error(error) classmethod

Sample configuration:

error:
    404:
        path: template.json         # Use a template
        autoescape: false           # with no autoescape
        whitespace: single          # as a single line
        headers:
            Content-Type: application/json
    500:
        function: module.fn
        args: [=status_code, =kwargs, =handler]
Source code in gramex\handlers\basehandler.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
@classmethod
def setup_error(cls, error):
    '''
    Sample configuration:

    ```yaml
    error:
        404:
            path: template.json         # Use a template
            autoescape: false           # with no autoescape
            whitespace: single          # as a single line
            headers:
                Content-Type: application/json
        500:
            function: module.fn
            args: [=status_code, =kwargs, =handler]
    ```
    '''
    if not error:
        return
    if not isinstance(error, dict):
        return app_log.error(f'url:{cls.name}.error is not a dict')
    # Compile all errors handlers
    cls.error = {}
    for error_code, error_config in error.items():
        try:
            error_code = int(error_code)
            if error_code < 100 or error_code > 1000:
                raise ValueError()
        except ValueError:
            app_log.error(f'url.{cls.name}.error code {error_code} is not a number (100-1000)')
            continue
        if not isinstance(error_config, dict):
            return app_log.error(f'url:{cls.name}.error.{error_code} is not a dict')
        # Make a copy of the original. When we add headers, etc, it shouldn't affect original
        error_config = AttrDict(error_config)
        error_path, error_function = error_config.get('path'), error_config.get('function')
        if error_function:
            if error_path:
                error_config.pop('path')
                app_log.warning(
                    f'url.{cls.name}.error.{error_code} has function:. Ignoring path:'
                )
            cls.error[error_code] = {
                'function': build_transform(
                    error_config,
                    vars={'status_code': None, 'kwargs': None, 'handler': None},
                    filename=f'url:{cls.name}.error.{error_code}',
                )
            }
        elif error_path:
            encoding = error_config.get('encoding', 'utf-8')
            cls.error[error_code] = {'function': cls._error_fn(error_code, error_config)}
            mime_type, encoding = mimetypes.guess_type(error_path, strict=False)
            if mime_type:
                error_config.setdefault('headers', {}).setdefault('Content-Type', mime_type)
        else:
            app_log.error(f'url.{cls.name}.error.{error_code} must have path: or function:')
        # Add the error configuration for reference
        if error_code in cls.error:
            cls.error[error_code]['conf'] = error_config
    cls._write_error, cls.write_error = cls.write_error, cls._write_custom_error

setup_xsrf(xsrf_cookies) classmethod

Sample configuration:

xsrf_cookies: false         # Disables xsrf_cookies
xsrf_cookies: true          # or anything other than false keeps it enabled
Source code in gramex\handlers\basehandler.py
693
694
695
696
697
698
699
700
701
702
703
@classmethod
def setup_xsrf(cls, xsrf_cookies):
    '''
    Sample configuration:

    ```yaml
    xsrf_cookies: false         # Disables xsrf_cookies
    xsrf_cookies: true          # or anything other than false keeps it enabled
    ```
    '''
    cls.check_xsrf_cookie = cls.noop if xsrf_cookies is False else cls.xsrf_ajax

xsrf_check_required()

Returns True if the request is (likely) from a browser and needs XSRF check.

This is used to handle XSRF. If the request is NOT from a browser (e.g. server, AJAX), no AJAX checks are required. If any of the following are true, it’s not a browser request.

  1. X-Requested-With: XMLHttpRequest. XMLHttpRequest sends this
  2. Sec-Fetch-Mode: cors. Fetch sends these
Source code in gramex\handlers\basehandler.py
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
def xsrf_check_required(self):
    '''Returns True if the request is (likely) from a browser and needs XSRF check.

    This is used to handle XSRF. If the request is NOT from a browser (e.g. server, AJAX),
    no AJAX checks are required. If any of the following are true, it's not a browser request.

    1. `X-Requested-With: XMLHttpRequest`. XMLHttpRequest sends this
    2. `Sec-Fetch-Mode: cors`. [Fetch sends these][H-Fetch]

    [H-Fetch]: https://developer.mozilla.org/en-US/docs/Glossary/Fetch_metadata_request_header
    [H-Origin]: https://developer.mozilla.org/en-US/docs/Web/API/Request/mode
    '''
    return not (
        self.request.headers.get('X-Requested-With', '').lower() == 'xmlhttprequest'
        or self.request.headers.get('Sec-Fetch-Mode', '').lower() == 'cors'
    )

xsrf_ajax()

Validates XSRF cookies if it’s a browser-request (not AJAX)

Internally, it uses Tornado’s check_xsrf_cookie().

Source code in gramex\handlers\basehandler.py
722
723
724
725
726
727
728
def xsrf_ajax(self):
    '''Validates XSRF cookies if it's a browser-request (not AJAX)

    Internally, it uses Tornado's check_xsrf_cookie().
    '''
    if self.xsrf_check_required():
        return super(BaseHandler, self).check_xsrf_cookie()

noop()

Does nothing. Used when overriding functions or providing a dummy operation

Source code in gramex\handlers\basehandler.py
730
731
732
def noop(self):
    '''Does nothing. Used when overriding functions or providing a dummy operation'''
    pass

save_redirect_page()

Loop through all redirect: methods and save the first available redirect page against the session. Defaults to previously set value, else /.

See setup_redirect.

Source code in gramex\handlers\basehandler.py
734
735
736
737
738
739
740
741
742
743
744
745
746
def save_redirect_page(self):
    '''
    Loop through all redirect: methods and save the first available redirect
    page against the session. Defaults to previously set value, else `/`.

    See [setup_redirect][gramex.handlers.BaseMixin.setup_redirect].
    '''
    for method in self.redirects:
        next_url = method(self)
        if next_url:
            self.session['_next_url'] = urljoin(self.xrequest_uri, next_url)
            return
    self.session.setdefault('_next_url', '/')

redirect_next()

Redirect the user session['_next_url']. If it does not exist, set it up first. Then redirect.

See setup_redirect.

Source code in gramex\handlers\basehandler.py
748
749
750
751
752
753
754
755
756
757
def redirect_next(self):
    '''
    Redirect the user `session['_next_url']`. If it does not exist,
    set it up first. Then redirect.

    See [setup_redirect][gramex.handlers.BaseMixin.setup_redirect].
    '''
    if '_next_url' not in self.session:
        self.save_redirect_page()
    self.redirect(self.session.pop('_next_url', '/'))

session property

By default, session is not implemented. You need to specify a session: section in gramex.yaml to activate it. It is replaced by the get_session method as a property.

get_session(expires_days=None, new=False)

Return the session object for the cookie “sid” value. If no “sid” cookie exists, set up a new one. If no session object exists for the sid, create it. By default, the session object contains a “id” holding the “sid” value.

The session is a dict. You must ensure that it is JSON serializable.

Sessions use these pre-defined timing keys (values are timestamps):

  • _t is the expiry time of the session
  • _l is the last time the user accessed a page. Updated by setup_redirect
  • _i is the inactive expiry duration in seconds, i.e. if now > _l + _i, the session has expired.

new= creates a new session to avoid session fixation. https://www.owasp.org/index.php/Session_fixation. [set_user()][gramex.handlers.AuthHandler.set_user] uses it. When the user logs in:

  • If no old session exists, it returns a new session object.
  • If an old session exists, it creates a new “sid” and new session object, copying all old contents, but updates the “id” and expiry (_t).
Source code in gramex\handlers\basehandler.py
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
def get_session(self, expires_days=None, new=False):
    '''
    Return the session object for the cookie "sid" value.
    If no "sid" cookie exists, set up a new one.
    If no session object exists for the sid, create it.
    By default, the session object contains a "id" holding the "sid" value.

    The session is a dict. You must ensure that it is JSON serializable.

    Sessions use these pre-defined timing keys (values are timestamps):

    - `_t` is the expiry time of the session
    - `_l` is the last time the user accessed a page. Updated by
      [setup_redirect][gramex.handlers.BaseMixin.set_last_visited]
    - `_i` is the inactive expiry duration in seconds, i.e. if `now > _l +
      _i`, the session has expired.

    `new=` creates a new session to avoid session fixation.
    https://www.owasp.org/index.php/Session_fixation.
    [`set_user()`][gramex.handlers.AuthHandler.set_user] uses it.
    When the user logs in:

    - If no old session exists, it returns a new session object.
    - If an old session exists, it creates a new "sid" and new session
      object, copying all old contents, but updates the "id" and expiry (_t).
    '''
    if expires_days is None:
        expires_days = self._session_expiry
    # If the expiry time is None, keep in the session store for 1 day
    store_expires = time.time() + (1 if expires_days is None else expires_days) * 24 * 60 * 60
    created_new_sid = False
    if getattr(self, '_session', None) is None:
        # Populate self._session based on the sid. If there's no sid cookie,
        # generate one and create an associated session object
        session_id = self.get_secure_cookie(self._session_cookie_id, max_age_days=9999999)
        # If there's no session id cookie "sid", create a random 32-char cookie
        if session_id is None:
            session_id = self._set_new_session_id(expires_days)
            created_new_sid = True
        # Convert bytes session to unicode before using
        session_id = session_id.decode('ascii')
        # If there's no stored session associated with it, create it
        self._session = self._session_store.load(session_id, {'_t': store_expires})
        # Overwrite id to the session ID even if a handler has changed it
        self._session['id'] = session_id
    # At this point, the "sid" cookie and self._session exist and are synced
    s = self._session
    old_sid = s['id']
    # If session has expiry keys _i and _l defined, check for expiry. Not otherwise
    if '_i' in s and '_l' in s and time.time() > s['_l'] + s['_i']:
        new = True
        s.clear()
    if new and not created_new_sid:
        new_sid = self._set_new_session_id(expires_days).decode('ascii')
        # Update expiry and new SID on session
        s.update(id=new_sid, _t=store_expires)
        # Delete old contents. No _t also means expired
        self._session_store.dump(old_sid, {})

    return s

save_session()

Persist the session object as a JSON

Source code in gramex\handlers\basehandler.py
911
912
913
914
def save_session(self):
    '''Persist the session object as a JSON'''
    if getattr(self, '_session', None) is not None:
        self._session_store.dump(self._session['id'], self._session)

otp(expire=60, user=None, size=None, type='OTP', kwargs)

Return one-time password valid for expire seconds.

The OTP is used as the X-Gramex-OTP header or in ?gramex-otp= on any request. This overrides the user with the passed user object for that session.

Parameters:

Name Type Description Default
expire float

Time when this token expires, in seconds (e.g. 60 means 1 minute from now)

60
user Union[str, dict]

User object to store against token. Defaults to current user. Raises HTTP 403 Unauthorized if there’s no user

None
size int

Length of the OTP in characters. None means a full hash string

None
type str

Identifier for type of OTP. OTP for OTPs. Use Key for API keys. Auth handlers use their class names, e.g. DBAuth, SMSAuth, EMailAuth.

'OTP'
kwargs Dict[str, Any]

Additional columns to add (if they exist)

{}

Returns:

Type Description
str

Generated OTP

Internally, this stores it in storelocations.otp database in a table with 4+ keys:

  1. token: Generated OTP with size characters
  2. user: The passed user string or dict, JSON-encoded
  3. type: The passed type string, stored as is
  4. expire: The expiry time in seconds since epoch
  5. Any additional columns passed in kwargs, if they exist
Source code in gramex\handlers\basehandler.py
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
def otp(
    self,
    expire: float = 60,
    user: Union[str, dict] = None,
    size: int = None,
    type: str = 'OTP',
    **kwargs: Dict[str, Any],
) -> str:
    '''Return one-time password valid for `expire` seconds.

    The OTP is used as the X-Gramex-OTP header or in `?gramex-otp=` on any request.
    This overrides the user with the passed `user` object for that session.

    Parameters:
        expire: Time when this token expires, in seconds (e.g. `60` means 1 minute from now)
        user: User object to store against token. Defaults to current user. Raises HTTP 403
            Unauthorized if there's no user
        size: Length of the OTP in characters. `None` means a full hash string
        type: Identifier for type of OTP. `OTP` for OTPs. Use `Key` for API keys. Auth handlers
            use their class names, e.g. `DBAuth`, `SMSAuth`, `EMailAuth`.
        kwargs: Additional columns to add (if they exist)

    Returns:
        Generated OTP

    Internally, this stores it in `storelocations.otp` database in a table with 4+ keys:

    1. `token`: Generated OTP with `size` characters
    2. `user`: The passed `user` string or dict, JSON-encoded
    3. `type`: The passed `type` string, stored as is
    4. `expire`: The expiry time in seconds since epoch
    5. Any additional columns passed in `kwargs`, if they exist
    '''
    user = self.current_user if user is None else user
    if not user:
        raise HTTPError(UNAUTHORIZED)
    from uuid import uuid4

    otp = uuid4().hex[:size]
    gramex.data.insert(
        **gramex.service.storelocations.otp,
        args={
            'token': [otp],
            'user': [json.dumps(user)],
            'type': [type],
            'expire': [time.time() + expire],
            **{key: [val] for key, val in kwargs.items()},
        },
    )
    return otp

get_otp(key, revoke=False)

Return the user object given the OTP key. Revoke the OTP if requested.

Parameters:

Name Type Description Default
key str

OTP to return

required
revoke bool

True to revoke the OTP. False to retain it

False

Returns:

Type Description
Union[str, dict, None]

None if the OTP key doesn’t exist or has expired. Else a dict with keys user, expire, type and token.

Source code in gramex\handlers\basehandler.py
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
def get_otp(self, key: str, revoke: bool = False) -> Union[str, dict, None]:
    '''Return the user object given the OTP key. Revoke the OTP if requested.

    Parameters:
        key: OTP to return
        revoke: True to revoke the OTP. False to retain it

    Returns:
        `None` if the OTP `key` doesn't exist or has expired.
            Else a dict with keys `user`, `expire`, `type` and `token`.
    '''
    rows = gramex.data.filter(**gramex.service.storelocations.otp, args={'token': [key]})
    if len(rows) == 0:
        return None
    row = rows.iloc[0].to_dict()
    if revoke:
        gramex.data.delete(
            **gramex.service.storelocations.otp, id=['token'], args={'token': [key]}
        )
    # Skip expired tokens
    if row['expire'] <= time.time():
        return None
    # Return the user column parsed as JSON.
    # Add custom keys from the table to the user object.
    row['user'] = json.loads(row['user'])
    custom_keys = [key for key in row if key not in {'user', 'token', 'expire'}]
    if isinstance(row['user'], dict):
        row['user'].update({key: row[key] for key in custom_keys})
    else:
        app_log.warning('Cannot add custom keys to non-dict "user" in: %r', row)
    return row

revoke_otp(key)

Revoke an OTP. Returns the user object from gramex.handlers.BaseMixin.get_otp.

Source code in gramex\handlers\basehandler.py
 999
1000
1001
def revoke_otp(self, key: str) -> Union[str, dict, None]:
    '''Revoke an OTP. Returns the user object from [gramex.handlers.BaseMixin.get_otp][].'''
    return self.get_otp(key, revoke=True)

apikey(expire=1000000000.0, user=None, size=None, kwargs)

Return API Key. Usage is same as gramex.handlers.BaseMixin.otp

The API key is used as the X-Gramex-Key header or in ?gramex-key= on any request. This overrides the user with the passed user object for that session.

Source code in gramex\handlers\basehandler.py
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
def apikey(
    self,
    expire: float = 1e9,
    user: Union[str, dict] = None,
    size: int = None,
    **kwargs: Dict[str, Any],
) -> str:
    '''Return API Key. Usage is same as [gramex.handlers.BaseMixin.otp][]

    The API key is used as the X-Gramex-Key header or in `?gramex-key=` on any request.
    This overrides the user with the passed `user` object for that session.
    '''
    return self.otp(expire=expire, user=user, size=size, type='Key', **kwargs)

revoke_apikey(key)

Revoke API Key. Returns the user object from gramex.handlers.BaseMixin.get_otp.

Source code in gramex\handlers\basehandler.py
1017
1018
1019
def revoke_apikey(self, key: str) -> Union[str, dict, None]:
    '''Revoke API Key. Returns the user object from [gramex.handlers.BaseMixin.get_otp][].'''
    return self.revoke_otp(key)

override_user()

Internal method to override the user.

Use X-Gramex-User HTTP header to override current user for the session. Use X-Gramex-OTP HTTP header to set user based on OTP, or ?gramex-otp=. Use X-Gramex-Key HTTP header to set user based on API key, or ?gramex-key=.

Source code in gramex\handlers\basehandler.py
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
def override_user(self):
    '''Internal method to override the user.

    Use `X-Gramex-User` HTTP header to override current user for the session.
    Use `X-Gramex-OTP` HTTP header to set user based on OTP, or `?gramex-otp=`.
    Use `X-Gramex-Key` HTTP header to set user based on API key, or `?gramex-key=`.
    '''
    headers = self.request.headers
    cipher = headers.get('X-Gramex-User')
    if cipher:
        try:
            user = json.loads(
                decode_signed_value(
                    conf.app.settings['cookie_secret'],
                    'user',
                    cipher,
                    max_age_days=self._session_expiry,
                )
            )
        except Exception:
            raise HTTPError(BAD_REQUEST, f'{self.name}: invalid X-Gramex-User: {cipher}')
        else:
            app_log.debug(f'{self.name}: Overriding user to {user!r}')
            self.session['user'] = user
            return
    # OTP is specified as an X-Gramex-OTP header or ?gramex-otp argument.
    # API Key is specified as an X-Gramex-Key header or ?gramex-key argument.
    # Override the user if either is specified.
    for key in ('OTP', 'Key'):
        token = headers.get(f'X-Gramex-{key}') or self.get_argument(
            f'gramex-{key.lower()}', None
        )
        if token:
            # Revoke OTP keys. Don't revoke API keys
            row = self.get_otp(token, revoke=key == 'OTP')
            if not row:
                raise HTTPError(
                    BAD_REQUEST, f'{self.name}: invalid/expired Gramex {key}: {token}'
                )
            self.session['user'] = row['user']

set_last_visited()

Update session last visited time if we track inactive expiry.

  • session._l is the last time the user accessed a page.
  • session._i is the seconds of inactivity after which the session expires.
  • If session._i is set (we track inactive expiry), we set session._l to now.
Source code in gramex\handlers\basehandler.py
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
def set_last_visited(self):
    '''Update session last visited time if we track inactive expiry.

    - `session._l` is the last time the user accessed a page.
    - `session._i` is the seconds of inactivity after which the session expires.
    - If `session._i` is set (we track inactive expiry), we set `session._l` to now.
    '''
    # Called by BaseHandler.prepare() when any user accesses a page.
    # For efficiency reasons, don't call get_session every time. Check
    # session only if there's a valid sid cookie (with possibly long expiry)
    if self.get_secure_cookie(self._session_cookie_id, max_age_days=9999999):
        session = self.get_session()
        if '_i' in session:
            session['_l'] = time.time()

check_ratelimit()

Raise HTTP 429 if usage exceeds rate limit. Set X-Ratelimit-* HTTP headers

Source code in gramex\handlers\basehandler.py
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
def check_ratelimit(self):
    '''Raise HTTP 429 if usage exceeds rate limit. Set X-Ratelimit-* HTTP headers'''
    for ratelimit in self._ratelimit:
        # If no expiry is specified, store for 100 years
        expiries = [3155760000]
        # Get the rate limit key, limit and expiry
        keys = [ratelimit.pool]
        for key_fn in ratelimit.key_fn:
            if 'key' in key_fn:
                predefined_key = key_fn['key'](self)
                if predefined_key in _PREDEFINED_KEYS:
                    keys.append(_PREDEFINED_KEYS[predefined_key]['function'](self))
                    expiries.append(_PREDEFINED_KEYS[predefined_key]['expiry'](self))
            if 'function' in key_fn:
                keys.append(key_fn['function'](self))
            if 'expiry' in key_fn:
                expiries.append(key_fn['expiry'](self))

        ratelimit.key = json.dumps(keys)
        # Note: if ratelimit_fn() returns a non-int, check_ratelimit will fail. Let it fail.
        ratelimit.limit = ratelimit.limit_fn(self)
        ratelimit.expiry = min(expiries)

        # Ensure usage does not hit limit
        ratelimit.usage = ratelimit.store.load(ratelimit.key, {'n': 0}).get('n', 0)
        if ratelimit.usage >= ratelimit.limit:
            raise HTTPError(
                TOO_MANY_REQUESTS,
                f'{ratelimit.pool}: {ratelimit.key} hit rate limit {ratelimit.limit}',
            )
    self.set_ratelimit_headers()

update_ratelimit()

If request succeeds, increase rate limit usage count by 1

Source code in gramex\handlers\basehandler.py
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
def update_ratelimit(self):
    '''If request succeeds, increase rate limit usage count by 1'''
    # If response is a HTTP error, don't count towards rate limit
    if self.get_status() >= 400:
        return
    for ratelimit in self._ratelimit:
        # If check_ratelimit failed (e.g. invalid function) and didn't set a key, skip update
        if 'key' not in ratelimit:
            return
        # Increment the rate limit by 1
        usage_obj = ratelimit.store.load(ratelimit.key, {'n': 0})
        usage_obj['n'] += 1
        usage_obj['_t'] = time.time() + ratelimit.expiry
        ratelimit.store.dump(ratelimit.key, usage_obj)

get_ratelimit()

Get the rate limit with the least remaining usage for the current request.

If there are multiple rate limits, it picks the one with least remaining usage and returns an AttrDict with these keys:

  • limit: the limit on the rate limit (e.g. 3)
  • usage: the usage so far (BEFORE current request, e.g. 0, 1, 2, 3, …)
  • remaining: the remaining requests (AFTER current request, e.g. 2, 1, 0, 0, …)
  • expiry: seconds to expiry for this rate limit
Source code in gramex\handlers\basehandler.py
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
def get_ratelimit(self):
    '''Get the rate limit with the least remaining usage for the current request.

    If there are multiple rate limits, it picks the one with least remaining usage and
    returns an AttrDict with these keys:

    - `limit`: the limit on the rate limit (e.g. 3)
    - `usage`: the usage so far (BEFORE current request, e.g. 0, 1, 2, 3, ...)
    - `remaining`: the remaining requests (AFTER current request, e.g. 2, 1, 0, 0, ...)
    - `expiry`: seconds to expiry for this rate limit
    '''
    ratelimit = min(self._ratelimit, key=lambda r: r.limit - r.usage)
    return AttrDict(
        limit=ratelimit.limit,
        usage=ratelimit.usage,
        # ratelimit.usage goes 0, 1, 2, ...
        # If limit is 3, remaining goes 2, 1, 0, ... -- use (limit - usage - 1)
        # But when usage hits 3, don't show remaining = -1. Show remaining = 0 using max()
        remaining=max(ratelimit.limit - ratelimit.usage - 1, 0),
        expiry=ratelimit.expiry,
    )

set_ratelimit_headers()

Sets the headers from the Ratelimit HTTP headers draft.

  • X-Ratelimit-Limit is the rate limit
  • X-Ratelimit-Remaining has the remaining requests
  • X-Ratelimit-Reset has seconds after which the rate limit resets
  • Retry-After is same as X-Ratelimit-Reset, but is set only if the limit is exceeded
Source code in gramex\handlers\basehandler.py
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
def set_ratelimit_headers(self):
    '''Sets the headers from the [Ratelimit HTTP headers draft][1].

    - `X-Ratelimit-Limit` is the rate limit
    - `X-Ratelimit-Remaining` has the remaining requests
    - `X-Ratelimit-Reset` has seconds after which the rate limit resets
    - `Retry-After` is same as X-Ratelimit-Reset, but is set only if the limit is exceeded

    [1]: https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html
    '''
    # Pick the rate limit with the lowest remaining count
    ratelimit = self.get_ratelimit()
    self.set_header('X-Ratelimit-Limit', str(ratelimit.limit))
    self.set_header('X-Ratelimit-Remaining', str(ratelimit.remaining))
    self.set_header('X-RateLimit-Reset', str(ratelimit.expiry))
    if ratelimit.usage >= ratelimit.limit:
        self.set_header('Retry-After', str(ratelimit.expiry))

initialize_handler()

Initialize self.args and other handler attributes

Source code in gramex\handlers\basehandler.py
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
def initialize_handler(self):
    '''Initialize self.args and other handler attributes'''
    # self.request.arguments does not handle unicode keys well. It returns latin-1 unicode.
    # https://github.com/tornadoweb/tornado/issues/2733
    # Convert this to proper unicode using UTF-8 and store in self.args
    self.args = {}
    for k in self.request.arguments:
        key = k.encode('latin-1').decode('utf-8')
        # Invalid unicode (e.g. ?x=%f4) throws HTTPError. This disrupts even
        # error handlers. So if there's invalid unicode, log & continue.
        try:
            self.args[key] = self.get_arguments(k)
        except HTTPError:
            app_log.exception(f'{self.name}: Invalid unicode ?{k}')
    self._session, self._session_json = None, 'null'
    if self.cache:
        self.cachefile = self.cache()
        self.original_get = self.get
        self.get = self._cached_get
    if self._set_xsrf:
        self.xsrf_token

update_body_args()

Update self.args with JSON body if Content-Type is application/json

Source code in gramex\handlers\basehandler.py
1186
1187
1188
1189
1190
1191
def update_body_args(self):
    '''Update self.args with JSON body if Content-Type is application/json'''
    if self.request.body:
        content_type = self.request.headers.get('Content-Type', '')
        if content_type == 'application/json':
            self.args.update(json.loads(self.request.body))

get_arg(name, default=Ellipsis, first=False)

Returns the value of the argument with the given name. Similar to .get_argument but uses self.args instead.

If default is not provided, the argument is considered to be required, and we raise a MissingArgumentError if it is missing.

If the argument is repeated, we return the last value. If first=True is passed, we return the first value.

self.args is always UTF-8 decoded unicode. Whitespaces are stripped.

Source code in gramex\handlers\basehandler.py
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
def get_arg(self, name, default=..., first=False):
    '''
    Returns the value of the argument with the given name. Similar to
    `.get_argument` but uses `self.args` instead.

    If default is not provided, the argument is considered to be
    required, and we raise a `MissingArgumentError` if it is missing.

    If the argument is repeated, we return the last value. If `first=True`
    is passed, we return the first value.

    `self.args` is always UTF-8 decoded unicode. Whitespaces are stripped.
    '''
    if name not in self.args:
        if default is ...:
            raise MissingArgumentError(name)
        return default
    return self.args[name][0 if first else -1]

BaseHandler

BaseHandler provides auth, caching and other services common to all request handlers. All RequestHandlers must inherit from BaseHandler.

get_current_user()

Return the user key from the session as an AttrDict if it exists.

Source code in gramex\handlers\basehandler.py
1258
1259
1260
1261
def get_current_user(self):
    '''Return the `user` key from the session as an AttrDict if it exists.'''
    result = self.session.get('user')
    return AttrDict(result) if isinstance(result, dict) else result

log_exception(typ, value, tb)

Store the exception value for logging

Source code in gramex\handlers\basehandler.py
1263
1264
1265
1266
1267
1268
def log_exception(self, typ, value, tb):
    '''Store the exception value for logging'''
    super(BaseHandler, self).log_exception(typ, value, tb)
    # _exception is stored for use by log_request. Sample error string:
    # ZeroDivisionError: integer division or modulo by zero
    self._exception = traceback.format_exception_only(typ, value)[0].strip()

argparse(args, kwargs)

Parse URL query parameters and return an AttrDict. For example:

args = handler.argparse('x', 'y')
args.x      # is the last value of ?x=value
args.y      # is the last value of ?y=value

A missing ?x= or ?y= raises a HTTP 400 error mentioning the missing key.

For optional arguments, use:

args = handler.argparse(z={'default': ''})
args.z      # returns '' if ?z= is missing

You can convert the value to a type:

args = handler.argparse(limit={'type': int, 'default': 100})
args.limit      # returns ?limit= as an integer

You can restrict the choice of values. If the query parameter is not in choices, we raise a HTTP 400 error mentioning the invalid key & value:

args = handler.argparse(gender={'choices': ['M', 'F']})
args.gender      # returns ?gender= which will be 'M' or 'F'

You can retrieve multiple values as a list:

args = handler.argparse(cols={'nargs': '*', 'default': []})
args.cols       # returns an array with all ?col= values

type: conversion and choices: apply to each value in the list.

To return all arguments as a list, pass list as the first parameter:

args = handler.argparse(list, 'x', 'y')
args.x      # ?x=1 sets args.x to ['1'], not '1'
args.y      # Similarly for ?y=1

To handle unicode arguments and return all arguments as str or unicode or bytes, pass the type as the first parameter:

args = handler.argparse(str, 'x', 'y')
args = handler.argparse(bytes, 'x', 'y')
args = handler.argparse(unicode, 'x', 'y')

By default, all arguments are added as str in PY3 and unicode in PY2.

There are the full list of parameters you can pass to each keyword argument:

  • name: Name of the URL query parameter to read. Defaults to the key
  • required: Whether or not the query parameter may be omitted
  • default: The value produced if the argument is missing. Implies required=False
  • nargs: The number of parameters that should be returned. ‘*’ or ‘+’ return all values as a list.
  • type: Python type to which the parameter should be converted (e.g. int)
  • choices: A container of the allowable values for the argument (after type conversion)

You can combine all these options. For example:

args = handler.argparse(
    'name',                         # Raise error if ?name= is missing
    department={'name': 'dept'},    # ?dept= is mapped to args.department
    org={'default': 'Gramener'},    # If ?org= is missing, defaults to Gramener
    age={'type': int},              # Convert ?age= to an integer
    married={'type': bool},         # Convert ?married to a boolean
    alias={'nargs': '*'},           # Convert all ?alias= to a list
    gender={'choices': ['M', 'F']}, # Raise error if gender is not M or F
)
Source code in gramex\handlers\basehandler.py
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
def argparse(self, *args, **kwargs):
    '''
    Parse URL query parameters and return an AttrDict. For example:

    ```python
    args = handler.argparse('x', 'y')
    args.x      # is the last value of ?x=value
    args.y      # is the last value of ?y=value
    ```

    A missing `?x=` or `?y=` raises a HTTP 400 error mentioning the
    missing key.

    For optional arguments, use:

    ```python
    args = handler.argparse(z={'default': ''})
    args.z      # returns '' if ?z= is missing
    ```

    You can convert the value to a type:

    ```python
    args = handler.argparse(limit={'type': int, 'default': 100})
    args.limit      # returns ?limit= as an integer
    ```

    You can restrict the choice of values. If the query parameter is not in
    choices, we raise a HTTP 400 error mentioning the invalid key & value:

    ```python
    args = handler.argparse(gender={'choices': ['M', 'F']})
    args.gender      # returns ?gender= which will be 'M' or 'F'
    ```

    You can retrieve multiple values as a list:

    ```python
    args = handler.argparse(cols={'nargs': '*', 'default': []})
    args.cols       # returns an array with all ?col= values
    ```

    `type:` conversion and `choices:` apply to each value in the list.

    To return all arguments as a list, pass `list` as the first parameter:

    ```python
    args = handler.argparse(list, 'x', 'y')
    args.x      # ?x=1 sets args.x to ['1'], not '1'
    args.y      # Similarly for ?y=1
    ```

    To handle unicode arguments and return all arguments as `str` or
    `unicode` or `bytes`, pass the type as the first parameter:

    ```python
    args = handler.argparse(str, 'x', 'y')
    args = handler.argparse(bytes, 'x', 'y')
    args = handler.argparse(unicode, 'x', 'y')
    ```

    By default, all arguments are added as str in PY3 and unicode in PY2.

    There are the full list of parameters you can pass to each keyword
    argument:

    - name: Name of the URL query parameter to read. Defaults to the key
    - required: Whether or not the query parameter may be omitted
    - default: The value produced if the argument is missing. Implies required=False
    - nargs: The number of parameters that should be returned. '*' or '+'
      return all values as a list.
    - type: Python type to which the parameter should be converted (e.g. `int`)
    - choices: A container of the allowable values for the argument (after type conversion)

    You can combine all these options. For example:

    ```python
    args = handler.argparse(
        'name',                         # Raise error if ?name= is missing
        department={'name': 'dept'},    # ?dept= is mapped to args.department
        org={'default': 'Gramener'},    # If ?org= is missing, defaults to Gramener
        age={'type': int},              # Convert ?age= to an integer
        married={'type': bool},         # Convert ?married to a boolean
        alias={'nargs': '*'},           # Convert all ?alias= to a list
        gender={'choices': ['M', 'F']}, # Raise error if gender is not M or F
    )
    ```
    '''
    result = AttrDict()

    args_type = str
    if len(args) > 0 and args[0] in (str, bytes, list, None):
        args_type, args = args[0], args[1:]

    for key in args:
        result[key] = self.get_argument(key, None)
        if result[key] is None:
            raise HTTPError(BAD_REQUEST, f'{key}: missing ?{key}=')
    for key, config in kwargs.items():
        name = config.get('name', key)
        val = self.args.get(name, [])

        # default: set if query is missing
        # required: check if query is defined at all
        if len(val) == 0:
            if 'default' in config:
                result[key] = config['default']
                continue
            if config.get('required', False):
                raise HTTPError(BAD_REQUEST, f'{key}: missing ?{name}=')

        # nargs: select the subset of items
        nargs = config.get('nargs', None)
        if isinstance(nargs, int):
            val = val[:nargs]
            if len(val) < nargs:
                val += [''] * (nargs - len(val))
        elif nargs not in ('*', '+', None):
            raise ValueError(f'{key}: invalid nargs {nargs}')

        # convert to specified type
        newtype = config.get('type', None)
        if newtype is not None:
            newval = []
            for v in val:
                try:
                    newval.append(newtype(v))
                except ValueError:
                    raise HTTPError(
                        BAD_REQUEST, f'{key}: type error ?{name}={v} to {newtype!r}'
                    )
            val = newval

        # choices: check valid items
        choices = config.get('choices', None)
        if isinstance(choices, (list, dict, set)):
            choices = set(choices)
            for v in val:
                if v not in choices:
                    raise HTTPError(BAD_REQUEST, f'{key}: invalid choice ?{name}={v}')

        # Set the final value
        if nargs is None:
            if len(val) > 0:
                result[key] = val[-1]
        else:
            result[key] = val

    # Parse remaining keys
    if args_type is list:
        for key, val in self.args.items():
            if key not in args and key not in kwargs:
                result[key] = val
    elif args_type in (str, bytes):
        for key, val in self.args.items():
            if key not in args and key not in kwargs:
                result[key] = args_type(val[0])

    return result

BaseWebSocketHandler

get_current_user()

Return the user key from the session as an AttrDict if it exists.

Source code in gramex\handlers\basehandler.py
1490
1491
1492
1493
def get_current_user(self):
    '''Return the `user` key from the session as an AttrDict if it exists.'''
    result = self.session.get('user')
    return AttrDict(result) if isinstance(result, dict) else result

authorize()

If a valid user isn’t logged in, send a message and close connection

Source code in gramex\handlers\basehandler.py
1495
1496
1497
1498
1499
1500
1501
1502
def authorize(self):
    '''If a valid user isn't logged in, send a message and close connection'''
    if not self.current_user:
        raise HTTPError(UNAUTHORIZED)
    for permit_generator in self.permissions:
        for result in permit_generator(self):
            if not result:
                raise HTTPError(FORBIDDEN)

CaptureHandler

Renders a web page as a PDF or as an image. It accepts the same arguments as Capture.

The page is called with the same args as Capture.capture(). It also accepts a ?start parameter that restarts capture.js if required.

ComicHandler

DirectoryHandler = FileHandler module-attribute

DriveHandler

Lets users manage files. Here’s a typical configuration

path: $GRAMEXDATA/apps/appname/     # Save files here
user_fields: [id, role, hd]         # user attributes to store
tags: [tag]                         # <input name=""> to store
allow: [.doc, .docx]                # Only allow these files
ignore: [.pdf]                      # Don't allow these files
max_file_size: 100000               # Files must be smaller than this
redirect:                           # After uploading the file,
    query: next                     #   ... redirect to ?next=
    url: /$YAMLURL/                 #   ... else to this directory

File metadata is stored in /.meta.db as SQLite

post(path_args, path_kwargs)

Saves uploaded files, then updates metadata DB

Source code in gramex\handlers\drivehandler.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@tornado.gen.coroutine
def post(self, *path_args, **path_kwargs):
    '''Saves uploaded files, then updates metadata DB'''
    user = self.current_user or {}
    uploads = self.request.files.get('file', [])
    n = len(uploads)
    # Initialize all DB columns (except ID) to have the same number of rows as uploads.
    # Add `n` rows, and then clip to `n` rows. Effective way to pad AND trim.
    for key in list(self._db_cols.keys())[1:]:
        self.args[key] = self.args.get(key, []) + [''] * n
    for key in self.args:
        self.args[key] = self.args[key][:n]
    for i, upload in enumerate(uploads):
        file = os.path.basename(upload.get('filename', ''))
        ext = os.path.splitext(file)[1]
        path = slug.filename(file)
        # B311:random random() is safe since it's for non-cryptographic use
        while os.path.exists(os.path.join(self.path, path)):
            randomletter = choice(digits + ascii_lowercase)  # nosec B311
            path = os.path.splitext(path)[0] + randomletter + ext
        self.args['file'][i] = file
        self.args['ext'][i] = ext.lower()
        self.args['path'][i] = path
        self.args['size'][i] = len(upload['body'])
        self.args['date'][i] = int(time.time())
        # Guess MIME type from filename if it's unknown
        self.args['mime'][i] = upload['content_type']
        if self.args['mime'][i] == 'application/unknown':
            self.args['mime'][i] = guess_type(file, strict=False)[0]
        # Append user attributes
        for s in self.user_fields:
            self.args[f'user_{s.replace(".", "_")}'][i] = objectpath(user, s)
    self.check_filelimits()
    self.files = self.args
    yield super().post(*path_args, **path_kwargs)

pre_modify(kwargs)

Called by FormHandler after updating the database, before modify. Save files here.

This allows the DriveHandler modify: action to access the files.

Source code in gramex\handlers\drivehandler.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def pre_modify(self, **kwargs):
    '''Called by FormHandler after updating the database, before modify. Save files here.

    This allows the DriveHandler modify: action to access the files.
    '''
    # If POST or PUT, save all files
    if self.request.method in {'POST', 'PUT'}:
        uploads = self.request.files.get('file', [])
        for upload, path in zip(uploads, self.files['path']):
            with open(os.path.join(self.path, path), 'wb') as handle:
                handle.write(upload['body'])
    elif self.request.method == 'DELETE':
        for relpath in self.files['path']:
            path = os.path.join(self.path, relpath)
            if os.path.exists(path):
                os.remove(path)

delete(path_args, path_kwargs)

Deletes files from metadata DB and from file system

Source code in gramex\handlers\drivehandler.py
157
158
159
160
161
162
163
@tornado.gen.coroutine
def delete(self, *path_args, **path_kwargs):
    '''Deletes files from metadata DB and from file system'''
    conf = self.datasets.data
    files = gramex.data.filter(conf.url, table=conf.table, args=self.args)
    self.files = files.to_dict(orient='list')
    yield super().delete(*path_args, **path_kwargs)

put(path_args, path_kwargs)

Update attributes and files

Source code in gramex\handlers\drivehandler.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@tornado.gen.coroutine
def put(self, *path_args, **path_kwargs):
    '''Update attributes and files'''
    uploads = self.request.files.get('file', [])[:1]
    id = self.args.get('id', [-1])
    # User cannot change the path, size, date or user attributes
    for s in ('path', 'size', 'date'):
        self.args.pop(s, None)
    for s in self.user_fields:
        self.args.pop(f'user_{s}', None)
    # These are updated only when a file is uploaded
    if len(uploads):
        user = self.current_user or {}
        self.args.setdefault('size', []).append(len(uploads[0]['body']))
        self.args.setdefault('date', []).append(int(time.time()))
        for s in self.user_fields:
            self.args.setdefault(f'user_{s.replace(".", "_")}', []).append(objectpath(user, s))
    conf = self.datasets['data']
    files = gramex.data.filter(conf.url, table=conf.table, args={'id': id})
    self.files = files.to_dict(orient='list')
    self.files.update(self.args)
    yield super().put(*path_args, **path_kwargs)

FacebookGraphHandler

Proxy for the Facebook Graph API via these kwargs:

pattern: /facebook/(.*)
handler: FacebookGraphHandler
kwargs:
    key: your-consumer-key
    secret: your-consumer-secret
    access_token: your-access-token     # Optional -- picked up from session
    methods: [get, post]                # HTTP methods to use for the API
    scope: user_posts,user_photos       # Permissions requested for the user
    path: /me/feed                      # Freeze Facebook Graph API request

Now POST /facebook/me returns the same response as the Facebook Graph API /me. To request specific access rights, specify the scope based on permissions required by the Graph API.

If you only want to expose a specific API, specify a path:. It overrides the URL path. The query parameters will still work.

By default, methods is POST, and GET logs the user in, storing the access token in the session for future use. But you can specify the access_token values and set methods to [get, post] to use both GET and POST requests to proxy the API.

FileHandler

setup(path, default_filename=None, index=None, index_template=None, headers={}, default={}, kwargs) classmethod

Serves files with transformations.

Parameters:

Name Type Description Default
path str

Can be one of these:

  • The filename to serve. For all files matching the pattern, this filename is returned.
  • The root directory from which files are served. The first parameter of the URL pattern is the file path under this directory. Relative paths are specified from where gramex was run.
  • A wildcard path where * is replaced by the URL pattern’s first (..) group.
  • A list of files to serve. These files are concatenated and served one after the other.
  • A dict of {regex: path}. If the URL matches the regex, the path is served. The path is string formatted using the regex capture groups
required
default_filename str

If the URL maps to a directory, this filename is displayed by default. For example, index.html or README.md. It can be a list of default filenames tried in order, e.g. [index.template.html, index.html, README.md]. The default is None, which displays all files in the directory using the index_template option.

None
index bool

If true, shows a directory index. If false, raises a HTTP 404: Not Found error when users try to access a directory.

None
ignore

List of glob patterns to ignore. Even if the path matches these, the files will not be served.

required
allow

List of glob patterns to allow. This overrides the ignore patterns, so use with care.

required
index_template str

The file to be used as the template for displaying the index. If this file is missing, it defaults to Gramex’s default filehandler.template.html. It can use these string variables:

  • $path - the directory name
  • $body - an unordered list with all filenames as links
None
headers dict

HTTP headers to set on the response.

{}
transform

Transformations that should be applied to the files. The key matches one or more glob patterns separated by space/comma (e.g. '*.md, 'data/**'.) The value is a dict with the same structure as FunctionHandler, and accepts keys:

  • function: The expression to return. E.g.: function: method(content, handler). content has the file contents. handler has the FileHandler object
  • encoding: Encoding to read the file with, e.g. utf-8. If None (default), file is read as bytes. Transform function MUST accept the content as bytes
  • headers: HTTP headers to set on the response
required
template

template="*.html" renders all HTML files as Tornado templates. template=True renders all files as Tornado templates (new in Gramex 1.14).

required
sass

sass="*.sass" renders all SASS files as CSS (new in Gramex 1.66).

required
scss

scss="*.scss" renders all SCSS files as CSS (new in Gramex 1.66).

required
ts

ts="*.ts" renders all TypeScript files as JS (new in Gramex 1.78).

required

FileHandler exposes these attributes:

  • root: Root path for this handler. Aligns with the path argument
  • path; Absolute path requested by the user, without adding a default filename
  • file: Absolute path served to the user, after adding a default filename
Source code in gramex\handlers\filehandler.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
@classmethod
def setup(
    cls,
    path: str,
    default_filename: str = None,
    index: bool = None,
    index_template: str = None,
    headers: dict = {},
    default={},
    **kwargs,
):
    '''
    Serves files with transformations.

    Parameters:

        path: Can be one of these:

            - The filename to serve. For all files matching the pattern, this
            filename is returned.
            - The root directory from which files are served. The first parameter of
            the URL pattern is the file path under this directory. Relative paths
            are specified from where gramex was run.
            - A wildcard path where `*` is replaced by the URL pattern's first
            `(..)` group.
            - A list of files to serve. These files are concatenated and served one
            after the other.
            - A dict of {regex: path}. If the URL matches the regex, the path is
            served. The path is string formatted using the regex capture groups

        default_filename: If the URL maps to a directory, this filename
            is displayed by default. For example, `index.html` or `README.md`.
            It can be a list of default filenames tried in order, e.g.
            `[index.template.html, index.html, README.md]`.
            The default is `None`, which displays all files in the directory
            using the `index_template` option.
        index: If `true`, shows a directory index. If `false`,
            raises a HTTP 404: Not Found error when users try to access a directory.
        ignore: List of glob patterns to ignore. Even if the path matches
            these, the files will not be served.
        allow: List of glob patterns to allow. This overrides the ignore
            patterns, so use with care.
        index_template: The file to be used as the template for
            displaying the index. If this file is missing, it defaults to Gramex's
            default `filehandler.template.html`. It can use these string
            variables:

            - `$path` - the directory name
            - `$body` - an unordered list with all filenames as links
        headers: HTTP headers to set on the response.
        transform: Transformations that should be applied to the files.
            The key matches one or more `glob patterns` separated by space/comma
            (e.g. `'*.md, 'data/**'`.) The value is a dict with the same
            structure as [FunctionHandler][gramex.handlers.FunctionHandler], and accepts keys:

            - `function`: The expression to return. E.g.: `function: method(content, handler)`.
                `content` has the file contents. `handler` has the FileHandler object
            - `encoding`: Encoding to read the file with, e.g. `utf-8`. If `None` (default),
                file is read as bytes. Transform `function` MUST accept the content as bytes
            - `headers`: HTTP headers to set on the response

        template: `template="*.html"` renders all HTML files as Tornado templates.
            `template=True` renders all files as Tornado templates (new in Gramex 1.14).
        sass: `sass="*.sass"` renders all SASS files as CSS (new in Gramex 1.66).
        scss: `scss="*.scss"` renders all SCSS files as CSS (new in Gramex 1.66).
        ts: `ts="*.ts"` renders all TypeScript files as JS (new in Gramex 1.78).

    FileHandler exposes these attributes:

    - `root`: Root path for this handler. Aligns with the `path` argument
    - `path`; Absolute path requested by the user, without adding a default filename
    - `file`: Absolute path served to the user, after adding a default filename
    '''
    # Convert template: '*.html' into transform: {'*.html': {function: template}}
    # Convert sass: ['*.scss', '*.sass'] into transform: {'*.scss': {function: sass}}
    # Do this before BaseHandler setup so that it can invoke the transforms required
    for key in ('template', 'sass', 'scss', 'ts'):
        val = kwargs.pop(key, None)
        if val:
            # template/sass/...: true is the same as template: '*'
            val = '*' if val is True else val if isinstance(val, (list, tuple)) else [val]
            kwargs.setdefault('transform', AttrDict()).update(
                {v: AttrDict(function=key) for v in val}
            )
    super(FileHandler, cls).setup(**kwargs)

    cls.root, cls.pattern = None, None
    if isinstance(path, dict):
        cls.root = AttrDict([(re.compile(p + '$'), val) for p, val in path.items()])
    elif isinstance(path, list):
        cls.root = [Path(path_item).absolute() for path_item in path]
    elif '*' in path:
        cls.pattern = path
    else:
        cls.root = Path(path).absolute()
    # Convert default_filename into a list
    if not default_filename:
        cls.default_filename = []
    elif isinstance(default_filename, list):
        cls.default_filename = default_filename
    else:
        cls.default_filename = [default_filename]
    cls.index = index
    cls.ignore = cls.set(cls.kwargs.ignore)
    cls.allow = cls.set(cls.kwargs.allow)
    cls.default = default
    cls.index_template = index_template or _default_index_template
    cls.headers = AttrDict(objectpath(gramex_conf, 'handlers.FileHandler.headers', {}))
    cls.headers.update(headers)
    cls.post = cls.put = cls.delete = cls.patch = cls.get
    if not kwargs.get('cors'):
        cls.options = cls.get

set(value) classmethod

Convert value to a set. If value is already a list, set, tuple, return as is. Ensure that the values are non-empty strings.

Source code in gramex\handlers\filehandler.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
@classmethod
def set(cls, value):
    '''
    Convert value to a set. If value is already a list, set, tuple, return as is.
    Ensure that the values are non-empty strings.
    '''
    result = set(value) if isinstance(value, (list, tuple, set)) else {value}
    for pattern in result:
        if not pattern:
            app_log.warning(f'{cls.name}: Ignoring empty pattern "{pattern!r}"')
        elif not isinstance(pattern, (str, bytes)):
            app_log.warning(f'{cls.name}: pattern "{pattern!r}" is not a string. Ignoring.')
        result.add(pattern)
    return result

allowed(path)

A path is allowed if it matches any allow:, or matches no ignore:. Override this method for a custom implementation.

Source code in gramex\handlers\filehandler.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def allowed(self, path):
    '''
    A path is allowed if it matches any allow:, or matches no ignore:.
    Override this method for a custom implementation.
    '''
    for ignore in self.ignore:
        if _match(path, ignore):
            # Check allows only if an ignore: is matched.
            # If any allow: is matched, allow it
            for allow in self.allow:
                if _match(path, allow):
                    return True
            app_log.debug(f'{self.name}: Disallow "{path}". It matches "{ignore}"')
            return False
    return True

FilterHandler

FormHandler

set_meta_headers(meta)

Add FH--: JSON(value) for each key: value in meta

Source code in gramex\handlers\formhandler.py
280
281
282
283
284
285
286
287
288
def set_meta_headers(self, meta):
    '''Add FH-<dataset>-<key>: JSON(value) for each key: value in meta'''
    prefix = 'FH-{}-{}'
    for dataset, metadata in meta.items():
        for key, value in metadata.items():
            string_value = json.dumps(
                value, separators=(',', ':'), ensure_ascii=True, cls=CustomJSONEncoder
            )
            self.set_header(prefix.format(dataset, key), string_value)

pre_modify(kwargs)

Called after inserting records into DB. Subclasses use it for additional processing

Source code in gramex\handlers\formhandler.py
290
291
292
def pre_modify(self, **kwargs):
    '''Called after inserting records into DB. Subclasses use it for additional processing'''
    pass

FunctionHandler

Renders the output of a function when the URL is called via GET or POST.

  • function: A Python expression that can use handler as a variable.
  • headers: HTTP headers to set on the response.
  • redirect: URL to redirect to when done, e.g. for calculations without output.

The function result is converted to a string and rendered. You can also yield one or more results. These are written immediately, in order.

GoogleAuth

exchange_refresh_token(user, refresh_token=None) classmethod

Exchange the refresh token for the current user for a new access token.

See https://developers.google.com/android-publisher/authorizatio#using_the_refresh_token

The token is picked up from the persistent user info store. Developers can explicitly pass a refresh_token as well.

Sample usage in a FunctionHandler coroutine

@tornado.gen.coroutine
def refresh(handler):
    # Get the Google auth handler though which the current user logged in
    auth_handler = gramex.service.url['google-handler'].handler_class
    # Exchange refresh token for access token
    yield auth_handler.exchange_refresh_token(handler.current_user)

Parameters:

Name Type Description Default
user dict

current user object, i.e. handler.current_user (read-only)

required
refresh_token str

optional. By default, the refresh token is picked up from handler.current_user.refresh_token

None
Source code in gramex\handlers\authhandler.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
@classmethod
@coroutine
def exchange_refresh_token(cls, user: dict, refresh_token: str = None):
    '''
    Exchange the refresh token for the current user for a new access token.

    See https://developers.google.com/android-publisher/authorizatio#using_the_refresh_token

    The token is picked up from the persistent user info store. Developers can explicitly pass
    a refresh_token as well.

    Sample usage in a FunctionHandler coroutine

    ```python
    @tornado.gen.coroutine
    def refresh(handler):
        # Get the Google auth handler though which the current user logged in
        auth_handler = gramex.service.url['google-handler'].handler_class
        # Exchange refresh token for access token
        yield auth_handler.exchange_refresh_token(handler.current_user)
    ```

    Parameters:

        user: current user object, i.e. `handler.current_user` (read-only)
        refresh_token: optional. By default, the refresh token is picked up from
            `handler.current_user.refresh_token`
    '''
    if refresh_token is None:
        if 'refresh_token' in user:
            refresh_token = user['refresh_token']
        else:
            raise HTTPError(FORBIDDEN, "No refresh_token provided")
    body = urlencode(
        {
            'grant_type': 'refresh_token',
            'client_id': cls.kwargs['key'],
            'client_secret': cls.kwargs['secret'],
            'refresh_token': refresh_token,
        }
    )
    http = tornado.httpclient.AsyncHTTPClient()
    response = yield http.fetch(cls._OAUTH_ACCESS_TOKEN_URL, method='POST', body=body)
    result = json.loads(response.body)
    # Update the current user info and persist it
    user.update(result)
    cls.update_user(user['email'], **result)

JSONHandler

setup(path=None, data=None, kwargs) classmethod

Provides a REST API for managing and persisting JSON data.

Sample URL configuration

pattern: /$YAMLURL/data/(.*)
handler: JSONHandler
kwargs:
    path: $YAMLPATH/data.json

Parameters:

Name Type Description Default
path str

optional file where the JSON data is persisted. If not specified, the JSON data is not persisted.

None
data str

optional initial dataset, used only if path is not specified. Defaults to null

None
Source code in gramex\handlers\jsonhandler.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@classmethod
def setup(cls, path: str = None, data: str = None, **kwargs):
    '''
    Provides a REST API for managing and persisting JSON data.

    Sample URL configuration

    ```yaml
    pattern: /$YAMLURL/data/(.*)
    handler: JSONHandler
    kwargs:
        path: $YAMLPATH/data.json
    ```

    Parameters:

        path: optional file where the JSON data is persisted. If not
            specified, the JSON data is not persisted.
        data: optional initial dataset, used only if path is not
            specified. Defaults to null
    '''
    super(JSONHandler, cls).setup(**kwargs)
    cls.path = path
    cls.default_data = data
    cls.json_kwargs = {
        'ensure_ascii': True,
        'separators': (',', ':'),
    }

jsonwalk(jsonpath, create=False)

Return a parent, key, value from the JSON store where parent[key] == value

Source code in gramex\handlers\jsonhandler.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def jsonwalk(self, jsonpath, create=False):
    '''Return a parent, key, value from the JSON store where parent[key] == value'''
    # Load data from self.path JSON file if it's specified, exists, and newer than last load.
    # Otherwise, load the default data provided.
    if self.path:
        path = self.path
        _jsonstores.setdefault(path, None)
        self.changed = False
        if os.path.exists(path):
            if _loaded.get(path, 0) <= os.stat(path).st_mtime:
                # Don't use encoding when reading JSON. We're using ensure_ascii=True
                # Besides, when handling Py2 & Py3, just ignoring encoding works best
                with open(path, mode='r') as handle:
                    try:
                        _jsonstores[path] = json.load(handle)
                        _loaded[path] = time.time()
                    except ValueError:
                        app_log.warning(f'Invalid JSON in {path}')
                        self.changed = True
        else:
            self.changed = True
    else:
        path = self.name
        _jsonstores.setdefault(path, self.default_data)

    # Walk down the path and find the parent, key and data represented by jsonpath
    parent, key, data = _jsonstores, path, _jsonstores[path]
    if not jsonpath:
        return parent, key, data
    # Split jsonpath by / -- but escape "\/" (or "%5C/") as part of the keys
    keys = [p.replace('\udfff', '/') for p in jsonpath.replace(r'\/', '\udfff').split('/')]
    keys.insert(0, path)
    for index, key in enumerate(keys[1:]):
        if hasattr(data, '__contains__') and key in data:
            parent, data = data, data[key]
            continue
        if isinstance(data, list) and key.isdigit():
            key = int(key)
            if key < len(data):
                parent, data = data, data[key]
                continue
        if create:
            if not hasattr(data, '__contains__'):
                parent[keys[index]] = data = {}
            data[key] = {}
            parent, data = data, data[key]
            continue
        return parent, key, None
    return parent, key, data

get(jsonpath)

Return the JSON data at jsonpath. Return null for invalid paths.

Source code in gramex\handlers\jsonhandler.py
110
111
112
113
def get(self, jsonpath):
    '''Return the JSON data at jsonpath. Return null for invalid paths.'''
    parent, key, data = self.jsonwalk(jsonpath, create=False)
    self.write(json.dumps(data, **self.json_kwargs))

post(jsonpath)

Add data as a new unique key under jsonpath. Return {name: new_key}

Source code in gramex\handlers\jsonhandler.py
115
116
117
118
119
120
121
122
123
124
125
126
def post(self, jsonpath):
    '''Add data as a new unique key under jsonpath. Return {name: new_key}'''
    parent, key, data = self.jsonwalk(jsonpath, create=True)
    if self.request.body:
        if data is None:
            parent[key] = data = {}
        new_key = str(uuid.uuid4())
        data[new_key] = self.parse_body_as_json()
        self.write(json.dumps({'name': new_key}, **self.json_kwargs))
        self.changed = True
    else:
        self.write(json.dumps(None))

put(jsonpath)

Set JSON data at jsonpath. Return the data provided

Source code in gramex\handlers\jsonhandler.py
128
129
130
131
132
133
134
135
136
def put(self, jsonpath):
    '''Set JSON data at jsonpath. Return the data provided'''
    parent, key, data = self.jsonwalk(jsonpath, create=True)
    if self.request.body:
        data = parent[key] = self.parse_body_as_json()
        self.write(json.dumps(data, **self.json_kwargs))
        self.changed = True
    else:
        self.write(json.dumps(None))

patch(jsonpath)

Update JSON data at jsonpath. Return the data provided

Source code in gramex\handlers\jsonhandler.py
138
139
140
141
142
143
144
145
def patch(self, jsonpath):
    '''Update JSON data at jsonpath. Return the data provided'''
    parent, key, data = self.jsonwalk(jsonpath)
    if data is not None:
        data = self.parse_body_as_json()
        parent[key].update(data)
        self.changed = True
    self.write(json.dumps(data, **self.json_kwargs))

delete(jsonpath)

Delete data at jsonpath. Return null

Source code in gramex\handlers\jsonhandler.py
147
148
149
150
151
152
153
def delete(self, jsonpath):
    '''Delete data at jsonpath. Return null'''
    parent, key, data = self.jsonwalk(jsonpath)
    if data is not None:
        del parent[key]
        self.changed = True
    self.write('null')

LogoutHandler

ModelHandler

Allows users to create API endpoints to train/test models exposed through Scikit-Learn. TODO: support Scikit-Learn Pipelines for data transformations.

prepare()

Gets called automatically at the beginning of every request. takes model name from request path and creates the pickle file path. Also merges the request body and the url query args. url query args have precedence over request body in case both exist. Expects multi-row paramets to be formatted as the output of handler.argparse.

Source code in gramex\handlers\modelhandler.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def prepare(self):
    '''
    Gets called automatically at the beginning of every request.
    takes model name from request path and creates the pickle file path.
    Also merges the request body and the url query args.
    url query args have precedence over request body in case both exist.
    Expects multi-row paramets to be formatted as the output of handler.argparse.
    '''
    self.set_header('Content-Type', 'application/json; charset=utf-8')
    self.pickle_file_path = os.path.join(self.path, self.path_args[0] + '.pkl')
    self.request_body = {}
    if self.request.body:
        self.request_body = tornado.escape.json_decode(self.request.body)
    if self.args:
        self.request_body.update(self.args)
    url = self.request_body.get('url', '')
    if url and gramex.data.get_engine(url) == 'file':
        self.request_body['url'] = os.path.join(self.path, os.path.split(url)[-1])

get_data_flag()

Return a True if the request is made to /model/name/data.

Source code in gramex\handlers\modelhandler.py
41
42
43
44
45
46
def get_data_flag(self):
    '''
    Return a True if the request is made to /model/name/data.
    '''
    if len(self.path_args) > 1 and self.path_args[1] == 'data':
        return True

get(path_args)

Request sent to model/name with no args returns model information, (that can be changed via PUT/POST). Request to model/name with args will accept model input and produce predictions. Request to model/name/data will return the training data specified in model.url, this should accept most formhandler flags and filters as well.

Source code in gramex\handlers\modelhandler.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def get(self, *path_args):
    '''
    Request sent to model/name with no args returns model information,
    (that can be changed via PUT/POST).
    Request to model/name with args will accept model input and produce predictions.
    Request to model/name/data will return the training data specified in model.url,
    this should accept most formhandler flags and filters as well.
    '''
    model = gramex.cache.open(self.pickle_file_path, gramex.ml.load)
    if self.get_data_flag():
        file_kwargs = self.listify(['engine', 'url', 'ext', 'table', 'query', 'id'])
        _format = file_kwargs.pop('_format', ['json'])[0]
        # TODO: Add Support for formhandler filters/limit/sorting/groupby
        data = gramex.data.filter(model.url, **file_kwargs)
        self.write(gramex.data.download(data, format=_format, **file_kwargs))
        return
    # If no model columns are passed, return model info
    if not vars(model).get('input', '') or not any(col in self.args for col in model.input):
        model_info = {k: v for k, v in vars(model).items() if k not in ('model', 'scaler')}
        self.write(json.dumps(model_info, indent=4))
        return
    self._predict(model)

put(path_args, path_kwargs)

Request to /model/name/ with no params will create a blank model. Request to /model/name/ with args will interpret as model paramters. Set Model-Retrain: true in headers to either train a model from scratch or extend it. To Extend a trained model, don’t update the parameters and send Model-Retrain in headers. Request to /model/name/data with args will update the training data, doesn’t currently work on DF’s thanks to the gramex.data bug.

Source code in gramex\handlers\modelhandler.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def put(self, *path_args, **path_kwargs):
    '''
    Request to /model/name/ with no params will create a blank model.
    Request to /model/name/ with args will interpret as model paramters.
    Set Model-Retrain: true in headers to either train a model from scratch or extend it.
    To Extend a trained model, don't update the parameters and send Model-Retrain in headers.
    Request to /model/name/data with args will update the training data,
    doesn't currently work on DF's thanks to the gramex.data bug.
    '''
    try:
        model = gramex.cache.open(self.pickle_file_path, gramex.ml.load)
    except EnvironmentError:
        model = gramex.ml.Classifier(**self.request_body)
    if self.get_data_flag():
        file_kwargs = self.listify(model.input + [model.output] + ['id'])
        gramex.data.update(model.url, args=file_kwargs, id=file_kwargs['id'])
    else:
        if not self._train(model):
            model.save(self.pickle_file_path)

post(path_args, path_kwargs)

Request to /model/name/ with Model-Retrain: true in the headers will, attempt to update model parameters and retrain/extend the model. Request to /model/name/ with model input as body/query args and no Model-Retrain, in headers will return predictions. Request to /model/name/data lets people add rows the test data.

Source code in gramex\handlers\modelhandler.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def post(self, *path_args, **path_kwargs):
    '''
    Request to /model/name/ with Model-Retrain: true in the headers will,
    attempt to update model parameters and retrain/extend the model.
    Request to /model/name/ with model input as body/query args and no Model-Retrain,
    in headers will return predictions.
    Request to /model/name/data lets people add rows the test data.
    '''
    # load model object - if it doesn't exist, send a response asking to create the model
    try:
        model = gramex.cache.open(self.pickle_file_path, gramex.ml.load)
    except EnvironmentError:
        # Log error
        self.write({'Error': 'Please Send PUT Request, model does not exist'})
        raise EnvironmentError
    if self.get_data_flag():
        file_kwargs = self.listify(model.input + [model.output])
        gramex.data.insert(model.url, args=file_kwargs)
    else:
        # If /data/ is not path_args[1] then post is sending a predict request
        if self._train(model):
            return
        self._predict(model)

delete(path_args)

Request to /model/name/ will delete the trained model. Request to /model/name/data needs id and will delete rows from the training data.

Source code in gramex\handlers\modelhandler.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def delete(self, *path_args):
    '''
    Request to /model/name/ will delete the trained model.
    Request to /model/name/data needs id and will delete rows from the training data.
    '''
    if self.get_data_flag():
        file_kwargs = self.listify(['id'])
        try:
            model = gramex.cache.open(self.pickle_file_path, gramex.ml.load)
        except EnvironmentError:
            self.write({'Error': 'Please Send PUT Request, model does not exist'})
            raise EnvironmentError
        gramex.data.delete(model.url, args=file_kwargs, id=file_kwargs['id'])
        return
    if os.path.exists(self.pickle_file_path):
        os.unlink(self.pickle_file_path)

listify(checklst)

Some functions in data.py expect list values, so creates them. checklst is list-like which contains the selected values to be returned.

Source code in gramex\handlers\modelhandler.py
165
166
167
168
169
170
171
172
173
def listify(self, checklst):
    '''Some functions in data.py expect list values, so creates them.
    checklst is list-like which contains the selected values to be returned.
    '''
    return {
        k: [v] if not isinstance(v, list) else v
        for k, v in self.request_body.items()
        if k in checklst
    }

MLHandler

MLPredictor

OpenAPIHandler

PPTXHandler

ProcessHandler

setup(args, shell=False, cwd=None, buffer=0, headers={}, kwargs) classmethod

Set up handler to stream process output.

Parameters:

Name Type Description Default
args Union[List[str], str]

The first value is the command. The rest are optional string arguments. Same as subprocess.Popen().

required
shell bool

True passes the args through the shell, allowing wildcards like *. If shell=True then use a single string for args that includes the arguments.

False
cwd str

Current working directory from where the command will run. Defaults to the same directory Gramex ran from.

None
buffer Union[str, int]

Number of bytes to buffer. Defaults: io.DEFAULT_BUFFER_SIZE. Or "line" to buffer by newline.

0
headers dict

HTTP headers to set on the response.

{}
Source code in gramex\handlers\processhandler.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@classmethod
def setup(
    cls,
    args: Union[List[str], str],
    shell: bool = False,
    cwd: str = None,
    buffer: Union[str, int] = 0,
    headers: dict = {},
    **kwargs,
):
    '''Set up handler to stream process output.

    Parameters:

        args: The first value is the command. The rest are optional string arguments.
            Same as `subprocess.Popen()`.
        shell: `True` passes the `args` through the shell, allowing wildcards like `*`.
            If `shell=True` then use a single string for `args` that includes the arguments.
        cwd: Current working directory from where the command will run.
            Defaults to the same directory Gramex ran from.
        buffer: Number of bytes to buffer. Defaults: `io.DEFAULT_BUFFER_SIZE`. Or `"line"`
            to buffer by newline.
        headers: HTTP headers to set on the response.
    '''
    super(ProcessHandler, cls).setup(**kwargs)
    cls.cmdargs = args
    cls.shell = shell
    cls._write_lock = RLock()
    cls.buffer_size = buffer
    # Normalize current directory for path, if provided
    cls.cwd = cwd if cwd is None else os.path.abspath(cwd)
    # File handles for stdout/stderr are cached in cls.handles
    cls.handles = {}

    cls.headers = headers
    cls.post = cls.get

initialize(stdout=None, stderr=None, stdin=None, kwargs)

Sets up I/O stream processing.

Parameters:

Name Type Description Default
stdout Union[List[str], str]

The process output can be sent to

None
stderr Union[List[str], str]

The process error stream has the same options as stdout.

None
stdin Union[List[str], str]

(TODO)

None

stdout, stderr and stdin can be one of the below, or a list of the below:

  • "pipe": Display the (transformed) output. This is the default
  • "false": Ignore the output
  • "filename.txt": Save output to a filename.txt
Source code in gramex\handlers\processhandler.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def initialize(
    self,
    stdout: Union[List[str], str] = None,
    stderr: Union[List[str], str] = None,
    stdin: Union[List[str], str] = None,
    **kwargs,
):
    '''Sets up I/O stream processing.

    Parameters:

        stdout: The process output can be sent to
        stderr: The process error stream has the same options as stdout.
        stdin: (**TODO**)

    `stdout`, `stderr` and `stdin` can be one of the below, or a list of the below:

    - `"pipe"`: Display the (transformed) output. This is the default
    - `"false"`: Ignore the output
    - `"filename.txt"`: Save output to a `filename.txt`
    '''
    super(ProcessHandler, self).initialize(**kwargs)
    self.stream_stdout = self.stream_callbacks(stdout, name='stdout')
    self.stream_stderr = self.stream_callbacks(stderr, name='stderr')

on_finish()

Close all open handles after the request has finished

Source code in gramex\handlers\processhandler.py
136
137
138
139
140
def on_finish(self):
    '''Close all open handles after the request has finished'''
    for handle in self.handles.values():
        handle.close()
    super(ProcessHandler, self).on_finish()

ProxyHandler

setup(url, request_headers={}, default={}, prepare=None, modify=None, headers={}, connect_timeout=20, request_timeout=20, kwargs) classmethod

Passes the request to another HTTP REST API endpoint and returns its response. This is useful when:

  • exposing another website but via Gramex authentication (e.g. R-Shiny apps)
  • a server-side REST API must be accessed via the browser (e.g. Twitter)
  • passing requests to an API that requires authentication (e.g. Google)
  • the request or response needs to be transformed (e.g. add sentiment)
  • caching is required on the API (e.g. cache for 10 min)

Parameters:

Name Type Description Default
url str

URL endpoint to forward to. If the pattern ends with (.*), that part is added to this url.

required
request_headers dict

HTTP headers to be passed to the url. - "*": true forwards all HTTP headers from the request as-is. - A value of true forwards this header from the request as-is. - Any string value is formatted with handler as a variable.

{}
default dict

Default URL query parameters

{}
headers dict

HTTP headers to set on the response

{}
prepare Callable

A function that accepts any of handler and request (a tornado.httpclient.HTTPRequest) and modifies the request in-place

None
modify Callable

A function that accepts any of handler, request and response (tornado.httpclient.HTTPResponse) and modifies the response in-place

None
connect_timeout int

Timeout for initial connection in seconds (default: 20)

20
request_timeout int

Timeout for entire request in seconds (default: 20)

20

The response has the same HTTP headers and body as the proxied request, but:

  • Connection and Transfer-Encoding headers are ignored
  • X-Proxy-Url: header has the final URL that responded (after redirects)

These headers can be over-ridden by the headers: section.

pattern: /gmail/(.*)
handler: ProxyHandler
kwargs:
    url: https://www.googleapis.com/gmail/v1/
    request_headers:
        "*": true           # Pass on all HTTP headers
        Cookie: true        # Pass on the Cookie HTTP header
        # Over-ride the Authorization header
        Authorization: 'Bearer {handler.session[google_access_token]}'
    default:
        alt: json
Source code in gramex\handlers\proxyhandler.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@classmethod
def setup(
    cls,
    url: str,
    request_headers: dict = {},
    default: dict = {},
    prepare: Callable = None,
    modify: Callable = None,
    headers: dict = {},
    connect_timeout: int = 20,
    request_timeout: int = 20,
    **kwargs,
):
    '''
    Passes the request to another HTTP REST API endpoint and returns its
    response. This is useful when:

    - exposing another website but via Gramex authentication (e.g. R-Shiny apps)
    - a server-side REST API must be accessed via the browser (e.g. Twitter)
    - passing requests to an API that requires authentication (e.g. Google)
    - the request or response needs to be transformed (e.g. add sentiment)
    - caching is required on the API (e.g. cache for 10 min)

    Parameters:

        url: URL endpoint to forward to. If the pattern ends with
            `(.*)`, that part is added to this url.
        request_headers: HTTP headers to be passed to the url.
            - `"*": true` forwards all HTTP headers from the request as-is.
            - A value of `true` forwards this header from the request as-is.
            - Any string value is formatted with `handler` as a variable.
        default: Default URL query parameters
        headers: HTTP headers to set on the response
        prepare: A function that accepts any of `handler` and `request`
            (a tornado.httpclient.HTTPRequest) and modifies the `request` in-place
        modify: A function that accepts any of `handler`, `request`
            and `response` (tornado.httpclient.HTTPResponse) and modifies the
            `response` in-place
        connect_timeout: Timeout for initial connection in seconds (default: 20)
        request_timeout: Timeout for entire request in seconds (default: 20)

    The response has the same HTTP headers and body as the proxied request, but:

    - Connection and Transfer-Encoding headers are ignored
    - `X-Proxy-Url:` header has the final URL that responded (after redirects)

    These headers can be over-ridden by the `headers:` section.

    ```yaml
    pattern: /gmail/(.*)
    handler: ProxyHandler
    kwargs:
        url: https://www.googleapis.com/gmail/v1/
        request_headers:
            "*": true           # Pass on all HTTP headers
            Cookie: true        # Pass on the Cookie HTTP header
            # Over-ride the Authorization header
            Authorization: 'Bearer {handler.session[google_access_token]}'
        default:
            alt: json
    ```
    '''
    super(ProxyHandler, cls).setup(**kwargs)
    WebSocketHandler._setup(cls, **kwargs)
    cls.url, cls.request_headers, cls.default = url, request_headers, default
    cls.headers = headers
    cls.connect_timeout, cls.request_timeout = connect_timeout, request_timeout
    cls.info = {}
    for key, fn in (('prepare', prepare), ('modify', modify)):
        if fn:
            cls.info[key] = build_transform(
                {'function': fn},
                filename=f'url:{cls.name}.{key}',
                vars={'handler': None, 'request': None, 'response': None},
            )
    cls.post = cls.put = cls.delete = cls.patch = cls.get
    if not kwargs.get('cors'):
        cls.options = cls.get

SetupFailedHandler

Reports that the setup() operation has failed.

Used by gramex.services.init() when setting up URLs. If it’s not able to set up a handler, it replaces it with this handler.

SimpleAuth

Eventually, change this to use an abstract base class for local authentication methods – i.e. where we render the login screen, not a third party service.

The login page is rendered in case of a login error as well. The page is a Tornado template that is passed an error variable. error is None by default. If the login fails, it must be a dict with attributes specific to the handler.

The simplest configuration (kwargs) for SimpleAuth is

credentials:                        # Mapping of user IDs and passwords
    user1: password1                # user1 maps to password1
    user2: password2

An alternate configuration is

credentials:                        # Mapping of user IDs and user info
    user1:                          # Each user ID has a dictionary of keys
        password: password1         # One of them MUST be password
        email: user1@example.org    # Any other attributes can be added
        role: employee              # These are available from the session info
    user2:
        password: password2
        email: user2@example.org
        role: manager

The full configuration (kwargs) for SimpleAuth looks like this

template: $YAMLPATH/auth.template.html  # Render the login form template
user:
    arg: user                       # ... the ?user= argument from the form.
password:
    arg: password                   # ... the ?password= argument from the form
data:
    ...                             # Same as above

The login flow is as follows:

  1. User visits the SimpleAuth page => shows template (with the user name and password inputs)
  2. User enters user name and password, and submits. Browser redirects with a POST request
  3. Application checks username and password. On match, redirects.
  4. On any error, shows template (with error)

TwitterRESTHandler

Proxy for the Twitter 1.1 REST API via these kwargs:

pattern: /twitter/(.*)
handler: TwitterRESTHandler
kwargs:
    key: your-consumer-key
    secret: your-consumer-secret
    access_key: your-access-key         # Optional -- picked up from session
    access_secret: your-access-token    # Optional -- picked up from session
    methods: [get, post]                # HTTP methods to use for the API
    path: /search/tweets.json           # Freeze Twitter API request

Now POST /twitter/search/tweets.json?q=gramener returns the same response as the Twitter REST API /search/tweets.json.

If you only want to expose a specific API, specify a path:. It overrides the URL path. The query parameters will still work.

By default, methods is POST, and GET logs the user in, storing the access token in the session for future use. But you can specify the access_... values and set methods to [get, post] to use both GET and POST requests to proxy the API.

UploadHandler

UploadHandler lets users upload files. Here’s a typical configuration:

    path: /$GRAMEXDATA/apps/appname/    # Save files here
    keys: [upload, file]                # <input name=""> can be upload / file
    store:
        type: sqlite                    # Store metadata in a SQLite store
        path: ...                       #   ... at the specified path
    redirect:                           # After uploading the file,
        query: next                     #   ... redirect to ?next=
        url: /$YAMLURL/                 #   ... else to this directory

WebSocketHandler

Creates a websocket microservice.

  • open: function. open(handler) is called when the connection is opened
  • on_message: function. on_message(handler, message: str) is called when client sends a message
  • on_close: function. on_close(handler) is called when connection is closed.
  • origins: a domain name or list of domain names. No wildcards

Functions can use handler.write_message(msg: str) to sends a message back to the client.