diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f6c3017ee..4260b2fa8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ Next Release (TBD) (`#1047 `__) * Add support for passing SNS ARNs to ``on_sns_message`` (`#1048 `__) +* Add support for Blueprints + (`#1023 `__) +* Add support for opting-in to experimental features + (`#1053 `__) 1.6.2 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 6014f0e69..76b977198 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -441,25 +441,257 @@ 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 we want to preserve 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) + 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): + 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) + self.experimental_feature_flags = set() + # This is marked as internal but is intended to be used by + # any code within Chalice. + self._features_used = set() def _initialize(self, env): if self.configure_logs: @@ -506,136 +738,15 @@ 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 register_blueprint(self, blueprint, name_prefix=None, url_prefix=None): + self._features_used.add('BLUEPRINTS') + blueprint.register(self, options={'name_prefix': name_prefix, + 'url_prefix': url_prefix}) - 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 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 +896,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 +1188,50 @@ 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): + self._import_name = import_name + self._deferred_registrations = [] + self._current_app = None + self._lambda_context = 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 + + @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() + 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..d5a807147 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]] @@ -129,13 +160,14 @@ class Chalice(object): builtin_auth_handlers = ... # type: List[BuiltinAuthConfig] event_sources = ... # type: List[BaseEventSourceConfig] pure_lambda_functions = ... # type: List[LambdaFunction] + # Used for feature flag validation + _features_used = ... # type: Set[str] + experimental_feature_flags = ... # type: Set[str] def __init__(self, app_name: str, debug: bool=False, 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], @@ -222,3 +254,8 @@ class SQSEventConfig(BaseEventSourceConfig): class CloudWatchEventConfig(BaseEventSourceConfig): schedule_expression = ... # type: Union[str, ScheduleExpression] + + +class Blueprint(DecoratorAPI): + current_request = ... # type: Request + lambda_context = ... # type: LambdaContext diff --git a/chalice/cli/__init__.py b/chalice/cli/__init__.py index 824e2c34e..b43038e0e 100644 --- a/chalice/cli/__init__.py +++ b/chalice/cli/__init__.py @@ -26,6 +26,7 @@ from chalice.logs import display_logs from chalice.utils import create_zip_file from chalice.deploy.validate import validate_routes, validate_python_version +from chalice.deploy.validate import ExperimentalFeatureError from chalice.utils import getting_started_prompt, UI, serialize_to_json from chalice.constants import CONFIG_VERSION, TEMPLATE_APP, GITIGNORE from chalice.constants import DEFAULT_STAGE_NAME @@ -466,6 +467,9 @@ def main(): "environment variable or set the " "region value in our ~/.aws/config file.", err=True) return 2 + except ExperimentalFeatureError as e: + click.echo(str(e)) + return 2 except Exception: click.echo(traceback.format_exc(), err=True) return 2 diff --git a/chalice/cli/factory.py b/chalice/cli/factory.py index 8c5fb3211..2b6d736ba 100644 --- a/chalice/cli/factory.py +++ b/chalice/cli/factory.py @@ -24,6 +24,7 @@ from chalice.utils import UI # noqa from chalice.utils import PipeReader # noqa from chalice.deploy import deployer # noqa +from chalice.deploy import validate from chalice.invoke import LambdaInvokeHandler from chalice.invoke import LambdaInvoker from chalice.invoke import LambdaResponseFormatter @@ -222,8 +223,11 @@ def create_lambda_invoke_handler(self, name, stage): return handler - def load_chalice_app(self, environment_variables=None): - # type: (Optional[MutableMapping]) -> Chalice + def load_chalice_app(self, environment_variables=None, + validate_feature_flags=True): + # type: (Optional[MutableMapping], Optional[bool]) -> Chalice + # validate_features indicates that we should validate that + # any expiremental features used have the appropriate feature flags. if self.project_dir not in sys.path: sys.path.insert(0, self.project_dir) # The vendor directory has its contents copied up to the top level of @@ -258,6 +262,8 @@ def load_chalice_app(self, environment_variables=None): 'SyntaxError: %s' ) % (getattr(e, 'filename'), e.lineno, e.text, e.msg) raise RuntimeError(message) + if validate_feature_flags: + validate.validate_feature_flags(chalice_app) return chalice_app def load_project_config(self): diff --git a/chalice/constants.py b/chalice/constants.py index 613144c24..0649452ce 100644 --- a/chalice/constants.py +++ b/chalice/constants.py @@ -217,6 +217,18 @@ def index(): """ +EXPERIMENTAL_ERROR_MSG = """ + +You are using experimental features without explicitly opting in. +Experimental features do not guarantee backwards compatibility and may be +removed in the future. If you'd still like to use these experimental features, +you can opt in by adding this to your app.py file:\n\n%s + +See https://chalice.readthedocs.io/en/latest/topics/experimental.html for more +details. +""" + + SQS_EVENT_SOURCE_POLICY = { "Effect": "Allow", "Action": [ diff --git a/chalice/deploy/validate.py b/chalice/deploy/validate.py index 6d3ff6c4c..f0bbe2c3a 100644 --- a/chalice/deploy/validate.py +++ b/chalice/deploy/validate.py @@ -5,6 +5,24 @@ from chalice import app # noqa from chalice.config import Config # noqa +from chalice.constants import EXPERIMENTAL_ERROR_MSG + + +class ExperimentalFeatureError(Exception): + def __init__(self, features_missing_opt_in): + # type: (Set[str]) -> None + self.features_missing_opt_in = features_missing_opt_in + msg = self._generate_msg(features_missing_opt_in) + super(ExperimentalFeatureError, self).__init__(msg) + + def _generate_msg(self, missing_features): + # type: (Set[str]) -> str + opt_in_line = ( + 'app.experimental_feature_flags.update([\n' + '%s\n' + '])\n' % ',\n'.join([" '%s'" % feature + for feature in missing_features])) + return EXPERIMENTAL_ERROR_MSG % opt_in_line def validate_configuration(config): @@ -23,6 +41,18 @@ def validate_configuration(config): _validate_manage_iam_role(config) validate_python_version(config) validate_unique_function_names(config) + validate_feature_flags(config.chalice_app) + + +def validate_feature_flags(chalice_app): + # type: (app.Chalice) -> None + missing_opt_in = set() + # pylint: disable=protected-access + for feature in chalice_app._features_used: + if feature not in chalice_app.experimental_feature_flags: + missing_opt_in.add(feature) + if missing_opt_in: + raise ExperimentalFeatureError(missing_opt_in) def validate_routes(routes): diff --git a/docs/source/api.rst b/docs/source/api.rst index 5b80df862..d84c898d6 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -300,6 +300,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 ======= @@ -656,8 +670,8 @@ CORS ``Access-Control-Allow-Credentials``. -Scheduled Events -================ +Event Sources +============= .. versionadded:: 1.0.0b1 @@ -954,3 +968,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'} diff --git a/docs/source/index.rst b/docs/source/index.rst index ede1a9b69..5c484f722 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -63,7 +63,9 @@ Topics topics/authorizers topics/events topics/purelambda + topics/blueprints topics/cd + topics/experimental API Reference diff --git a/docs/source/topics/blueprints.rst b/docs/source/topics/blueprints.rst new file mode 100644 index 000000000..2347866f7 --- /dev/null +++ b/docs/source/topics/blueprints.rst @@ -0,0 +1,214 @@ +Blueprints +========== + + +.. warning:: + + Blueprints are considered an experimental API. You'll need to opt-in + to this feature using the ``BLUEPRINTS`` feature flag: + + .. code-block:: python + + app = Chalice('myapp') + app.experimental_feature_flags.extend([ + 'BLUEPRINTS' + ]) + + See :doc:`experimental` for more information. + + +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. Blueprints support any decorator available on an application +object. + + +.. 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 name +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/docs/source/topics/experimental.rst b/docs/source/topics/experimental.rst new file mode 100644 index 000000000..25384922b --- /dev/null +++ b/docs/source/topics/experimental.rst @@ -0,0 +1,90 @@ +Experimental APIs +================= + +Chalice maintains backwards compatibility for all features that appear in this +documentation. Any Chalice application using version 1.x will continue to work +for all future versions of 1.x. + +We also believe that Chalice has a lot of potential for new ideas and APIs, +many of which will take several iterations to get right. We may implement a +new idea and need to make changes based on customer usage and feedback. This +may include backwards incompatible changes all the way up to the removal of +a feature. + +To accommodate these new features, Chalice has support for experimental APIs, +which are features that are added to Chalice on a provisional basis. Because +these features may include backwards incompatible changes, you must explicitly +opt-in to using these features. This makes it clear that you are using an +experimental feature that may change. + +Opting-in to Experimental APIs +------------------------------ + +Each experimental feature in chalice has a name associated with it. To opt-in +to an experimental API, you must have the feature name to the +``experimental_feature_flags`` attribute on your ``app`` object. +This attribute's type is a set of strings. + +.. code-block:: python + + from chalice import Chalice + + app = Chalice('myapp') + app.experimental_feature_flags.update([ + 'MYFEATURE1', + 'MYFEATURE2', + ]) + + +If you use an experimental API without opting-in, you will receive +a message whenever you run a Chalice CLI command. The error message +tells you which feature flags you need to add:: + + $ chalice deploy + You are using experimental features without explicitly opting in. + Experimental features do not guarantee backwards compatibility and may be removed in the future. + If you still like to use these experimental features, you can opt-in by adding this to your app.py file: + + app.experimental_feature_flags.update([ + 'BLUEPRINTS' + ]) + + + See https://chalice.readthedocs.io/en/latest/topics/experimental.rst for more details. + +The feature flag only happens when running CLI commands. There are no runtime +checks for experimental features once your application is deployed. + + +List of Experimental APIs +------------------------- + +In the table below, the "Feature Flag Name" column is the value you +must add to the ``app.experimental_feature_flags`` attribute. +The status of an experimental API can be: + +* ``Trial`` - You must explicitly opt-in to use this feature. +* ``Accepted`` - This feature has graduated from an experimental + feature to a fully supported, backwards compatible feature in Chalice. + Accepted features still appear in the table for auditing purposes. +* ``Rejected`` - This feature has been removed. + + +.. list-table:: Experimental APIs + :header-rows: 1 + + * - Feature + - Feature Flag Name + - Version Added + - Status + - GitHub Issue(s) + * - :doc:`blueprints` + - ``BLUEPRINTS`` + - 1.7.0 + - Trial + - `#1023 `__, + `#651 `__ + + +See the `original discussion `__ +for more background information and alternative proposals. diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index b006a92df..937540d15 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -19,6 +19,7 @@ from chalice.invoke import LambdaInvokeHandler from chalice.invoke import UnhandledLambdaError from chalice.awsclient import ReadTimeout +from chalice.deploy.validate import ExperimentalFeatureError class FakeConfig(object): @@ -460,3 +461,24 @@ def test_invoke_does_raise_if_no_function_found(runner, mock_cli_factory): cli_factory=mock_cli_factory) assert result.exit_code == 2 assert 'foo' in result.output + + +def test_error_message_displayed_when_missing_feature_opt_in(runner): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + sys.modules.pop('app', None) + with open(os.path.join('testproject', 'app.py'), 'w') as f: + # Rather than pick an existing experimental feature, we're + # manually injecting a feature flag into our app. This ensures + # we don't have to update this test if a feature graduates + # from trial to accepted. The '_features_used' is a "package + # private" var for chalice code. + f.write( + 'from chalice import Chalice\n' + 'app = Chalice("myapp")\n' + 'app._features_used.add("MYTESTFEATURE")\n' + ) + os.chdir('testproject') + result = _run_cli_command(runner, cli.package, ['out']) + assert isinstance(result.exception, ExperimentalFeatureError) + assert 'MYTESTFEATURE' in str(result.exception) diff --git a/tests/unit/deploy/test_validate.py b/tests/unit/deploy/test_validate.py index 63b1dfef2..0a091d859 100644 --- a/tests/unit/deploy/test_validate.py +++ b/tests/unit/deploy/test_validate.py @@ -6,7 +6,8 @@ from chalice import CORSConfig from chalice.deploy.validate import validate_configuration, validate_routes, \ validate_python_version, validate_route_content_types, \ - validate_unique_function_names + validate_unique_function_names, validate_feature_flags, \ + ExperimentalFeatureError def test_trailing_slash_routes_result_in_error(): @@ -225,3 +226,19 @@ def index(): assert validate_route_content_types(sample_app.routes, sample_app.api.binary_types) is None + + +def test_can_validate_feature_flags(sample_app): + # The _features_used is marked internal because we don't want + # chalice users to access it, but this attribute is intended to be + # accessed by anything within the chalice codebase. + sample_app._features_used.add('SOME_NEW_FEATURE') + with pytest.raises(ExperimentalFeatureError): + validate_feature_flags(sample_app) + # Now if we opt in, validation is fine. + sample_app.experimental_feature_flags.add('SOME_NEW_FEATURE') + try: + validate_feature_flags(sample_app) + except ExperimentalFeatureError: + raise AssertionError("App was not suppose to raise an error when " + "opting in to features via a feature flag.") diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 707f51a1c..2524067f5 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 @@ -15,6 +16,8 @@ from chalice import NotFoundError from chalice.app import APIGateway, Request, Response, handle_decimals from chalice import __version__ as chalice_version +from chalice.deploy.validate import ExperimentalFeatureError +from chalice.deploy.validate import validate_feature_flags # These are used to generate sample data for hypothesis tests. @@ -86,6 +89,21 @@ def json_response_body(response): return json.loads(response['body']) +def assert_requires_opt_in(app, flag): + with pytest.raises(ExperimentalFeatureError): + validate_feature_flags(app) + # Now ensure if we opt in to the feature, we don't + # raise an exception. + app.experimental_feature_flags.add(flag) + try: + validate_feature_flags(app) + except ExperimentalFeatureError: + raise AssertionError( + "Opting in to feature %s still raises an " + "ExperimentalFeatureError." % flag + ) + + @fixture def sample_app(): demo = app.Chalice('demo-app') @@ -894,7 +912,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 +1624,209 @@ 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_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): + 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 + + +def test_blueprint_gated_behind_feature_flag(): + # Blueprints won't validate unless you enable their feature flag. + myapp = app.Chalice('myapp') + bp = app.Blueprint('app.chalicelib.blueprints.foo') + + @bp.route('/') + def index(): + pass + + myapp.register_blueprint(bp) + assert_requires_opt_in(myapp, flag='BLUEPRINTS')