Package: chalice-plus
Chalice PlusChalice Plus is an opinionated serverless python framework for quickly building REST APIs based on SQLAlchemy models.It is an extension for AWS Chalice that adds tools to speed up development and avoid boilerplate code.Features include:Class based viewsSQLAlchemy and alembic integrationMarshmallow schema integrationAuthenticationPermissionsField maskingURL parameter typesSSM parameter supportCustom deploy commandsCompatibilitychalice_plusrequires Python 3.9+InstallationYou can installchalice_pluswith pip:$ pip install chalice_plusAttach an engine to the app inapp.py:from sqlalchemy import create_engine
app = Chalice(app_name='books-api')
app.engine = create_engine(f"postgresql://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}/{DATABASE_NAME}")OverviewAPI endpoints can be created quickly and easily using class-based views - see the example below.Note: Application code is stored in thechalicelibfolder perchaliceconventions.app.py:from chalicelib.urls import urlpatterns

register_urls(app, urlpatterns)chalicelib/urls.py:urlpatterns = [
    ("/books", BookListView.as_view()),
    ("/books/{uuid:id}", BookDetailView.as_view()),
]chalicelib/apps/books/views.py:from chalice_plus.views import RetrieveUpdateDeleteView, CreateListView

class BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema

class BookListView(CreateListView):
    model = Book
    schema_class = BookSchemachalicelib/apps/books/models.py:class Book(Base):
    __tablename__ = "books"
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True)
    title = Column(String, nullable=False)
    description = Column(Text(), nullable=False)chalicelib/apps/books/schemas.py:from marshmallow_sqlalchemy import SQLAlchemyAutoSchema

class BookSchema(SQLAlchemyAutoSchema):
    class Meta:
        model = Book
        include_relationships = True
        load_instance = TrueExample ProjectTo see how everything fits together, please see theexample project.Class Based ViewsViews can inherit from pre-definedchalice_plusviews:RetrieveViewUpdateViewDeleteViewRetrieveUpdateViewRetrieveDeleteViewUpdateDeleteViewRetrieveUpdateDeleteViewListViewCreateViewCreateListViewAlternatively a custom view can be created using the genericAPIViewand defining custom methods and attributes.The current request is available usingself.requestand the current database session is available usingself.session. Any url kwargs can also be accessed inself.kwargs.By default, the retrieve, update and delete views fetch an object based on the id in the url, but custom behaviour can also be defined.To use a different url id, definepk_url_kwarg:urlpatterns = [
    ("/books/{uuid:my_book_id}", BookDetailView.as_view()),
]

class BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema
    pk_url_kwarg = "my_book_id"To override the fetch behaviour, defineget_object:class BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema

    def get_object(self):
        return self.session.query(Book).filter_by(
            published=True,
            id=self.pk,
        ).first()Or to completely define the behaviour, override the http method:class BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema

    def get(self, request, *args, **kwargs):
        return {"custom": "data"}For list views the queryset can be overridden:class BookListView(ListView):
    model = Book
    schema_class = BookSchema

    def get_queryset(self):
        queryset = super().get_queryset()
        queryset = queryset.filter_by(published=True)
        return querysetFor update and create views, the request data an be intercepted:class BookCreateView(CreateView):
    model = Book
    schema_class = BookSchema

    def get_request_data(self):
        data = super().get_request_data()
        data['intercepted'] = True
        return dataThe model can also be intercepted before saving by overridingload_object- this can be useful when assigningcreated_byorupdated_byfields:class BookCreateView(CreateView):
    model = Book
    schema_class = BookSchema
    permission_classes = {"post": [IsAuthenticated]}
    authenticator_class = MyAuthenticator

    def load_object(self, *args, **kwargs):
        obj = super().load_object(*args, **kwargs)
        obj.created_by = self.authenticator.user
        return objTo restrict which http methods are allowed on a view,allowed_methodscan be set:class BookListView(APIView):
    allowed_methods = ("post", "get")When inheriting from achalice_plusview,allowed_methodswill already be set appropriately, but can be overridden.For example, by defaultputrequests are not allowed (usepatchfor partial updates). To enableputon anUpdateView:class BookUpdateView(UpdateView):
    model = Book
    schema_class = BookSchema
    allowed_methods = ("patch", "put")URLsURLs can be defined as follows:urlpatterns = [
    ("/authors", AuthorListView.as_view()),
    ("/authors/{uuid:id}", AuthorDetailView.as_view()),
    ("/books", BookListView.as_view()),
    ("/books/{uuid:id}", BookDetailView.as_view()),
]Then registered inapp.py:from chalice_plus.urls import register_urls
from chalicelib.urls import urlpatterns

register_urls(app, urlpatterns)Note that in the above example, a parameter type ofuuidhas been defined. This is optional, but useful to validate input. The following types are available:uuid- validates as a UUID. The parameter value is available inself.kwargsas a string.int- validates as an integer. The parameter value is available inself.kwargsas an integer.str- validates as a string. The parameter value is available inself.kwargsas a string.AuthorizationAn authorizer can be passed toregister_urls. The authorizer will then be used to authorize any views which definepermissions.
If a view does not define permissions for an http method, no authorization will be applied.app.pyfrom chalice_plus.urls import register_urls

