Added examples and form processing

This commit is contained in:
Channel Cat 2016-10-09 15:28:31 -07:00
parent 8fbc6c2c4e
commit 49c499f44d
12 changed files with 448 additions and 145 deletions

11
examples/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM python:3.5
MAINTAINER Channel Cat <channelcat@gmail.com>
ADD . /code
RUN pip3 install git+https://github.com/channelcat/sanic
EXPOSE 8000
WORKDIR /code
CMD ["python", "simple_server.py"]

View File

@ -0,0 +1,6 @@
version: '2'
services:
sanic:
build: .
ports:
- "8000:8000"

10
examples/simple_server.py Normal file
View File

@ -0,0 +1,10 @@
from sanic import Sanic
from sanic.response import json
app = Sanic(__name__)
@app.route("/")
async def test(request):
return json({ "test": True })
app.run(host="0.0.0.0", port=8000)

View File

@ -0,0 +1,59 @@
from sanic import Sanic
from sanic.log import log
from sanic.response import json, text
from sanic.exceptions import ServerError
app = Sanic(__name__)
@app.route("/")
async def test_async(request):
return json({ "test": True })
@app.route("/sync", methods=['GET', 'POST'])
def test_sync(request):
return json({ "test": True })
@app.route("/dynamic/<name>/<id:int>")
def test_params(request, name, id):
return text("yeehaww {} {}".format(name, id))
@app.route("/exception")
def exception(request):
raise ServerError("It's dead jim")
# ----------------------------------------------- #
# Exceptions
# ----------------------------------------------- #
@app.exception(ServerError)
async def test(request, exception):
return json({ "exception": "{}".format(exception), "status": exception.status_code }, status=exception.status_code)
# ----------------------------------------------- #
# Read from request
# ----------------------------------------------- #
@app.route("/json")
def post_json(request):
return json({ "received": True, "message": request.json })
@app.route("/form")
def post_json(request):
return json({ "received": True, "form_data": request.form, "penos": request.form.get('penos') })
@app.route("/query_string")
def query_string(request):
return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string })
# ----------------------------------------------- #
# Run Server
# ----------------------------------------------- #
def before_start(loop):
log.info("OH OH OH OH OHHHHHHHH")
def before_stop(loop):
log.info("TRIED EVERYTHING")
app.run(host="0.0.0.0", port=8000, debug=True, before_start=before_start, before_stop=before_stop)

View File

@ -2,7 +2,10 @@ from .response import text
from traceback import format_exc from traceback import format_exc
class SanicException(Exception): class SanicException(Exception):
pass def __init__(self, message, status_code=None):
super().__init__(message)
if status_code is not None:
self.status_code = status_code
class NotFound(SanicException): class NotFound(SanicException):
status_code = 404 status_code = 404
@ -13,26 +16,28 @@ class ServerError(SanicException):
class Handler: class Handler:
handlers = None handlers = None
debug = False def __init__(self, sanic):
def __init__(self):
self.handlers = {} self.handlers = {}
self.sanic = sanic
def add(self, exception_type, handler): def add(self, exception, handler):
self.handlers[exception_type] = handler self.handlers[exception] = handler
def response(self, request, exception): def response(self, request, exception):
handler = self.handlers.get(type(exception)) """
if handler: Fetches and executes an exception handler and returns a reponse object
response = handler(request, exception) :param request: Request
else: :param exception: Exception to handle
response = Handler.default(request, exception, self.debug) :return: Response object
"""
handler = self.handlers.get(type(exception), self.default)
response = handler(request=request, exception=exception)
return response return response
@staticmethod def default(self, request, exception):
def default(request, exception, debug):
if issubclass(type(exception), SanicException): if issubclass(type(exception), SanicException):
return text("Error: {}".format(exception), status=getattr(exception, 'status_code', 500)) return text("Error: {}".format(exception), status=getattr(exception, 'status_code', 500))
elif debug: elif self.sanic.debug:
return text("Error: {}\nException: {}".format(exception, format_exc()), status=500) return text("Error: {}\nException: {}".format(exception, format_exc()), status=500)
else: else:
return text("An error occurred while generating the request", status=500) return text("An error occurred while generating the request", status=500)

