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