Implemented a more explicit Pagelet return API using class instances to describe the action to take.

This commit is contained in:
s3lph 2018-07-09 20:50:02 +02:00
parent e3c65776b5
commit 1b00c80133
9 changed files with 186 additions and 75 deletions

View file

@ -7,4 +7,5 @@ server will attempt to serve the request with a static resource in a previously
""" """
from .requestargs import RequestArgument, RequestArguments from .requestargs import RequestArgument, RequestArguments
from .responses import PageletResponse, RedirectResponse, TemplateResponse
from .httpd import MatematWebserver, HttpHandler, pagelet from .httpd import MatematWebserver, HttpHandler, pagelet

View file

@ -16,7 +16,7 @@ import jinja2
from matemat import __version__ as matemat_version from matemat import __version__ as matemat_version
from matemat.exceptions import HttpException from matemat.exceptions import HttpException
from matemat.webserver import RequestArguments from matemat.webserver import RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.webserver.util import parse_args from matemat.webserver.util import parse_args
# #
@ -37,8 +37,7 @@ _PAGELET_PATHS: Dict[str, Callable[[str, # HTTP method (GET, POST, ...)
Dict[str, str]], # Response headers Dict[str, str]], # Response headers
Union[ # Return type: either a response body, or a redirect Union[ # Return type: either a response body, or a redirect
bytes, str, # Response body: will assign HTTP/1.0 200 OK bytes, str, # Response body: will assign HTTP/1.0 200 OK
Tuple[int, str], # Redirect: First element must be 301, second the redirect path PageletResponse, # A generic response
Tuple[str, Dict[str, Any]] # Jinja template name and kwargs
]]] = dict() ]]] = dict()
# Inactivity timeout for client sessions # Inactivity timeout for client sessions
@ -67,9 +66,8 @@ def pagelet(path: str):
headers: The dictionary of HTTP response headers. Add headers you wish to send with the response. headers: The dictionary of HTTP response headers. Add headers you wish to send with the response.
returns: One of the following: returns: One of the following:
- A HTTP Response body as str or bytes - 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) - A PageletResponse class instance: An instance of (a subclass of)
- A Jinja template call: A tuple of the template name (a string) and the template rendering matemat.webserver.PageletResponse, e.g. encapsulating a redirect or a Jinja2 template.
arguments (a kwargs dict)
raises: HttpException: If a non-200 HTTP status code should be returned raises: HttpException: If a non-200 HTTP status code should be returned
:param path: The path to register the function for. :param path: The path to register the function for.
@ -82,8 +80,7 @@ def pagelet(path: str):
Dict[str, str]], Dict[str, str]],
Union[ Union[
bytes, str, bytes, str,
Tuple[int, str], PageletResponse
Tuple[str, Dict[str, Any]]
]]): ]]):
# Add the function to the dict of pagelets # Add the function to the dict of pagelets
_PAGELET_PATHS[path] = fun _PAGELET_PATHS[path] = fun
@ -221,8 +218,7 @@ class HttpHandler(BaseHTTPRequestHandler):
def _parse_pagelet_result(self, def _parse_pagelet_result(self,
pagelet_res: Union[bytes, # Response body as bytes pagelet_res: Union[bytes, # Response body as bytes
str, # Response body as str str, # Response body as str
Tuple[int, str], # Redirect PageletResponse], # Encapsulated or unresolved response body
Tuple[str, Dict[str, Any]]], # Jinja template name, kwargs dict
headers: Dict[str, str]) \ headers: Dict[str, str]) \
-> Tuple[int, bytes]: -> Tuple[int, bytes]:
""" """
@ -235,36 +231,34 @@ class HttpHandler(BaseHTTPRequestHandler):
""" """
# The HTTP Response Status Code, defaults to 200 OK # The HTTP Response Status Code, defaults to 200 OK
hsc: int = 200 hsc: int = 200
# The HTTP Response body, defaults to None # The HTTP Response body, defaults to empty
data: Union[bytes, str] = None data: bytes = bytes()
if isinstance(pagelet_res, tuple):
# If the return type is a tuple, it has to be either a redirect, in which case the first element must be # If the response is a bytes object, it is used without further modification
# int(301), or it is a template call, in which casse the first element must be the template name and the if isinstance(pagelet_res, bytes):
# second element must be the kwargs dict to the template's render function
head, tail = pagelet_res
if head == 301 and isinstance(tail, str):
# Set the HTTP Response Status Code, and the redirect header
hsc = 301
headers['Location'] = tail
elif isinstance(head, str) and isinstance(tail, dict):
# Load the Jinja2 template and render it with the provided arguments
template = self.server.jinja_env.get_template(head)
tail['matemat_version'] = self.server_version
data = template.render(**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 data = pagelet_res
# If the response is a str object, it is encoded into a bytes object
elif isinstance(pagelet_res, str):
data = pagelet_res.encode('utf-8')
# If the response is a PageletResponse object, the status code is extracted. Generation of the body depends
# on the subtype
elif isinstance(pagelet_res, PageletResponse):
hsc = pagelet_res.status
# If the object is a RedirectResponse instance, no body is needed
if isinstance(pagelet_res, RedirectResponse):
headers['Location'] = pagelet_res.location
# If the object is a TemplateRespinse instance, pass the Jinja2 environment instance for rendering
elif isinstance(pagelet_res, TemplateResponse):
# noinspection PyProtectedMember
data = pagelet_res._render(self.server.jinja_env)
# else: Empty body
else: else:
# Return value is not a response body or a redirect
raise TypeError(f'Return value of pagelet not understood: {pagelet_res}') 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 the resulting status code and body
return hsc, data return hsc, data

View file

@ -1,8 +1,8 @@
from typing import Any, Dict, Tuple, Union from typing import Any, Dict, Union
from matemat.exceptions import AuthenticationError, HttpException from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.primitives import User from matemat.primitives import User
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
@ -13,17 +13,17 @@ def login_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str])\ headers: Dict[str, str])\
-> Union[bytes, str, Tuple[int, str], Tuple[str, Dict[str, Any]]]: -> Union[bytes, str, PageletResponse]:
if 'user' in session_vars: if 'user' in session_vars:
return 301, '/' return RedirectResponse('/')
if method == 'GET': if method == 'GET':
return 'login.html', {} return TemplateResponse('login.html')
elif method == 'POST': elif method == 'POST':
with MatematDatabase('test.db') as db: with MatematDatabase('test.db') as db:
try: try:
user: User = db.login(str(args.username), str(args.password)) user: User = db.login(str(args.username), str(args.password))
except AuthenticationError: except AuthenticationError:
return 301, '/login' return RedirectResponse('/login')
session_vars['user'] = user session_vars['user'] = user
return 301, '/' return RedirectResponse('/')
raise HttpException(405) raise HttpException(405)

