diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 63589d6..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include cirrina/static/* diff --git a/README.md b/README.md index 380160c..0e85f6b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # cirrina diff --git a/cirrina/client.py b/cirrina/client.py index 61d57cc..3847d9a 100644 --- a/cirrina/client.py +++ b/cirrina/client.py @@ -7,21 +7,19 @@ """ import aiohttp_jrpc -import asyncio -class RPCClient(object): +class RPCClient: + """ Base JsonRPC Client """ + # pylint: disable=too-few-public-methods def __init__(self, url): self.remote = aiohttp_jrpc.Client(url) def __getattr__(self, attr): - @asyncio.coroutine - def wrapper(*args, **kw): - ret = yield from self.remote.call(attr, {'args': args, 'kw': kw}) - if ret.error: - if ret.error['code'] == -32602: - raise TypeError(ret.error['message']) + async def _wrapper(*args, **kw): + ret = await self.remote.call(attr, {'args': args, 'kw': kw}) + if ret.error and ret.error['code'] == -32602: + raise TypeError(ret.error['message']) return ret.result - return wrapper - + return _wrapper diff --git a/cirrina/server.py b/cirrina/server.py index 331aa9a..7da83ef 100644 --- a/cirrina/server.py +++ b/cirrina/server.py @@ -4,24 +4,32 @@ Implementation of server code. :license: LGPL, see LICENSE for details + +TODO: + + Maybe restructure: + - WS Management + - JSONRPC management + - Sessions and auth management + + What about using an external authentication handling library? + Pretty sure that should be better covered somewhere. + """ +from functools import wraps +from pathlib import Path import asyncio import base64 -from functools import wraps -import json import logging -import os -from cryptography import fernet from aiohttp import web, WSMsgType -from aiohttp_session import setup, get_session, session_middleware +from aiohttp_jrpc import JError, JResponse, decode, InternalError +from aiohttp_jrpc import ParseError, InvalidRequest +from aiohttp_session import setup, get_session from aiohttp_session.cookie_storage import EncryptedCookieStorage -from aiohttp_jrpc import JError, JResponse, decode, InvalidParams, InternalError -from validictory import validate, ValidationError, SchemaError -from aiohttp._ws_impl import WSMsgType from aiohttp_swagger import setup_swagger -from functools import wraps +from cryptography.fernet import Fernet #: Holds the cirrina logger instance logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -29,34 +37,26 @@ def _session_wrapper(func): @wraps(func) - def _addsess(request): - session = yield from get_session(request) - return (yield from func(request, session)) + async def _addsess(request): + return await func(request, await get_session(request)) return _addsess class Server: - """ - cirrina Server implementation. - """ - - DEFAULT_STATIC_PATH = os.path.join(os.path.dirname(__file__), 'static') + """ Cirrina Server implementation. """ + # pylint: disable=no-member, too-many-instance-attributes + DEFAULT_STATIC_PATH = Path(__file__).parent.absolute() / 'static' - def __init__(self, loop=None, login_url="/login", logout_url="/logout"): - if loop is None: - loop = asyncio.get_event_loop() + def __init__(self, loop=None, login_url="/login", logout_url="/logout", + debug=False): #: Holds the asyncio event loop which is used to handle requests. - self.loop = loop + self.loop = loop if loop is not None else asyncio.get_event_loop() # remember the login/logout urls - self.login_url = login_url - self.logout_url = logout_url + self.urls = {"login": login_url, 'logout': logout_url} - #: Holds the aiohttp web application instance. - self.app = web.Application(loop=self.loop) #, middlewares=[session_middleware]) - - #: Holds the asyncio server instance. - self.srv = None + #: Holds the web application instance. + self.app = web.Application(loop=self.loop, debug=debug) #: Holds all websocket connections. self.websockets = [] @@ -70,145 +70,95 @@ def __init__(self, loop=None, login_url="/login", logout_url="/logout"): self.rpc_methods = {} # setup cookie encryption for user sessions. - fernet_key = fernet.Fernet.generate_key() - secret_key = base64.urlsafe_b64decode(fernet_key) - setup(self.app, EncryptedCookieStorage(secret_key)) + setup(self.app, EncryptedCookieStorage( + base64.urlsafe_b64decode(Fernet.generate_key()))) #: Holds authentication functions self.auth_handlers = [] + #: Holds functions which are called upon logout self.logout_handlers = [] + #: Holds functions which are called on startup self.startup_handlers = [] + #: Holds functions which are called on shutdown self.shutdown_handlers = [] + # In the end I dont like this solution. + # But I dont like to repeat the same code over and over again + # either. + self.startup = self.handler_register(self.startup_handlers) + self.shutdown = self.handler_register(self.shutdown_handlers) + self.auth_handler = self.handler_register(self.auth_handlers) + self.logout_handler = self.handler_register(self.logout_handlers) + self.websocket_connect = self.handler_register(self.on_ws_connect) + self.websocket_message = self.handler_register(self.on_ws_message) + self.websocket_disconnect = self.handler_register( + self.on_ws_disconnect) + + self.http_get = self.wrapper('GET') + self.http_post = self.wrapper('POST') + self.http_head = self.wrapper('HEAD') + self.http_put = self.wrapper('PUT') + self.http_patch = self.wrapper('PATCH') + self.http_delete = self.wrapper('DELETE') + # add default routes to request handler. - self.http_post(self.login_url)(self._login) - self.http_post(self.logout_url)(self._logout) + self.http_post(self.urls['login'])(self._login) + self.http_post(self.urls['logout'])(self._logout) - # swagger documentation - self.title = "Cirrina based web application" - self.description = """Cirrina is a web application framework using aiohttp. - See https://github.com/neolynx/cirrina.""" - self.api_version = "0.1" - self.contact = "André Roth " + def handler_register(self, where): + """ Register handlers wrapper """ + def _register(func): + where.append(func) + return func + return _register + def wrapper(self, method): + """ Return a func wrapper for session management """ + def _wrapper1(location): + def _wrapper(func): + self.app.router.add_route( + method, location, _session_wrapper(func)) + return func + return _wrapper + return _wrapper1 - @asyncio.coroutine - def _start(self, address, port): + def run(self, address='127.0.0.1', port=2100, swagger_info=None): """ - Start cirrina server. - - This method starts the asyncio loop server which uses - the aiohttp web application.: + Run cirrina server event loop. """ - # setup API documentation - setup_swagger(self.app, - description=self.description, - title=self.title, - api_version=self.api_version, - contact=self.contact) + if swagger_info: + setup_swagger(self.app, **swagger_info) for handler in self.startup_handlers: - handler() - - self.srv = yield from self.loop.create_server(self.app.make_handler(), address, port) - - @asyncio.coroutine - def _stop(self): - """ - Stop cirrina server. + self.app.on_startup.append(handler) - This method stops the asyncio loop server which uses - the aiohttp web application.: - """ - logger.debug('Stopping cirrina server...') for handler in self.shutdown_handlers: - handler() - for ws in self.websockets: - ws.close() - self.app.shutdown() - - def run(self, address='127.0.0.1', port=2100, debug=False): - """ - Run cirrina server event loop. - """ - # set cirrina logger loglevel - logger.setLevel(logging.DEBUG if debug else logging.INFO) - - self.loop.run_until_complete(self._start(address, port)) - logger.info("Server started at http://%s:%d", address, port) - - try: - self.loop.run_forever() - except KeyboardInterrupt: - pass - - self.loop.run_until_complete(self._stop()) - logger.debug("Closing all tasks...") - for task in asyncio.Task.all_tasks(): - task.cancel() - self.loop.run_until_complete(asyncio.gather(*asyncio.Task.all_tasks())) - logger.debug("Closing the loop...") - self.loop.close() + self.app.on_shutdown.append(handler) - logger.info('Stopped cirrina server') + return web.run_app(self.app, host=address, port=port) - - def startup(self, func): - """ - Decorator to provide one or more startup - handlers. - """ - self.startup_handlers.append(func) - return func - - - def shutdown(self, func): - """ - Decorator to provide one or more shutdown - handlers. - """ - self.shutdown_handlers.append(func) - return func - - - ### Authentication ### - - def auth_handler(self, func): - """ - Decorator to provide one or more authentication - handlers. - """ - self.auth_handlers.append(func) - return func - - def logout_handler(self, func): - """ - Decorator to specify function which should - be called upon user logout. - """ - self.logout_handlers.append(func) - return func - - @asyncio.coroutine - def _login(self, request, session): - """ - Authenticate the user with the given request data. + async def _login(self, request, session): + """ Authenticate the user with the given request data. Username and Password a received with the HTTP POST data and the ``username`` and ``password`` fields. On success a new session will be created. --- - description: This is the login handler + description: Login handler + tags: - Authentication + consumes: - application/x-www-form-urlencoded + parameters: + - name: username in: formData required: true @@ -216,160 +166,80 @@ def _login(self, request, session): minLength: 8 maxLength: 64 type: string + - name: password in: formData required: true type: string format: password + produces: - - text/plain + - text/html + responses: "302": - description: successful login. + description: Login successfull/unsuccessfull + (will be redirected) "405": description: invalid HTTP Method """ # get username and password from POST request - yield from request.post() - username = request.POST.get('username') - password = request.POST.get('password') - - # check if username and password are valid - for auth_handler in self.auth_handlers: - if (yield from auth_handler(username, password)) == True: - logger.debug('User authenticated: %s', username) - session['username'] = username - response = web.Response(status=302) - response.headers['Location'] = request.POST.get('path', '/') - return response - logger.debug('User authentication failed: %s', username) - response = web.Response(status=302) - response.headers['Location'] = self.login_url + ldata = await request.post() + + # check if username and password are valid in any of the auth handlers + for auth in self.auth_handlers: + if await auth(ldata['username'], ldata['password']): + session['username'] = ldata["username"] + raise web.HTTPFound(ldata.get('path', '/')) + session.invalidate() - return response + raise web.HTTPFound(self.login_url) - @asyncio.coroutine - def _logout(self, request, session): - """ - Logout the user which is used in this request session. + async def _logout(self, _, session): + """ Logout the user which is used in this request session. If the request is not part of a user session - nothing happens. - --- - description: This is the logout handler + description: Logout handler + tags: - Authentication + produces: - text/plain + responses: "200": description: successful logout. """ if not session: - logger.debug('No valid session in request for logout') - return web.Response(status=200) # FIXME: what should be returned? + raise web.HTTPUnauthorized() - # run all logout handlers before invalidating session for func in self.logout_handlers: - func(session) + await func(session) - logger.debug('Logout user from session') session.invalidate() return web.Response(status=200) def authenticated(self, func): - """ - Decorator to enforce valid session before - executing the decorated function. + """ Decorator to enforce valid session before + executing the decorated function. """ @wraps(func) - @asyncio.coroutine - def _wrapper(request, session): # pylint: disable=missing-docstring + async def _wrapper(request, session): if session.new: - response = web.Response(status=302) - response.headers['Location'] = self.login_url + "?path=" + request.path_qs - return response - return (yield from func(request, session)) - return _wrapper - - - ### HTTP protocol ### - - def http_static(self, location, path): - """ - Register new route to static path. - """ - self.app.router.add_static(location, path) - - - def http_get(self, location): - """ - Register HTTP GET route. - """ - def _wrapper(func): - self.app.router.add_route('GET', location, _session_wrapper(func)) - return func - return _wrapper - - def http_head(self, location): - """ - Register HTTP HEAD route. - """ - def _wrapper(func): - self.app.router.add_route('HEAD', location, _session_wrapper(func)) - return func - return _wrapper - - def http_options(self, location): - """ - Register HTTP OPTIONS route. - """ - def _wrapper(func): - self.app.router.add_route('OPTIONS', location, _session_wrapper(func)) - return func - return _wrapper - - def http_post(self, location): - """ - Register HTTP POST route. - """ - def _wrapper(func): - self.app.router.add_route('POST', location, _session_wrapper(func)) - return func + raise web.HTTPFound("{}?path={}".format( + self.urls['login'], request.path_qa)) + return await func(request, session) return _wrapper - def http_put(self, location): - """ - Register HTTP PUT route. - """ - def _wrapper(func): - self.app.router.add_route('PUT', location, _session_wrapper(func)) - return func - return _wrapper - - def http_patch(self, location): - """ - Register HTTP PATCH route. - """ - def _wrapper(func): - self.app.router.add_route('PATCH', location, _session_wrapper(func)) - return func - return _wrapper - - def http_delete(self, location): - """ - Register HTTP DELETE route. - """ - def _wrapper(func): - self.app.router.add_route('DELETE', location, _session_wrapper(func)) - return func - return _wrapper - - - ### WebSocket protocol ### + def http_static(self, loc, path): + """ Http static path """ + return self.app.router.add_static(loc, path) + # WebSocket protocol def enable_websockets(self, location): """ Enable websocket communication. @@ -381,32 +251,9 @@ def websocket_broadcast(self, msg): Broadcast a message to all websocket connections. """ for websocket in self.websockets: - # FIXME: use array - websocket.send_str('{"status": 200, "message": %s}'%json.dumps(msg)) - - def websocket_connect(self, func): - """ - Add callback for websocket connect event. - """ - self.on_ws_connect.append(func) - return func + websocket.send_json({"status": 200, "message": msg}) - def websocket_message(self, func): - """ - Add callback for websocket message event. - """ - self.on_ws_message.append(func) - return func - - def websocket_disconnect(self, func): - """ - Add callback for websocket disconnect event. - """ - self.on_ws_disconnect.append(func) - return func - - @asyncio.coroutine - def _ws_handler(self, request): + async def _ws_handler(self, request): """ Handle websocket connections. @@ -416,43 +263,39 @@ def _ws_handler(self, request): * messages """ websocket = web.WebSocketResponse() - yield from websocket.prepare(request) + await websocket.prepare(request) - session = yield from get_session(request) - if session.new: - logger.debug('websocket: not logged in') - websocket.send_str(json.dumps({'status': 401, 'text': "Unauthorized"})) + session = await get_session(request) + + # Allow no-auth if we haven't setup any authentication methods. + if session.new and self.auth_handlers: + logger.debug('Not logged in websocket attempt') + websocket.send_json({'status': 401, 'text': "Unauthorized"}) websocket.close() return websocket self.websockets.append(websocket) + for func in self.on_ws_connect: - yield from func(websocket, session) + await func(websocket, session) - while True: - msg = yield from websocket.receive() - if msg.type == WSMsgType.CLOSE or msg.type == WSMsgType.CLOSED: - logger.debug('websocket closed') + async for msg in websocket: + errors = (WSMsgType.ERROR, WSMsgType.CLOSE, WSMsgType.CLOSED) + if msg.type in errors: + logger.debug('Websocket closed (%s)', websocket.exception()) break - - logger.debug("websocket got: %s", msg) - if msg.type == WSMsgType.TEXT: + elif msg.type == WSMsgType.TEXT: for func in self.on_ws_message: - yield from func(websocket, session, msg.data) - elif msg.type == WSMsgType.ERROR: - logger.debug('websocket closed with exception %s', websocket.exception()) + await func(websocket, session, msg) - yield from asyncio.sleep(0.1) + await asyncio.sleep(0.1) self.websockets.remove(websocket) for func in self.on_ws_disconnect: - yield from func(session) + await func(session) return websocket - - ### JRPC protocol ### - def enable_rpc(self, location): """ Register new JSON RPC method. @@ -470,44 +313,44 @@ def _rpc_handler(self): """ Handle rpc calls. """ - class _rpc(object): - cirrina = self - - def __new__(cls, request): - """ Return on call class """ - return cls.__run(cls, request) - - @asyncio.coroutine - def __run(self, request): - """ Run service """ - try: - data = yield from decode(request) - except ParseError: - return JError().parse() - except InvalidRequest: - return JError().request() - except InternalError: - return JError().internal() - - try: - method = _rpc.cirrina.rpc_methods[data['method']] - except Exception: - return JError(data).method() - - session = yield from get_session(request) - try: - resp = yield from method(request, session, *data['params']['args'], **data['params']['kw']) - except TypeError as e: - # workaround for JError.custom bug - return JResponse(jsonrpc={ - 'id': data['id'], - 'error': {'code': -32602, 'message': str(e)}, - }) - except InternalError: - return JError(data).internal() - + async def _run(request): + """ Return a coroutine upon object initialization """ + try: + error = None + data = await decode(request) + except ParseError: + error = JError().parse() + except InvalidRequest: + error = JError().request() + except InternalError: + error = JError().internal() + finally: + if error is not None: + # pylint: disable=lost-exception + return error + + # pylint: disable=bare-except + try: + method = self.cirrina.rpc_methods[data['method']] + except: + return JError(data).method() + + session = await get_session(request) + + try: + resp = await method(request, session, + *data['params']['args'], + **data['params']['kw']) + except TypeError as err: + # workaround for JError.custom bug return JResponse(jsonrpc={ - "id": data['id'], "result": resp - }) + 'id': data['id'], + 'error': {'code': -32602, 'message': str(err)}}) + except InternalError: + return JError(data).internal() + + return JResponse( + jsonrpc={"id": data['id'], "result": resp}) - return _rpc + _run.self = self + return _run diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index e9b5dc4..0000000 --- a/debian/changelog +++ /dev/null @@ -1,5 +0,0 @@ -cirrina (0.1.0) jessie; urgency=medium - - * debianized - - -- André Roth Sat, 19 Nov 2016 17:16:25 +0100 diff --git a/debian/compat b/debian/compat deleted file mode 100644 index 7f8f011..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -7 diff --git a/debian/control b/debian/control deleted file mode 100644 index 8d2473c..0000000 --- a/debian/control +++ /dev/null @@ -1,11 +0,0 @@ -Source: cirrina -Maintainer: André Roth -Section: python -Priority: optional -Build-Depends: debhelper (>= 7), dh-python, python3-setuptools, python3-all, python3-cryptography, python3-aiohttp, python3-aiohttp-jrpc, python3-aiohttp-session, python3-aiohttp-swagger -Standards-Version: 3.9.7 - -Package: python3-cirrina -Architecture: all -Depends: ${misc:Depends}, ${python:Depends}, python3-cryptography, python3-aiohttp, python3-aiohttp-jrpc, python3-aiohttp-session, python3-aiohttp-swagger -Description: Opinionated asynchronous web framework based on aiohttp diff --git a/debian/rules b/debian/rules deleted file mode 100755 index a039a04..0000000 --- a/debian/rules +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/make -f - -# This file was automatically generated by stdeb 0.8.5 at -# Wed, 31 Aug 2016 17:20:31 +0200 -export PYBUILD_NAME = cirrina - -%: - dh $@ --with python3 --buildsystem=pybuild - -override_dh_auto_test: - echo "Cannot test, missing dependencies (aiohttp version)" diff --git a/debian/source/format b/debian/source/format deleted file mode 100644 index 89ae9db..0000000 --- a/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (native) diff --git a/debian/source/options b/debian/source/options deleted file mode 100644 index bcc4bbb..0000000 --- a/debian/source/options +++ /dev/null @@ -1 +0,0 @@ -extend-diff-ignore="\.egg-info$" \ No newline at end of file diff --git a/cirrina.jpg b/doc/cirrina.jpg similarity index 100% rename from cirrina.jpg rename to doc/cirrina.jpg diff --git a/examples/basic/client.py b/doc/examples/basic/client.py similarity index 100% rename from examples/basic/client.py rename to doc/examples/basic/client.py diff --git a/examples/basic/server.py b/doc/examples/basic/server.py similarity index 100% rename from examples/basic/server.py rename to doc/examples/basic/server.py diff --git a/cirrina/static/cirrina.js b/doc/examples/basic/static/cirrina.js similarity index 100% rename from cirrina/static/cirrina.js rename to doc/examples/basic/static/cirrina.js