This repository has been archived by the owner on Apr 29, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 02c76ae
Showing
39 changed files
with
3,299 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
__pycache__ | ||
.coverage | ||
.mypy_cache | ||
.pytest_cache | ||
htmlcov | ||
molten.egg-info | ||
setup.py |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
from .app import App, BaseApp | ||
from .dependency_injection import Component, DependencyInjector, DependencyResolver | ||
from .errors import DIError, HeaderMissing, HTTPError, MoltenError, ParamMissing, RequestParserNotAvailable | ||
from .http import Headers, QueryParams, Request, Response | ||
from .http.status_codes import * | ||
from .middleware import ResponseRendererMiddleware | ||
from .parsers import JSONParser, RequestParser, URLEncodingParser | ||
from .renderers import JSONRenderer, ResponseRenderer | ||
from .router import Include, Route, Router | ||
from .testing import TestClient, to_environ | ||
from .typing import Header, Host, Method, Port, QueryParam, QueryString, RequestBody, RequestData, RequestInput, Scheme | ||
|
||
__version__ = "0.1.0" | ||
|
||
__all__ = [ | ||
"BaseApp", "App", | ||
|
||
# Router | ||
"Router", "Route", "Include", | ||
|
||
# HTTP | ||
"Method", "Scheme", "Host", "Port", "QueryString", "QueryParams", "QueryParam", | ||
"Headers", "Header", "RequestInput", "RequestBody", "RequestData", | ||
"Request", "Response", | ||
|
||
# Dependency-injection | ||
"DependencyInjector", "DependencyResolver", "Component", | ||
|
||
# Parsers | ||
"RequestParser", "JSONParser", "URLEncodingParser", | ||
|
||
# Renderers | ||
"ResponseRenderer", "JSONRenderer", | ||
|
||
# Middleware | ||
"ResponseRendererMiddleware", | ||
|
||
# Errors | ||
"MoltenError", "DIError", "HTTPError", "HeaderMissing", "ParamMissing", "RequestParserNotAvailable", | ||
|
||
# Testing | ||
"TestClient", "to_environ", | ||
|
||
# Status codes | ||
# 1xx | ||
"HTTP_100", "HTTP_101", "HTTP_102", | ||
|
||
# 2xx | ||
"HTTP_200", "HTTP_201", "HTTP_202", "HTTP_203", "HTTP_204", "HTTP_205", "HTTP_206", "HTTP_207", "HTTP_208", | ||
|
||
# 3xx | ||
"HTTP_300", "HTTP_301", "HTTP_302", "HTTP_303", "HTTP_304", "HTTP_305", "HTTP_307", "HTTP_308", | ||
|
||
# 4xx | ||
"HTTP_400", "HTTP_401", "HTTP_402", "HTTP_403", "HTTP_404", "HTTP_405", "HTTP_406", "HTTP_407", "HTTP_408", | ||
"HTTP_409", "HTTP_410", "HTTP_411", "HTTP_412", "HTTP_413", "HTTP_414", "HTTP_415", "HTTP_416", "HTTP_417", | ||
"HTTP_418", "HTTP_421", "HTTP_422", "HTTP_423", "HTTP_424", "HTTP_426", "HTTP_428", "HTTP_429", "HTTP_431", | ||
"HTTP_444", "HTTP_451", "HTTP_499", | ||
|
||
# 5xx | ||
"HTTP_500", "HTTP_501", "HTTP_502", "HTTP_503", "HTTP_504", "HTTP_505", "HTTP_506", "HTTP_507", "HTTP_508", | ||
"HTTP_510", "HTTP_511", "HTTP_599", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import logging | ||
import sys | ||
from functools import partial | ||
from typing import Any, Callable, Iterable, List, Optional | ||
from wsgiref.util import FileWrapper # type: ignore | ||
|
||
from .components import HeaderComponent, QueryParamComponent, RequestBodyComponent, RequestDataComponent | ||
from .dependency_injection import Component, DependencyInjector | ||
from .errors import RequestParserNotAvailable | ||
from .http import HTTP_204, HTTP_404, HTTP_415, HTTP_500, Headers, QueryParams, Request, Response | ||
from .middleware import ResponseRendererMiddleware | ||
from .parsers import JSONParser, RequestParser, URLEncodingParser | ||
from .renderers import JSONRenderer, ResponseRenderer | ||
from .router import RouteLike, Router | ||
from .typing import Environ, Host, Method, Port, QueryString, RequestInput, Scheme, StartResponse | ||
|
||
LOGGER = logging.getLogger(__name__) | ||
|
||
#: The type of middleware functions. | ||
Middleware = Callable[[Callable[..., Any]], Callable[..., Any]] | ||
|
||
|
||
class BaseApp: | ||
"""Base class for App implementations. | ||
""" | ||
|
||
def __init__( | ||
self, | ||
routes: Optional[List[RouteLike]] = None, | ||
middleware: Optional[List[Middleware]] = None, | ||
components: Optional[List[Component]] = None, | ||
parsers: Optional[List[RequestParser]] = None, | ||
renderers: Optional[List[ResponseRenderer]] = None, | ||
) -> None: | ||
self.router = Router(routes) | ||
self.add_route = self.router.add_route | ||
self.add_routes = self.router.add_routes | ||
|
||
self.parsers = parsers or [ | ||
JSONParser(), | ||
URLEncodingParser(), | ||
] | ||
self.renderers = renderers or [JSONRenderer()] | ||
self.middleware = middleware or [ | ||
ResponseRendererMiddleware(self.renderers) | ||
] | ||
self.components = (components or []) + [ | ||
HeaderComponent(), | ||
QueryParamComponent(), | ||
RequestBodyComponent(), | ||
RequestDataComponent(self.parsers) | ||
] | ||
self.injector = DependencyInjector(self.components) | ||
|
||
def handle_404(self) -> Response: | ||
"""Called whenever a route cannot be found. | ||
""" | ||
return Response(HTTP_404, content="Not Found") | ||
|
||
def handle_415(self) -> Response: | ||
"""Called whenever a request comes in with an unsupported | ||
content type. | ||
""" | ||
return Response(HTTP_415, content="Unsupported Media Type") | ||
|
||
def handle_exception(self, exception: BaseException) -> Response: | ||
"""Called whenever an unhandled exception occurs in middleware | ||
or a handler. Dependencies are injected into this just like a | ||
normal handler. | ||
""" | ||
LOGGER.exception("An unhandled exception occurred.") | ||
return Response(HTTP_500, content="Internal Server Error") | ||
|
||
def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]: # pragma: no cover | ||
raise NotImplementedError("apps must implement '__call__'") | ||
|
||
|
||
class App(BaseApp): | ||
"""An application that implements the WSGI interface. | ||
""" | ||
|
||
def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]: | ||
request = Request.from_environ(environ) | ||
resolver = self.injector.get_resolver({ | ||
Request: request, | ||
Method: Method(request.method), | ||
Scheme: Scheme(request.scheme), | ||
Host: Host(request.host), | ||
Port: Port(request.port), | ||
QueryString: QueryString(environ["QUERY_STRING"]), | ||
QueryParams: request.params, | ||
Headers: request.headers, | ||
RequestInput: RequestInput(request.body_file), | ||
}) | ||
|
||
try: | ||
route_and_params = self.router.match(request.method, request.path) | ||
if route_and_params is not None: | ||
route, params = route_and_params | ||
handler: Callable[..., Any] = partial(route.handler, **params) | ||
else: | ||
params = {} | ||
handler = self.handle_404 | ||
|
||
handler = resolver.resolve(handler, params) | ||
for middleware in reversed(self.middleware): | ||
handler = resolver.resolve(middleware(handler)) | ||
|
||
exc_info = None | ||
response = handler() | ||
except RequestParserNotAvailable: | ||
exc_info = None | ||
response = resolver.resolve(self.handle_415)() | ||
except Exception as e: | ||
exc_info = sys.exc_info() | ||
response = resolver.resolve(self.handle_exception, {"exception": e})() | ||
|
||
response.headers.add("content-length", str(response.content_length)) | ||
start_response(response.status, list(response.headers), exc_info) | ||
if response.status != HTTP_204: | ||
wrapper = environ.get("wsgi.file_wrapper", FileWrapper) | ||
return wrapper(response.stream) | ||
else: | ||
return [] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
from collections import defaultdict | ||
from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union | ||
|
||
Mapping = Union[Dict[str, Union[str, List[str]]], List[Tuple[str, str]]] | ||
|
||
|
||
class MultiDict(Iterable[Tuple[str, str]]): | ||
"""A mapping from param names to lists of values. Once | ||
constructed, these instances cannot be modified. | ||
""" | ||
|
||
__slots__ = ["_data"] | ||
|
||
def __init__(self, mapping: Optional[Mapping] = None) -> None: | ||
self._data: Dict[str, List[str]] = defaultdict(list) | ||
self._add_all(mapping or {}) | ||
|
||
def _add(self, name: str, value: Union[str, List[str]]) -> None: | ||
"""Add values for a particular key. | ||
""" | ||
if isinstance(value, list): | ||
self._data[name].extend(value) | ||
else: | ||
self._data[name].append(value) | ||
|
||
def _add_all(self, mapping: Mapping) -> None: | ||
"""Add a group of values. | ||
""" | ||
items: Iterable[Tuple[str, Union[str, List[str]]]] | ||
|
||
if isinstance(mapping, dict): | ||
items = mapping.items() | ||
else: | ||
items = mapping | ||
|
||
for name, value_or_values in items: | ||
self._add(name, value_or_values) | ||
|
||
def get(self, name: str, default: Optional[str] = None) -> Optional[str]: | ||
"""Get the last value for a given key. | ||
""" | ||
try: | ||
return self[name] | ||
except KeyError: | ||
return default | ||
|
||
def get_all(self, name: str) -> List[str]: | ||
"""Get all the values for a given key. | ||
""" | ||
return self._data[name] | ||
|
||
def __getitem__(self, name: str) -> str: | ||
"""Get the last value for a given key. | ||
Raises: | ||
KeyError: When the key is missing. | ||
""" | ||
try: | ||
return self._data[name][-1] | ||
except IndexError: | ||
raise KeyError(name) | ||
|
||
def __iter__(self) -> Iterator[Tuple[str, str]]: | ||
"""Iterate over all the parameters. | ||
""" | ||
for name, values in self._data.items(): | ||
for value in values: | ||
yield name, value | ||
|
||
def __repr__(self) -> str: | ||
mapping = ", ".join(f"{repr(name)}: {repr(value)}" for name, value in self._data.items()) | ||
return f"{type(self).__name__}({{{mapping}}})" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
from inspect import Parameter | ||
from typing import List, Optional, TypeVar | ||
|
||
from .dependency_injection import DependencyResolver | ||
from .errors import HeaderMissing, HTTPError, ParamMissing, RequestParserNotAvailable | ||
from .http import HTTP_400, Headers, QueryParams | ||
from .parsers import RequestParser | ||
from .typing import Header, QueryParam, RequestBody, RequestData, RequestInput, extract_optional_annotation | ||
|
||
_T = TypeVar("_T") | ||
|
||
|
||
class HeaderComponent: | ||
"""Retrieves a named header from the request. | ||
""" | ||
|
||
is_cacheable = False | ||
is_singleton = False | ||
|
||
def can_handle_parameter(self, parameter: Parameter) -> bool: | ||
_, annotation = extract_optional_annotation(parameter.annotation) | ||
return annotation is Header | ||
|
||
def resolve(self, parameter: Parameter, headers: Headers) -> Optional[str]: | ||
is_optional, _ = extract_optional_annotation(parameter.annotation) | ||
header_name = parameter.name.replace("_", "-") | ||
|
||
try: | ||
return headers[header_name] | ||
except HeaderMissing: | ||
if is_optional: | ||
return None | ||
|
||
raise HTTPError(HTTP_400, {header_name: "missing"}) | ||
|
||
|
||
class QueryParamComponent: | ||
"""Retrieves a named query param from the request. | ||
""" | ||
|
||
is_cacheable = False | ||
is_singleton = False | ||
|
||
def can_handle_parameter(self, parameter: Parameter) -> bool: | ||
_, annotation = extract_optional_annotation(parameter.annotation) | ||
return annotation is QueryParam | ||
|
||
def resolve(self, parameter: Parameter, params: QueryParams) -> Optional[str]: | ||
is_optional, _ = extract_optional_annotation(parameter.annotation) | ||
|
||
try: | ||
return params[parameter.name] | ||
except ParamMissing: | ||
if is_optional: | ||
return None | ||
|
||
raise HTTPError(HTTP_400, {parameter.name: "missing"}) | ||
|
||
|
||
class RequestBodyComponent: | ||
"""A component that reads the entire request body into a string. | ||
""" | ||
|
||
is_cacheable = True | ||
is_singleton = False | ||
|
||
def can_handle_parameter(self, parameter: Parameter) -> bool: | ||
return parameter.annotation is RequestBody | ||
|
||
def resolve(self, content_length: Header, body_file: RequestInput) -> RequestBody: | ||
return RequestBody(body_file.read(int(content_length))) | ||
|
||
|
||
class RequestDataComponent: | ||
"""A component that parses request data. | ||
""" | ||
|
||
is_cacheable = True | ||
is_singleton = False | ||
|
||
def __init__(self, parsers: List[RequestParser]) -> None: | ||
self.parsers = parsers | ||
|
||
def can_handle_parameter(self, parameter: Parameter) -> bool: | ||
return parameter.annotation is RequestData | ||
|
||
def resolve(self, content_type: Optional[Header], resolver: DependencyResolver) -> RequestData: | ||
content_type_str = content_type or "" | ||
for parser in self.parsers: | ||
if parser.can_parse_content(content_type_str.lower()): | ||
return RequestData(resolver.resolve(parser.parse)()) | ||
raise RequestParserNotAvailable(content_type_str) |
Oops, something went wrong.