View file

@ -1,7 +1,7 @@
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, Union
from matemat.webserver import pagelet, RequestArguments from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
@pagelet('/logout') @pagelet('/logout')
@ -10,7 +10,7 @@ def logout(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str])\ headers: Dict[str, str])\
-> Union[bytes, str, Tuple[int, str], Tuple[str, Dict[str, Any]]]: -> Union[bytes, str, PageletResponse]:
if 'user' in session_vars: if 'user' in session_vars:
del session_vars['user'] del session_vars['user']
return 301, '/' return RedirectResponse('/')

View file

@ -1,7 +1,7 @@
from typing import Any, Dict, Optional, Tuple, Union from typing import Any, Dict, Union
from matemat.webserver import pagelet, RequestArguments from matemat.webserver import pagelet, RequestArguments, PageletResponse, TemplateResponse
from matemat.primitives import User from matemat.primitives import User
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
@ -12,12 +12,12 @@ def main_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str])\ headers: Dict[str, str])\
-> Union[bytes, str, Tuple[int, str], Tuple[str, Dict[str, Any]]]: -> Union[bytes, str, PageletResponse]:
with MatematDatabase('test.db') as db: with MatematDatabase('test.db') as db:
if 'user' in session_vars: if 'user' in session_vars:
user: User = session_vars['user'] user: User = session_vars['user']
products = db.list_products() products = db.list_products()
return 'main.html', {'user': user, 'list': products} return TemplateResponse('main.html', user=user, list=products)
else: else:
users = db.list_users() users = db.list_users()
return 'main.html', {'list': users} return TemplateResponse('main.html', list=users)

View file

