Merge branch 'pagelet-return-api' into 'webserver-impl'

Pagelet return API

See merge request s3lph/matemat!8
This commit is contained in:
s3lph 2018-07-08 17:54:58 +00:00
commit 50a9425fe2
13 changed files with 162 additions and 94 deletions

2
doc

@ -1 +1 @@
Subproject commit 14b8380090858c3bed5c3b2ee7cf1408aaa133df
Subproject commit 51e940460ddbaebb7f2ffc48d00d9ef19cf8d33f

View file

@ -2,7 +2,7 @@
from typing import Optional
class AuthenticationError(BaseException):
class AuthenticationError(Exception):
def __init__(self, msg: Optional[str] = None) -> None:
super().__init__()

View file

@ -0,0 +1,20 @@
class HttpException(Exception):
def __init__(self, status: int = 500, title: str = 'An error occurred', message: str = None) -> None:
super().__init__()
self.__status: int = status
self.__title: str = title
self.__message: str = message
@property
def status(self) -> int:
return self.__status
@property
def title(self) -> str:
return self.__title
@property
def message(self) -> str:
return self.__message

View file

@ -4,3 +4,4 @@ This package provides custom exception classes used in the Matemat codebase.
from .AuthenticatonError import AuthenticationError
from .DatabaseConsistencyError import DatabaseConsistencyError
from .HttpException import HttpException

View file

