-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathserver.py
474 lines (377 loc) · 13.4 KB
/
server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
import asyncio
import json
import re
from traceback import print_exc
from collections import namedtuple
###############################################################################
# Types
###############################################################################
Request = namedtuple('Request', (
'reader',
'writer',
'method',
'path',
'query',
'headers',
'body',
))
DEFAULT_RESPONSE_HEADERS = {
'content-type': 'text/html',
'connection': 'close',
}
class Response():
CORS_ENABLED = True
def __init__(self, status_int=None, headers=None, body=None):
if status_int is not None:
self.status_int = status_int
_headers = DEFAULT_RESPONSE_HEADERS.copy()
if self.CORS_ENABLED:
_headers['access-control-allow-origin'] = '*'
if hasattr(self, 'headers'):
_headers.update(self.headers)
if headers is not None:
_headers.update(headers)
self.headers = _headers
self.body = body
class _200(Response):
status_int = 200
class _303(Response):
status_int = 303
def __init__(self, location):
Response.__init__(self, headers={'location': location})
class ErrorResponse(Response):
headers = {'content-type': 'text/plain'}
def __init__(self, details=None):
body = '{} {}'.format(self.status_int, self.body)
if details is not None:
body = '{} - {}'.format(body, details)
Response.__init__(self, body=body)
class _400(ErrorResponse):
status_int = 400
body = 'Invalid Request'
class _404(ErrorResponse):
status_int = 404
body = 'Not Found'
class _405(ErrorResponse):
status_int = 405
body = 'Method Not Allowed'
class _500(ErrorResponse):
status_int = 500
body = 'Server Error'
class _503(ErrorResponse):
status_int = 503
body = 'Service Unavailable'
###############################################################################
# Constants
###############################################################################
DEBUG = False
APPLICATION_JAVASCRIPT = 'application/javascript'
APPLICATION_JSON = 'application/json'
APPLICATION_OCTET_STREAM = 'application/octet-stream'
APPLICATION_PYTHON = 'application/x-python'
APPLICATION_SCHEMA_JSON = 'application/schema+json'
IMAGE_GIF = 'image/gif'
IMAGE_JPEG = 'image/jpeg'
IMAGE_PNG = 'image/png'
TEXT_HTML = 'text/html'
TEXT_PLAIN = 'text/plain'
FILE_LOWER_EXTENSION_CONTENT_TYPE_MAP = {
'js': APPLICATION_JAVASCRIPT,
'schema.json': APPLICATION_SCHEMA_JSON,
'json': APPLICATION_JSON,
'gif': IMAGE_GIF,
'jpeg': IMAGE_JPEG,
'jpg': IMAGE_JPEG,
'png': IMAGE_PNG,
'html': TEXT_HTML,
'py': APPLICATION_PYTHON,
'txt': TEXT_PLAIN,
}
MAX_FILE_EXTENSION_SEGMENTS = max(
k.count('.') + 1 for k in FILE_LOWER_EXTENSION_CONTENT_TYPE_MAP
)
DELETE = 'DELETE'
GET = 'GET'
POST = 'POST'
PUT = 'PUT'
###############################################################################
# Exceptions
###############################################################################
class HTTPServerException(Exception): pass
class ShortRead(HTTPServerException): pass
class ZeroRead(HTTPServerException): pass
class CouldNotParse(HTTPServerException): pass
###############################################################################
# Query Parameter Parsers
###############################################################################
def parsing_error():
raise CouldNotParse
def as_type(t):
def f(x):
# Prevent casting of None, which str() will happily do.
if x is None and t is not None:
parsing_error()
try:
return t(x)
except (TypeError, ValueError):
parsing_error()
return f
def as_choice(*choices):
return lambda x: x if x in choices else parsing_error()
def as_nonempty(parser):
def f(x):
x = parser(x)
return x if len(x) > 0 else parsing_error()
return f
def with_default_as(parser, default):
def f(x):
try:
return parser(x)
except CouldNotParse:
return default
return f
def maybe_as(parser):
def f(x):
try:
return parser(x)
except CouldNotParse:
return x if x is None else parsing_error()
return f
###############################################################################
# Utility Functions
###############################################################################
HTTP_DELIM = b'\r\n'
_decode = lambda b: b.decode('ISO-8859-1')
def parse_uri(uri):
if '?' not in uri:
return uri, {}
# TODO - URL unencoding
path, query_string = uri.split('?')
query = {}
for pair_str in query_string.split('&'):
kv = pair_str.split('=')
kv_len = len(kv)
if kv_len == 2:
k, v = kv
query[k] = v
elif kv_len == 1:
query[kv[0]] = None
else:
if DEBUG:
print('Unparsable query param: "{}"'.format(pair_str))
return path, query
async def parse_request(reader, writer):
# Get the first 1024 bytes
data = await reader.read(1024)
if not data:
raise ZeroRead
# Consume the request line
request_line, data = data.split(HTTP_DELIM, 1)
method, uri, protocol_version = _decode(request_line).split()
path, query = parse_uri(uri)
# Parse the headers.
headers = {}
start_idx = 0
end_idx = None
while True:
# Find the next delimiter index
delim_idx = data[start_idx:].find(HTTP_DELIM)
if delim_idx == -1:
# No delimiter was found which probably indicates that our initial
# read was a short one, so, for now, raise an exception and we can
# come back later and do more reading if necessary.
raise ShortRead
end_idx = start_idx + delim_idx
if delim_idx == 0:
# We've reached the double CRLF chars that signal the end od the
# header, so return the headers map and the rest of data as the
# request body.
body = data[end_idx + 2:]
break
# Delimiter found so parse the header key/value pair, lowercasing the
# header name for internal consistency.
k, v = _decode(data[start_idx : start_idx + delim_idx]).split(':', 1)
headers[k.strip().lower()] = v.strip()
start_idx = end_idx + 2
return Request(
reader=reader,
writer=writer,
method=method,
path=path,
query=query,
headers=headers,
body=body,
)
get_file_extension_content_type = \
lambda ext: FILE_LOWER_EXTENSION_CONTENT_TYPE_MAP.get(ext.lower(), None)
def get_file_path_content_type(fs_path):
# Attempt to greedily match (i.e. the extension with the most segments) the
# filename extension to a content type.
splits = fs_path.rsplit('.', MAX_FILE_EXTENSION_SEGMENTS)
num_segs = len(splits) - 1
while num_segs > 0:
ext = '.'.join(splits[-num_segs:])
content_type = get_file_extension_content_type(ext)
if content_type is not None:
return content_type
num_segs -= 1
# Filename didn't match any definde content type so return the default
# 'application/octet-stream'.
return APPLICATION_OCTET_STREAM
###############################################################################
# Connection Handling
###############################################################################
async def service_connection(reader, writer):
try:
request = await parse_request(reader, writer)
if DEBUG:
print('request: {}'.format(request))
await dispatch(request)
except KeyboardInterrupt:
writer.close()
await writer.wait_closed()
raise
except Exception as e:
print_exc()
try:
await send(writer, _500(str(e)))
except Exception:
print_exc()
writer.close()
await writer.wait_closed()
async def serve(host='0.0.0.0', port='8000', backlog=5, enable_cors=True):
Response.CORS_ENABLED = enable_cors
return await asyncio.start_server(
service_connection,
host,
port,
backlog=backlog
)
async def send(writer, response):
if DEBUG:
print('sending response: {}'.format(response))
writer.write('HTTP/1.1 {} OK\n'.format(response.status_int).encode())
for k, v in response.headers.items():
writer.write('{}: {}\n'.format(k, v).encode())
writer.write(b'\n')
await writer.drain()
if response.body is not None:
if not hasattr(response.body, 'readinto'):
# Assume that body is a string and send it.
writer.write(response.body.encode())
await writer.drain()
else:
# Assume that body is a file-type object and iterate over it
# sending each chunk to avoid exhausting the available memory by
# doing it all in one go.
chunk_mv = memoryview(bytearray(1024))
num_bytes = 0
while True:
num_bytes = response.body.readinto(chunk_mv)
if num_bytes == 0 or num_bytes is None:
break
writer.write(chunk_mv[:num_bytes])
await writer.drain()
writer.close()
await writer.wait_closed()
###############################################################################
# Routing
###############################################################################
# Define a module-level variable to store (<pathRegex>, <allowedMethods>,
# <query_param_parser_map>, <func>) tuples for functions decorated with @route.
_routes = []
def route(path_pattern, methods=('GET',), query_param_parser_map=None):
"""A decorator to register a function as the handler for requests to the
specified path regex pattern and send any returned response.
"""
def decorator(func):
"""Return a function that will accept a request argument, invoke the
handler, and send any response.
"""
# If no line start/end chars are present in the path pattern, add both,
# i.e. "^<path_pattern>$".
if not path_pattern.startswith('^') and not path_pattern.endswith('$'):
path_regex = re.compile('^{}$'.format(path_pattern))
else:
path_regex = re.compile(path_pattern)
async def wrapper(request, *args, **kwargs):
"""Invoke the request handler and send any response.
"""
response = await func(request, *args, **kwargs)
if response is not None:
await send(request.writer, response)
# Register this wrapper for the path.
_routes.append((path_regex, methods, query_param_parser_map, wrapper))
return wrapper
return decorator
def as_json(func):
"""A request handler decorator that JSON-encodes the Response body and sets
the Content-Type to "application/json".
"""
async def wrapper(*args, **kwargs):
response = await func(*args, **kwargs)
response.body = json.dumps(response.body)
response.headers['content-type'] = 'application/json'
return response
return wrapper
def parse_json_body(func):
"""A request handler decorator that attempts to parse a JSON-encoded
request body and pass it as an argument to the handler.
"""
async def wrapper(request, *args, **kwargs):
if request.headers.get('content-type') != APPLICATION_JSON:
return _400('Expected Content-Type: {}'.format(APPLICATION_JSON))
try:
data = json.loads(request.body)
except Exception:
return _400('Could not parse request body as JSON')
return await func(request, data, *args, **kwargs)
return wrapper
def parse_query_params(request, parser_map):
"""Apply parsers to the request query params.
"""
query = request.query
ok_params = {}
bad_params = {}
for k, parser in parser_map.items():
v = query.get(k)
try:
ok_params[k] = parser(v)
except CouldNotParse:
bad_params[k] = v
return ok_params, bad_params
async def dispatch(request):
"""Attempt to find and invoke the handler for the specified request path
and return a bool indicating whether a handler was found.
"""
any_path_matches = False
for regex, methods, query_param_parser_map, func in _routes:
match = regex.match(request.path)
any_path_matches |= match is not None
if match and request.method in methods:
if query_param_parser_map is None:
await func(request)
return
ok_params, bad_params = parse_query_params(
request,
query_param_parser_map
)
if not bad_params:
await func(request, **ok_params)
else:
await send(
request.writer,
_400('invalid params: {}'.format(bad_params))
)
return
if any_path_matches:
# Send a Method-Not-Allowed response if any path matched.
await send(request.writer, _405())
else:
# Otherwise, send a Not-Found respose.
await send(request.writer, _404())
# Run the server if executed as a script.
if __name__ == '__main__':
event_loop = asyncio.get_event_loop()
event_loop.create_task(serve())
event_loop.run_forever()