@ -4,7 +4,7 @@ from typing import Any, Dict, Tuple, Union
import urllib.parse import urllib.parse
from matemat.exceptions import AuthenticationError, HttpException from matemat.exceptions import AuthenticationError, HttpException
from matemat.webserver import pagelet, RequestArguments from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.primitives import User from matemat.primitives import User
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
@ -15,18 +15,18 @@ def touchkey_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str])\ headers: Dict[str, str])\
-> Union[bytes, str, Tuple[int, str], Tuple[str, Dict[str, Any]]]: -> Union[bytes, str, PageletResponse]:
if 'user' in session_vars: if 'user' in session_vars:
return 301, '/' return RedirectResponse('/')
if method == 'GET': if method == 'GET':
return 'touchkey.html', {'username': str(args.username)} if 'username' in args else {} return TemplateResponse('touchkey.html', username=str(args.username) if 'username' in args else None)
elif method == 'POST': elif method == 'POST':
with MatematDatabase('test.db') as db: with MatematDatabase('test.db') as db:
try: try:
user: User = db.login(str(args.username), touchkey=str(args.touchkey)) user: User = db.login(str(args.username), touchkey=str(args.touchkey))
except AuthenticationError: except AuthenticationError:
quoted = urllib.parse.quote_plus(bytes(args.username)) quoted = urllib.parse.quote_plus(bytes(args.username))
return 301, f'/touchkey?username={quoted}' return RedirectResponse(f'/touchkey?username={quoted}')
session_vars['user'] = user session_vars['user'] = user
return 301, '/' return RedirectResponse('/')
raise HttpException(405) raise HttpException(405)

View file

@ -0,0 +1,63 @@
from jinja2 import Environment, Template
class PageletResponse:
"""
Base class for pagelet return values that require more action than simply sending plain data.
An instance of this base class will result in an empty 200 OK response.
"""
def __init__(self, status: int = 200):
"""
Create an empty response.
:param status: The HTTP status code, defaults to 200 (OK).
"""
self.status: int = status
class RedirectResponse(PageletResponse):
"""
A pagelet response that causes the server to redirect to another location, using a 301 Permanently Moved (uncached)
response status, and a Location header.
"""
def __init__(self, location: str):
"""
Create a redirection response with the given redirection location.
:param location: The location to redirect to.
"""
super().__init__(status=301)
self.location: str = location
class TemplateResponse(PageletResponse):
"""
A pagelet response that causes the server to load a Jinja2 template and render it with the provided arguments, then
sending the result as response body, with a 200 OK response status.
"""
def __init__(self, name: str, **kwargs):
"""
Create a template response with the given template name and arguments.
:param name: Name of the template to load.
:param kwargs: Arguments for rendering the template, will be passed to jinja2.Template.render as is.
"""
super().__init__()
self.name: str = name
self.kwargs = kwargs
def _render(self, jinja_env: Environment) -> bytes:
"""
Load and render the template using the Jinja2 environment managed by the web server instance. This method
should not be called by a pagelet.
:param jinja_env: The Jinja2 environment.
:return: An UTF-8 encoded bytes object containing the template rendering result.
"""
template: Template = jinja_env.get_template(self.name)
return template.render(**self.kwargs).encode('utf-8')

View file

@ -9,6 +9,8 @@ from abc import ABC
from datetime import datetime from datetime import datetime
from http.server import HTTPServer from http.server import HTTPServer
import jinja2
from matemat.webserver import pagelet, RequestArguments from matemat.webserver import pagelet, RequestArguments
@ -107,6 +109,10 @@ class MockServer:
self.session_vars: Dict[str, Tuple[datetime, Dict[str, Any]]] = dict() self.session_vars: Dict[str, Tuple[datetime, Dict[str, Any]]] = dict()
# Webroot for statically served content # Webroot for statically served content
self.webroot: str = webroot self.webroot: str = webroot
# Jinja environment with a single, static template
self.jinja_env = jinja2.Environment(
loader=jinja2.DictLoader({'test.txt': 'Hello, {{ what }}!'})
)
class MockSocket(bytes): class MockSocket(bytes):

View file