@ -1,5 +1,5 @@
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
from typing import Any, Callable, Dict, Tuple, Type, Union
import traceback
@ -13,10 +13,10 @@ from uuid import uuid4
from datetime import datetime, timedelta
from matemat import __version__ as matemat_version
from matemat.exceptions import HttpException
from matemat.webserver import RequestArguments
from matemat.webserver.util import parse_args
#
# Python internal class hacks
#
@ -27,16 +27,16 @@ TCPServer.address_family = socket.AF_INET6
BaseHTTPRequestHandler.log_request = lambda self, code='-', size='-': None
BaseHTTPRequestHandler.log_error = lambda self, fstring='', *args: None
# Dictionary to hold registered pagelet paths and their handler functions
_PAGELET_PATHS: Dict[str, Callable[[str, # HTTP method (GET, POST, ...)
str, # Request path
RequestArguments, # HTTP Request arguments
Dict[str, Any], # Session vars
Dict[str, str]], # Response headers
# Returns: (status code, response body)
Tuple[int, Optional[Union[bytes, str]]]]] = dict()
Union[ # Return type: either a response body, or a redirect
bytes, str, # Response body: will assign HTTP/1.0 200 OK
Tuple[int, str] # Redirect: First element must be 301, second the redirect path
]]] = dict()
# Inactivity timeout for client sessions
_SESSION_TIMEOUT: int = 3600
@ -55,15 +55,17 @@ def pagelet(path: str):
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str])
-> (int, Optional[Union[str, bytes]])
-> Union[bytes, str, Tuple[int, str]]
method: The HTTP method (GET, POST) that was used.
path: The path that was requested.
args: The arguments that were passed with the request (as GET or POST arguments).
session_vars: The session storage. May be read from and written to.
headers: The dictionary of HTTP response headers. Add headers you wish to send with the response.
returns: A tuple consisting of the HTTP status code (as an int) and the response body (as str or bytes,
may be None)
returns: One of the following:
- A HTTP Response body as str or bytes
- A HTTP redirect: A tuple of 301 (an int) and the path to redirect to (a str)
raises: HttpException: If a non-200 HTTP status code should be returned
:param path: The path to register the function for.
"""
@ -73,11 +75,15 @@ def pagelet(path: str):
RequestArguments,
Dict[str, Any],
Dict[str, str]],
Tuple[int, Optional[Union[bytes, str]]]]):
Union[
bytes, str,
Tuple[int, str]
]]):
# Add the function to the dict of pagelets
_PAGELET_PATHS[path] = fun
# Don't change the function itself at all
return fun
# Return the inner function (Python requires a "real" function annotation to not have any arguments except
# the function itself)
return http_handler
@ -182,7 +188,7 @@ class HttpHandler(BaseHTTPRequestHandler):
if session_id not in self.server.session_vars:
self.server.session_vars[session_id] = (now + timedelta(seconds=_SESSION_TIMEOUT)), dict()
else:
self.server.session_vars[session_id] =\
self.server.session_vars[session_id] = \
(now + timedelta(seconds=_SESSION_TIMEOUT), self.server.session_vars[session_id][1])
# Return the session ID and timeout
return session_id, self.server.session_vars[session_id][0]
@ -196,6 +202,45 @@ class HttpHandler(BaseHTTPRequestHandler):
if session_id in self.server.session_vars:
del self.server.session_vars[session_id]
@staticmethod
def _parse_pagelet_result(pagelet_res: Union[bytes, str, Tuple[int, str]], headers: Dict[str, str]) \
-> Tuple[int, bytes]:
"""
Process the return value of a pagelet function call.
:param pagelet_res: The pagelet return value.
:param headers: The dict of HTTP response headers, needed for setting the redirect header.
:return: The HTTP Response status code (an int) and body (a bytes).
:raises TypeError: If the pagelet result was not in the expected form.
"""
# The HTTP Response Status Code, defaults to 200 OK
hsc: int = 200
# The HTTP Response body, defaults to None
data: Union[bytes, str] = None
if isinstance(pagelet_res, tuple):
# If the return type is a tuple, the first element must be 301 (the HTTP Redirect status code)
head, tail = pagelet_res
if head == 301:
# Set the HTTP Response Status Code, and the redirect header
hsc = 301
headers['Location'] = tail
else:
raise TypeError(f'Return value of pagelet not understood: {pagelet_res}')
elif isinstance(pagelet_res, str) or isinstance(pagelet_res, bytes):
# Return value is a response body
data = pagelet_res
else:
# Return value is not a response body or a redirect
raise TypeError(f'Return value of pagelet not understood: {pagelet_res}')
# The pagelet may return None as data as a shorthand for an empty response
if data is None:
data = bytes()
# If the pagelet returns a Python str, convert it to an UTF-8 encoded bytes object
if isinstance(data, str):
data = data.encode('utf-8')
# Return the resulting status code and body
return hsc, data
def _handle(self, method: str, path: str, args: RequestArguments) -> None:
"""
Handle a HTTP request by either dispatching it to the appropriate pagelet or by serving a static resource.
@ -224,13 +269,9 @@ class HttpHandler(BaseHTTPRequestHandler):
'Cache-Control': 'no-cache'
}
# Call the pagelet function
hsc, data = _PAGELET_PATHS[path](method, path, args, self.session_vars, headers)
# The pagelet may return None as data as a shorthand for an empty response
if data is None:
data = bytes()
# If the pagelet returns a Python str, convert it to an UTF-8 encoded bytes object
if isinstance(data, str):
data = data.encode('utf-8')
pagelet_res = _PAGELET_PATHS[path](method, path, args, self.session_vars, headers)
# Parse the pagelet's return value, vielding a HTTP status code and a response body
hsc, data = HttpHandler._parse_pagelet_result(pagelet_res, headers)
# Send the HTTP status code
self.send_response(hsc)
# Format the session cookie timeout string and send the session cookie header
@ -287,16 +328,17 @@ class HttpHandler(BaseHTTPRequestHandler):
path, args = parse_args(self.path)
self._handle('GET', path, args)
# Special handling for some errors
except HttpException as e:
self.send_error(e.status, e.title, e.message)
except PermissionError:
self.send_response(403, 'Forbidden')
self.end_headers()
self.send_error(403, 'Forbidden')
except ValueError:
self.send_response(400, 'Bad Request')
self.end_headers()
except BaseException:
self.send_error(400, 'Bad Request')
except BaseException as e:
# Generic error handling
self.send_response(500, 'Internal Server Error')
self.end_headers()
self.send_error(500, 'Internal Server Error')
print(e)
traceback.print_tb(e.__traceback__)
# noinspection PyPep8Naming
def do_POST(self) -> None:
@ -314,19 +356,15 @@ class HttpHandler(BaseHTTPRequestHandler):
# Parse the request and hand it to the handle function
self._handle('POST', path, args)
# Special handling for some errors
except HttpException as e:
self.send_error(e.status, e.title, e.message)
except PermissionError:
self.send_response(403, 'Forbidden')
self.end_headers()
self.send_error(403, 'Forbidden')
except ValueError:
self.send_response(400, 'Bad Request')
self.end_headers()
except TypeError:
self.send_response(400, 'Bad Request')
self.end_headers()
self.send_error(400, 'Bad Request')
except BaseException as e:
# Generic error handling
self.send_response(500, 'Internal Server Error')
self.end_headers()
self.send_error(500, 'Internal Server Error')
print(e)
traceback.print_tb(e.__traceback__)

