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
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):
status_code = 404
@ -13,26 +16,28 @@ class ServerError(SanicException):
class Handler:
handlers = None
debug = False
def __init__(self):
def __init__(self, sanic):
self.handlers = {}
self.sanic = sanic
def add(self, exception_type, handler):
self.handlers[exception_type] = handler
def add(self, exception, handler):
self.handlers[exception] = handler
def response(self, request, exception):
handler = self.handlers.get(type(exception))
if handler:
response = handler(request, exception)
else:
response = Handler.default(request, exception, self.debug)
"""
Fetches and executes an exception handler and returns a reponse object
:param request: Request
:param exception: Exception to handle
:return: Response object
"""
handler = self.handlers.get(type(exception), self.default)
response = handler(request=request, exception=exception)
return response
@staticmethod
def default(request, exception, debug):
def default(self, request, exception):
if issubclass(type(exception), SanicException):
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)
else:
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 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:
__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):
# TODO: Content-Encoding detection
@ -17,27 +35,38 @@ class Request:
# Init but do not inhale
self.body = None
self.parsed_json = None
self.parsed_form = None
self.parsed_args = None
@property
def json(self):
if not self.parsed_json:
if not self.body:
raise ValueError("No body to parse")
try:
self.parsed_json = json_loads(self.body)
except:
pass
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
def args(self):
if self.parsed_args is None:
if self.query_string:
parsed_query_string = parse_qs(self.query_string).items()
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)
self.parsed_args = RequestParameters(parse_qs(self.query_string))
else:
self.parsed_args = {}
return self.parsed_args
# TODO: Files

View File

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

View File

@ -1,18 +1,123 @@
from .log import log
from .exceptions import NotFound
import re
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
def __init__(self):
self.routes = {}
def add(self, uri, handler):
self.routes[uri] = handler
def add(self, uri, methods, 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):
handler = self.routes.get(request.url)
if handler:
return handler
route = self.routes.get(request.url)
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, [], {}
else:
raise NotFound("Requested URL {} not found".format(request.url))

View File

@ -6,6 +6,7 @@ from .router import Router
from .server import serve
from .exceptions import ServerError
from inspect import isawaitable
from traceback import format_exc
class Sanic:
name = None
@ -17,47 +18,89 @@ class Sanic:
def __init__(self, name, router=None, error_handler=None):
self.name = name
self.router = router or Router()
self.error_handler = error_handler or Handler()
self.error_handler = error_handler or Handler(self)
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):
self.router.add(uri=uri, handler=handler)
self.router.add(uri=uri, methods=methods, handler=handler)
return handler
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):
self.error_handler.add(*args, **kwargs)
for exception in exceptions:
self.error_handler.add(exception, handler)
return handler
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:
handler = self.router.get(request)
handler, args, kwargs = self.router.get(request)
if handler is None:
raise ServerError("'None' was returned while requesting a handler from the router")
response = handler(request)
# Check if the handler is asynchronous
response = handler(request, *args, **kwargs)
if isawaitable(response):
response = await response
except Exception as e:
try:
response = self.error_handler.response(request, e)
if isawaitable(response):
response = await response
except Exception as e:
if self.debug:
response = HTTPResponse("Error while handling error: {}\nStack: {}".format(e, format_exc()))
else:
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.debug = debug
@ -68,13 +111,16 @@ class Sanic:
# Serve
log.info('Goin\' Fast @ {}:{}'.format(host, port))
return serve(
try:
serve(
host=host,
port=port,
debug=debug,
on_start=on_start,
on_stop=on_stop,
before_start=before_start,
before_stop=before_stop,
request_handler=self.handle_request,
request_timeout=self.config.REQUEST_TIMEOUT,
request_max_size=self.config.REQUEST_MAX_SIZE,
)
except:
pass

View File

@ -78,10 +78,10 @@ class HttpProtocol(asyncio.Protocol):
self.url = url
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))
self.headers.append((name, value.decode('utf-8')))
self.headers.append((name.decode(), value.decode('utf-8')))
def on_headers_complete(self):
self.request = Request(
@ -122,15 +122,25 @@ class HttpProtocol(asyncio.Protocol):
self.headers = None
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
loop = async_loop.new_event_loop()
asyncio.set_event_loop(loop)
loop.set_debug(debug)
# Run the on_start function if provided
if on_start:
result = on_start(loop)
if before_start:
result = before_start(loop)
if isawaitable(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...")
# Run the on_stop function if provided
if on_stop:
result = on_stop(loop)
if before_stop:
result = before_stop(loop)
if isawaitable(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
signal.stopped = True
for connection in connections.keys():
connection.close_if_idle()
while connections:
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 + '/../../../')
from sanic import Sanic
from sanic.response import json, text
from sanic.exceptions import ServerError
from sanic.response import json
app = Sanic("test")
@ -15,71 +14,4 @@ app = Sanic("test")
async def test(request):
return json({ "test": True })
@app.route("/sync")
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)
app.run(host="0.0.0.0", port=sys.argv[1])

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)