@ -1,21 +1,31 @@
from typing import Any, Dict from typing import Any, Dict, Union
import os import os
import os.path import os.path
from matemat.exceptions import HttpException from matemat.exceptions import HttpException
from matemat.webserver import HttpHandler, RequestArguments from matemat.webserver import HttpHandler, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@test_pagelet('/just/testing/serve_pagelet_ok') @test_pagelet('/just/testing/serve_pagelet_str')
def serve_test_pagelet_ok(method: str, def serve_test_pagelet_str(method: str,
path: str, path: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str]): headers: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
headers['Content-Type'] = 'text/plain' headers['Content-Type'] = 'text/plain'
return 'serve test pagelet ok' return 'serve test pagelet str'
@test_pagelet('/just/testing/serve_pagelet_bytes')
def serve_test_pagelet_bytes(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
headers['Content-Type'] = 'application/octet-stream'
return b'serve\x80test\xffpagelet\xfebytes'
@test_pagelet('/just/testing/serve_pagelet_redirect') @test_pagelet('/just/testing/serve_pagelet_redirect')
@ -23,16 +33,27 @@ def serve_test_pagelet_redirect(method: str,
path: str, path: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str]): headers: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
return 301, '/foo/bar' return RedirectResponse('/foo/bar')
@test_pagelet('/just/testing/serve_pagelet_template')
def serve_test_pagelet_template(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
headers['Content-Type'] = 'text/plain'
return TemplateResponse('test.txt', what='World')
# noinspection PyTypeChecker
@test_pagelet('/just/testing/serve_pagelet_fail') @test_pagelet('/just/testing/serve_pagelet_fail')
def serve_test_pagelet_fail(method: str, def serve_test_pagelet_fail(method: str,
path: str, path: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
headers: Dict[str, str]): headers: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
session_vars['test'] = 'hello, world!' session_vars['test'] = 'hello, world!'
headers['Content-Type'] = 'text/plain' headers['Content-Type'] = 'text/plain'
raise HttpException() raise HttpException()
@ -54,17 +75,29 @@ class TestServe(AbstractHttpdTest):
f.write('This should not be readable') f.write('This should not be readable')
os.chmod(forbidden, 0) os.chmod(forbidden, 0)
def test_serve_pagelet_ok(self): def test_serve_pagelet_str(self):
# Call the test pagelet that produces a 200 OK result # Call the test pagelet that produces a 200 OK result
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_ok HTTP/1.1\r\n\r\n') self.client_sock.set_request(b'GET /just/testing/serve_pagelet_str HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server) HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response() packet = self.client_sock.get_response()
# Make sure the correct pagelet was called # Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_ok', packet.pagelet) self.assertEqual('serve_test_pagelet_str', packet.pagelet)
# Make sure the expected content is served # Make sure the expected content is served
self.assertEqual(200, packet.statuscode) self.assertEqual(200, packet.statuscode)
self.assertEqual(b'serve test pagelet ok', packet.body) self.assertEqual(b'serve test pagelet str', packet.body)
def test_serve_pagelet_bytes(self):
# Call the test pagelet that produces a 200 OK result
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_bytes 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 pagelet was called
self.assertEqual('serve_test_pagelet_bytes', packet.pagelet)
# Make sure the expected content is served
self.assertEqual(200, packet.statuscode)
self.assertEqual(b'serve\x80test\xffpagelet\xfebytes', packet.body)
def test_serve_pagelet_fail(self): def test_serve_pagelet_fail(self):
# Call the test pagelet that produces a 500 Internal Server Error result # Call the test pagelet that produces a 500 Internal Server Error result
@ -81,12 +114,26 @@ class TestServe(AbstractHttpdTest):
HttpHandler(self.client_sock, ('::1', 45678), self.server) HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response() packet = self.client_sock.get_response()
# Make sure the correct pagelet was called
self.assertEqual('serve_test_pagelet_redirect', packet.pagelet)
# Make sure the correct redirect is issued # Make sure the correct redirect is issued
self.assertEqual(301, packet.statuscode) self.assertEqual(301, packet.statuscode)
self.assertEqual('/foo/bar', packet.headers['Location']) self.assertEqual('/foo/bar', packet.headers['Location'])
# Make sure the response body is empty # Make sure the response body is empty
self.assertEqual(0, len(packet.body)) self.assertEqual(0, len(packet.body))
def test_serve_pagelet_template(self):
# Call the test pagelet that redirects to another path
self.client_sock.set_request(b'GET /just/testing/serve_pagelet_template 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 pagelet was called
self.assertEqual('serve_test_pagelet_template', packet.pagelet)
self.assertEqual(200, packet.statuscode)
# Make sure the response body was rendered correctly by the templating engine
self.assertEqual(b'Hello, World!', packet.body)
def test_serve_static_ok(self): def test_serve_static_ok(self):
# Request a static resource # Request a static resource
self.client_sock.set_request(b'GET /static_resource.txt HTTP/1.1\r\n\r\n') self.client_sock.set_request(b'GET /static_resource.txt HTTP/1.1\r\n\r\n')