Skip to main content

Extension for Chalice, adding support for class-based views, SQLAlchemy and more...

Project description

Chalice Plus

Chalice 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:

Compatibility

chalice_plus requires Python 3.9+

Installation

You can install chalice_plus with pip:

$ pip install chalice_plus

Attach an engine to the app in app.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}")

Overview

API endpoints can be created quickly and easily using class-based views - see the example below.

Note: Application code is stored in the chalicelib folder per chalice conventions.

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 = BookSchema

chalicelib/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 = True

Example Project

To see how everything fits together, please see the example project.

Class Based Views

Views can inherit from pre-defined chalice_plus views:

  • RetrieveView
  • UpdateView
  • DeleteView
  • RetrieveUpdateView
  • RetrieveDeleteView
  • UpdateDeleteView
  • RetrieveUpdateDeleteView
  • ListView
  • CreateView
  • CreateListView

Alternatively a custom view can be created using the generic APIView and defining custom methods and attributes.

The current request is available using self.request and the current database session is available using self.session. Any url kwargs can also be accessed in self.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, define pk_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, define get_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 queryset

For 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 data

The model can also be intercepted before saving by overriding load_object - this can be useful when assigning created_by or updated_by fields:

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 obj

To restrict which http methods are allowed on a view, allowed_methods can be set:

class BookListView(APIView):
    allowed_methods = ("post", "get")

When inheriting from a chalice_plus view, allowed_methods will already be set appropriately, but can be overridden.

For example, by default put requests are not allowed (use patch for partial updates). To enable put on an UpdateView:

class BookUpdateView(UpdateView):
    model = Book
    schema_class = BookSchema
    allowed_methods = ("patch", "put")

URLs

URLs 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 in app.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 of uuid has 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 in self.kwargs as a string.
  • int - validates as an integer. The parameter value is available in self.kwargs as an integer.
  • str - validates as a string. The parameter value is available in self.kwargs as a string.

Authorization

An authorizer can be passed to register_urls. The authorizer will then be used to authorize any views which define permissions. If a view does not define permissions for an http method, no authorization will be applied.

app.py

from 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)

Authentication

Views can also define an authenticator class. The authenticator is used to get the current user: self.authenticator.user or self.authenticator.user_id.

chalice_plus comes with a CognitoAuthenticator, however the get_user function needs to be defined manually. For example:

chalicelib/apps/users/authenticators.py

import 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.py

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

Permissions

Views can be restricted by permission and will generally require an authenticator.

chalice_plus comes with a few default permission classes:

  • IsAuthenticated
  • IsAdmin
  • IsOwner
  • IsOwnerOrAdmin

The owner is checked by looking at object.created_by and admin is checked by looking at user.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_staff

Permissions 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 in allowed_methods), it is openly available without authorization.

Field masking

chalice_plus supports partial object fetching by supplying a custom header in the request.

By default the header is X-Fields but it can be changed by setting the view's mask_header attribute.

For example, to only fetch the id and title of 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 queryset

To allow the X-Fields header, a CORSConfig needs to be defined in app.py:

from chalice import Chalice, CORSConfig

cors_config = CORSConfig(allow_headers=['X-Fields'])
register_urls(app, urlpatterns, cors=cors_config)

Alembic integration

Create an alembic folder at the same level as app.py:

$ alembic init alembic

In alembic.ini, ensure sqlalchemy.url is set to blank:

sqlalchemy.url =

This helps us dynamically set the value in alembic/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 sets sqlalchemy.url to the remote database during deploy. It is important that sqlalchemy.url doesn't get overwritten in env.py if it has already been set externally.

All models should be registered in env.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 deploy

chalice_plus includes a deploy command which will migrate the remote database before deploy, reverting if the deploy fails:

$ chalice_plus deploy

To 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_NAME

SSM Parameters

It can be useful to store secret variables as SSM parameters, chalice_plus can 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-migration

Filtering, sorting & pagination

Not currently supported, but coming soon.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

chalice-plus-0.0.1.tar.gz (17.7 kB view details)

Uploaded Source

Built Distribution

chalice_plus-0.0.1-py3-none-any.whl (16.3 kB view details)

Uploaded Python 3

File details

Details for the file chalice-plus-0.0.1.tar.gz.

File metadata

  • Download URL: chalice-plus-0.0.1.tar.gz
  • Upload date:
  • Size: 17.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/4.0.2 CPython/3.10.12

File hashes

Hashes for chalice-plus-0.0.1.tar.gz
Algorithm Hash digest
SHA256 d2c7d9cc4ab3bc7c1f02eed724a83da6881b5fa9ed1d3f656647397e4e2daab7
MD5 be4b6ac41055ebb7193f004b497105e2
BLAKE2b-256 76399489d623cdeaea447a0f0fdeb39a0ed8e36a62cf8385cca47b2661efe3ca

See more details on using hashes here.

File details

Details for the file chalice_plus-0.0.1-py3-none-any.whl.

File metadata

  • Download URL: chalice_plus-0.0.1-py3-none-any.whl
  • Upload date:
  • Size: 16.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/4.0.2 CPython/3.10.12

File hashes

Hashes for chalice_plus-0.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 eea7571ec0eb22e10c43d40f0e4580b489dd003d946e3c2036a54c4aa01c38cc
MD5 5bed22f21bb1082021c9e0b21210b400
BLAKE2b-256 4187b015425aa86a2d71bd787dd2c1c011ac1955a09f22ba176646d2a717d815

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page