View File

@ -2,8 +2,26 @@ from httptools import parse_url
from urllib.parse import parse_qs from urllib.parse import parse_qs
from ujson import loads as json_loads from ujson import loads as json_loads
class RequestParameters(dict):
"""
Hosts a dict with lists as values where get returns the first
value of the list and getlist returns the whole shebang
"""
def __init__(self, *args, **kwargs):
self.super = super()
self.super.__init__(*args, **kwargs)
def get(self, name, default=None):
values = self.super.get(name)
return values[0] if values else default
def getlist(self, name, default=None):
return self.super.get(name, default)
class Request: class Request:
__slots__ = ('url', 'headers', 'version', 'method', 'query_string', 'body', 'parsed_json', 'parsed_args') __slots__ = (
'url', 'headers', 'version', 'method',
'query_string', 'body',
'parsed_json', 'parsed_args', 'parsed_form',
)
def __init__(self, url_bytes, headers, version, method): def __init__(self, url_bytes, headers, version, method):
# TODO: Content-Encoding detection # TODO: Content-Encoding detection
@ -17,27 +35,38 @@ class Request:
# Init but do not inhale # Init but do not inhale
self.body = None self.body = None
self.parsed_json = None self.parsed_json = None
self.parsed_form = None
self.parsed_args = None self.parsed_args = None
@property @property
def json(self): def json(self):
if not self.parsed_json: if not self.parsed_json:
if not self.body: try:
raise ValueError("No body to parse")
self.parsed_json = json_loads(self.body) self.parsed_json = json_loads(self.body)
except:
pass
return self.parsed_json return self.parsed_json
@property
def form(self):
if not self.parsed_form:
content_type = self.headers.get('Content-Type')
try:
# TODO: form-data
if content_type is None or content_type == 'application/x-www-form-urlencoded':
self.parsed_form = RequestParameters(parse_qs(self.body.decode('utf-8')))
except:
pass
return self.parsed_form
@property @property
def args(self): def args(self):
if self.parsed_args is None: if self.parsed_args is None:
if self.query_string: if self.query_string:
parsed_query_string = parse_qs(self.query_string).items() self.parsed_args = RequestParameters(parse_qs(self.query_string))
self.parsed_args = {k:[_v for _v in v] if len(v)>1 else v[0] for k,v in parsed_query_string}
print(self.parsed_args)
else: else:
self.parsed_args = {} self.parsed_args = {}
return self.parsed_args return self.parsed_args
# TODO: Files

View File

@ -10,7 +10,7 @@ STATUS_CODES = {
402: 'Payment Required', 402: 'Payment Required',
403: 'Forbidden', 403: 'Forbidden',
404: 'Not Found', 404: 'Not Found',
400: 'Method Not Allowed', 405: 'Method Not Allowed',
500: 'Internal Server Error', 500: 'Internal Server Error',
501: 'Not Implemented', 501: 'Not Implemented',
502: 'Bad Gateway', 502: 'Bad Gateway',
@ -19,9 +19,9 @@ STATUS_CODES = {
} }
class HTTPResponse: class HTTPResponse:
__slots__ = ('body', 'status', 'content_type') __slots__ = ('body', 'status', 'content_type', 'headers')
def __init__(self, body=None, status=200, content_type='text/plain', body_bytes=b''): def __init__(self, body=None, status=200, headers=[], content_type='text/plain', body_bytes=b''):
self.content_type = content_type self.content_type = content_type
if not body is None: if not body is None:
@ -30,6 +30,7 @@ class HTTPResponse:
self.body = body_bytes self.body = body_bytes
self.status = status self.status = status
self.headers = headers
def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None):
# This is all returned in a kind-of funky way # This is all returned in a kind-of funky way
@ -37,6 +38,9 @@ class HTTPResponse:
additional_headers = [] additional_headers = []
if keep_alive and not keep_alive_timeout is None: if keep_alive and not keep_alive_timeout is None:
additional_headers = [b'Keep-Alive: timeout=', str(keep_alive_timeout).encode(), b's\r\n'] additional_headers = [b'Keep-Alive: timeout=', str(keep_alive_timeout).encode(), b's\r\n']
if self.headers:
for name, value in self.headers.items():
additional_headers.append('{}: {}\r\n'.format(name, value).encode('utf-8'))
return b''.join([ return b''.join([
'HTTP/{} {} {}\r\n'.format(version, self.status, STATUS_CODES.get(self.status, 'FAIL')).encode(), 'HTTP/{} {} {}\r\n'.format(version, self.status, STATUS_CODES.get(self.status, 'FAIL')).encode(),
@ -48,9 +52,9 @@ class HTTPResponse:
self.body, self.body,
]) ])
def json(body, status=200): def json(body, status=200, headers=None):
return HTTPResponse(ujson.dumps(body), status=status, content_type="application/json") return HTTPResponse(ujson.dumps(body), headers=headers, status=status, content_type="application/json")
def text(body, status=200): def text(body, status=200, headers=None):
return HTTPResponse(body, status=status, content_type="text/plain") return HTTPResponse(body, status=status, headers=headers, content_type="text/plain")
def html(body, status=200): def html(body, status=200, headers=None):
return HTTPResponse(body, status=status, content_type="text/html") return HTTPResponse(body, status=status, headers=headers, content_type="text/html")

