diff --git a/.travis.yml b/.travis.yml index 7ff0893..8ee83db 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ language: python python: - '3.6' env: - - SANIC=19.12.2 SPF=0.9.0.b1 + - SANIC=19.12.2 SPF=0.9.1 install: - pip install -U setuptools pep8 coverage docutils pygments aiohttp sanic==$SANIC sanic-plugins-framework==$SPF script: diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c0d43..bed591c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,22 @@ # Change Log +## 0.10.0.post1 +- Fixed the errors seen in Sanic 19.12+ where the CORS exception handler could be triggered + _before_ the request context for a given request is created. +- If on Sanic 19.9+ fallback to using the request.ctx object when request_context is not available +- Fixes #41 + +## 0.10.0 +- Fixed catch LookupError when request context doesn't exist +- Release 0.10.0 + ## 0.10.0.b1 - New minimum supported sanic version is 18.12LTS - Fixed bugs with Sanic 19.12 - Max supported sanic version for this release series is unknown for now. +## _**Note**_, Sanic v19.12.0 (and 19.12.2) _do not_ work with Sanic-CORS 0.9.9 series or earlier. -A new version coming out soon will work with sanic v19.12. ## 0.9.9.post4 This is the last version of sanic-cors to support Sanic 0.8.3 diff --git a/sanic_cors/core.py b/sanic_cors/core.py index 1167653..3a22d56 100644 --- a/sanic_cors/core.py +++ b/sanic_cors/core.py @@ -224,26 +224,22 @@ def get_cors_headers(options, request_headers, request_method): return CIMultiDict((k, v) for k, v in headers.items() if v) -def set_cors_headers(req, resp, context, options): +def set_cors_headers(req, resp, req_context, options): """ Performs the actual evaluation of Sanic-CORS options and actually modifies the response object. - This function is used both in the decorator and the after_request - callback + This function is used in the decorator, the CORS exception wrapper, + and the after_request callback :param sanic.request.Request req: """ - try: - request_context = context.request[id(req)] - except (AttributeError, LookupError): - LOG.debug("Cannot find the request context. Is request already finished?") - return resp # If CORS has already been evaluated via the decorator, skip - evaluated = request_context.get(SANIC_CORS_EVALUATED, False) - if evaluated: - LOG.debug('CORS have been already evaluated, skipping') - return resp + if req_context is not None: + evaluated = getattr(req_context, SANIC_CORS_EVALUATED, False) + if evaluated: + LOG.debug('CORS have been already evaluated, skipping') + return resp # `resp` can be None or [] in the case of using Websockets # however this case should have been handled in the `extension` and `decorator` methods diff --git a/sanic_cors/extension.py b/sanic_cors/extension.py index 9c9909c..97562eb 100644 --- a/sanic_cors/extension.py +++ b/sanic_cors/extension.py @@ -8,10 +8,12 @@ :copyright: (c) 2020 by Ashley Sommer (based on flask-cors by Cory Dolphin). :license: MIT, see LICENSE for more details. """ +from asyncio import iscoroutinefunction from functools import update_wrapper, partial from inspect import isawaitable from sanic import exceptions, response, __version__ as sanic_version +from sanic.exceptions import MethodNotSupported, NotFound from sanic.handlers import ErrorHandler from spf import SanicPlugin from .core import * @@ -21,6 +23,9 @@ SANIC_VERSION = LooseVersion(sanic_version) SANIC_18_12_0 = LooseVersion("18.12.0") +SANIC_19_9_0 = LooseVersion("19.9.0") +SANIC_19_12_0 = LooseVersion("19.12.0") + class CORS(SanicPlugin): @@ -207,9 +212,19 @@ async def route_wrapper(self, route, req, context, request_args, request_kw, # resp can be `None` or `[]` if using Websockets if not resp: return None - request_context = context.request[id(req)] - set_cors_headers(req, resp, context, options) - request_context[SANIC_CORS_EVALUATED] = "1" + try: + request_context = context.request[id(req)] + except (AttributeError, LookupError): + if SANIC_19_9_0 <= SANIC_VERSION: + request_context = req.ctx + else: + request_context = None + set_cors_headers(req, resp, request_context, options) + if request_context is not None: + setattr(request_context, SANIC_CORS_EVALUATED, "1") + else: + context.log(logging.DEBUG, "Cannot access a sanic request " + "context. Has request started? Is request ended?") return resp def unapplied_cors_request_middleware(req, context): @@ -227,9 +242,20 @@ def unapplied_cors_request_middleware(req, context): "Using options: {}".format( path, get_regexp_pattern(res_regex), res_options)) resp = response.HTTPResponse() - request_context = context.request[id(req)] - set_cors_headers(req, resp, context, res_options) - request_context[SANIC_CORS_EVALUATED] = "1" + + try: + request_context = context.request[id(req)] + except (AttributeError, LookupError): + if SANIC_19_9_0 <= SANIC_VERSION: + request_context = req.ctx + else: + request_context = None + context.log(logging.DEBUG, + "Cannot access a sanic request " + "context. Has request started? Is request ended?") + set_cors_headers(req, resp, request_context, res_options) + if request_context is not None: + setattr(req.ctx, SANIC_CORS_EVALUATED, "1") return resp else: debug('No CORS rule matches') @@ -238,23 +264,29 @@ def unapplied_cors_request_middleware(req, context): async def unapplied_cors_response_middleware(req, resp, context): log = context.log debug = partial(log, logging.DEBUG) - try: - request_context = context.request[id(req)] - except (AttributeError, LookupError): - debug("Cannot find the request context. Is request already finished?") - return False # `resp` can be None or [] in the case of using Websockets if not resp: return False - if request_context.get(SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE): - debug('CORS was handled in the exception handler, skipping') - return False - - # If CORS headers are set in a view decorator, pass - elif request_context.get(SANIC_CORS_EVALUATED): - debug('CORS have been already evaluated, skipping') - return False - + try: + request_context = context.request[id(req)] + except (AttributeError, LookupError): + if SANIC_19_9_0 <= SANIC_VERSION: + request_context = req.ctx + else: + debug("Cannot find the request context. " + "Is request already finished? Is request not started?") + request_context = None + if request_context is not None: + # If CORS headers are set in the CORS error handler + if getattr(request_context, + SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE, False): + debug('CORS was handled in the exception handler, skipping') + return False + + # If CORS headers are set in a view decorator, pass + elif getattr(request_context, SANIC_CORS_EVALUATED, False): + debug('CORS have been already evaluated, skipping') + return False try: path = req.path except AttributeError: @@ -265,8 +297,9 @@ async def unapplied_cors_response_middleware(req, resp, context): if try_match(path, res_regex): debug("Request to '{}' matches CORS resource '{:s}'. Using options: {}".format( path, get_regexp_pattern(res_regex), res_options)) - set_cors_headers(req, resp, context, res_options) - request_context[SANIC_CORS_EVALUATED] = "1" + set_cors_headers(req, resp, request_context, res_options) + if request_context is not None: + setattr(request_context, SANIC_CORS_EVALUATED, "1") break else: debug('No CORS rule matches') @@ -296,6 +329,13 @@ def _apply_cors_to_exception(cls, ctx, req, resp): resources = ctx.resources log = ctx.log debug = partial(log, logging.DEBUG) + try: + request_context = ctx.request[id(req)] + except (AttributeError, LookupError): + if SANIC_19_9_0 <= SANIC_VERSION: + request_context = req.ctx + else: + request_context = None for res_regex, res_options in resources: if try_match(path, res_regex): debug( @@ -303,7 +343,7 @@ def _apply_cors_to_exception(cls, ctx, req, resp): "Using options: {}".format( path, get_regexp_pattern(res_regex), res_options)) - set_cors_headers(req, resp, ctx, res_options) + set_cors_headers(req, resp, request_context, res_options) break else: debug('No CORS rule matches') @@ -325,10 +365,13 @@ def lookup(self, exception): # wrap app's original exception response function # so that error responses have proper CORS headers @classmethod - def wrapper(cls, f, ctx, req, e): + async def wrapper(cls, f, ctx, req, e): opts = ctx.options # get response from the original handler + do_await = iscoroutinefunction(f) resp = f(req, e) + if do_await: + resp = await resp # SanicExceptions are equiv to Flask Aborts, # always apply CORS to them. if (req is not None and resp is not None) and \ @@ -340,22 +383,34 @@ def wrapper(cls, f, ctx, req, e): # not sure why certain exceptions doesn't have # an accompanying request pass - if req is not None: - # These exceptions have normal CORS middleware applied automatically. - # So set a flag to skip our manual application of the middleware. - try: - request_context = ctx.request[id(req)] - request_context[SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE] = "1" - except (LookupError, AttributeError): + if req is None: + return resp + # These exceptions have normal CORS middleware applied automatically. + # So set a flag to skip our manual application of the middleware. + try: + request_context = ctx.request[id(req)] + except (LookupError, AttributeError): + # On Sanic 19.12.0, a NotFound error can be thrown _before_ + # the request_context is set up. This is a fallback routine: + if SANIC_19_12_0 <= SANIC_VERSION and \ + isinstance(e, (NotFound, MethodNotSupported)): + # On sanic 19.9.0+ request is a dict, so we can add our + # flag directly to it. + request_context = req.ctx + else: log = ctx.log log(logging.DEBUG, - "Cannot find the request context. " + "Cannot find the request context. Is request started? " "Is request already finished?") + request_context = None + if request_context is not None: + setattr(request_context, + SANIC_CORS_SKIP_RESPONSE_MIDDLEWARE, "1") return resp - def response(self, request, exception): + async def response(self, request, exception): orig_resp_handler = self.orig_handler.response - return self.wrapper(orig_resp_handler, self.ctx, request, exception) + return await self.wrapper(orig_resp_handler, self.ctx, request, exception) instance = cors = CORS() __all__ = ["cors", "CORS"] diff --git a/sanic_cors/version.py b/sanic_cors/version.py index 9d1bb72..04d75ba 100644 --- a/sanic_cors/version.py +++ b/sanic_cors/version.py @@ -1 +1 @@ -__version__ = '0.10.0' +__version__ = '0.10.0.post1'