View file

@ -1,7 +1,7 @@
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Tuple, Union
from matemat.exceptions import AuthenticationError
from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments
from matemat.primitives import User
from matemat.db import MatematDatabase
@ -13,10 +13,9 @@ def login_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str])\
-> Tuple[int, Optional[Union[str, bytes]]]:
-> Union[bytes, str, Tuple[int, str]]:
if 'user' in session_vars:
headers['Location'] = '/'
return 301, None
return 301, '/'
if method == 'GET':
data = '''
<DOCTYPE html>
@ -41,15 +40,13 @@ def login_page(method: str,
</body>
</html>
'''
return 200, data.format(msg=str(args.msg) if 'msg' in args else '')
return data.format(msg=str(args.msg) if 'msg' in args else '')
elif method == 'POST':
with MatematDatabase('test.db') as db:
try:
user: User = db.login(str(args.username), str(args.password))
except AuthenticationError:
headers['Location'] = '/login?msg=Username%20or%20password%20wrong.%20Please%20try%20again.'
return 301, bytes()
return 301, '/login?msg=Username%20or%20password%20wrong.%20Please%20try%20again.'
session_vars['user'] = user
headers['Location'] = '/'
return 301, bytes()
return 405, None
return 301, '/'
raise HttpException(405)

View file

@ -10,8 +10,7 @@ def logout(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str])\
-> Tuple[int, Optional[Union[str, bytes]]]:
-> Union[bytes, str, Tuple[int, str]]:
if 'user' in session_vars:
del session_vars['user']
headers['Location'] = '/'
return 301, None
return 301, '/'

View file

@ -12,7 +12,7 @@ def main_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str])\
-> Tuple[int, Optional[Union[str, bytes]]]:
-> Union[bytes, str, Tuple[int, str]]:
data = '''
<DOCTYPE html>
<html>
@ -34,24 +34,19 @@ def main_page(method: str,
</body>
</html>
'''
try:
with MatematDatabase('test.db') as db:
if 'user' in session_vars:
user: User = session_vars['user']
products = db.list_products()
plist = '\n'.join([f'<li/> <b>{p.name}</b> ' +
f'{p.price_member//100 if user.is_member else p.price_non_member//100}' +
f'.{p.price_member%100 if user.is_member else p.price_non_member%100}'
for p in products])
uname = f'<b>{user.name}</b> <a href="/logout">(Logout)</a>'
data = data.format(user=uname, list=plist)
else:
users = db.list_users()
ulist = '\n'.join([f'<li/> <b><a href=/touchkey?username={u.name}>{u.name}</a></b>' for u in users])
ulist = ulist + '<li/> <a href=/login>Password login</a>'
data = data.format(user='', list=ulist)
return 200, data
except BaseException as e:
import traceback
traceback.print_tb(e.__traceback__)
return 500, None
with MatematDatabase('test.db') as db:
if 'user' in session_vars:
user: User = session_vars['user']
products = db.list_products()
plist = '\n'.join([f'<li/> <b>{p.name}</b> ' +
f'{p.price_member//100 if user.is_member else p.price_non_member//100}' +
f'.{p.price_member%100 if user.is_member else p.price_non_member%100}'
for p in products])
uname = f'<b>{user.name}</b> <a href="/logout">(Logout)</a>'
data = data.format(user=uname, list=plist)
else:
users = db.list_users()
ulist = '\n'.join([f'<li/> <b><a href=/touchkey?username={u.name}>{u.name}</a></b>' for u in users])
ulist = ulist + '<li/> <a href=/login>Password login</a>'
data = data.format(user='', list=ulist)
return data

View file