View File

@ -1,18 +1,123 @@
from .log import log import re
from .exceptions import NotFound from collections import namedtuple
from .exceptions import NotFound, InvalidUsage
class Router(): Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters'])
Parameter = namedtuple("Parameter", ['name', 'cast'])
class Router:
"""
Router supports basic routing with parameters and method checks
Usage:
@sanic.route('/my/url/<my_parameter>', methods=['GET', 'POST', ...])
def my_route(request, my_parameter):
do stuff...
Parameters will be passed as keyword arguments to the request handling function provided
Parameters can also have a type by appending :type to the <parameter>. If no type is provided,
a string is expected. A regular expression can also be passed in as the type
TODO:
This probably needs optimization for larger sets of routes,
since it checks every route until it finds a match which is bad and I should feel bad
"""
routes = None
regex_types = {
"string": (None, "\w+"),
"int": (int, "\d+"),
"number": (float, "[0-9\\.]+"),
"alpha": (None, "[A-Za-z]+"),
}
def __init__(self):
self.routes = []
def add(self, uri, methods, handler):
"""
Adds a handler to the route list
:param uri: Path to match
:param methods: Array of accepted method names. If none are provided, any method is allowed
:param handler: Request handler function. When executed, it should provide a response object.
:return: Nothing
"""
# Dict for faster lookups of if method allowed
methods_dict = { method: True for method in methods } if methods else None
parameters = []
def add_parameter(match):
# We could receive NAME or NAME:PATTERN
parts = match.group(1).split(':')
if len(parts) == 2:
parameter_name, parameter_pattern = parts
else:
parameter_name = parts[0]
parameter_pattern = 'string'
# Pull from pre-configured types
parameter_regex = self.regex_types.get(parameter_pattern)
if parameter_regex:
parameter_type, parameter_pattern = parameter_regex
else:
parameter_type = None
parameter = Parameter(name=parameter_name, cast=parameter_type)
parameters.append(parameter)
return "({})".format(parameter_pattern)
pattern_string = re.sub("<(.+?)>", add_parameter, uri)
pattern = re.compile("^{}$".format(pattern_string))
route = Route(handler=handler, methods=methods_dict, pattern=pattern, parameters=parameters)
self.routes.append(route)
def get(self, request):
"""
Gets a request handler based on the URL of the request, or raises an error
:param request: Request object
:return: handler, arguments, keyword arguments
"""
route = None
args = []
kwargs = {}
for _route in self.routes:
match = _route.pattern.match(request.url)
if match:
for index, parameter in enumerate(_route.parameters, start=1):
value = match.group(index)
kwargs[parameter.name] = parameter.cast(value) if parameter.cast is not None else value
route = _route
break
if route:
if route.methods and not request.method in route.methods:
raise InvalidUsage("Method {} not allowed for URL {}".format(request.method, request.url), status_code=405)
return route.handler, args, kwargs
else:
raise NotFound("Requested URL {} not found".format(request.url))
class SimpleRouter:
"""
Simple router records and reads all routes from a dictionary
It does not support parameters in routes, but is very fast
"""
routes = None routes = None
def __init__(self): def __init__(self):
self.routes = {} self.routes = {}
def add(self, uri, handler): def add(self, uri, methods, handler):
self.routes[uri] = handler # Dict for faster lookups of method allowed
methods_dict = { method: True for method in methods } if methods else None
self.routes[uri] = Route(handler=handler, methods=methods_dict, pattern=uri, parameters=None)
def get(self, request): def get(self, request):
handler = self.routes.get(request.url) route = self.routes.get(request.url)
if handler: if route:
return handler if route.methods and not request.method in route.methods:
raise InvalidUsage("Method {} not allowed for URL {}".format(request.method, request.url), status_code=405)
return route.handler, [], {}
else: else:
raise NotFound("Requested URL {} not found".format(request.url)) raise NotFound("Requested URL {} not found".format(request.url))

