crossbario / autobahn-python

WebSocket and WAMP in Python for Twisted and asyncio
https://crossbar.io/autobahn
MIT License
2.48k stars 770 forks source link

Make WAMP component API first class / recommended #964

Open oberstet opened 6 years ago

oberstet commented 6 years ago

This issue is to finally move the new **WAMP Component API ("new API") into "first class support" stage.

Eg here is an example https://github.com/crossbario/autobahn-python/blob/master/examples/twisted/wamp/component/frontend_scram.py


Things that come to mind for this:


The issues that have been opened over the time where we discussed various approaches to "new API", until we settled on the component API:

frol commented 5 years ago

May I suggest Flask Blueprints API design?

Flask, basically, splits Component into two parts: the top-level server (flask.Flask) and module-level components (flask.Blueprint). This allows developers to build modular components exposing blueprints and register all of them in the application root, thus leaving the configuration up to the application. Here is how I would love to use Autobahn:

# FILE: app/modules/demo/__init__.py

from autobahn.asyncio.component import Component

demo_component = Component(common_prefix='com.example.demo.')

# NOTE: the function gets automatically registered with
# `com.example.demo.random` id (i.e. `common_prefix` + function name)
@demo_component.register
async def random(*args, **kwargs):
    return 42

# FILE: app/modules/__init__.py
from . import demo

components = [
    demo.demo_component,
]

# FILE: app/__init__.py
from .modules import components

def create_app():
    app = autobahn.Server(
        'ws://127.0.0.1:8080/ws',
        authentication={...},
        ...
    )
    for component in components:
        app.register_component(component)

    return app

if __name__ == '__main__':
    app = create_app()
    app.run()
meejah commented 5 years ago

@frol Some of the above registration stuff you can "kind-of" accomplish in certain ways .. I do like the idea of "an API-provider that can be registered at a WAMP prefix". I find myself wanting this very often. I think the Component API in WAMP already embodies the concept of "connecting to a place, and authorization plus possible re-connection" .. which is what I think you mean with Server above?

So perhaps what's wanted (instead of changing / expanding what Component abstracts) is a new thing that knows about "an API" and can register it (optionally at a prefix); then it can be paired with a Component (or even just a Session) to register that API (i.e. uses Component.register or Session.register to hook up the methods). I could imagine, for example, a third-party library that wants to provide optional Autobahn support could then implement one of these new things. Ideally maybe it also knows about the concept of "readiness" (i.e. "is this API ready to go") and can publish that fact. Perhaps it also knows about dependencies (i.e. "this API needs APIs A and B to be ready first").

meejah commented 5 years ago

(Perhaps this discussion should move to some related-but-new ticket?) One thing the @demo_component.register sketch above misses is, "what about publishing?". So if random wants to publish something, it needs a currently-valid session.

Here is something similar, which should function right now:

# appfoo/login.py                                                                                              

import lmdb
from autobahn import wamp
from autobahn.wamp.types import Deny

async def on_join(session, details):
    env, db = await _create_db_connection()
    login = _ApplicationLogin(env, db, session)
    # if required, could session.on('leave', cleanup) for example
    await session.register(login, prefix="com.appfoo.auth.")   # like "register_component")
    session.publish("com.appfoo.auth.ready", True)
    return

async def _create_db_connection():
    env = lmdb.open(
        path='...',
        max_dbs=16,
    )
    db = env.open_db(b'auth_db')
    return env, db

class _ApplicationLogin(object):

    def __init__(self, env, db, session):
        self.env = env
        self.db = db
        self.session = session

    async def _get_pubkey_and_role(self, authid):
        with lmdb.Transaction(env, db) as txn:
            data = txn.get(pubkey.encode('ascii'))
            if data is None:
                raise KeyError("No such authid '{}'".format(authid))
            user = json.loads(data.decode('utf8'))
            return (user['pubkey'], user['role'])

    # this will end up at "com.appfoo.auth.authenticate"                                                       
    @wamp.register(None)
    async def authenticate(self, realm, authid, extra):
        try:
            pubkey, role = await self._get_pubkey_and_role(authid)
        except KeyError:
            return Deny()
        return {
            "role": role,
            "pubkey": pubkey,
        }

As a bonus, this lets you hook up "a valid session" (and, for example, register this thing on multiple components / session). It also handles dis- and re-connect well, because the "on_join" handler will run every time we re-connect to the router. There are two ways to hook this up; in code you could do this:

comp = Component(...)
from appfoo import login
comp.on('join', login.on_join)

If using crossbar, you can add a function type component in the config:

              {
                    "type": "function",
                    "callbacks": {
                        "join": "appfoo.login.on_join"
                    },
                    "realm": "com.example.authenticator",
                    "role": "auth"
              },

Perhaps there's a way to abstract this a little more and also possibly avoid some boilerplate?