authorizer = CognitoUserPoolAuthorizer(COGNITO_USER_POOL_NAME, provider_arns=[COGNITO_USER_POOL_ARN])
register_urls(app, urlpatterns, cors=cors_config, authorizer=authorizer)AuthenticationViews can also define an authenticator class. The authenticator is used to get the current user:self.authenticator.userorself.authenticator.user_id.chalice_pluscomes with aCognitoAuthenticator, however theget_userfunction needs to be defined manually. For example:chalicelib/apps/users/authenticators.pyimport os
from chalice_plus.authenticators import CognitoAuthenticator
from chalicelib.apps.users.models import User

class CustomCognitoAuthenticator(CognitoAuthenticator):
    def get_user(self):
        if self.user_id:
            return self.session.get(User, self.user_id)

    def get_user_id(self):
        if "AWS_CHALICE_CLI_MODE" in os.environ:
            return "069522e8-a001-70a7-616e-1273a47f3f02"
        return super().get_user_id()chalicelib/apps/books/views.pyclass BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema
    authenticator_class = CustomCognitoAuthenticatorPermissionsViews can be restricted by permission and will generally require an authenticator.chalice_pluscomes with a few default permission classes:IsAuthenticatedIsAdminIsOwnerIsOwnerOrAdminThe owner is checked by looking atobject.created_byand admin is checked by looking atuser.is_superuser.If these assumptions do not apply, custom permission classes can be written:class IsStaff:
    message = "User is not staff"

    def has_permission(self, view):
        user = view.authenticator.user
        return user and user.is_staffPermissions are applied as a list on a per http-method basis. All permissions in the list need to pass, otherwise a 403 forbidden response will be issued:class BookDetailView(RetrieveUpdateDeleteView):
    model = Book
    schema_class = BookSchema
    authenticator_class = CustomCognitoAuthenticator
    permission_classes = {
        "get": [IsAuthenticated]
        "delete": [IsOwnerOrAdmin],
        "patch": [IsOwnerOrAdmin],
    }If no permission is specified for a method (and it is inallowed_methods), it is openly available without authorization.Field maskingchalice_plussupports partial object fetching by supplying a custom header in the request.By default the header isX-Fieldsbut it can be changed by setting the view'smask_headerattribute.For example, to only fetch theidandtitleof books, we can use the{id,title}mask:$ curl 127.0.0.1:8000/books -H "X-Fields: {id,title}"
[{"id":"f235adde-69a3-468f-b008-d22cd576dd98","title":"The Very Hungry Caterpillar"},{"id":"10e417a6-2cb4-4a03-8679-63491c0d17b9","title":"The Shining"}]It is also possible to span relationships using the field mask:$ curl 127.0.0.1:8000/books -H "X-Fields: {id,title,author{name}}"
[{"id":"f235adde-69a3-468f-b008-d22cd576dd98","title":"The Very Hungry Caterpillar", "author": {"name": "Eric Carle"}},{"id":"10e417a6-2cb4-4a03-8679-63491c0d17b9","title":"The Shining", "author": {"name": "Stephen King"}}]Note that in this case separate queries are performed to fetch the author for each book. This can be optimised by joining the authors table in the view:class BookListView(ListView):
    model = Book
    schema_class = BookSchema

    def get_queryset(self):
        queryset = super().get_queryset()
        if self.mask and "author" in self.mask:
            queryset = queryset.options(joinedload(Book.author))
        return querysetTo allow theX-Fieldsheader, aCORSConfigneeds to be defined inapp.py:from chalice import Chalice, CORSConfig

cors_config = CORSConfig(allow_headers=['X-Fields'])
register_urls(app, urlpatterns, cors=cors_config)Alembic integrationCreate an alembic folder at the same level asapp.py:$ alembic init alembicInalembic.ini, ensuresqlalchemy.urlis set to blank:sqlalchemy.url =This helps us dynamically set the value inalembic/env.py:if not config.get_main_option("sqlalchemy.url"):
    connection_string = f"postgresql+psycopg2://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}/{settings.DATABASE_NAME}"
    config.set_main_option("sqlalchemy.url", connection_string)Note that the deploy command below setssqlalchemy.urlto the remote database during deploy. It is important thatsqlalchemy.urldoesn't get overwritten inenv.pyif it has already been set externally.All models  should be registered inenv.py:from chalicelib.models import Base
from chalicelib.apps.books.models import Author, Book
from chalicelib.apps.users.models import User
target_metadata = [Base.metadata]chalice_plus deploychalice_plusincludes a deploy command which will migrate the remote database before deploy, reverting if the deploy fails:$ chalice_plus deployTo connect to the remote database, credentials will be fetched from SSM.At a minimum, the following need to be set in SSM:{app_name}.{stage}.DATABASE_USER{app_name}.{stage}.DATABASE_PASSWORD{app_name}.{stage}.DATABASE_HOST{app_name}.{stage}.DATABASE_NAMESSM ParametersIt can be useful to store secret variables as SSM parameters,chalice_pluscan fetch these during a deploy and save as environment variables within the lambda.In.chalice/config.json, set the parameter names using"ssm_parameters":{
  "version": "2.0",
  "app_name": "book-api",
  "automatic_layer": true,
  "ssm_parameters": [
    "DATABASE_USER",
    "DATABASE_PASSWORD",
    "DATABASE_HOST",
    "DATABASE_NAME"
  ],
  ...
}Any SSM parameters must have a name in the following format:{app_name}.{stage}.{parameter_name}It's also possible to skip alembic migrations - the following just deploys as usual, but with SSM parameter support:$ chalice_plus deploy --skip-migrationFiltering, sorting & paginationNot currently supported, but coming soon.