View File

@ -6,6 +6,7 @@ from .router import Router
from .server import serve from .server import serve
from .exceptions import ServerError from .exceptions import ServerError
from inspect import isawaitable from inspect import isawaitable
from traceback import format_exc
class Sanic: class Sanic:
name = None name = None
@ -17,47 +18,89 @@ class Sanic:
def __init__(self, name, router=None, error_handler=None): def __init__(self, name, router=None, error_handler=None):
self.name = name self.name = name
self.router = router or Router() self.router = router or Router()
self.error_handler = error_handler or Handler() self.error_handler = error_handler or Handler(self)
self.config = Config() self.config = Config()
def route(self, uri): # -------------------------------------------------------------------- #
# Decorators
# -------------------------------------------------------------------- #
def route(self, uri, methods=None):
"""
Decorates a function to be registered as a route
:param uri: path of the URL
:param methods: list or tuple of methods allowed
:return: decorated function
"""
def response(handler): def response(handler):
self.router.add(uri=uri, handler=handler) self.router.add(uri=uri, methods=methods, handler=handler)
return handler return handler
return response return response
def exception(self, *args, **kwargs): def exception(self, *exceptions):
"""
Decorates a function to be registered as a route
:param uri: path of the URL
:param methods: list or tuple of methods allowed
:return: decorated function
"""
def response(handler): def response(handler):
self.error_handler.add(*args, **kwargs) for exception in exceptions:
self.error_handler.add(exception, handler)
return handler return handler
return response return response
async def handle_request(self, request, respond): # -------------------------------------------------------------------- #
# Request Handling
# -------------------------------------------------------------------- #
async def handle_request(self, request, response_callback):
"""
Takes a request from the HTTP Server and returns a response object to be sent back
The HTTP Server only expects a response object, so exception handling must be done here
:param request: HTTP Request object
:param response_callback: Response function to be called with the response as the only argument
:return: Nothing
"""
try: try:
handler = self.router.get(request) handler, args, kwargs = self.router.get(request)
if handler is None: if handler is None:
raise ServerError("'None' was returned while requesting a handler from the router") raise ServerError("'None' was returned while requesting a handler from the router")
response = handler(request) response = handler(request, *args, **kwargs)
# Check if the handler is asynchronous
if isawaitable(response): if isawaitable(response):
response = await response response = await response
except Exception as e: except Exception as e:
try: try:
response = self.error_handler.response(request, e) response = self.error_handler.response(request, e)
if isawaitable(response):
response = await response
except Exception as e: except Exception as e:
if self.debug: if self.debug:
response = HTTPResponse("Error while handling error: {}\nStack: {}".format(e, format_exc())) response = HTTPResponse("Error while handling error: {}\nStack: {}".format(e, format_exc()))
else: else:
response = HTTPResponse("An error occured while handling an error") response = HTTPResponse("An error occured while handling an error")
respond(response) response_callback(response)
# -------------------------------------------------------------------- #
# Execution
# -------------------------------------------------------------------- #
def run(self, host="127.0.0.1", port=8000, debug=False, on_start=None, on_stop=None): def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None, before_stop=None):
"""
Runs the HTTP Server and listens until keyboard interrupt or term signal.
On termination, drains connections before closing.
:param host: Address to host on
:param port: Port to host on
:param debug: Enables debug output (slows server)
:param before_start: Function to be executed after the event loop is created and before the server starts
:param before_stop: Function to be executed when a stop signal is received before it is respected
:return: Nothing
"""
self.error_handler.debug=True self.error_handler.debug=True
self.debug = debug self.debug = debug
@ -68,13 +111,16 @@ class Sanic:
# Serve # Serve
log.info('Goin\' Fast @ {}:{}'.format(host, port)) log.info('Goin\' Fast @ {}:{}'.format(host, port))
return serve( try:
serve(
host=host, host=host,
port=port, port=port,
debug=debug, debug=debug,
on_start=on_start, before_start=before_start,
on_stop=on_stop, before_stop=before_stop,
request_handler=self.handle_request, request_handler=self.handle_request,
request_timeout=self.config.REQUEST_TIMEOUT, request_timeout=self.config.REQUEST_TIMEOUT,
request_max_size=self.config.REQUEST_MAX_SIZE, request_max_size=self.config.REQUEST_MAX_SIZE,
) )
except:
pass

