forked from s3lph/matemat
Merge branch 'multipart' into 'webserver-impl'
Implemented parsing of multipart/form-data POST requests. Also syntactic sugar for request arguments handling. See merge request s3lph/matemat!6
This commit is contained in:
commit
9d6c0c9de2
14 changed files with 1529 additions and 123 deletions
|
@ -6,4 +6,5 @@ API that can be used by 'pagelets' - single pages of a web service. If a reques
|
||||||
server will attempt to serve the request with a static resource in a previously configured webroot directory.
|
server will attempt to serve the request with a static resource in a previously configured webroot directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .requestargs import RequestArgument, RequestArguments
|
||||||
from .httpd import MatematWebserver, HttpHandler, pagelet
|
from .httpd import MatematWebserver, HttpHandler, pagelet
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, Tuple, Union
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import urllib.parse
|
|
||||||
from socketserver import TCPServer
|
from socketserver import TCPServer
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
|
@ -14,6 +13,8 @@ from uuid import uuid4
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from matemat import __version__ as matemat_version
|
from matemat import __version__ as matemat_version
|
||||||
|
from matemat.webserver import RequestArguments
|
||||||
|
from matemat.webserver.util import parse_args
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -28,12 +29,17 @@ BaseHTTPRequestHandler.log_error = lambda self, fstring='', *args: None
|
||||||
|
|
||||||
|
|
||||||
# Dictionary to hold registered pagelet paths and their handler functions
|
# Dictionary to hold registered pagelet paths and their handler functions
|
||||||
_PAGELET_PATHS: Dict[str, Callable[[str, str, Dict[str, str], Dict[str, Any], Dict[str, str], bytes],
|
_PAGELET_PATHS: Dict[str, Callable[[str, # HTTP method (GET, POST, ...)
|
||||||
Tuple[int, Union[bytes, str]]]] = dict()
|
str, # Request path
|
||||||
|
RequestArguments, # HTTP Request arguments
|
||||||
|
Dict[str, Any], # Session vars
|
||||||
|
Dict[str, str]], # Response headers
|
||||||
|
Tuple[int, Union[bytes, str]]]] = dict() # Returns: (status code, response body)
|
||||||
|
|
||||||
|
|
||||||
# Inactivity timeout for client sessions
|
# Inactivity timeout for client sessions
|
||||||
_SESSION_TIMEOUT: int = 3600
|
_SESSION_TIMEOUT: int = 3600
|
||||||
|
_MAX_POST: int = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def pagelet(path: str):
|
def pagelet(path: str):
|
||||||
|
@ -43,8 +49,12 @@ def pagelet(path: str):
|
||||||
|
|
||||||
The function must have the following signature:
|
The function must have the following signature:
|
||||||
|
|
||||||
(method: str, path: str, args: Dict[str, Union[str, List[str]], session_vars: Dict[str, Any],
|
(method: str,
|
||||||
headers: Dict[str, str]) -> (int, Optional[Union[str, bytes]])
|
path: str,
|
||||||
|
args: RequestArguments,
|
||||||
|
session_vars: Dict[str, Any],
|
||||||
|
headers: Dict[str, str])
|
||||||
|
-> (int, Optional[Union[str, bytes]])
|
||||||
|
|
||||||
method: The HTTP method (GET, POST) that was used.
|
method: The HTTP method (GET, POST) that was used.
|
||||||
path: The path that was requested.
|
path: The path that was requested.
|
||||||
|
@ -56,7 +66,12 @@ def pagelet(path: str):
|
||||||
|
|
||||||
:param path: The path to register the function for.
|
:param path: The path to register the function for.
|
||||||
"""
|
"""
|
||||||
def http_handler(fun: Callable[[str, str, Dict[str, str], Dict[str, Any], Dict[str, str], bytes],
|
|
||||||
|
def http_handler(fun: Callable[[str,
|
||||||
|
str,
|
||||||
|
RequestArguments,
|
||||||
|
Dict[str, Any],
|
||||||
|
Dict[str, str]],
|
||||||
Tuple[int, Union[bytes, str]]]):
|
Tuple[int, Union[bytes, str]]]):
|
||||||
# Add the function to the dict of pagelets
|
# Add the function to the dict of pagelets
|
||||||
_PAGELET_PATHS[path] = fun
|
_PAGELET_PATHS[path] = fun
|
||||||
|
@ -166,7 +181,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||||
if session_id in self.server.session_vars:
|
if session_id in self.server.session_vars:
|
||||||
del self.server.session_vars[session_id]
|
del self.server.session_vars[session_id]
|
||||||
|
|
||||||
def _handle(self, method: str, path: str, args: Dict[str, Union[str, List[str]]]) -> None:
|
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.
|
Handle a HTTP request by either dispatching it to the appropriate pagelet or by serving a static resource.
|
||||||
|
|
||||||
|
@ -238,7 +253,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||||
mimetype = 'application/octet-stream'
|
mimetype = 'application/octet-stream'
|
||||||
# Send content type and length header
|
# Send content type and length header
|
||||||
self.send_header('Content-Type', mimetype)
|
self.send_header('Content-Type', mimetype)
|
||||||
self.send_header('Content-Length', len(data))
|
self.send_header('Content-Length', str(len(data)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
# Send the requested resource as response body
|
# Send the requested resource as response body
|
||||||
self.wfile.write(data)
|
self.wfile.write(data)
|
||||||
|
@ -247,36 +262,6 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_args(request: str, postbody: Optional[str] = None) -> Tuple[str, Dict[str, Union[str, List[str]]]]:
|
|
||||||
"""
|
|
||||||
Given a HTTP request path, and optionally a HTTP POST body in application/x-www-form-urlencoded form, parse the
|
|
||||||
arguments and return them as a dictionary.
|
|
||||||
|
|
||||||
If a key is used both in GET and in POST, the POST value takes precedence, and the GET value is discarded.
|
|
||||||
|
|
||||||
:param request: The request string to parse.
|
|
||||||
:param postbody: The POST body to parse, defaults to None.
|
|
||||||
:return: A tuple consisting of the base path and a dictionary with the parsed key/value pairs.
|
|
||||||
"""
|
|
||||||
# Parse the request "URL" (i.e. only the path)
|
|
||||||
tokens = urllib.parse.urlparse(request)
|
|
||||||
# Parse the GET arguments
|
|
||||||
args = urllib.parse.parse_qs(tokens.query)
|
|
||||||
|
|
||||||
if postbody is not None:
|
|
||||||
# Parse the POST body
|
|
||||||
postargs = urllib.parse.parse_qs(postbody)
|
|
||||||
# Write all POST values into the dict, overriding potential duplicates from GET
|
|
||||||
for k, v in postargs.items():
|
|
||||||
args[k] = v
|
|
||||||
# urllib.parse.parse_qs turns ALL arguments into arrays. This turns arrays of length 1 into scalar values
|
|
||||||
for k, v in args.items():
|
|
||||||
if len(v) == 1:
|
|
||||||
args[k] = v[0]
|
|
||||||
# Return the path and the parsed arguments
|
|
||||||
return tokens.path, args
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
# noinspection PyPep8Naming
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -284,7 +269,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse the request and hand it to the handle function
|
# Parse the request and hand it to the handle function
|
||||||
path, args = self._parse_args(self.path)
|
path, args = parse_args(self.path)
|
||||||
self._handle('GET', path, args)
|
self._handle('GET', path, args)
|
||||||
# Special handling for some errors
|
# Special handling for some errors
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
|
@ -305,25 +290,24 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Read the POST body, if it exists, and its MIME type is application/x-www-form-urlencoded
|
# Read the POST body, if it exists, and its MIME type is application/x-www-form-urlencoded
|
||||||
clen: str = self.headers.get('Content-Length', failobj='0')
|
clen: int = int(str(self.headers.get('Content-Length', failobj='0')))
|
||||||
|
if clen > _MAX_POST:
|
||||||
|
raise ValueError('Request too big')
|
||||||
ctype: str = self.headers.get('Content-Type', failobj='application/octet-stream')
|
ctype: str = self.headers.get('Content-Type', failobj='application/octet-stream')
|
||||||
post: str = ''
|
post: bytes = self.rfile.read(clen)
|
||||||
if ctype == 'application/x-www-form-urlencoded':
|
path, args = parse_args(self.path, postbody=post, enctype=ctype)
|
||||||
post = self.rfile.read(int(clen)).decode('utf-8')
|
|
||||||
# Parse the request and hand it to the handle function
|
# Parse the request and hand it to the handle function
|
||||||
path, args = self._parse_args(self.path, postbody=post)
|
|
||||||
self._handle('POST', path, args)
|
self._handle('POST', path, args)
|
||||||
# Special handling for some errors
|
# Special handling for some errors
|
||||||
except PermissionError as e:
|
except PermissionError:
|
||||||
self.send_response(403, 'Forbidden')
|
self.send_response(403, 'Forbidden')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
print(e)
|
except ValueError:
|
||||||
traceback.print_tb(e.__traceback__)
|
self.send_response(400, 'Bad Request')
|
||||||
except ValueError as e:
|
self.end_headers()
|
||||||
|
except TypeError:
|
||||||
self.send_response(400, 'Bad Request')
|
self.send_response(400, 'Bad Request')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
print(e)
|
|
||||||
traceback.print_tb(e.__traceback__)
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
# Generic error handling
|
# Generic error handling
|
||||||
self.send_response(500, 'Internal Server Error')
|
self.send_response(500, 'Internal Server Error')
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
from matemat.exceptions import AuthenticationError
|
from matemat.exceptions import AuthenticationError
|
||||||
from matemat.webserver import pagelet
|
from matemat.webserver import pagelet, RequestArguments
|
||||||
from matemat.primitives import User
|
from matemat.primitives import User
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/login')
|
@pagelet('/login')
|
||||||
def login_page(method: str, path: str, args: Dict[str, str], session_vars: Dict[str, Any], headers: Dict[str, str]):
|
def login_page(method: str,
|
||||||
|
path: str,
|
||||||
|
args: RequestArguments,
|
||||||
|
session_vars: Dict[str, Any],
|
||||||
|
headers: Dict[str, str])\
|
||||||
|
-> Tuple[int, Optional[Union[str, bytes]]]:
|
||||||
if 'user' in session_vars:
|
if 'user' in session_vars:
|
||||||
headers['Location'] = '/'
|
headers['Location'] = '/'
|
||||||
return 301, None
|
return 301, None
|
||||||
|
@ -36,15 +41,15 @@ def login_page(method: str, path: str, args: Dict[str, str], session_vars: Dict[
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
'''
|
'''
|
||||||
return 200, data.format(msg=args['msg'] if 'msg' in args else '')
|
return 200, data.format(msg=str(args.msg) if 'msg' in args else '')
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
print(args)
|
|
||||||
with MatematDatabase('test.db') as db:
|
with MatematDatabase('test.db') as db:
|
||||||
try:
|
try:
|
||||||
user: User = db.login(args['username'], args['password'])
|
user: User = db.login(str(args.username), str(args.password))
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
headers['Location'] = '/login?msg=Username%20or%20password%20wrong.%20Please%20try%20again.'
|
headers['Location'] = '/login?msg=Username%20or%20password%20wrong.%20Please%20try%20again.'
|
||||||
return 301, bytes()
|
return 301, bytes()
|
||||||
session_vars['user'] = user
|
session_vars['user'] = user
|
||||||
headers['Location'] = '/'
|
headers['Location'] = '/'
|
||||||
return 301, bytes()
|
return 301, bytes()
|
||||||
|
return 405, None
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from matemat.webserver import pagelet
|
from matemat.webserver import pagelet, RequestArguments
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/logout')
|
@pagelet('/logout')
|
||||||
def logout(method: str, path: str, args: Dict[str, str], session_vars: Dict[str, Any], headers: Dict[str, str]):
|
def logout(method: str,
|
||||||
|
path: str,
|
||||||
|
args: RequestArguments,
|
||||||
|
session_vars: Dict[str, Any],
|
||||||
|
headers: Dict[str, str])\
|
||||||
|
-> Tuple[int, Optional[Union[str, bytes]]]:
|
||||||
if 'user' in session_vars:
|
if 'user' in session_vars:
|
||||||
del session_vars['user']
|
del session_vars['user']
|
||||||
headers['Location'] = '/'
|
headers['Location'] = '/'
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Optional, Tuple, Union
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
from matemat.webserver import MatematWebserver, pagelet
|
from matemat.webserver import pagelet, RequestArguments
|
||||||
from matemat.primitives import User
|
from matemat.primitives import User
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/')
|
@pagelet('/')
|
||||||
def main_page(method: str, path: str, args: Dict[str, str], session_vars: Dict[str, Any], headers: Dict[str, str])\
|
def main_page(method: str,
|
||||||
|
path: str,
|
||||||
|
args: RequestArguments,
|
||||||
|
session_vars: Dict[str, Any],
|
||||||
|
headers: Dict[str, str])\
|
||||||
-> Tuple[int, Optional[Union[str, bytes]]]:
|
-> Tuple[int, Optional[Union[str, bytes]]]:
|
||||||
data = '''
|
data = '''
|
||||||
<DOCTYPE html>
|
<DOCTYPE html>
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
from matemat.exceptions import AuthenticationError
|
from matemat.exceptions import AuthenticationError
|
||||||
from matemat.webserver import pagelet
|
from matemat.webserver import pagelet, RequestArguments
|
||||||
from matemat.primitives import User
|
from matemat.primitives import User
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/touchkey')
|
@pagelet('/touchkey')
|
||||||
def touchkey_page(method: str, path: str, args: Dict[str, str], session_vars: Dict[str, Any], headers: Dict[str, str]):
|
def touchkey_page(method: str,
|
||||||
|
path: str,
|
||||||
|
args: RequestArguments,
|
||||||
|
session_vars: Dict[str, Any],
|
||||||
|
headers: Dict[str, str])\
|
||||||
|
-> Tuple[int, Optional[Union[str, bytes]]]:
|
||||||
if 'user' in session_vars:
|
if 'user' in session_vars:
|
||||||
headers['Location'] = '/'
|
headers['Location'] = '/'
|
||||||
return 301, bytes()
|
return 301, bytes()
|
||||||
|
@ -35,14 +40,15 @@ def touchkey_page(method: str, path: str, args: Dict[str, str], session_vars: Di
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
'''
|
'''
|
||||||
return 200, data.format(username=args['username'] if 'username' in args else '')
|
return 200, data.format(username=str(args.username) if 'username' in args else '')
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
with MatematDatabase('test.db') as db:
|
with MatematDatabase('test.db') as db:
|
||||||
try:
|
try:
|
||||||
user: User = db.login(args['username'], touchkey=args['touchkey'])
|
user: User = db.login(str(args.username), touchkey=str(args.touchkey))
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
headers['Location'] = f'/touchkey?username={args["username"]}&msg=Please%20try%20again.'
|
headers['Location'] = f'/touchkey?username={args["username"]}&msg=Please%20try%20again.'
|
||||||
return 301, bytes()
|
return 301, bytes()
|
||||||
session_vars['user'] = user
|
session_vars['user'] = user
|
||||||
headers['Location'] = '/'
|
headers['Location'] = '/'
|
||||||
return 301, None
|
return 301, None
|
||||||
|
return 405, None
|
||||||
|
|
324
matemat/webserver/requestargs.py
Normal file
324
matemat/webserver/requestargs.py
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
|
||||||
|
from typing import Dict, Iterator, List, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
class RequestArguments(object):
|
||||||
|
"""
|
||||||
|
Container for HTTP Request arguments.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
# Create empty instance
|
||||||
|
ra = RequestArguments()
|
||||||
|
# Add an entry for the key 'foo' with the value 'bar' and Content-Type 'text/plain'
|
||||||
|
ra['foo'].append('text/plain', 'bar')
|
||||||
|
# Retrieve the value for the key 'foo', as a string...
|
||||||
|
foo = str(ra.foo)
|
||||||
|
# ... or as raw bytes
|
||||||
|
foo = bytes(ra.foo)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
Create an empty container instance.
|
||||||
|
"""
|
||||||
|
self.__container: Dict[str, RequestArgument] = dict()
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> 'RequestArgument':
|
||||||
|
"""
|
||||||
|
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
|
||||||
|
|
||||||
|
:param key: Name of the argument to retrieve.
|
||||||
|
:return: A RequestArgument instance.
|
||||||
|
:raises TypeError: If key is not a string.
|
||||||
|
"""
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise TypeError('key must be a str')
|
||||||
|
# Create empty argument, if it doesn't exist
|
||||||
|
if key not in self.__container:
|
||||||
|
self.__container[key] = RequestArgument(key)
|
||||||
|
# Return the argument for the name
|
||||||
|
return self.__container[key]
|
||||||
|
|
||||||
|
def __getattr__(self, key: str) -> 'RequestArgument':
|
||||||
|
"""
|
||||||
|
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
|
||||||
|
returned as an immutable view.
|
||||||
|
|
||||||
|
:param key: Name of the argument to retrieve.
|
||||||
|
:return: An immutable view of the RequestArgument instance.
|
||||||
|
"""
|
||||||
|
return _View.of(self.__container[key])
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator['RequestArguments']:
|
||||||
|
"""
|
||||||
|
Returns an iterator over the values in this instance. Values are represented as immutable views.
|
||||||
|
|
||||||
|
:return: An iterator that yields immutable views of the values.
|
||||||
|
"""
|
||||||
|
for ra in self.__container.values():
|
||||||
|
# Yield an immutable scalar view for each value
|
||||||
|
yield _View.of(ra)
|
||||||
|
|
||||||
|
def __contains__(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks whether an argument with a given name exists in the RequestArguments instance.
|
||||||
|
|
||||||
|
:param key: The name to check whether it exists.
|
||||||
|
:return: True, if present, False otherwise.
|
||||||
|
"""
|
||||||
|
return key in self.__container
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""
|
||||||
|
:return: The number of arguments in this instance.
|
||||||
|
"""
|
||||||
|
return len(self.__container)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestArgument(object):
|
||||||
|
"""
|
||||||
|
Container class for HTTP request arguments that simplifies dealing with
|
||||||
|
- scalar and array arguments:
|
||||||
|
Automatically converts between single values and arrays where necessary: Arrays with one element can be
|
||||||
|
accessed as scalars, and scalars can be iterated, yielding themselves as a single item.
|
||||||
|
- UTF-8 strings and binary data (e.g. file uploads):
|
||||||
|
All data can be retrieved both as a str (if utf-8 decoding is possible) and a bytes object.
|
||||||
|
|
||||||
|
The objects returned from iteration or indexing are immutable views of (parts of) this object.
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
|
||||||
|
qsargs = urllib.parse.parse_qs(qs, strict_parsing=True, keep_blank_values=True, errors='strict')
|
||||||
|
args: RequestArguments
|
||||||
|
for k, vs in qsargs:
|
||||||
|
args[k].clear()
|
||||||
|
for v in vs:
|
||||||
|
# text/plain usually is a sensible choice for values decoded from urlencoded strings
|
||||||
|
# IF ALREADY IN STRING FORM (which parse_qs does)!
|
||||||
|
args[k].append('text/plain', v)
|
||||||
|
|
||||||
|
if 'username' in args and args.username.is_scalar:
|
||||||
|
username = str(args.username)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
name: str,
|
||||||
|
value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]] = None) -> None:
|
||||||
|
"""
|
||||||
|
Create a new RequestArgument with a name and optionally an initial value.
|
||||||
|
|
||||||
|
:param name: The name for this argument, as provided via GET or POST.
|
||||||
|
:param value: The initial value, if any. Optional, initializes with empty array if omitted.
|
||||||
|
"""
|
||||||
|
# Assign name
|
||||||
|
self.__name: str = name
|
||||||
|
# Initialize value
|
||||||
|
self.__value: List[Tuple[str, Union[bytes, str]]] = []
|
||||||
|
# Default to empty array
|
||||||
|
if value is None:
|
||||||
|
self.__value = []
|
||||||
|
else:
|
||||||
|
if isinstance(value, list):
|
||||||
|
# Store the array
|
||||||
|
self.__value = value
|
||||||
|
else:
|
||||||
|
# Turn scalar into an array before storing
|
||||||
|
self.__value = [value]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_array(self) -> bool:
|
||||||
|
"""
|
||||||
|
:return: True, if the value is a (possibly empty) array, False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self.__value) != 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scalar(self) -> bool:
|
||||||
|
"""
|
||||||
|
:return: True, if the value is a single scalar value, False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self.__value) == 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_view(self) -> bool:
|
||||||
|
"""
|
||||||
|
:return: True, if this instance is an immutable view, False otherwise.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""
|
||||||
|
:return: The name of this argument.
|
||||||
|
"""
|
||||||
|
return self.__name
|
||||||
|
|
||||||
|
def get_str(self, index: int = 0) -> str:
|
||||||
|
"""
|
||||||
|
Attempts to return a value as a string. The index defaults to 0.
|
||||||
|
|
||||||
|
:param index: The index of the value to retrieve. Default: 0.
|
||||||
|
:return: An UTF-8 string representation of the requested value.
|
||||||
|
:raises UnicodeDecodeError: If the value cannot be decoded into an UTF-8 string.
|
||||||
|
:raises IndexError: If the index is out of bounds.
|
||||||
|
:raises TypeError: If the index is not an int.
|
||||||
|
:raises TypeError: If the requested value is neither a str nor a bytes object.
|
||||||
|
"""
|
||||||
|
if not isinstance(index, int):
|
||||||
|
# Index must be an int
|
||||||
|
raise TypeError('index must be an int')
|
||||||
|
# Type hint; access array element
|
||||||
|
v: Tuple[str, Union[bytes, str]] = self.__value[index]
|
||||||
|
if isinstance(v[1], str):
|
||||||
|
# The value already is a string, return
|
||||||
|
return v[1]
|
||||||
|
elif isinstance(v[1], bytes):
|
||||||
|
# The value is a bytes object, attempt to decode
|
||||||
|
return v[1].decode('utf-8')
|
||||||
|
raise TypeError('Value is neither a str nor bytes')
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""
|
||||||
|
Attempts to return the first value as a string.
|
||||||
|
:return: An UTF-8 string representation of the first value.
|
||||||
|
:raises UnicodeDecodeError: If the value cannot be decoded into an UTF-8 string.
|
||||||
|
"""
|
||||||
|
return self.get_str()
|
||||||
|
|
||||||
|
def get_bytes(self, index: int = 0) -> bytes:
|
||||||
|
"""
|
||||||
|
Attempts to return a value as a bytes object. The index defaults to 0.
|
||||||
|
|
||||||
|
:param index: The index of the value to retrieve. Default: 0.
|
||||||
|
:return: A bytes object representation of the requested value. Strings will be encoded as UTF-8.
|
||||||
|
:raises IndexError: If the index is out of bounds.
|
||||||
|
:raises TypeError: If the index is not an int.
|
||||||
|
:raises TypeError: If the requested value is neither a str nor a bytes object.
|
||||||
|
"""
|
||||||
|
if not isinstance(index, int):
|
||||||
|
# Index must be a int
|
||||||
|
raise TypeError('index must be an int')
|
||||||
|
# Type hint; access array element
|
||||||
|
v: Tuple[str, Union[bytes, str]] = self.__value[index]
|
||||||
|
if isinstance(v[1], bytes):
|
||||||
|
# The value already is a bytes object, return
|
||||||
|
return v[1]
|
||||||
|
elif isinstance(v[1], str):
|
||||||
|
# The value is a string, encode first
|
||||||
|
return v[1].encode('utf-8')
|
||||||
|
raise TypeError('Value is neither a str nor bytes')
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Attempts to return the first value as a bytes object.
|
||||||
|
:return: A bytes string representation of the first value.
|
||||||
|
"""
|
||||||
|
return self.get_bytes()
|
||||||
|
|
||||||
|
def get_content_type(self, index: int = 0) -> str:
|
||||||
|
"""
|
||||||
|
Attempts to retrieve a value's Content-Type. The index defaults to 0.
|
||||||
|
|
||||||
|
:param index: The index of the value to retrieve. Default: 0.
|
||||||
|
:return: The Content-Type of the requested value, as sent by the client. Not necessarily trustworthy.
|
||||||
|
:raises IndexError: If the index is out of bounds.
|
||||||
|
:raises TypeError: If the index is not an int.
|
||||||
|
"""
|
||||||
|
# instance is an array value
|
||||||
|
if not isinstance(index, int):
|
||||||
|
# Needs an index for array values
|
||||||
|
raise TypeError('index must be an int')
|
||||||
|
# Type hint; access array element
|
||||||
|
va: Tuple[str, Union[bytes, str]] = self.__value[index]
|
||||||
|
# Return the content type of the requested value
|
||||||
|
if not isinstance(va[0], str):
|
||||||
|
raise TypeError('Content-Type is not a str')
|
||||||
|
return va[0]
|
||||||
|
|
||||||
|
def append(self, ctype: str, value: Union[str, bytes]) -> None:
|
||||||
|
"""
|
||||||
|
Append a value to this instance. Turns an empty argument into a scalar and a scalar into an array.
|
||||||
|
|
||||||
|
:param ctype: The Content-Type, as provided in the request.
|
||||||
|
:param value: The scalar value to append, either a string or bytes object.
|
||||||
|
:raises TypeError: If called on an immutable view.
|
||||||
|
"""
|
||||||
|
if self.is_view:
|
||||||
|
# This is an immutable view, raise exception
|
||||||
|
raise TypeError('A RequestArgument view is immutable!')
|
||||||
|
self.__value.append((ctype, value))
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Remove all values from this instance.
|
||||||
|
|
||||||
|
:raises TypeError: If called on an immutable view.
|
||||||
|
"""
|
||||||
|
if self.is_view:
|
||||||
|
# This is an immutable view, raise exception
|
||||||
|
raise TypeError('A RequestArgument view is immutable!')
|
||||||
|
self.__value.clear()
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""
|
||||||
|
:return: Number of values for this argument.
|
||||||
|
"""
|
||||||
|
return len(self.__value)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator['RequestArgument']:
|
||||||
|
"""
|
||||||
|
Iterate the values of this argument. Each value is accessible as if it were a scalar RequestArgument in turn,
|
||||||
|
although they are immutable.
|
||||||
|
|
||||||
|
:return: An iterator that yields immutable views of the values.
|
||||||
|
"""
|
||||||
|
for v in self.__value:
|
||||||
|
# Yield an immutable scalar view for each (ctype, value) element in the array
|
||||||
|
yield _View(self.__name, v)
|
||||||
|
|
||||||
|
def __getitem__(self, index: Union[int, slice]) -> 'RequestArgument':
|
||||||
|
"""
|
||||||
|
Index the argument with either an int or a slice. The returned values are represented as immutable
|
||||||
|
RequestArgument views.
|
||||||
|
|
||||||
|
:param index: The index or slice.
|
||||||
|
:return: An immutable view of the indexed elements of this argument.
|
||||||
|
"""
|
||||||
|
# Pass the index or slice through to the array, packing the result in an immutable view
|
||||||
|
return _View(self.__name, self.__value[index])
|
||||||
|
|
||||||
|
|
||||||
|
class _View(RequestArgument):
|
||||||
|
"""
|
||||||
|
This class represents an immutable view of a (subset of a) RequestArgument object. Should not be instantiated
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]])\
|
||||||
|
-> None:
|
||||||
|
"""
|
||||||
|
Create a new immutable view of a (subset of a) RequestArgument.
|
||||||
|
|
||||||
|
:param name: The name for this argument, same as in the original RequestArgument.
|
||||||
|
:param value: The values to represent in this view, obtained by e.g. indexing or slicing.
|
||||||
|
"""
|
||||||
|
super().__init__(name, value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def of(argument: 'RequestArgument') ->'RequestArgument':
|
||||||
|
"""
|
||||||
|
Create an immutable, unsliced view of an RequestArgument instance.
|
||||||
|
|
||||||
|
:param argument: The RequestArgument instance to create a view of.
|
||||||
|
:return: An immutable view of the provided RequestArgument instance.
|
||||||
|
"""
|
||||||
|
return argument[:]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_view(self) -> bool:
|
||||||
|
"""
|
||||||
|
:return: True, if this instance is an immutable view, False otherwise.
|
||||||
|
"""
|
||||||
|
return True
|
|
@ -9,7 +9,7 @@ from abc import ABC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.server import HTTPServer
|
from http.server import HTTPServer
|
||||||
|
|
||||||
from matemat.webserver.httpd import pagelet
|
from matemat.webserver import pagelet, RequestArguments
|
||||||
|
|
||||||
|
|
||||||
class HttpResponse:
|
class HttpResponse:
|
||||||
|
@ -31,8 +31,8 @@ class HttpResponse:
|
||||||
'Content-Length': 0
|
'Content-Length': 0
|
||||||
}
|
}
|
||||||
self.pagelet: str = None
|
self.pagelet: str = None
|
||||||
# The response body. Only UTF-8 strings are supported
|
# The response body
|
||||||
self.body: str = ''
|
self.body: bytes = bytes()
|
||||||
# Parsing phase, one of 'begin', 'hdr', 'body' or 'done'
|
# Parsing phase, one of 'begin', 'hdr', 'body' or 'done'
|
||||||
self.parse_phase = 'begin'
|
self.parse_phase = 'begin'
|
||||||
# Buffer for uncompleted lines
|
# Buffer for uncompleted lines
|
||||||
|
@ -55,7 +55,7 @@ class HttpResponse:
|
||||||
return
|
return
|
||||||
# If in the body phase, simply decode and append to the body, while the body is not complete yet
|
# If in the body phase, simply decode and append to the body, while the body is not complete yet
|
||||||
elif self.parse_phase == 'body':
|
elif self.parse_phase == 'body':
|
||||||
self.body += fragment.decode('utf-8')
|
self.body += fragment
|
||||||
if len(self.body) >= int(self.headers['Content-Length']):
|
if len(self.body) >= int(self.headers['Content-Length']):
|
||||||
self.__finalize()
|
self.__finalize()
|
||||||
return
|
return
|
||||||
|
@ -66,24 +66,24 @@ class HttpResponse:
|
||||||
if not fragment.endswith(b'\r\n'):
|
if not fragment.endswith(b'\r\n'):
|
||||||
# Special treatment for no trailing CR-LF: Add remainder to buffer
|
# Special treatment for no trailing CR-LF: Add remainder to buffer
|
||||||
head, tail = fragment.rsplit(b'\r\n', 1)
|
head, tail = fragment.rsplit(b'\r\n', 1)
|
||||||
data: str = (self.buffer + head).decode('utf-8')
|
data: bytes = (self.buffer + head)
|
||||||
self.buffer = tail
|
self.buffer = tail
|
||||||
else:
|
else:
|
||||||
data: str = (self.buffer + fragment).decode('utf-8')
|
data: bytes = (self.buffer + fragment)
|
||||||
self.buffer = bytes()
|
self.buffer = bytes()
|
||||||
# Iterate the lines that are ready to be parsed
|
# Iterate the lines that are ready to be parsed
|
||||||
for line in data.split('\r\n'):
|
for line in data.split(b'\r\n'):
|
||||||
# The 'begin' phase indicates that the parser is waiting for the HTTP status line
|
# The 'begin' phase indicates that the parser is waiting for the HTTP status line
|
||||||
if self.parse_phase == 'begin':
|
if self.parse_phase == 'begin':
|
||||||
if line.startswith('HTTP/'):
|
if line.startswith(b'HTTP/'):
|
||||||
# Parse the statuscode and advance to header parsing
|
# Parse the statuscode and advance to header parsing
|
||||||
_, statuscode, _ = line.split(' ', 2)
|
_, statuscode, _ = line.decode('utf-8').split(' ', 2)
|
||||||
self.statuscode = int(statuscode)
|
self.statuscode = int(statuscode)
|
||||||
self.parse_phase = 'hdr'
|
self.parse_phase = 'hdr'
|
||||||
elif self.parse_phase == 'hdr':
|
elif self.parse_phase == 'hdr':
|
||||||
# Parse a header line and add it to the header dict
|
# Parse a header line and add it to the header dict
|
||||||
if len(line) > 0:
|
if len(line) > 0:
|
||||||
k, v = line.split(':', 1)
|
k, v = line.decode('utf-8').split(':', 1)
|
||||||
self.headers[k.strip()] = v.strip()
|
self.headers[k.strip()] = v.strip()
|
||||||
else:
|
else:
|
||||||
# Empty line separates header from body
|
# Empty line separates header from body
|
||||||
|
@ -156,12 +156,16 @@ class MockSocket(bytes):
|
||||||
|
|
||||||
def test_pagelet(path: str):
|
def test_pagelet(path: str):
|
||||||
|
|
||||||
def with_testing_headers(fun: Callable[[str, str, Dict[str, str], Dict[str, Any], Dict[str, str]],
|
def with_testing_headers(fun: Callable[[str,
|
||||||
|
str,
|
||||||
|
RequestArguments,
|
||||||
|
Dict[str, Any],
|
||||||
|
Dict[str, str]],
|
||||||
Tuple[int, Union[bytes, str]]]):
|
Tuple[int, Union[bytes, str]]]):
|
||||||
@pagelet(path)
|
@pagelet(path)
|
||||||
def testing_wrapper(method: str,
|
def testing_wrapper(method: str,
|
||||||
path: str,
|
path: str,
|
||||||
args: Dict[str, str],
|
args: RequestArguments,
|
||||||
session_vars: Dict[str, Any],
|
session_vars: Dict[str, Any],
|
||||||
headers: Dict[str, str]):
|
headers: Dict[str, str]):
|
||||||
status, body = fun(method, path, args, session_vars, headers)
|
status, body = fun(method, path, args, session_vars, headers)
|
||||||
|
|
347
matemat/webserver/test/test_parse_request.py
Normal file
347
matemat/webserver/test/test_parse_request.py
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from matemat.webserver.util import parse_args
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseRequest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_parse_get_root(self):
|
||||||
|
path, args = parse_args('/')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(0, len(args))
|
||||||
|
|
||||||
|
def test_parse_get_no_args(self):
|
||||||
|
path, args = parse_args('/index.html')
|
||||||
|
self.assertEqual('/index.html', path)
|
||||||
|
self.assertEqual(0, len(args))
|
||||||
|
|
||||||
|
def test_parse_get_root_getargs(self):
|
||||||
|
path, args = parse_args('/?foo=42&bar=1337&baz=Hello,%20World!')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(3, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual('text/plain', args['foo'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['bar'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['baz'].get_content_type())
|
||||||
|
self.assertEqual('42', args['foo'].get_str())
|
||||||
|
self.assertEqual('1337', args['bar'].get_str())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
||||||
|
|
||||||
|
def test_parse_get_getargs(self):
|
||||||
|
path, args = parse_args('/abc/def?foo=42&bar=1337&baz=Hello,%20World!')
|
||||||
|
self.assertEqual('/abc/def', path)
|
||||||
|
self.assertEqual(3, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual('text/plain', args['foo'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['bar'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['baz'].get_content_type())
|
||||||
|
self.assertEqual('42', args['foo'].get_str())
|
||||||
|
self.assertEqual('1337', args['bar'].get_str())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
||||||
|
|
||||||
|
def test_parse_get_getarray(self):
|
||||||
|
path, args = parse_args('/abc/def?foo=42&foo=1337&baz=Hello,%20World!')
|
||||||
|
self.assertEqual('/abc/def', path)
|
||||||
|
self.assertEqual(2, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_array)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual(2, len(args['foo']))
|
||||||
|
self.assertEqual('42', args['foo'].get_str(0))
|
||||||
|
self.assertEqual('1337', args['foo'].get_str(1))
|
||||||
|
|
||||||
|
def test_parse_get_zero_arg(self):
|
||||||
|
path, args = parse_args('/abc/def?foo=&bar=42')
|
||||||
|
self.assertEqual(2, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertEqual(1, len(args['foo']))
|
||||||
|
self.assertEqual('', args['foo'].get_str())
|
||||||
|
self.assertEqual('42', args['bar'].get_str())
|
||||||
|
|
||||||
|
def test_parse_get_urlencoded_encoding_fail(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_args('/?foo=42&bar=%80&baz=Hello,%20World!')
|
||||||
|
|
||||||
|
def test_parse_post_urlencoded(self):
|
||||||
|
path, args = parse_args('/',
|
||||||
|
postbody=b'foo=42&bar=1337&baz=Hello,%20World!',
|
||||||
|
enctype='application/x-www-form-urlencoded')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(3, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual('text/plain', args['foo'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['bar'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['baz'].get_content_type())
|
||||||
|
self.assertEqual('42', args['foo'].get_str())
|
||||||
|
self.assertEqual('1337', args['bar'].get_str())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
||||||
|
|
||||||
|
def test_parse_post_urlencoded_array(self):
|
||||||
|
path, args = parse_args('/',
|
||||||
|
postbody=b'foo=42&foo=1337&baz=Hello,%20World!',
|
||||||
|
enctype='application/x-www-form-urlencoded')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(2, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_array)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual(2, len(args['foo']))
|
||||||
|
self.assertEqual('42', args['foo'].get_str(0))
|
||||||
|
self.assertEqual('1337', args['foo'].get_str(1))
|
||||||
|
|
||||||
|
def test_parse_post_urlencoded_zero_arg(self):
|
||||||
|
path, args = parse_args('/abc/def', postbody=b'foo=&bar=42', enctype='application/x-www-form-urlencoded')
|
||||||
|
self.assertEqual(2, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertEqual(1, len(args['foo']))
|
||||||
|
self.assertEqual('', args['foo'].get_str())
|
||||||
|
self.assertEqual('42', args['bar'].get_str())
|
||||||
|
|
||||||
|
def test_parse_post_urlencoded_encoding_fail(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'foo=42&bar=%80&baz=Hello,%20World!',
|
||||||
|
enctype='application/x-www-form-urlencoded')
|
||||||
|
|
||||||
|
def test_parse_post_multipart_no_args(self):
|
||||||
|
path, args = parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(0, len(args))
|
||||||
|
|
||||||
|
def test_parse_post_multipart(self):
|
||||||
|
path, args = parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
self.assertEqual('/', path)
|
||||||
|
self.assertEqual(3, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertTrue(args['baz'].is_scalar)
|
||||||
|
self.assertEqual('text/plain', args['foo'].get_content_type())
|
||||||
|
self.assertEqual('application/octet-stream', args['bar'].get_content_type())
|
||||||
|
self.assertEqual('text/plain', args['baz'].get_content_type())
|
||||||
|
self.assertEqual('42', args['foo'].get_str())
|
||||||
|
self.assertEqual(b'1337', args['bar'].get_bytes())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
||||||
|
|
||||||
|
def test_parse_post_multipart_zero_arg(self):
|
||||||
|
path, args = parse_args('/abc/def',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
self.assertEqual(2, len(args))
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertTrue(args['foo'].is_scalar)
|
||||||
|
self.assertTrue(args['bar'].is_scalar)
|
||||||
|
self.assertEqual(1, len(args['foo']))
|
||||||
|
self.assertEqual('', args['foo'].get_str())
|
||||||
|
self.assertEqual('42', args['bar'].get_str())
|
||||||
|
|
||||||
|
def test_parse_post_multipart_broken_boundaries(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Boundary not defined in Content-Type
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Corrupted "--" head at first boundary
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'-+testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Missing "--" tail at end boundary
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Missing Content-Type header in one part
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Missing Content-Disposition header in one part
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Missing form-data name argument
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# Unknown Content-Disposition
|
||||||
|
parse_args('/',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: attachment; name="bar"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
|
||||||
|
def test_get_post_precedence_urlencoded(self):
|
||||||
|
path, args = parse_args('/foo?foo=thisshouldnotbethere&bar=isurvived',
|
||||||
|
postbody=b'foo=42&foo=1337&baz=Hello,%20World!',
|
||||||
|
enctype='application/x-www-form-urlencoded')
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertEqual(2, len(args['foo']))
|
||||||
|
self.assertEqual(1, len(args['bar']))
|
||||||
|
self.assertEqual(1, len(args['baz']))
|
||||||
|
self.assertEqual('42', args['foo'].get_str(0))
|
||||||
|
self.assertEqual('1337', args['foo'].get_str(1))
|
||||||
|
self.assertEqual('isurvived', args['bar'].get_str())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
||||||
|
|
||||||
|
def test_get_post_precedence_multipart(self):
|
||||||
|
path, args = parse_args('/foo?foo=thisshouldnotbethere&bar=isurvived',
|
||||||
|
postbody=b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'42\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"; filename="bar.bin"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'1337\r\n'
|
||||||
|
b'--testBoundary1337\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="baz"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'--testBoundary1337--\r\n',
|
||||||
|
enctype='multipart/form-data; boundary=testBoundary1337')
|
||||||
|
self.assertIn('foo', args)
|
||||||
|
self.assertIn('bar', args)
|
||||||
|
self.assertIn('baz', args)
|
||||||
|
self.assertEqual(2, len(args['foo']))
|
||||||
|
self.assertEqual(1, len(args['bar']))
|
||||||
|
self.assertEqual(1, len(args['baz']))
|
||||||
|
self.assertEqual('42', args['foo'].get_str(0))
|
||||||
|
self.assertEqual('1337', args['foo'].get_str(1))
|
||||||
|
self.assertEqual('isurvived', args['bar'].get_str())
|
||||||
|
self.assertEqual('Hello, World!', args['baz'].get_str())
|
|
@ -1,14 +1,16 @@
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from matemat.webserver.httpd import HttpHandler
|
from matemat.webserver import HttpHandler, RequestArguments
|
||||||
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
|
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
|
||||||
|
|
||||||
@test_pagelet('/just/testing/post')
|
@test_pagelet('/just/testing/post')
|
||||||
def post_test_pagelet(method: str,
|
def post_test_pagelet(method: str,
|
||||||
path: str,
|
path: str,
|
||||||
args: Dict[str, str],
|
args: RequestArguments,
|
||||||
session_vars: Dict[str, Any],
|
session_vars: Dict[str, Any],
|
||||||
headers: Dict[str, str]):
|
headers: Dict[str, str]):
|
||||||
"""
|
"""
|
||||||
|
@ -16,8 +18,12 @@ def post_test_pagelet(method: str,
|
||||||
"""
|
"""
|
||||||
headers['Content-Type'] = 'text/plain'
|
headers['Content-Type'] = 'text/plain'
|
||||||
dump: str = ''
|
dump: str = ''
|
||||||
for k, v in args.items():
|
for ra in args:
|
||||||
dump += f'{k}: {v if isinstance(v, str) else ",".join(v)}\n'
|
for a in ra:
|
||||||
|
if a.get_content_type().startswith('text/'):
|
||||||
|
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 200, dump
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,7 +32,7 @@ class TestPost(AbstractHttpdTest):
|
||||||
Test cases for the content serving of the web server.
|
Test cases for the content serving of the web server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_post_get_only_args(self):
|
def test_post_urlenc_get_only_args(self):
|
||||||
"""
|
"""
|
||||||
Test a POST request that only contains GET arguments.
|
Test a POST request that only contains GET arguments.
|
||||||
"""
|
"""
|
||||||
|
@ -38,17 +44,17 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
||||||
|
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
self.assertEqual('bar', kv['foo'])
|
self.assertEqual('bar', kv['foo'])
|
||||||
self.assertEqual('1', kv['test'])
|
self.assertEqual('1', kv['test'])
|
||||||
|
|
||||||
def test_post_post_only_args(self):
|
def test_post_urlenc_post_only_args(self):
|
||||||
"""
|
"""
|
||||||
Test a POST request that only contains POST arguments (urlencoded).
|
Test a POST request that only contains POST arguments (urlencoded).
|
||||||
"""
|
"""
|
||||||
|
@ -61,17 +67,17 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
||||||
|
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
self.assertEqual('bar', kv['foo'])
|
self.assertEqual('bar', kv['foo'])
|
||||||
self.assertEqual('1', kv['test'])
|
self.assertEqual('1', kv['test'])
|
||||||
|
|
||||||
def test_post_mixed_args(self):
|
def test_post_urlenc_mixed_args(self):
|
||||||
"""
|
"""
|
||||||
Test that mixed POST and GET args are properly parsed, and that POST takes precedence over GET.
|
Test that mixed POST and GET args are properly parsed, and that POST takes precedence over GET.
|
||||||
"""
|
"""
|
||||||
|
@ -84,10 +90,10 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
||||||
|
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
|
@ -95,7 +101,7 @@ class TestPost(AbstractHttpdTest):
|
||||||
self.assertEqual('1', kv['gettest'])
|
self.assertEqual('1', kv['gettest'])
|
||||||
self.assertEqual('2', kv['posttest'])
|
self.assertEqual('2', kv['posttest'])
|
||||||
|
|
||||||
def test_post_get_array(self):
|
def test_post_urlenc_get_array(self):
|
||||||
"""
|
"""
|
||||||
Test a POST request that contains GET array arguments.
|
Test a POST request that contains GET array arguments.
|
||||||
"""
|
"""
|
||||||
|
@ -107,17 +113,21 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
k = k.strip()
|
||||||
|
v = v.strip()
|
||||||
|
if k in kv:
|
||||||
|
kv[k] += f',{v}'
|
||||||
|
else:
|
||||||
|
kv[k] = v
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
self.assertListEqual(['bar', 'baz'], kv['foo'])
|
self.assertEqual('bar,baz', kv['foo'])
|
||||||
self.assertEqual('1', kv['test'])
|
self.assertEqual('1', kv['test'])
|
||||||
|
|
||||||
def test_post_post_array(self):
|
def test_post_urlenc_post_array(self):
|
||||||
"""
|
"""
|
||||||
Test a POST request that contains POST array arguments.
|
Test a POST request that contains POST array arguments.
|
||||||
"""
|
"""
|
||||||
|
@ -130,17 +140,21 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
k = k.strip()
|
||||||
|
v = v.strip()
|
||||||
|
if k in kv:
|
||||||
|
kv[k] += f',{v}'
|
||||||
|
else:
|
||||||
|
kv[k] = v
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
self.assertListEqual(['bar', 'baz'], kv['foo'])
|
self.assertEqual('bar,baz', kv['foo'])
|
||||||
self.assertEqual('1', kv['test'])
|
self.assertEqual('1', kv['test'])
|
||||||
|
|
||||||
def test_post_mixed_array(self):
|
def test_post_urlenc_mixed_array(self):
|
||||||
"""
|
"""
|
||||||
Test a POST request that contains both GET and POST array arguments.
|
Test a POST request that contains both GET and POST array arguments.
|
||||||
"""
|
"""
|
||||||
|
@ -153,13 +167,58 @@ class TestPost(AbstractHttpdTest):
|
||||||
packet = self.client_sock.get_response()
|
packet = self.client_sock.get_response()
|
||||||
|
|
||||||
# Parse response body
|
# Parse response body
|
||||||
lines: List[str] = packet.body.split('\n')[:-1]
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
kv: Dict[str, str] = dict()
|
kv: Dict[str, str] = dict()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
k, v = l.split(':', 1)
|
k, v = l.decode('utf-8').split(':', 1)
|
||||||
kv[k.strip()] = v.strip() if ',' not in v else v.strip().split(',')
|
k = k.strip()
|
||||||
|
v = v.strip()
|
||||||
|
if k in kv:
|
||||||
|
kv[k] += f',{v}'
|
||||||
|
else:
|
||||||
|
kv[k] = v
|
||||||
# Make sure the arguments were properly parsed
|
# Make sure the arguments were properly parsed
|
||||||
self.assertListEqual(['postbar', 'postbaz'], kv['foo'])
|
self.assertEqual('postbar,postbaz', kv['foo'])
|
||||||
self.assertListEqual(['1', '42'], kv['gettest'])
|
self.assertEqual('1,42', kv['gettest'])
|
||||||
self.assertListEqual(['1', '2'], kv['posttest'])
|
self.assertEqual('1,2', kv['posttest'])
|
||||||
|
|
||||||
|
def test_post_no_body(self):
|
||||||
|
"""
|
||||||
|
Test a POST request that contains no headers or body.
|
||||||
|
"""
|
||||||
|
# Send POST request
|
||||||
|
self.client_sock.set_request(b'POST /just/testing/post?foo=bar HTTP/1.1\r\n\r\n')
|
||||||
|
HttpHandler(self.client_sock, ('::1', 45678), self.server)
|
||||||
|
packet = self.client_sock.get_response()
|
||||||
|
# Make sure a 400 Bad Request is returned
|
||||||
|
self.assertEqual(400, packet.statuscode)
|
||||||
|
|
||||||
|
def test_post_multipart_post_only(self):
|
||||||
|
"""
|
||||||
|
Test a POST request with a miutipart/form-data body.
|
||||||
|
"""
|
||||||
|
# Send POST request
|
||||||
|
formdata = (b'------testboundary\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="foo"\r\n'
|
||||||
|
b'Content-Type: text/plain\r\n\r\n'
|
||||||
|
b'Hello, World!\r\n'
|
||||||
|
b'------testboundary\r\n'
|
||||||
|
b'Content-Disposition: form-data; name="bar"; filename="foo.bar"\r\n'
|
||||||
|
b'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x80\x0b\x0c\x73\x0e\x0f\r\n'
|
||||||
|
b'------testboundary--\r\n')
|
||||||
|
|
||||||
|
self.client_sock.set_request(f'POST /just/testing/post HTTP/1.1\r\n'
|
||||||
|
f'Content-Type: multipart/form-data; boundary=----testboundary\r\n'
|
||||||
|
f'Content-Length: {len(formdata)}\r\n\r\n'.encode('utf-8') + formdata)
|
||||||
|
HttpHandler(self.client_sock, ('::1', 45678), self.server)
|
||||||
|
packet = self.client_sock.get_response()
|
||||||
|
lines: List[bytes] = packet.body.split(b'\n')[:-1]
|
||||||
|
kv: Dict[str, Any] = dict()
|
||||||
|
for l in lines:
|
||||||
|
k, v = l.split(b':', 1)
|
||||||
|
kv[k.decode('utf-8').strip()] = v.strip()
|
||||||
|
self.assertIn('foo', kv)
|
||||||
|
self.assertIn('bar', kv)
|
||||||
|
self.assertEqual(kv['foo'], b'Hello, World!')
|
||||||
|
self.assertEqual(kv['bar'], b'00010203040506070809800b0c730e0f')
|
||||||
|
|
529
matemat/webserver/test/test_requestargs.py
Normal file
529
matemat/webserver/test/test_requestargs.py
Normal file
|
@ -0,0 +1,529 @@
|
||||||
|
|
||||||
|
from typing import Dict, List, Set, Tuple
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from matemat.webserver import RequestArgument, RequestArguments
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
from matemat.webserver.requestargs import _View
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestArguments(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Test cases for the RequestArgument class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_create_default(self):
|
||||||
|
"""
|
||||||
|
Test creation of an empty RequestArgument
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
# Name must be set to 1st argument
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
# Must be a 0-length array
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
self.assertTrue(ra.is_array)
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_create_str_scalar(self):
|
||||||
|
"""
|
||||||
|
Test creation of a scalar RequestArgument with string value.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('text/plain', 'bar'))
|
||||||
|
# Name must be set to 1st argument
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
# Must be a scalar, length 1
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
self.assertFalse(ra.is_array)
|
||||||
|
# Scalar value must be representable both as str and bytes
|
||||||
|
self.assertEqual('bar', ra.get_str())
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes())
|
||||||
|
# Content-Type must be set correctly
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type())
|
||||||
|
# Using 0 indices must yield the same results
|
||||||
|
self.assertEqual('bar', ra.get_str(0))
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes(0))
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type(0))
|
||||||
|
# Using other indices must result in an error
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_str(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_bytes(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_content_type(1)
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_create_str_scalar_array(self):
|
||||||
|
"""
|
||||||
|
Test creation of a scalar RequestArgument with string value, passing an array instead of a single tuple.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [('text/plain', 'bar')])
|
||||||
|
# Name must be set to 1st argument
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
# Must be a scalar, length 1
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
self.assertFalse(ra.is_array)
|
||||||
|
# Scalar value must be representable both as str and bytes
|
||||||
|
self.assertEqual('bar', ra.get_str())
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes())
|
||||||
|
# Content-Type must be set correctly
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type())
|
||||||
|
# Using 0 indices must yield the same results
|
||||||
|
self.assertEqual('bar', ra.get_str(0))
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes(0))
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type(0))
|
||||||
|
# Using other indices must result in an error
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_str(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_bytes(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_content_type(1)
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_create_bytes_scalar(self):
|
||||||
|
"""
|
||||||
|
Test creation of a scalar RequestArgument with bytes value.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('application/octet-stream', b'\x00\x80\xff\xfe'))
|
||||||
|
# Name must be set to 1st argument
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
# Must be a scalar, length 1
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
self.assertFalse(ra.is_array)
|
||||||
|
# Conversion to UTF-8 string must fail; bytes representation must work
|
||||||
|
with self.assertRaises(UnicodeDecodeError):
|
||||||
|
ra.get_str()
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes())
|
||||||
|
# Content-Type must be set correctly
|
||||||
|
self.assertEqual('application/octet-stream', ra.get_content_type())
|
||||||
|
# Using 0 indices must yield the same results
|
||||||
|
with self.assertRaises(UnicodeDecodeError):
|
||||||
|
ra.get_str(0)
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(0))
|
||||||
|
self.assertEqual('application/octet-stream', ra.get_content_type(0))
|
||||||
|
# Using other indices must result in an error
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_str(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_bytes(1)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_content_type(1)
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_create_array(self):
|
||||||
|
"""
|
||||||
|
Test creation of an array RequestArgument with mixed str and bytes initial value.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [
|
||||||
|
('text/plain', 'bar'),
|
||||||
|
('application/octet-stream', b'\x00\x80\xff\xfe')
|
||||||
|
])
|
||||||
|
# Name must be set to 1st argument
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
# Must be an array, length 2
|
||||||
|
self.assertEqual(2, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
self.assertTrue(ra.is_array)
|
||||||
|
# Retrieving values without an index must yield the first element
|
||||||
|
self.assertEqual('bar', ra.get_str())
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes())
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type())
|
||||||
|
# The first value must be representable both as str and bytes, and have ctype text/plain
|
||||||
|
self.assertEqual('bar', ra.get_str(0))
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes(0))
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type(0))
|
||||||
|
# Conversion of the second value to UTF-8 string must fail; bytes representation must work
|
||||||
|
with self.assertRaises(UnicodeDecodeError):
|
||||||
|
ra.get_str(1)
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
|
||||||
|
# The second value's ctype must be correct
|
||||||
|
self.assertEqual('application/octet-stream', ra.get_content_type(1))
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_append_empty_str(self):
|
||||||
|
"""
|
||||||
|
Test appending a str value to an empty RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the empty RequestArgument
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
|
||||||
|
# Append a string value
|
||||||
|
ra.append('text/plain', 'bar')
|
||||||
|
# New length must be 1, empty array must be converted to scalar
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
# Retrieval of the new value must work both in str and bytes representation
|
||||||
|
self.assertEqual('bar', ra.get_str())
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes())
|
||||||
|
# Content type of the new value must be correct
|
||||||
|
self.assertEqual('text/plain', ra.get_content_type())
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_append_empty_bytes(self):
|
||||||
|
"""
|
||||||
|
Test appending a bytes value to an empty RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the empty RequestArgument
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
|
||||||
|
# Append a bytes value
|
||||||
|
ra.append('application/octet-stream', b'\x00\x80\xff\xfe')
|
||||||
|
# New length must be 1, empty array must be converted to scalar
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
# Conversion of the new value to UTF-8 string must fail; bytes representation must work
|
||||||
|
with self.assertRaises(UnicodeDecodeError):
|
||||||
|
ra.get_str()
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes())
|
||||||
|
# Content type of the new value must be correct
|
||||||
|
self.assertEqual('application/octet-stream', ra.get_content_type())
|
||||||
|
# Must not be a view
|
||||||
|
self.assertFalse(ra.is_view)
|
||||||
|
|
||||||
|
def test_append_multiple(self):
|
||||||
|
"""
|
||||||
|
Test appending multiple values to an empty RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the empty RequestArgument
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
|
||||||
|
# Append a first value
|
||||||
|
ra.append('text/plain', 'bar')
|
||||||
|
# New length must be 1, empty array must be converted to scalar
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes())
|
||||||
|
|
||||||
|
# Append a second value
|
||||||
|
ra.append('application/octet-stream', b'\x00\x80\xff\xfe')
|
||||||
|
# New length must be 2, scalar must be converted to array
|
||||||
|
self.assertEqual(2, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes(0))
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
|
||||||
|
|
||||||
|
# Append a third value
|
||||||
|
ra.append('text/plain', 'Hello, World!')
|
||||||
|
# New length must be 3, array must remain array
|
||||||
|
self.assertEqual(3, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
self.assertEqual(b'bar', ra.get_bytes(0))
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', ra.get_bytes(1))
|
||||||
|
self.assertEqual(b'Hello, World!', ra.get_bytes(2))
|
||||||
|
|
||||||
|
def test_clear_empty(self):
|
||||||
|
"""
|
||||||
|
Test clearing an empty RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the empty RequestArgument
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
ra.clear()
|
||||||
|
# Clearing an empty RequestArgument shouldn't have any effect
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
|
||||||
|
def test_clear_scalar(self):
|
||||||
|
"""
|
||||||
|
Test clearing a scalar RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the scalar RequestArgument
|
||||||
|
ra = RequestArgument('foo', ('text/plain', 'bar'))
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
ra.clear()
|
||||||
|
# Clearing a scalar RequestArgument should reduce its size to 0
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_str()
|
||||||
|
|
||||||
|
def test_clear_array(self):
|
||||||
|
"""
|
||||||
|
Test clearing an array RequestArgument.
|
||||||
|
"""
|
||||||
|
# Initialize the array RequestArgument
|
||||||
|
ra = RequestArgument('foo', [
|
||||||
|
('text/plain', 'bar'),
|
||||||
|
('application/octet-stream', b'\x00\x80\xff\xfe'),
|
||||||
|
('text/plain', 'baz'),
|
||||||
|
])
|
||||||
|
self.assertEqual(3, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
ra.clear()
|
||||||
|
# Clearing an array RequestArgument should reduce its size to 0
|
||||||
|
self.assertEqual('foo', ra.name)
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
ra.get_str()
|
||||||
|
|
||||||
|
def test_iterate_empty(self):
|
||||||
|
"""
|
||||||
|
Test iterating an empty RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo')
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
# No value must be yielded from iterating an empty instance
|
||||||
|
for _ in ra:
|
||||||
|
self.fail()
|
||||||
|
|
||||||
|
def test_iterate_scalar(self):
|
||||||
|
"""
|
||||||
|
Test iterating a scalar RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('text/plain', 'bar'))
|
||||||
|
self.assertTrue(ra.is_scalar)
|
||||||
|
# Counter for the number of iterations
|
||||||
|
count: int = 0
|
||||||
|
for it in ra:
|
||||||
|
# Make sure the yielded value is a scalar view and has the same name as the original instance
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
self.assertTrue(it.is_view)
|
||||||
|
self.assertEqual('foo', it.name)
|
||||||
|
self.assertTrue(it.is_scalar)
|
||||||
|
count += 1
|
||||||
|
# Only one value must be yielded from iterating a scalar instance
|
||||||
|
self.assertEqual(1, count)
|
||||||
|
|
||||||
|
def test_iterate_array(self):
|
||||||
|
"""
|
||||||
|
Test iterating an array RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [('text/plain', 'bar'), ('abc', b'def'), ('xyz', '1337')])
|
||||||
|
self.assertFalse(ra.is_scalar)
|
||||||
|
# Container to put the iterated ctypes into
|
||||||
|
items: List[str] = list()
|
||||||
|
for it in ra:
|
||||||
|
# Make sure the yielded values are scalar views and have the same name as the original instance
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
self.assertTrue(it.is_view)
|
||||||
|
self.assertTrue(it.is_scalar)
|
||||||
|
# Collect the value's ctype
|
||||||
|
items.append(it.get_content_type())
|
||||||
|
# Compare collected ctypes with expected result
|
||||||
|
self.assertEqual(['text/plain', 'abc', 'xyz'], items)
|
||||||
|
|
||||||
|
def test_slice(self):
|
||||||
|
"""
|
||||||
|
Test slicing an array RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd'), ('e', 'f'), ('g', 'h'), ('i', 'j'), ('k', 'l')])
|
||||||
|
# Create the sliced view
|
||||||
|
sliced = ra[1:4:2]
|
||||||
|
# Make sure the sliced value is a view
|
||||||
|
self.assertIsInstance(sliced, _View)
|
||||||
|
self.assertTrue(sliced.is_view)
|
||||||
|
# Make sure the slice has the same name
|
||||||
|
self.assertEqual('foo', sliced.name)
|
||||||
|
# Make sure the slice has the expected shape (array of the 2nd and 4th scalar in the original)
|
||||||
|
self.assertTrue(sliced.is_array)
|
||||||
|
self.assertEqual(2, len(sliced))
|
||||||
|
self.assertEqual('d', sliced.get_str(0))
|
||||||
|
self.assertEqual('h', sliced.get_str(1))
|
||||||
|
|
||||||
|
def test_iterate_sliced(self):
|
||||||
|
"""
|
||||||
|
Test iterating a sliced array RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd'), ('e', 'f'), ('g', 'h'), ('i', 'j'), ('k', 'l')])
|
||||||
|
# Container to put the iterated ctypes into
|
||||||
|
items: List[str] = list()
|
||||||
|
# Iterate the sliced view
|
||||||
|
for it in ra[1:4:2]:
|
||||||
|
# Make sure the yielded values are scalar views and have the same name as the original instance
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
self.assertTrue(it.is_view)
|
||||||
|
self.assertEqual('foo', it.name)
|
||||||
|
self.assertTrue(it.is_scalar)
|
||||||
|
items.append(it.get_content_type())
|
||||||
|
# Make sure the expected values are collected (array of the 2nd and 4th scalar in the original)
|
||||||
|
self.assertEqual(['c', 'g'], items)
|
||||||
|
|
||||||
|
def test_index_scalar(self):
|
||||||
|
"""
|
||||||
|
Test indexing of a scalar RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('bar', 'baz'))
|
||||||
|
# Index the scalar RequestArgument instance, obtaining an immutable view
|
||||||
|
it = ra[0]
|
||||||
|
# Make sure the value is a scalar view with the same properties as the original instance
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
self.assertTrue(it.is_scalar)
|
||||||
|
self.assertEqual('foo', it.name)
|
||||||
|
self.assertEqual('bar', it.get_content_type())
|
||||||
|
self.assertEqual('baz', it.get_str())
|
||||||
|
# Make sure other indices don't work
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
_ = ra[1]
|
||||||
|
|
||||||
|
def test_index_array(self):
|
||||||
|
"""
|
||||||
|
Test indexing of an array RequestArgument.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', [('a', 'b'), ('c', 'd')])
|
||||||
|
# Index the array RequestArgument instance, obtaining an immutable view
|
||||||
|
it = ra[1]
|
||||||
|
# Make sure the value is a scalar view with the same properties as the value in the original instance
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
self.assertEqual('foo', it.name)
|
||||||
|
self.assertEqual('c', it.get_content_type())
|
||||||
|
self.assertEqual('d', it.get_str())
|
||||||
|
|
||||||
|
def test_view_immutable(self):
|
||||||
|
"""
|
||||||
|
Test immutability of views.
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('bar', 'baz'))
|
||||||
|
# Index the scalar RequestArgument instance, obtaining an immutable view
|
||||||
|
it = ra[0]
|
||||||
|
# Make sure the returned value is a view
|
||||||
|
self.assertIsInstance(it, _View)
|
||||||
|
# Make sure the returned value is immutable
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
it.append('foo', 'bar')
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
it.clear()
|
||||||
|
|
||||||
|
def test_str_shorthand(self):
|
||||||
|
"""
|
||||||
|
Test the shorthand for get_str(0).
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('bar', 'baz'))
|
||||||
|
self.assertEqual('baz', str(ra))
|
||||||
|
|
||||||
|
def test_bytes_shorthand(self):
|
||||||
|
"""
|
||||||
|
Test the shorthand for get_bytes(0).
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', ('bar', b'\x00\x80\xff\xfe'))
|
||||||
|
self.assertEqual(b'\x00\x80\xff\xfe', bytes(ra))
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
def test_insert_garbage(self):
|
||||||
|
"""
|
||||||
|
Test proper handling with non-int indices and non-str/non-bytes data
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
ra = RequestArgument('foo', 42)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
str(ra)
|
||||||
|
ra = RequestArgument('foo', (None, 42))
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
str(ra)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
bytes(ra)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
ra.get_content_type()
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
ra.get_str('foo')
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
ra.get_bytes('foo')
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
ra.get_content_type('foo')
|
||||||
|
|
||||||
|
def test_requestarguments_index(self):
|
||||||
|
"""
|
||||||
|
Make sure indexing a RequestArguments instance creates a new entry on the fly.
|
||||||
|
"""
|
||||||
|
ra = RequestArguments()
|
||||||
|
self.assertEqual(0, len(ra))
|
||||||
|
self.assertFalse('foo' in ra)
|
||||||
|
# Create new entry
|
||||||
|
_ = ra['foo']
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
self.assertTrue('foo' in ra)
|
||||||
|
# Already exists, no new entry created
|
||||||
|
_ = ra['foo']
|
||||||
|
self.assertEqual(1, len(ra))
|
||||||
|
# Entry must be empty and mutable, and have the correct name
|
||||||
|
self.assertFalse(ra['foo'].is_view)
|
||||||
|
self.assertEqual(0, len(ra['foo']))
|
||||||
|
self.assertEqual('foo', ra['foo'].name)
|
||||||
|
# Key must be a string
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
_ = ra[42]
|
||||||
|
|
||||||
|
def test_requestarguments_attr(self):
|
||||||
|
"""
|
||||||
|
Test attribute access syntactic sugar.
|
||||||
|
"""
|
||||||
|
ra = RequestArguments()
|
||||||
|
# Attribute should not exist yet
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
_ = ra.foo
|
||||||
|
# Create entry
|
||||||
|
_ = ra['foo']
|
||||||
|
# Creating entry should have created the attribute
|
||||||
|
self.assertEqual('foo', ra.foo.name)
|
||||||
|
# Attribute access should yield an immutable view
|
||||||
|
self.assertTrue(ra.foo.is_view)
|
||||||
|
|
||||||
|
def test_requestarguments_iterate(self):
|
||||||
|
"""
|
||||||
|
Test iterating a RequestArguments instance.
|
||||||
|
"""
|
||||||
|
# Create an instance with some values
|
||||||
|
ra = RequestArguments()
|
||||||
|
ra['foo'].append('a', 'b')
|
||||||
|
ra['bar'].append('c', 'd')
|
||||||
|
ra['foo'].append('e', 'f')
|
||||||
|
# Container for test values (name, value)
|
||||||
|
items: Set[Tuple[str, str]] = set()
|
||||||
|
# Iterate RequestArguments instance, adding the name and value of each to the set
|
||||||
|
for a in ra:
|
||||||
|
items.add((a.name, str(a)))
|
||||||
|
# Compare result with expected value
|
||||||
|
self.assertEqual(2, len(items))
|
||||||
|
self.assertIn(('foo', 'b'), items)
|
||||||
|
self.assertIn(('bar', 'd'), items)
|
||||||
|
|
||||||
|
def test_requestarguments_full_use_case(self):
|
||||||
|
"""
|
||||||
|
Simulate a minimal RequestArguments use case.
|
||||||
|
"""
|
||||||
|
# Create empty RequestArguments instance
|
||||||
|
ra = RequestArguments()
|
||||||
|
# Parse GET request
|
||||||
|
getargs: Dict[str, List[str]] = urllib.parse.parse_qs('foo=42&bar=1337&foo=43&baz=Hello,%20World!')
|
||||||
|
# Insert GET arguments into RequestArguments
|
||||||
|
for k, vs in getargs.items():
|
||||||
|
for v in vs:
|
||||||
|
ra[k].append('text/plain', v)
|
||||||
|
# Parse POST request
|
||||||
|
postargs: Dict[str, List[str]] = urllib.parse.parse_qs('foo=postfoo&postbar=42&foo=postfoo')
|
||||||
|
# Insert POST arguments into RequestArguments
|
||||||
|
for k, vs in postargs.items():
|
||||||
|
# In this implementation, POST args replace GET args
|
||||||
|
ra[k].clear()
|
||||||
|
for v in vs:
|
||||||
|
ra[k].append('text/plain', v)
|
||||||
|
|
||||||
|
# Someplace else: Use the RequestArguments instance.
|
||||||
|
self.assertEqual('1337', ra.bar.get_str())
|
||||||
|
self.assertEqual('Hello, World!', ra.baz.get_str())
|
||||||
|
self.assertEqual('42', ra.postbar.get_str())
|
||||||
|
for a in ra.foo:
|
||||||
|
self.assertEqual('postfoo', a.get_str())
|
|
@ -3,14 +3,14 @@ from typing import Any, Dict
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
from matemat.webserver.httpd import HttpHandler
|
from matemat.webserver import HttpHandler, RequestArguments
|
||||||
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_ok')
|
||||||
def serve_test_pagelet_ok(method: str,
|
def serve_test_pagelet_ok(method: str,
|
||||||
path: str,
|
path: str,
|
||||||
args: Dict[str, str],
|
args: RequestArguments,
|
||||||
session_vars: Dict[str, Any],
|
session_vars: Dict[str, Any],
|
||||||
headers: Dict[str, str]):
|
headers: Dict[str, str]):
|
||||||
headers['Content-Type'] = 'text/plain'
|
headers['Content-Type'] = 'text/plain'
|
||||||
|
@ -20,7 +20,7 @@ def serve_test_pagelet_ok(method: str,
|
||||||
@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: Dict[str, str],
|
args: RequestArguments,
|
||||||
session_vars: Dict[str, Any],
|
session_vars: Dict[str, Any],
|
||||||
headers: Dict[str, str]):
|
headers: Dict[str, str]):
|
||||||
session_vars['test'] = 'hello, world!'
|
session_vars['test'] = 'hello, world!'
|
||||||
|
@ -54,7 +54,7 @@ class TestServe(AbstractHttpdTest):
|
||||||
self.assertEqual('serve_test_pagelet_ok', packet.pagelet)
|
self.assertEqual('serve_test_pagelet_ok', 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('serve test pagelet ok', packet.body)
|
self.assertEqual(b'serve test pagelet ok', 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
|
||||||
|
@ -66,7 +66,7 @@ class TestServe(AbstractHttpdTest):
|
||||||
self.assertEqual('serve_test_pagelet_fail', packet.pagelet)
|
self.assertEqual('serve_test_pagelet_fail', packet.pagelet)
|
||||||
# Make sure the expected content is served
|
# Make sure the expected content is served
|
||||||
self.assertEqual(500, packet.statuscode)
|
self.assertEqual(500, packet.statuscode)
|
||||||
self.assertEqual('serve test pagelet fail', packet.body)
|
self.assertEqual(b'serve test pagelet fail', packet.body)
|
||||||
|
|
||||||
def test_serve_static_ok(self):
|
def test_serve_static_ok(self):
|
||||||
# Request a static resource
|
# Request a static resource
|
||||||
|
@ -78,7 +78,7 @@ class TestServe(AbstractHttpdTest):
|
||||||
self.assertIsNone(packet.pagelet)
|
self.assertIsNone(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('static resource test', packet.body)
|
self.assertEqual(b'static resource test', packet.body)
|
||||||
|
|
||||||
def test_serve_static_forbidden(self):
|
def test_serve_static_forbidden(self):
|
||||||
# Request a static resource with lacking permissions
|
# Request a static resource with lacking permissions
|
||||||
|
@ -90,7 +90,7 @@ class TestServe(AbstractHttpdTest):
|
||||||
self.assertIsNone(packet.pagelet)
|
self.assertIsNone(packet.pagelet)
|
||||||
# Make sure a 403 header is served
|
# Make sure a 403 header is served
|
||||||
self.assertEqual(403, packet.statuscode)
|
self.assertEqual(403, packet.statuscode)
|
||||||
self.assertNotEqual('This should not be readable', packet.body)
|
self.assertNotEqual(b'This should not be readable', packet.body)
|
||||||
|
|
||||||
def test_serve_not_found(self):
|
def test_serve_not_found(self):
|
||||||
# Request a nonexistent resource
|
# Request a nonexistent resource
|
||||||
|
@ -116,7 +116,10 @@ class TestServe(AbstractHttpdTest):
|
||||||
|
|
||||||
def test_static_post_not_allowed(self):
|
def test_static_post_not_allowed(self):
|
||||||
# Request a resource outside the webroot
|
# Request a resource outside the webroot
|
||||||
self.client_sock.set_request(b'POST /iwanttouploadthis HTTP/1.1\r\n\r\nq=this%20should%20not%20be%20uploaded')
|
self.client_sock.set_request(b'POST /iwanttopostthis HTTP/1.1\r\n'
|
||||||
|
b'Content-Type: application/x-www-form-urlencoded\r\n'
|
||||||
|
b'Content-length: 37\r\n\r\n'
|
||||||
|
b'q=this%20should%20not%20be%20uploaded')
|
||||||
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()
|
||||||
|
|
||||||
|
|
|
@ -4,14 +4,14 @@ from typing import Any, Dict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from matemat.webserver.httpd import HttpHandler
|
from matemat.webserver import HttpHandler, RequestArguments
|
||||||
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/sessions')
|
@test_pagelet('/just/testing/sessions')
|
||||||
def session_test_pagelet(method: str,
|
def session_test_pagelet(method: str,
|
||||||
path: str,
|
path: str,
|
||||||
args: Dict[str, str],
|
args: RequestArguments,
|
||||||
session_vars: Dict[str, Any],
|
session_vars: Dict[str, Any],
|
||||||
headers: Dict[str, str]):
|
headers: Dict[str, str]):
|
||||||
session_vars['test'] = 'hello, world!'
|
session_vars['test'] = 'hello, world!'
|
||||||
|
|
135
matemat/webserver/util.py
Normal file
135
matemat/webserver/util.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from matemat.webserver import RequestArguments, RequestArgument
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_multipart(body: bytes, boundary: str) -> List[RequestArgument]:
|
||||||
|
"""
|
||||||
|
Given a HTTP body with form-data in multipart form, and the multipart-boundary, parse the multipart items and
|
||||||
|
return them as a dictionary.
|
||||||
|
|
||||||
|
:param body: The HTTP multipart/form-data body.
|
||||||
|
:param boundary: The multipart boundary.
|
||||||
|
:return: A dictionary of field names as key, and content types and field values as value.
|
||||||
|
"""
|
||||||
|
# Prepend a CRLF for the first boundary to match
|
||||||
|
body = b'\r\n' + body
|
||||||
|
# Generate item header boundary and terminating boundary from general boundary string
|
||||||
|
_boundary = f'\r\n--{boundary}\r\n'.encode('utf-8')
|
||||||
|
_end_boundary = f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||||
|
# Split at the end boundary and make sure there comes nothing after it
|
||||||
|
allparts = body.split(_end_boundary, 1)
|
||||||
|
if len(allparts) != 2 or allparts[1] != b'':
|
||||||
|
raise ValueError('Last boundary missing or corrupted')
|
||||||
|
# Split remaining body into its parts, and verify at least 1 part is there
|
||||||
|
parts: List[bytes] = (allparts[0]).split(_boundary)
|
||||||
|
if len(parts) < 1 or parts[0] != b'':
|
||||||
|
raise ValueError('First boundary missing or corrupted')
|
||||||
|
# Remove the first, empty part
|
||||||
|
parts = parts[1:]
|
||||||
|
|
||||||
|
# Results go into this dict
|
||||||
|
args: Dict[str, RequestArgument] = dict()
|
||||||
|
|
||||||
|
# Parse each multipart part
|
||||||
|
for part in parts:
|
||||||
|
# Parse multipart headers
|
||||||
|
hdr: Dict[str, str] = dict()
|
||||||
|
while True:
|
||||||
|
head, part = part.split(b'\r\n', 1)
|
||||||
|
# Break on header/body delimiter
|
||||||
|
if head == b'':
|
||||||
|
break
|
||||||
|
# Add header to hdr dict
|
||||||
|
hk, hv = head.decode('utf-8').split(':')
|
||||||
|
hdr[hk.strip()] = hv.strip()
|
||||||
|
# At least Content-Type and Content-Disposition must be present
|
||||||
|
if 'Content-Type' not in hdr or 'Content-Disposition' not in hdr:
|
||||||
|
raise ValueError('Missing Content-Type or Content-Disposition header')
|
||||||
|
# Extract Content-Disposition header value and its arguments
|
||||||
|
cd, *cdargs = hdr['Content-Disposition'].split(';')
|
||||||
|
# Content-Disposition MUST be form-data; everything else is rejected
|
||||||
|
if cd.strip() != 'form-data':
|
||||||
|
raise ValueError(f'Unknown Content-Disposition: {cd}')
|
||||||
|
# Extract the "name" header argument
|
||||||
|
has_name = False
|
||||||
|
for cdarg in cdargs:
|
||||||
|
k, v = cdarg.split('=', 1)
|
||||||
|
if k.strip() == 'name':
|
||||||
|
has_name = True
|
||||||
|
name: str = v.strip()
|
||||||
|
# Remove quotation marks around the name value
|
||||||
|
if name.startswith('"') and name.endswith('"'):
|
||||||
|
name = v[1:-1]
|
||||||
|
# Add the Content-Type and the content to the header, with the provided name
|
||||||
|
if name not in args:
|
||||||
|
args[name] = RequestArgument(name)
|
||||||
|
args[name].append(hdr['Content-Type'].strip(), part)
|
||||||
|
if not has_name:
|
||||||
|
# Content-Disposition header without name attribute
|
||||||
|
raise ValueError('mutlipart/form-data part without name attribute')
|
||||||
|
|
||||||
|
return list(args.values())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(request: str, postbody: Optional[bytes] = None, enctype: str = 'text/plain') \
|
||||||
|
-> Tuple[str, RequestArguments]:
|
||||||
|
"""
|
||||||
|
Given a HTTP request path, and optionally a HTTP POST body in application/x-www-form-urlencoded or
|
||||||
|
multipart/form-data form, parse the arguments and return them as a dictionary.
|
||||||
|
|
||||||
|
If a key is used both in GET and in POST, the POST value takes precedence, and the GET value is discarded.
|
||||||
|
|
||||||
|
:param request: The request string to parse.
|
||||||
|
:param postbody: The POST body to parse, defaults to None.
|
||||||
|
:param enctype: Encoding of the POST body; supported values are application/x-www-form-urlencoded and
|
||||||
|
multipart/form-data.
|
||||||
|
:return: A tuple consisting of the base path and a dictionary with the parsed key/value pairs, and the value's
|
||||||
|
content type.
|
||||||
|
"""
|
||||||
|
# Parse the request "URL" (i.e. only the path)
|
||||||
|
tokens = urllib.parse.urlparse(request)
|
||||||
|
# Parse the GET arguments
|
||||||
|
if len(tokens.query) == 0:
|
||||||
|
getargs = dict()
|
||||||
|
else:
|
||||||
|
getargs = urllib.parse.parse_qs(tokens.query, strict_parsing=True, keep_blank_values=True, errors='strict')
|
||||||
|
|
||||||
|
args = RequestArguments()
|
||||||
|
for k, vs in getargs.items():
|
||||||
|
args[k].clear()
|
||||||
|
for v in vs:
|
||||||
|
args[k].append('text/plain', v)
|
||||||
|
|
||||||
|
if postbody is not None:
|
||||||
|
if enctype == 'application/x-www-form-urlencoded':
|
||||||
|
# Parse the POST body
|
||||||
|
pb: str = postbody.decode('utf-8')
|
||||||
|
if len(pb) == 0:
|
||||||
|
postargs = dict()
|
||||||
|
else:
|
||||||
|
postargs = urllib.parse.parse_qs(pb, strict_parsing=True, keep_blank_values=True, errors='strict')
|
||||||
|
# Write all POST values into the dict, overriding potential duplicates from GET
|
||||||
|
for k, vs in postargs.items():
|
||||||
|
args[k].clear()
|
||||||
|
for v in vs:
|
||||||
|
args[k].append('text/plain', v)
|
||||||
|
elif enctype.startswith('multipart/form-data'):
|
||||||
|
# Parse the multipart boundary from the Content-Type header
|
||||||
|
try:
|
||||||
|
boundary: str = enctype.split('boundary=')[1].strip()
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError('Multipart boundary in header not set or corrupted')
|
||||||
|
# Parse the multipart body
|
||||||
|
mpargs = _parse_multipart(postbody, boundary)
|
||||||
|
for ra in mpargs:
|
||||||
|
args[ra.name].clear()
|
||||||
|
for a in ra:
|
||||||
|
args[ra.name].append(a.get_content_type(), bytes(a))
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported Content-Type: {enctype}')
|
||||||
|
# Return the path and the parsed arguments
|
||||||
|
return tokens.path, args
|
Loading…
Reference in a new issue