Skip to content

Cookbook

Short recipes for changes that are common but not part of the core API.

Authorization and rate limiting

@api.authorization sets one callback for every handler:

@api.authorization
async def authorize(request):
    """Check the request.

    ---
    bearerAuth:
        type: http
        scheme: bearer
    """
    return request.headers.get("authorization", "")

Handlers call it through self.authorize. Override that to add per-resource rules and call super() to keep the API callback:

from muffin_rest import APIError
from muffin_rest.limits import MemoryRateLimiter

limiter = MemoryRateLimiter(60, period=60)

class Pets(PWRESTHandler):
    async def authorize(self, request):
        if not await limiter.check(request.client[0]):
            raise APIError.TOO_MANY_REQUESTS("Rate limit exceeded")
        return await super().authorize(request)

MemoryRateLimiter stores counters in the process. It is fine for a single worker and for tests. For multiple workers use RedisRateLimiter:

from muffin_rest.limits import RedisRateLimiter

limiter = RedisRateLimiter(60, period=60, redis=redis)

The limiter is keyed by whatever string you pass. Use a user id or an API key when you have one; the client address is only a fallback.

Scope every query to the current user

Override prepare_collection and use self.auth:

class Orders(PWRESTHandler):
    class Meta:
        model = Order

    async def prepare_collection(self, request):
        return super().prepare_collection(request).where(Order.user_id == self.auth)

The same works for SQLAlchemy:

return super().prepare_collection(request).where(order_table.c.user_id == self.auth)

For Mongo, prepare_collection returns a MongoChain:

async def prepare_collection(self, request):
    return super().prepare_collection(request).find({"user_id": self.auth})

A detail request loads the resource through the same collection, so a row belonging to another user returns 404.

Soft delete

Keep the rows, hide them from queries, and turn delete into an update:

import datetime as dt

class Pets(PWRESTHandler):
    class Meta:
        model = Pet

    async def prepare_collection(self, request):
        return super().prepare_collection(request).where(Pet.deleted_at.is_null())

    async def remove(self, request, resource=None):
        if resource is not None:
            resources = [resource]
        else:
            ids = await request.data()
            if not ids:
                raise APIError.NOT_FOUND()
            resources = await self.meta.manager.fetchall(
                self.collection.where(self.meta.model_pk << ids)
            )

        if not resources:
            raise APIError.NOT_FOUND()

        for item in resources:
            item.deleted_at = dt.datetime.now(dt.UTC)
            await item.save()

        return resource.get_id() if resource else [item.get_id() for item in resources]

PWRESTHandler.delete calls remove, so this covers both detail and batch deletes. For SQLAlchemy and Mongo, override delete instead; see the note in Handlers and lifecycle.

Custom filter

Use a backend filter class when the query language has the operator:

from muffin_rest.peewee.filters import PWFilter

class ContainsFilter(PWFilter):
    async def filter(self, collection, *ops, **ctx):
        return collection.where(self.field.contains(ops[0][1]))

class Users(PWRESTHandler):
    class Meta:
        model = User
        filters = (ContainsFilter("name"),)

The filter name still comes from Filter.name, so the request is ?where={"name": "uf"}. The operator is ignored because this filter always uses contains. The schema_field is taken from the schema field with the same name, which converts the value to the right type.

Expose a column under another name

Map the public filter name to the database column:

class Meta:
    model = Order
    filters = (("user", {"field": "user_id"}),)
    sorting = (("user", {"field": "user_id"}),)

Now ?where={"user": 42} and ?sort=-user work without exposing user_id.

File upload

A custom route can read multipart data and update the loaded resource:

from pathlib import Path

@PWRESTHandler.route("/pet/{id}/image", methods="post")
async def upload_image(self, request, *, resource: Pet):
    """Upload a pet image.

    ---
    requestBody:
        content:
            multipart/form-data:
                schema:
                    type: object
                    properties:
                        file: {type: string, format: binary}
    """
    form = await request.form(upload_to=Path("uploads"))
    resource.image = form["file"].name
    await resource.save()
    return resource.image

Returning a string is fine; Muffin turns it into a text response. Annotate the method with -> ResponseText if you want the OpenAPI response type to be text/plain.

Return a different status code

Override the HTTP method and adjust the response:

async def post(self, request, *, resource=None):
    response = await super().post(request, resource=resource)
    if resource is None:
        response.status_code = 201
    return response

resource is None means a create on the collection route; a POST with an id is an update and stays 200.

Batch insert in one transaction

save_many loops over save by default. Wrap it when a partial batch must not be visible:

class Pets(PWRESTHandler):
    class Meta:
        model = Pet

    async def save_many(self, request, data, *, update=False):
        async with self.meta.manager.transaction():
            return await super().save_many(request, data, update=update)

For SQLAlchemy, use self.meta.database.database.transaction() in the same shape. Check whether your plugin already opens a transaction per request before adding another one.

Test a handler

Use ASGITestClient and open the lifespan so startup hooks run:

from asgi_tools.tests import ASGITestClient

async def test_list(app):
    client = ASGITestClient(app)
    async with client.lifespan():
        response = await client.get("/api/pet")
        assert response.status_code == 200
        assert await response.json() == []

Query parameters with JSON need encoding:

import json

response = await client.get(
    "/api/pet",
    query={"where": json.dumps({"status": "available"})},
)

Backend test suites pin the async library to asyncio:

import pytest

@pytest.fixture(scope="module")
def aiolib():
    return "asyncio", {"loop_factory": None}

The base suite runs under asyncio, trio, and curio. Backend suites use asyncio because the database drivers do.