View File

@ -78,10 +78,10 @@ class HttpProtocol(asyncio.Protocol):
self.url = url self.url = url
def on_header(self, name, value): def on_header(self, name, value):
if name == 'Content-Length' and int(value) > self.request_max_size: if name == b'Content-Length' and int(value) > self.request_max_size:
return self.bail_out("Request body too large ({}), connection closed".format(value)) return self.bail_out("Request body too large ({}), connection closed".format(value))
self.headers.append((name, value.decode('utf-8'))) self.headers.append((name.decode(), value.decode('utf-8')))
def on_headers_complete(self): def on_headers_complete(self):
self.request = Request( self.request = Request(
@ -122,15 +122,25 @@ class HttpProtocol(asyncio.Protocol):
self.headers = None self.headers = None
self._total_request_size = 0 self._total_request_size = 0
def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False, request_timeout=60, request_max_size=None): def close_if_idle(self):
"""
Close the connection if a request is not being sent or received
:return: boolean - True if closed, false if staying open
"""
if not self.parser:
self.transport.close()
return True
return False
def serve(host, port, request_handler, before_start=None, before_stop=None, debug=False, request_timeout=60, request_max_size=None):
# Create Event Loop # Create Event Loop
loop = async_loop.new_event_loop() loop = async_loop.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
loop.set_debug(debug) loop.set_debug(debug)
# Run the on_start function if provided # Run the on_start function if provided
if on_start: if before_start:
result = on_start(loop) result = before_start(loop)
if isawaitable(result): if isawaitable(result):
loop.run_until_complete(result) loop.run_until_complete(result)
@ -154,8 +164,8 @@ def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False,
log.info("Stop requested, draining connections...") log.info("Stop requested, draining connections...")
# Run the on_stop function if provided # Run the on_stop function if provided
if on_stop: if before_stop:
result = on_stop(loop) result = before_stop(loop)
if isawaitable(result): if isawaitable(result):
loop.run_until_complete(result) loop.run_until_complete(result)
@ -165,6 +175,9 @@ def serve(host, port, request_handler, on_start=None, on_stop=None, debug=False,
# Complete all tasks on the loop # Complete all tasks on the loop
signal.stopped = True signal.stopped = True
for connection in connections.keys():
connection.close_if_idle()
while connections: while connections:
loop.run_until_complete(asyncio.sleep(0.1)) loop.run_until_complete(asyncio.sleep(0.1))

View File

@ -6,8 +6,7 @@ currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentfram
sys.path.insert(0,currentdir + '/../../../') sys.path.insert(0,currentdir + '/../../../')
from sanic import Sanic from sanic import Sanic
from sanic.response import json, text from sanic.response import json
from sanic.exceptions import ServerError
app = Sanic("test") app = Sanic("test")
@ -15,71 +14,4 @@ app = Sanic("test")
async def test(request): async def test(request):
return json({ "test": True }) return json({ "test": True })
@app.route("/sync") app.run(host="0.0.0.0", port=sys.argv[1])
def test(request):
return json({ "test": True })
@app.route("/text")
def rtext(request):
return text("yeehaww")
@app.route("/exception")
def exception(request):
raise ServerError("yep")
@app.route("/exception/async")
async def test(request):
raise ServerError("asunk")
@app.route("/post_json")
def post_json(request):
return json({ "received": True, "message": request.json })
@app.route("/query_string")
def query_string(request):
return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string })
import sys
app.run(host="0.0.0.0", port=sys.argv[1],)#, on_start=setup)
# import asyncio_redis
# import asyncpg
# async def setup(sanic, loop):
# sanic.conn = []
# sanic.redis = []
# for x in range(10):
# sanic.conn.append(await asyncpg.connect(user='postgres', password='zomgdev', database='postgres', host='192.168.99.100'))
# for n in range(30):
# connection = await asyncio_redis.Connection.create(host='192.168.99.100', port=6379)
# sanic.redis.append(connection)
# c=0
# @app.route("/postgres")
# async def postgres(request):
# global c
# values = await app.conn[c].fetch('''SELECT * FROM players''')
# c += 1
# if c == 10:
# c = 0
# return text("yep")
# r=0
# @app.route("/redis")
# async def redis(request):
# global r
# try:
# values = await app.redis[r].get('my_key')
# except asyncio_redis.exceptions.ConnectionLostError:
# app.redis[r] = await asyncio_redis.Connection.create(host='127.0.0.1', port=6379)
# values = await app.redis[r].get('my_key')
# r += 1
# if r == 30:
# r = 0
# return text(values)

