Source code for molten.app

# This file is a part of molten.
#
# Copyright (C) 2018 CLEARTYPE SRL <[email protected]>
#
# molten is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at
# your option) any later version.
#
# molten is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import logging
import sys
from typing import Any, Callable, Iterable, List, Optional
from wsgiref.util import FileWrapper  # type: ignore

from .components import (
    CookiesComponent, HeaderComponent, QueryParamComponent, RequestBodyComponent,
    RequestDataComponent, RouteComponent, RouteParamsComponent, SchemaComponent,
    UploadedFileComponent
)
from .dependency_injection import Component, DependencyInjector
from .errors import ParseError, RequestHandled, RequestParserNotAvailable
from .http import (
    HTTP_204, HTTP_400, HTTP_404, HTTP_415, HTTP_500, Headers, QueryParams, Request, Response
)
from .parsers import JSONParser, MultiPartParser, RequestParser, URLEncodingParser
from .renderers import JSONRenderer, ResponseRenderer
from .router import RouteLike, Router
from .typing import (
    Environ, Host, Method, Middleware, Port, QueryString, RequestInput, Scheme, StartResponse
)

try:
    from gunicorn.http.errors import NoMoreData
except ImportError:  # pragma: no cover
    pass

LOGGER = logging.getLogger(__name__)


class BaseApp:
    """Base class for App implementations.

    Parameters:
      routes: An optional list of routes to register with the router.
      middleware: An optional list of middleware.  If provided, this
        replaces the default set of middleware, including the response
        renderer so make sure to include that in your middleware list.
      parsers: An optional list of request parsers to use.  If
        provided, this replaces the default list of request parsers.
      renderers: An optional list of response renderers.  If provided,
        this replaces the default list of response renderers.
    """

    def __init__(
            self,
            routes: Optional[List[RouteLike]] = None,
            middleware: Optional[List[Middleware]] = None,
            components: Optional[List[Component[Any]]] = None,
            parsers: Optional[List[RequestParser]] = None,
            renderers: Optional[List[ResponseRenderer]] = None,
    ) -> None:
        # ResponseRendererMiddleware needs to be able to look up
        # middleware off of the app which leads to an import cycle
        # since the two are dependant on one another.
        from .middleware import ResponseRendererMiddleware

        self.router = Router(routes)
        self.add_route = self.router.add_route
        self.add_routes = self.router.add_routes
        self.reverse_uri = self.router.reverse_uri

        self.parsers = parsers or [
            JSONParser(),
            URLEncodingParser(),
            MultiPartParser(),
        ]
        self.renderers = renderers or [
            JSONRenderer(),
        ]
        self.middleware = middleware or [
            ResponseRendererMiddleware()
        ]
        self.components = (components or []) + [
            HeaderComponent(),
            CookiesComponent(),
            QueryParamComponent(),
            RequestBodyComponent(),
            RequestDataComponent(self.parsers),
            SchemaComponent(),
            UploadedFileComponent(),
        ]
        self.injector = DependencyInjector(
            components=self.components,
            singletons={BaseApp: self},  # type: ignore
        )

    def handle_404(self) -> Response:
        """Called whenever a route cannot be found.  Dependencies are
        injected into this just like a normal handler.
        """
        return Response(HTTP_404, content="Not Found")

    def handle_415(self) -> Response:
        """Called whenever a request comes in with an unsupported
        content type.  Dependencies are injected into this just like a
        normal handler.
        """
        return Response(HTTP_415, content="Unsupported Media Type")

    def handle_parse_error(self, exception: ParseError) -> Response:
        """Called whenever a request comes in with a payload that fails
        to parse.  Dependencies are injected into this just like a
        normal handler.

        Parameters:
          exception: The ParseError that was raised by the request
            parser on failure.
        """
        LOGGER.warning("Request cannot be parsed: %s", exception)
        return Response(HTTP_400, content=f"Request cannot be parsed: {exception}")

    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.

        Parameters:
          exception: The exception that occurred.
        """
        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__'")


[docs]class App(BaseApp): """An application that implements the WSGI interface. Parameters: routes: An optional list of routes to register with the router. middleware: An optional list of middleware. If provided, this replaces the default set of middleware, including the response renderer so make sure to include that in your middleware list. parsers: An optional list of request parsers to use. If provided, this replaces the default list of request parsers. renderers: An optional list of response renderers. If provided, this replaces the default list of response renderers. """ def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]: request = Request.from_environ(environ) resolver = self.injector.get_resolver({ Environ: environ, Headers: request.headers, Host: Host(request.host), Method: Method(request.method), Port: Port(request.port), QueryParams: request.params, QueryString: QueryString(environ.get("QUERY_STRING", "")), Request: request, RequestInput: RequestInput(request.body_file), Scheme: Scheme(request.scheme), StartResponse: start_response, # type: ignore }) try: handler: Callable[..., Any] route_and_params = self.router.match(request.method, request.path) if route_and_params is not None: route, params = route_and_params handler = route.handler resolver.add_component(RouteComponent(route)) resolver.add_component(RouteParamsComponent(params)) else: handler = self.handle_404 resolver.add_component(RouteComponent(None)) handler = resolver.resolve(handler) for middleware in reversed(self.middleware): handler = resolver.resolve(middleware(handler)) exc_info = None response = handler() except RequestHandled: # This is used to break out of gunicorn's keep-alive loop. # If we don't do this, then gunicorn might attempt to read # from a closed socket. raise NoMoreData() except RequestParserNotAvailable: exc_info = None response = resolver.resolve(self.handle_415)() except ParseError as e: exc_info = None response = resolver.resolve(self.handle_parse_error)(exception=e) except Exception as e: exc_info = sys.exc_info() response = resolver.resolve(self.handle_exception)(exception=e) content_length = response.get_content_length() if content_length is not None: response.headers.add("content-length", str(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 []