@ -1,7 +1,7 @@
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Tuple, Union
from matemat.exceptions import AuthenticationError
from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments
from matemat.primitives import User
from matemat.db import MatematDatabase
@ -13,10 +13,9 @@ def touchkey_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str])\
-> Tuple[int, Optional[Union[str, bytes]]]:
-> Union[bytes, str, Tuple[int, str]]:
if 'user' in session_vars:
headers['Location'] = '/'
return 301, bytes()
return 301, '/'
if method == 'GET':
data = '''
<DOCTYPE html>
@ -40,15 +39,13 @@ def touchkey_page(method: str,
</body>
</html>
'''
return 200, data.format(username=str(args.username) if 'username' in args else '')
return data.format(username=str(args.username) if 'username' in args else '')
elif method == 'POST':
with MatematDatabase('test.db') as db:
try:
user: User = db.login(str(args.username), touchkey=str(args.touchkey))
except AuthenticationError:
headers['Location'] = f'/touchkey?username={args["username"]}&msg=Please%20try%20again.'
return 301, bytes()
return 301, f'/touchkey?username={args["username"]}&msg=Please%20try%20again.'
session_vars['user'] = user
headers['Location'] = '/'
return 301, None
return 405, None
return 301, '/'
raise HttpException(405)

View file

@ -16,6 +16,8 @@ class HttpResponse:
"""
A really basic HTTP response container and parser class, just good enough for unit testing a HTTP server, if even.
DO NOT USE THIS OUTSIDE UNIT TESTING!
Usage:
response = HttpResponse()
while response.parse_phase != 'done'
@ -161,16 +163,16 @@ def test_pagelet(path: str):
RequestArguments,
Dict[str, Any],
Dict[str, str]],
Tuple[int, Union[bytes, str]]]):
Union[bytes, str, Tuple[int, str]]]):
@pagelet(path)
def testing_wrapper(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str]):
status, body = fun(method, path, args, session_vars, headers)
headers['X-Test-Pagelet'] = fun.__name__
return status, body
result = fun(method, path, args, session_vars, headers)
return result
return testing_wrapper
return with_testing_headers

View file

@ -24,7 +24,7 @@ def post_test_pagelet(method: str,
dump += f'{a.name}: {a.get_str()}\n'
else:
dump += f'{a.name}: {codecs.encode(a.get_bytes(), "hex").decode("utf-8")}\n'
return 200, dump
return dump
class TestPost(AbstractHttpdTest):

View file

@ -3,6 +3,7 @@ from typing import Any, Dict
import os
import os.path
from matemat.exceptions import HttpException
from matemat.webserver import HttpHandler, RequestArguments
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@ -14,7 +15,16 @@ def serve_test_pagelet_ok(method: str,
session_vars: Dict[str, Any],
headers: Dict[str, str]):
headers['Content-Type'] = 'text/plain'
return 200, 'serve test pagelet ok'
return 'serve test pagelet ok'
@test_pagelet('/just/testing/serve_pagelet_redirect')
def serve_test_pagelet_redirect(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str]):
return 301, '/foo/bar'
@test_pagelet('/just/testing/serve_pagelet_fail')
@ -25,7 +35,7 @@ def serve_test_pagelet_fail(method: str,
headers: Dict[str, str]):
session_vars['test'] = 'hello, world!'
headers['Content-Type'] = 'text/plain'
return 500, 'serve test pagelet fail'
raise HttpException()
class TestServe(AbstractHttpdTest):
@ -62,11 +72,20 @@ class TestServe(AbstractHttpdTest):
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_fail', packet.pagelet)
# Make sure the expected content is served
# Make sure an error is raised
self.assertEqual(500, packet.statuscode)
self.assertEqual(b'serve test pagelet fail', packet.body)
def test_serve_pagelet_redirect(self):
# Call the test pagelet that redirects to another path
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_redirect HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
# Make sure the correct redirect is issued
self.assertEqual(301, packet.statuscode)
self.assertEqual('/foo/bar', packet.headers['Location'])
# Make sure the response body is empty
self.assertEqual(0, len(packet.body))
def test_serve_static_ok(self):
# Request a static resource

View file

@ -16,7 +16,7 @@ def session_test_pagelet(method: str,
headers: Dict[str, str]):
session_vars['test'] = 'hello, world!'
headers['Content-Type'] = 'text/plain'
return 200, 'session test'
return 'session test'
class TestSession(AbstractHttpdTest):