View File

@ -0,0 +1,83 @@
import sys
import os
import inspect
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
sys.path.insert(0,currentdir + '/../../../')
from sanic import Sanic
from sanic.response import json, text
from sanic.exceptions import ServerError
app = Sanic("test")
@app.route("/")
async def test(request):
return json({ "test": True })
@app.route("/sync", methods=['GET', 'POST'])
def test(request):
return json({ "test": True })
@app.route("/text/<name>/<butt:int>")
def rtext(request, name, butt):
return text("yeehaww {} {}".format(name, butt))
@app.route("/exception")
def exception(request):
raise ServerError("yep")
@app.route("/exception/async")
async def test(request):
raise ServerError("asunk")
@app.route("/post_json")
def post_json(request):
return json({ "received": True, "message": request.json })
@app.route("/query_string")
def query_string(request):
return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string })
import sys
app.run(host="0.0.0.0", port=sys.argv[1])
# import asyncio_redis
# import asyncpg
# async def setup(sanic, loop):
# sanic.conn = []
# sanic.redis = []
# for x in range(10):
# sanic.conn.append(await asyncpg.connect(user='postgres', password='zomgdev', database='postgres', host='192.168.99.100'))
# for n in range(30):
# connection = await asyncio_redis.Connection.create(host='192.168.99.100', port=6379)
# sanic.redis.append(connection)
# c=0
# @app.route("/postgres")
# async def postgres(request):
# global c
# values = await app.conn[c].fetch('''SELECT * FROM players''')
# c += 1
# if c == 10:
# c = 0
# return text("yep")
# r=0
# @app.route("/redis")
# async def redis(request):
# global r
# try:
# values = await app.redis[r].get('my_key')
# except asyncio_redis.exceptions.ConnectionLostError:
# app.redis[r] = await asyncio_redis.Connection.create(host='127.0.0.1', port=6379)
# values = await app.redis[r].get('my_key')
# r += 1
# if r == 30:
# r = 0
# return text(values)