From 25a046b93dedf6aabde12590ed7294bb73b5d97f Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Fri, 21 Dec 2018 12:43:24 -0800 Subject: [PATCH 1/8] Add support for blueprints This adds support for Flask style blueprints. As part of this change, the internals app.py have been refactored to remove duplication when registering resources. This should make it easier to refactor the event sources into separate classes in the future. --- chalice/__init__.py | 2 +- chalice/app.py | 442 +++++++++++++++++++++--------- chalice/app.pyi | 4 + docs/source/index.rst | 1 + docs/source/topics/blueprints.rst | 198 +++++++++++++ tests/unit/test_app.py | 165 ++++++++++- 6 files changed, 673 insertions(+), 139 deletions(-) create mode 100644 docs/source/topics/blueprints.rst diff --git a/chalice/__init__.py b/chalice/__init__.py index ddbbcdfed..e6b33c65d 100644 --- a/chalice/__init__.py +++ b/chalice/__init__.py @@ -1,4 +1,4 @@ -from chalice.app import Chalice +from chalice.app import Chalice, Blueprint from chalice.app import ( ChaliceViewError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, Response, CORSConfig, diff --git a/chalice/app.py b/chalice/app.py index 6d1acb4a4..a226e8670 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -441,22 +441,250 @@ def default_binary_types(self): return list(self._DEFAULT_BINARY_TYPES) -class Chalice(object): +class DecoratorAPI(object): + def authorizer(self, ttl_seconds=None, execution_role=None, name=None): + return self._create_registration_function( + handler_type='authorizer', + name=name, + registration_kwargs={ + 'ttl_seconds': ttl_seconds, 'execution_role': execution_role, + } + ) + + def on_s3_event(self, bucket, events=None, + prefix=None, suffix=None, name=None): + return self._create_registration_function( + handler_type='on_s3_event', + name=name, + registration_kwargs={ + 'bucket': bucket, 'events': events, + 'prefix': prefix, 'suffix': suffix, + } + ) + + def on_sns_message(self, topic, name=None): + return self._create_registration_function( + handler_type='on_sns_message', + name=name, + registration_kwargs={'topic': topic} + ) + + def on_sqs_message(self, queue, batch_size=1, name=None): + return self._create_registration_function( + handler_type='on_sqs_message', + name=name, + registration_kwargs={'queue': queue, 'batch_size': batch_size} + ) + + def schedule(self, expression, name=None): + return self._create_registration_function( + handler_type='schedule', + name=name, + registration_kwargs={'expression': expression}, + ) + + def route(self, path, **kwargs): + return self._create_registration_function( + handler_type='route', + name=kwargs.get('name'), + # This looks a little weird taking kwargs as a key, + # but to preserve backwards compatibility we have to + # keep the **kwargs signature in the route decorator. + registration_kwargs={'path': path, 'kwargs': kwargs}, + ) + + def lambda_function(self, name=None): + return self._create_registration_function( + handler_type='lambda_function', name=name) + + def _create_registration_function(self, handler_type, name=None, + registration_kwargs=None): + def _register_handler(user_handler): + handler_name = name + if handler_name is None: + handler_name = user_handler.__name__ + if registration_kwargs is not None: + kwargs = registration_kwargs + else: + kwargs = {} + wrapped = self._wrap_handler(handler_type, handler_name, + user_handler, kwargs) + self._register_handler(handler_type, handler_name, + user_handler, wrapped, kwargs) + return wrapped + return _register_handler + + def _wrap_handler(self, handler_type, handler_name, user_handler, kwargs): + event_classes = { + 'on_s3_event': S3Event, + 'on_sns_message': SNSEvent, + 'on_sqs_message': SQSEvent, + 'schedule': CloudWatchEvent, + } + if handler_type in event_classes: + return EventSourceHandler( + user_handler, event_classes[handler_type]) + if handler_type == 'authorizer': + # Authorizer is special cased and doesn't quite fit the + # EventSourceHandler pattern. + return ChaliceAuthorizer(handler_name, user_handler) + return user_handler + + def _register_handler(self, handler_type, name, + user_handler, wrapped_handler, kwargs, options=None): + raise NotImplementedError("_register_handler") + + +class _HandlerRegistration(object): + + def __init__(self): + self.routes = defaultdict(dict) + self.builtin_auth_handlers = [] + self.event_sources = [] + self.pure_lambda_functions = [] + + def _do_register_handler(self, handler_type, name, user_handler, + wrapped_handler, kwargs, options=None): + url_prefix = None + name_prefix = None + module_name = 'app' + if options is not None: + name_prefix = options.get('name_prefix') + if name_prefix is not None: + name = name_prefix + name + url_prefix = options.get('url_prefix') + if url_prefix is not None: + # Move url_prefix into kwargs so only the + # route() handler gets a url_prefix kwarg. + kwargs['url_prefix'] = url_prefix + # module_name is always provided if options is not None. + module_name = options['module_name'] + handler_string = '%s.%s' % (module_name, user_handler.__name__) + getattr(self, '_register_%s' % handler_type)( + name=name, + user_handler=user_handler, + handler_string=handler_string, + wrapped_handler=wrapped_handler, + kwargs=kwargs, + ) + + def _register_lambda_function(self, name, user_handler, + handler_string, **unused): + wrapper = LambdaFunction( + user_handler, name=name, + handler_string=handler_string, + ) + self.pure_lambda_functions.append(wrapper) + + def _register_on_s3_event(self, name, handler_string, kwargs, **unused): + events = kwargs['events'] + if events is None: + events = ['s3:ObjectCreated:*'] + s3_event = S3EventConfig( + name=name, + bucket=kwargs['bucket'], + events=events, + prefix=kwargs['prefix'], + suffix=kwargs['suffix'], + handler_string=handler_string, + ) + self.event_sources.append(s3_event) + + def _register_on_sns_message(self, name, handler_string, kwargs, **unused): + sns_config = SNSEventConfig( + name=name, + handler_string=handler_string, + topic=kwargs['topic'], + ) + self.event_sources.append(sns_config) + + def _register_on_sqs_message(self, name, handler_string, kwargs, **unused): + sqs_config = SQSEventConfig( + name=name, + handler_string=handler_string, + queue=kwargs['queue'], + batch_size=kwargs['batch_size'], + ) + self.event_sources.append(sqs_config) + + def _register_schedule(self, name, handler_string, kwargs, **unused): + event_source = CloudWatchEventConfig( + name=name, + schedule_expression=kwargs['expression'], + handler_string=handler_string, + ) + self.event_sources.append(event_source) + + def _register_authorizer(self, name, handler_string, wrapped_handler, + kwargs, **unused): + actual_kwargs = kwargs.copy() + ttl_seconds = actual_kwargs.pop('ttl_seconds', None) + execution_role = actual_kwargs.pop('execution_role', None) + if actual_kwargs: + raise TypeError( + 'TypeError: authorizer() got unexpected keyword ' + 'arguments: %s' % ', '.join(list(actual_kwargs))) + auth_config = BuiltinAuthConfig( + name=name, + handler_string=handler_string, + ttl_seconds=ttl_seconds, + execution_role=execution_role, + ) + wrapped_handler.config = auth_config + self.builtin_auth_handlers.append(auth_config) + + def _register_route(self, name, user_handler, kwargs, **unused): + actual_kwargs = kwargs['kwargs'] + path = kwargs['path'] + url_prefix = kwargs.pop('url_prefix', None) + if url_prefix is not None: + path = '/'.join([url_prefix.rstrip('/'), + path.strip('/')]) + methods = actual_kwargs.pop('methods', ['GET']) + route_kwargs = { + 'authorizer': actual_kwargs.pop('authorizer', None), + 'api_key_required': actual_kwargs.pop('api_key_required', None), + 'content_types': actual_kwargs.pop('content_types', + ['application/json']), + 'cors': actual_kwargs.pop('cors', False), + } + if not isinstance(route_kwargs['content_types'], list): + raise ValueError( + 'In view function "%s", the content_types ' + 'value must be a list, not %s: %s' % ( + name, type(route_kwargs['content_types']), + route_kwargs['content_types'])) + if actual_kwargs: + raise TypeError('TypeError: route() got unexpected keyword ' + 'arguments: %s' % ', '.join(list(actual_kwargs))) + for method in methods: + if method in self.routes[path]: + raise ValueError( + "Duplicate method: '%s' detected for route: '%s'\n" + "between view functions: \"%s\" and \"%s\". A specific " + "method may only be specified once for " + "a particular path." % ( + method, path, self.routes[path][method].view_name, + name) + ) + entry = RouteEntry(user_handler, name, path, method, + **route_kwargs) + self.routes[path][method] = entry + + +class Chalice(_HandlerRegistration, DecoratorAPI): FORMAT_STRING = '%(name)s - %(levelname)s - %(message)s' def __init__(self, app_name, debug=False, configure_logs=True, env=None): + super(Chalice, self).__init__() self.app_name = app_name self.api = APIGateway() - self.routes = defaultdict(dict) self.current_request = None self.lambda_context = None self._debug = debug self.configure_logs = configure_logs self.log = logging.getLogger(self.app_name) - self.builtin_auth_handlers = [] - self.event_sources = [] - self.pure_lambda_functions = [] if env is None: env = os.environ self._initialize(env) @@ -506,136 +734,14 @@ def _configure_log_level(self): level = logging.ERROR self.log.setLevel(level) - def authorizer(self, name=None, **kwargs): - def _register_authorizer(auth_func): - auth_name = name - if auth_name is None: - auth_name = auth_func.__name__ - ttl_seconds = kwargs.pop('ttl_seconds', None) - execution_role = kwargs.pop('execution_role', None) - if kwargs: - raise TypeError( - 'TypeError: authorizer() got unexpected keyword ' - 'arguments: %s' % ', '.join(list(kwargs))) - auth_config = BuiltinAuthConfig( - name=auth_name, - handler_string='app.%s' % auth_func.__name__, - ttl_seconds=ttl_seconds, - execution_role=execution_role, - ) - self.builtin_auth_handlers.append(auth_config) - return ChaliceAuthorizer(auth_name, auth_func, auth_config) - return _register_authorizer - - def on_s3_event(self, bucket, events=None, - prefix=None, suffix=None, name=None): - def _register_s3_event(event_func): - handler_name = name - if handler_name is None: - handler_name = event_func.__name__ - trigger_events = events - if trigger_events is None: - trigger_events = ['s3:ObjectCreated:*'] - s3_event = S3EventConfig( - name=handler_name, - bucket=bucket, - events=trigger_events, - prefix=prefix, - suffix=suffix, - handler_string='app.%s' % event_func.__name__, - ) - self.event_sources.append(s3_event) - return EventSourceHandler(event_func, S3Event) - return _register_s3_event - - def on_sns_message(self, topic, name=None): - def _register_sns_message(event_func): - handler_name = name - if handler_name is None: - handler_name = event_func.__name__ - sns_config = SNSEventConfig( - name=handler_name, - handler_string='app.%s' % event_func.__name__, - topic=topic, - ) - self.event_sources.append(sns_config) - return EventSourceHandler(event_func, SNSEvent) - return _register_sns_message - - def on_sqs_message(self, queue, batch_size=1, name=None): - def _register_sqs_message(event_func): - handler_name = name - if handler_name is None: - handler_name = event_func.__name__ - sqs_config = SQSEventConfig( - name=handler_name, - handler_string='app.%s' % event_func.__name__, - queue=queue, - batch_size=batch_size, - ) - self.event_sources.append(sqs_config) - return EventSourceHandler(event_func, SQSEvent) - return _register_sqs_message - - def schedule(self, expression, name=None): - def _register_schedule(event_func): - handler_name = name - if handler_name is None: - handler_name = event_func.__name__ - event_source = CloudWatchEventConfig( - name=handler_name, - schedule_expression=expression, - handler_string='app.%s' % event_func.__name__) - self.event_sources.append(event_source) - return EventSourceHandler(event_func, CloudWatchEvent) - return _register_schedule - - def lambda_function(self, name=None): - def _register_lambda_function(lambda_func): - handler_name = name - if handler_name is None: - handler_name = lambda_func.__name__ - wrapper = LambdaFunction( - lambda_func, name=handler_name, - handler_string='app.%s' % lambda_func.__name__) - self.pure_lambda_functions.append(wrapper) - return wrapper - return _register_lambda_function + def register_blueprint(self, blueprint, name_prefix=None, url_prefix=None): + blueprint.register(self, options={'name_prefix': name_prefix, + 'url_prefix': url_prefix}) - def route(self, path, **kwargs): - def _register_view(view_func): - self._add_route(path, view_func, **kwargs) - return view_func - return _register_view - - def _add_route(self, path, view_func, **kwargs): - name = kwargs.pop('name', view_func.__name__) - methods = kwargs.pop('methods', ['GET']) - authorizer = kwargs.pop('authorizer', None) - api_key_required = kwargs.pop('api_key_required', None) - content_types = kwargs.pop('content_types', ['application/json']) - cors = kwargs.pop('cors', False) - if not isinstance(content_types, list): - raise ValueError('In view function "%s", the content_types ' - 'value must be a list, not %s: %s' - % (name, type(content_types), content_types)) - if kwargs: - raise TypeError('TypeError: route() got unexpected keyword ' - 'arguments: %s' % ', '.join(list(kwargs))) - for method in methods: - if method in self.routes[path]: - raise ValueError( - "Duplicate method: '%s' detected for route: '%s'\n" - "between view functions: \"%s\" and \"%s\". A specific " - "method may only be specified once for " - "a particular path." % ( - method, path, self.routes[path][method].view_name, - name) - ) - entry = RouteEntry(view_func, name, path, method, - api_key_required, content_types, - cors, authorizer) - self.routes[path][method] = entry + def _register_handler(self, handler_type, name, user_handler, + wrapped_handler, kwargs, options=None): + self._do_register_handler(handler_type, name, user_handler, + wrapped_handler, kwargs, options) def __call__(self, event, context): # This is what's invoked via lambda. @@ -785,13 +891,36 @@ def __init__(self, name, handler_string, ttl_seconds=None, self.execution_role = execution_role +# ChaliceAuthorizer is unique in that the runtime component (the thing +# that wraps the decorated function) also needs a reference to the config +# object (the object the describes how to create the resource). In +# most event sources these are separate and don't need to know about +# each other, but ChaliceAuthorizer does. This is because the way +# you associate a builtin authorizer with a view function is by passing +# a direct reference: +# +# @app.authorizer(...) +# def my_auth_function(...): pass +# +# @app.route('/', auth=my_auth_function) +# +# The 'route' part needs to know about the auth function for two reasons: +# +# 1. We use ``view.authorizer`` to figure out how to deploy the app +# 2. We need a reference to the runtime handler for the auth in order +# to support local mode testing. +# I *think* we can refactor things to handle both of those issues but +# we would need more research to know for sure. For now, this is a +# special cased runtime class that knows about its config. class ChaliceAuthorizer(object): - def __init__(self, name, func, config): + def __init__(self, name, func): self.name = name self.func = func - self.config = config + # This is filled in during the @app.authorizer() + # processing. + self.config = None - def __call__(self, event, content): + def __call__(self, event, context): auth_request = self._transform_event(event) result = self.func(auth_request) if isinstance(result, AuthResponse): @@ -1054,3 +1183,42 @@ class SQSRecord(BaseLambdaEvent): def _extract_attributes(self, event_dict): self.body = event_dict['body'] self.receipt_handle = event_dict['receiptHandle'] + + +class Blueprint(DecoratorAPI): + def __init__(self, import_name, name_prefix=None, url_prefix=None): + self._import_name = import_name + self._name_prefix = name_prefix + self._url_prefix = url_prefix + self._deferred_registrations = [] + self._current_app = None + + @property + def current_request(self): + if self._current_app is None: + raise RuntimeError( + "Can only access Blueprint.current_request if it's registered " + "to an app." + ) + return self._current_app.current_request + + def register(self, app, options): + self._current_app = app + all_options = options.copy() + all_options['module_name'] = self._import_name + for function in self._deferred_registrations: + function(app, all_options) + + def _register_handler(self, handler_type, name, user_handler, + wrapped_handler, kwargs, options=None): + # If we go through the public API (app.route, app.schedule, etc) then + # we have to duplicate either the methods or the params in this + # class. We're using _register_handler as a tradeoff for cutting + # down on the duplication. + self._deferred_registrations.append( + # pylint: disable=protected-access + lambda app, options: app._register_handler( + handler_type, name, user_handler, wrapped_handler, + kwargs, options + ) + ) diff --git a/chalice/app.pyi b/chalice/app.pyi index 938259b2a..a871c478d 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -222,3 +222,7 @@ class SQSEventConfig(BaseEventSourceConfig): class CloudWatchEventConfig(BaseEventSourceConfig): schedule_expression = ... # type: Union[str, ScheduleExpression] + + +class Blueprint(Chalice): + pass diff --git a/docs/source/index.rst b/docs/source/index.rst index ede1a9b69..71631b416 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -63,6 +63,7 @@ Topics topics/authorizers topics/events topics/purelambda + topics/blueprints topics/cd diff --git a/docs/source/topics/blueprints.rst b/docs/source/topics/blueprints.rst new file mode 100644 index 000000000..3e8dbd13f --- /dev/null +++ b/docs/source/topics/blueprints.rst @@ -0,0 +1,198 @@ +Blueprints +========== + + +Chalice blueprints are used to organize your application into logical +components. Using a blueprint, you define your resources and decorators in +modules outside of your ``app.py``. You then register a blueprint in your main +``app.py`` file. Any decorator supported on an application object is also +supported in a blueprint. + + +.. note:: + + The Chalice blueprints are conceptually similar to `Blueprints + `__ in Flask. Flask + blueprints allow you to define a set of URL routes separately from the main + ``Flask`` object. This concept is extended to all resources in Chalice. A + Chalice blueprint can have Lambda functions, event handlers, built in + authorizers, etc. in addition to a collection of routes. + + +Example +------- + +In this example we'll create a blueprint with part of our routes defined in a +separate file. First, let's create an application:: + + $ chalice new-project blueprint-demo + $ cd blueprint-demo + $ mkdir chalicelib + $ touch chalicelib/__init__.py + $ touch chalicelib/blueprints.py + +Next, we'll oen the ``chalicelib/blueprints.py`` file: + +.. code-block:: python + + from chalice import Blueprint + + + extra_routes = Blueprint(__name__) + + + @extra_routes.route('/foo') + def foo(): + return {'foo': 'bar'} + + +The ``__name__`` is used to denote the import path of the blueprint. This must +match the import name of the module so the function can be properly imported +when running in Lambda. We'll now import this module in our ``app.py`` and +register this blueprint. We'll also add a route in our ``app.py`` directly: + +.. code-block:: python + + from chalice import Chalice + from chalicelib.blueprints import extra_routes + + app = Chalice(app_name='blueprint-demo') + app.register_blueprint(extra_routes) + + + @app.route('/') + def index(): + return {'hello': 'world'} + +At this point we've defined two routes. One route, ``/``, is directly defined +in our ``app.py`` file. The other route, ``/foo`` is defined in +``chalicelib/blueprints.py``. It was added to our Chalice app when we +registered it via ``app.register_blueprint(extra_routes)``. + +We can deploy our application to verify this works as expected:: + + $ chalice deploy + Creating deployment package. + Creating IAM role: blueprint-demo-dev + Creating lambda function: blueprint-demo-dev + Creating Rest API + Resources deployed: + - Lambda ARN: arn:aws:lambda:us-west-2:1234:function:blueprint-demo-dev + - Rest API URL: https://rest-api.execute-api.us-west-2.amazonaws.com/api/ + + +We should now be able to request the ``/`` and ``/foo`` routes:: + + $ http https://rest-api.execute-api.us-west-2.amazonaws.com/api/ + HTTP/1.1 200 OK + Connection: keep-alive + Content-Length: 17 + Content-Type: application/json + Date: Sat, 22 Dec 2018 01:05:48 GMT + Via: 1.1 5ab5dc09da67e3ea794ec8a82992cc89.cloudfront.net (CloudFront) + X-Amz-Cf-Id: Cdsow9--fnTH5EdjkjWBMWINCCMD4nGmi4S_3iMYMK0rpc8Mpiymgw== + X-Amzn-Trace-Id: Root=1-5c1d8dec-f1ef3ee83c7c654ca7fb3a70;Sampled=0 + X-Cache: Miss from cloudfront + x-amz-apigw-id: SSMc6H_yvHcFcEw= + x-amzn-RequestId: b7bd0c87-0585-11e9-90cf-59b71c1a1de1 + + { + "hello": "world" + } + + $ http https://rest-api.execute-api.us-west-2.amazonaws.com/api/foo + HTTP/1.1 200 OK + Connection: keep-alive + Content-Length: 13 + Content-Type: application/json + Date: Sat, 22 Dec 2018 01:05:51 GMT + Via: 1.1 95b0ac620fa3a80ee590ecf1cda1c698.cloudfront.net (CloudFront) + X-Amz-Cf-Id: HX4l1BNdWvYDRXan17PFZya1vaomoJel4rP7d8_stdw2qT50v7Iybg== + X-Amzn-Trace-Id: Root=1-5c1d8def-214e7f681ff82c00fd81f37a;Sampled=0 + X-Cache: Miss from cloudfront + x-amz-apigw-id: SSMdXF40vHcF-mg= + x-amzn-RequestId: b96f77bf-0585-11e9-b229-01305cd40040 + + { + "foo": "bar" + } + + +Blueprint Registration +---------------------- + +The ``app.register_blueprint`` function accepts two optional arguments, +``name_prefix`` and ``url_prefix``. This allows you to register the resources +in your blueprint at a certain url and name prefix. If you specify +``url_prefix`` any routes defined in your blueprint will have the +``url_prefix`` prepended to it. If you specify the ``name_prefix``, any Lambda +functions created will have the ``name_prefix`` prepended to the resource name. + + +Advanced Example +---------------- + +Let's create a more advanced example. If this application, let's say we want +to organize our application into separate modules for our API and our event +sources. We can create an app with these files:: + + $ ls -la chalicelib/ + __init__.py + api.py + events.py + + +The contents of ``api.py`` are: + +.. code-block:: python + + from chalice import Blueprint + + + myapi = Blueprint(__name__) + + + @myapi.route('/') + def index(): + return {'hello': 'world'} + + + @myapi.route('/foo') + def index(): + return {'foo': 'bar'} + + +The contents of ``events.py`` are: + +.. code-block:: python + + from chalice import Blueprint + + + myevents = Blueprint(__name__) + + + @myevents.schedule('rate(5 minutes)') + def cron(event): + pass + + + @myevents.on_sns_message('MyTopic') + def handle_sns_message(event): + pass + +In our ``app.py`` we'll register these blueprints: + +.. code-block:: python + + from chalice import Chalice + from chalicelib.events import myevents + from chalicelib.api import myapi + + app = Chalice(app_name='blueprint-demo') + app.register_blueprint(myevents) + app.register_blueprint(myapi) + + +Now our ``app.py`` only registers the necessary blueprints, and all our +resources are defined in blueprints. diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 707f51a1c..98502fbd3 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -894,7 +894,6 @@ def index_view(): assert isinstance(authorizer, app.BuiltinAuthConfig) assert authorizer.name == 'my_auth' assert authorizer.handler_string == 'app.my_auth' - assert my_auth.name == 'my_auth' def test_builtin_auth_can_transform_event(): @@ -1607,3 +1606,167 @@ def index(): return Response(body=payload, status_code=200, headers=custom_headers) return demo + + +def test_can_register_blueprint_on_app(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('foo') + + @foo.route('/foo') + def first(): + pass + + myapp.register_blueprint(foo) + assert sorted(list(myapp.routes.keys())) == ['/foo'] + + +def test_can_combine_multiple_blueprints_in_single_app(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('foo') + bar = app.Blueprint('bar') + + @foo.route('/foo') + def myfoo(): + pass + + @bar.route('/bar') + def mybar(): + pass + + myapp.register_blueprint(foo) + myapp.register_blueprint(bar) + + assert sorted(list(myapp.routes)) == ['/bar', '/foo'] + + +def test_can_mount_apis_at_url_prefix(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('foo') + + @foo.route('/foo') + def myfoo(): + pass + + @foo.route('/bar') + def mybar(): + pass + + myapp.register_blueprint(foo, url_prefix='/myprefix') + assert list(sorted(myapp.routes)) == ['/myprefix/bar', '/myprefix/foo'] + + +def test_can_combine_lambda_functions_and_routes_in_blueprints(): + myapp = app.Chalice('myapp') + + foo = app.Blueprint('app.chalicelib.blueprints.foo') + + @foo.route('/foo') + def myfoo(): + pass + + @foo.lambda_function() + def myfunction(event, context): + pass + + myapp.register_blueprint(foo) + assert len(myapp.pure_lambda_functions) == 1 + lambda_function = myapp.pure_lambda_functions[0] + assert lambda_function.name == 'myfunction' + assert lambda_function.handler_string == ( + 'app.chalicelib.blueprints.foo.myfunction') + + assert list(myapp.routes) == ['/foo'] + + +def test_can_mount_lambda_functions_with_name_prefix(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('app.chalicelib.blueprints.foo') + + @foo.lambda_function() + def myfunction(event, context): + return event, context + + myapp.register_blueprint(foo, name_prefix='myprefix_') + assert len(myapp.pure_lambda_functions) == 1 + lambda_function = myapp.pure_lambda_functions[0] + assert lambda_function.name == 'myprefix_myfunction' + assert lambda_function.handler_string == ( + 'app.chalicelib.blueprints.foo.myfunction') + + assert myfunction('foo', 'bar') == ('foo', 'bar') + + +def test_can_mount_event_sources_with_blueprint(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('app.chalicelib.blueprints.foo') + + @foo.schedule('rate(5 minutes)') + def myfunction(event): + return event + + myapp.register_blueprint(foo, name_prefix='myprefix_') + assert len(myapp.event_sources) == 1 + event_source = myapp.event_sources[0] + assert event_source.name == 'myprefix_myfunction' + assert event_source.schedule_expression == 'rate(5 minutes)' + assert event_source.handler_string == ( + 'app.chalicelib.blueprints.foo.myfunction') + + +def test_can_mount_all_decorators_in_blueprint(): + myapp = app.Chalice('myapp') + foo = app.Blueprint('app.chalicelib.blueprints.foo') + + @foo.route('/foo') + def routefoo(): + pass + + @foo.lambda_function(name='mylambdafunction') + def mylambda(event, context): + pass + + @foo.schedule('rate(5 minutes)') + def bar(event): + pass + + @foo.on_s3_event('MyBucket') + def on_s3(event): + pass + + @foo.on_sns_message('MyTopic') + def on_sns(event): + pass + + @foo.on_sqs_message('MyQueue') + def on_sqs(event): + pass + + myapp.register_blueprint(foo, name_prefix='myprefix_', url_prefix='/bar') + event_sources = myapp.event_sources + assert len(event_sources) == 4 + lambda_functions = myapp.pure_lambda_functions + assert len(lambda_functions) == 1 + # Handles the name prefix and the name='' override in the decorator. + assert lambda_functions[0].name == 'myprefix_mylambdafunction' + assert list(myapp.routes) == ['/bar/foo'] + + +def test_can_call_current_request_on_blueprint_when_mounted(create_event): + myapp = app.Chalice('myapp') + bp = app.Blueprint('app.chalicelib.blueprints.foo') + + @bp.route('/todict') + def todict(): + return bp.current_request.to_dict() + + myapp.register_blueprint(bp) + event = create_event('/todict', 'GET', {}) + response = json_response_body(myapp(event, context=None)) + assert isinstance(response, dict) + assert response['method'] == 'GET' + + +def test_runtime_error_if_current_request_access_on_non_registered_blueprint(): + bp = app.Blueprint('app.chalicelib.blueprints.foo') + with pytest.raises(RuntimeError): + bp.current_request From f04a78c8dde75a0827efa73ee4f2fbc51e39715a Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 17:35:22 -0800 Subject: [PATCH 2/8] Update pyi file to be consistent with app.py --- chalice/app.pyi | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/chalice/app.pyi b/chalice/app.pyi index a871c478d..aa53de69b 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -116,7 +116,38 @@ class APIGateway(object): binary_types = ... # type: List[str] -class Chalice(object): +class DecoratorAPI(object): + def authorizer(self, + ttl_seconds: Optional[int]=None, + execution_role: Optional[str]=None, + name: Optional[str]=None) -> Callable[..., Any]: ... + + def on_s3_event(self, + bucket: str, + events: Optional[List[str]]=None, + prefix: Optional[str]=None, + suffix: Optional[str]=None, + name: Optional[str]=None) -> Callable[..., Any]: ... + + def on_sns_message(self, + topic: str, + name: Optional[str]=None) -> Callable[..., Any]: ... + + def on_sqs_message(self, + queue: str, + batch_size: int=1, + name: Optional[str]=None) -> Callable[..., Any]: ... + + def schedule(self, + expression: str, + name: Optional[str]=None) -> Callable[..., Any]: ... + + def route(self, path: str, **kwargs: Any) -> Callable[..., Any]: ... + + def lambda_function(self, name: Optional[str]=None) -> Callable[..., Any]: ... + + +class Chalice(DecoratorAPI): app_name = ... # type: str api = ... # type: APIGateway routes = ... # type: Dict[str, Dict[str, RouteEntry]] @@ -134,8 +165,6 @@ class Chalice(object): configure_logs: bool=True, env: Optional[Dict[str, str]]=None) -> None: ... - def route(self, path: str, **kwargs: Any) -> Callable[..., Any]: ... - def _add_route(self, path: str, view_func: Callable[..., Any], **kwargs: Any) -> None: ... def __call__(self, event: Any, context: Any) -> Any: ... def _get_view_function_response(self, view_function: Callable[..., Any], @@ -224,5 +253,6 @@ class CloudWatchEventConfig(BaseEventSourceConfig): schedule_expression = ... # type: Union[str, ScheduleExpression] -class Blueprint(Chalice): - pass +class Blueprint(DecoratorAPI): + current_request = ... # type: Request + lambda_context = ... # type: LambdaContext From db39c5299f43fe75d3c48cf282489fd5e95dfd75 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 17:42:48 -0800 Subject: [PATCH 3/8] Map lambda context to blueprint --- chalice/app.py | 10 ++++++++++ tests/unit/test_app.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/chalice/app.py b/chalice/app.py index a226e8670..950d902e5 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -1192,6 +1192,7 @@ def __init__(self, import_name, name_prefix=None, url_prefix=None): self._url_prefix = url_prefix self._deferred_registrations = [] self._current_app = None + self._lambda_context = None @property def current_request(self): @@ -1202,6 +1203,15 @@ def current_request(self): ) return self._current_app.current_request + @property + def lambda_context(self): + if self._current_app is None: + raise RuntimeError( + "Can only access Blueprint.lambda_context if it's registered " + "to an app." + ) + return self._current_app.lambda_context + def register(self, app, options): self._current_app = app all_options = options.copy() diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 98502fbd3..5b6fbd6d4 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -1766,6 +1766,20 @@ def todict(): assert response['method'] == 'GET' +def test_can_call_lambda_context_on_blueprint_when_mounted(create_event): + myapp = app.Chalice('myapp') + bp = app.Blueprint('app.chalicelib.blueprints.foo') + + @bp.route('/context') + def context(): + return bp.lambda_context + + myapp.register_blueprint(bp) + event = create_event('/context', 'GET', {}) + response = json_response_body(myapp(event, context={'context': 'foo'})) + assert response == {'context': 'foo'} + + def test_runtime_error_if_current_request_access_on_non_registered_blueprint(): bp = app.Blueprint('app.chalicelib.blueprints.foo') with pytest.raises(RuntimeError): From dea8ec9cac51285771359343cb16d4bb216b95c8 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 17:46:31 -0800 Subject: [PATCH 4/8] Remove unused kwargs param --- chalice/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chalice/app.py b/chalice/app.py index 950d902e5..229520fa8 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -508,13 +508,13 @@ def _register_handler(user_handler): else: kwargs = {} wrapped = self._wrap_handler(handler_type, handler_name, - user_handler, kwargs) + user_handler) self._register_handler(handler_type, handler_name, user_handler, wrapped, kwargs) return wrapped return _register_handler - def _wrap_handler(self, handler_type, handler_name, user_handler, kwargs): + def _wrap_handler(self, handler_type, handler_name, user_handler): event_classes = { 'on_s3_event': S3Event, 'on_sns_message': SNSEvent, From 2c50417fa429a398a0e3d939c0acb4e435813873 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 17:47:33 -0800 Subject: [PATCH 5/8] Clarify comment on taking kwargs in the route decorator --- chalice/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chalice/app.py b/chalice/app.py index 229520fa8..f54b553f9 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -488,8 +488,8 @@ def route(self, path, **kwargs): handler_type='route', name=kwargs.get('name'), # This looks a little weird taking kwargs as a key, - # but to preserve backwards compatibility we have to - # keep the **kwargs signature in the route decorator. + # but we want to preserve keep the **kwargs signature + # in the route decorator. registration_kwargs={'path': path, 'kwargs': kwargs}, ) From 27b901193cc22420521d6c8969eee226aa72029e Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 18:00:14 -0800 Subject: [PATCH 6/8] Add test to verify blueprint has all decorators --- tests/unit/test_app.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 5b6fbd6d4..af8048c3e 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -3,6 +3,7 @@ import logging import json import gzip +import inspect import pytest from pytest import fixture @@ -1784,3 +1785,18 @@ def test_runtime_error_if_current_request_access_on_non_registered_blueprint(): bp = app.Blueprint('app.chalicelib.blueprints.foo') with pytest.raises(RuntimeError): bp.current_request + + +def test_every_decorator_added_to_blueprint(): + def is_public_method(obj): + return inspect.isfunction(obj) and not obj.__name__.startswith('_') + public_api = inspect.getmembers( + app.DecoratorAPI, + predicate=is_public_method + ) + blueprint_api = [ + i[0] for i in + inspect.getmembers(app.Blueprint, predicate=is_public_method) + ] + for method_name, _ in public_api: + assert method_name in blueprint_api From 9cee7c3c2d1ab8f14d267f7f101663c24e0a0a2d Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 18:19:19 -0800 Subject: [PATCH 7/8] Add API docs for blueprints --- docs/source/api.rst | 58 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 110180463..d0d69921e 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -301,6 +301,20 @@ Chalice entire lambda function name. This parameter is optional. If it is not provided, the name of the python function will be used. + .. method:: register_blueprint(blueprint, name_prefix=None, url_prefix=None) + + Register a :class:`Blueprint` to a Chalice app. + See :doc:`topics/blueprints` for more information. + + :param blueprint: The :class:`Blueprint` to register to the app. + + :param name_prefix: An optional name prefix that's added to all the + resources specified in the blueprint. + + :param url_prefix: An optional url prefix that's added to all the + routes defined the Blueprint. This allows you to set the root mount + point for all URLs in a Blueprint. + Request ======= @@ -657,8 +671,8 @@ CORS ``Access-Control-Allow-Credentials``. -Scheduled Events -================ +Event Sources +============= .. versionadded:: 1.0.0b1 @@ -955,3 +969,43 @@ Scheduled Events Return the original dictionary associated with the given message. This is useful if you need direct access to the lambda event. + + +Blueprints +========== + +.. class:: Blueprint(import_name) + + An object used for grouping related handlers together. + This is primarily used as a mechanism for organizing your lambda + handlers. Any decorator methods defined in the :class:`Chalice` + object are also defined on a ``Blueprint`` object. You can register + a blueprint to a Chalice app using the :meth:`Chalice.register_blueprint` + method. + + The ``import_name`` is the module in which the Blueprint is defined. + It is used to construct the appropriate handler string when creating + the Lambda functions associated with a Blueprint. This is typically + the `__name__` attribute:``mybp = Blueprint(__name__)``. + + See :doc:`topics/blueprints` for more information. + + .. code-block:: python + + # In ./app.py + + from chalice import Chalice + from chalicelib import myblueprint + + app = Chalice(app_name='blueprints') + app.register_blueprint(myblueprint) + + # In chalicelib/myblueprint.py + + from chalice import Blueprint + + myblueprint = Blueprint(__name__) + + @myblueprint.route('/') + def index(): + return {'hello': 'world'} From 53cfc473884745786305d1dd1d735656e7d95fb5 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 7 Jan 2019 18:20:46 -0800 Subject: [PATCH 8/8] Remove unused params in Blueprint --- chalice/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/chalice/app.py b/chalice/app.py index f54b553f9..a82d49599 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -1186,10 +1186,8 @@ def _extract_attributes(self, event_dict): class Blueprint(DecoratorAPI): - def __init__(self, import_name, name_prefix=None, url_prefix=None): + def __init__(self, import_name): self._import_name = import_name - self._name_prefix = name_prefix - self._url_prefix = url_prefix self._deferred_registrations = [] self._current_app = None self._lambda_context = None