Skip to content

Resources & CRUD

A Resource is the core abstraction of Craft Easy: one Beanie model plus one registration line equals a full REST API. Craft Easy generates the endpoints, wires up validation, enforces access control, emits audit entries, applies cascade rules, handles ETag concurrency, and exposes the resource to the admin schema — all from the same registration.

The Resource class

from craft_easy.core.crud import Resource
from my_project.models.product import Product

app.register_resource(Resource(Product, path="/products"))

What this gives you out of the box:

Method Path Behaviour
GET /products List with filtering, sorting, pagination.
GET /products/{id} Get one, returns an ETag header.
POST /products Create.
PATCH /products/{id} Partial update, requires If-Match.
PUT /products/{id} Full replace, requires If-Match.
DELETE /products/{id} Soft delete, requires If-Match.

Every endpoint is automatically:

  • Tenant-scoped — callers only see their tenant's data.
  • Access-controlled — feature and attribute guards apply.
  • Audited — mutations are logged with before/after diffs.
  • HookedBEFORE_* / AFTER_* hooks fire for your custom logic.
  • Schema-exposed — appears in /admin/schema for the admin app.

Defining a model

Models are Beanie documents. Use TenantScopedDocument for anything that should be tenant-isolated:

from beanie import Document, Indexed
from pydantic import Field
from craft_easy.core.models import TenantScopedDocument

class Product(TenantScopedDocument):
    name: Indexed(str)
    price: float = Field(..., ge=0)
    sku: Indexed(str, unique=True)
    description: str | None = None
    in_stock: bool = True

    class Settings:
        name = "products"

TenantScopedDocument adds:

  • tenant_id: PydanticObjectId — auto-populated on create.
  • created_at, updated_at, created_by, updated_by — audit fields.
  • is_deleted: bool, deleted_at, deleted_by — soft delete.
  • Version field used by the ETag middleware.

Non-tenant-scoped documents can inherit from BaseDocument instead.

Registering resources

from craft_easy import create_base_app
from craft_easy.core.crud import Resource, ResourceConfig

def create_app():
    app = create_base_app()

    # Minimal registration
    app.register_resource(Resource(Product, path="/products"))

    # With custom configuration
    app.register_resource(Resource(
        Order,
        path="/orders",
        config=ResourceConfig(
            searchable_fields=["reference", "customer_name"],
            default_sort="-created_at",
            default_page_size=25,
            max_page_size=200,
            allow_bulk_delete=False,
        ),
    ))

    return app

Customising endpoints

Override a handler

Override any CRUD method on the Resource subclass to add custom behaviour:

class ProductResource(Resource[Product]):
    async def create(self, data: dict, *, context) -> Product:
        # Custom logic before delegating to default
        if data.get("price", 0) < 0:
            raise ValueError("Price must be non-negative")
        return await super().create(data, context=context)

app.register_resource(ProductResource(Product, path="/products"))

Add custom endpoints on a resource

A Resource exposes a router attribute (a fastapi.APIRouter). You can add custom routes alongside the auto-generated CRUD:

resource = Resource(Product, path="/products")

@resource.router.post("/{id}/publish")
async def publish(id: str):
    product = await Product.get(id)
    product.published = True
    await product.save()
    return product

app.register_resource(resource)

Bulk operations

Resource(
    Invoice,
    path="/invoices",
    config=ResourceConfig(allow_bulk_create=True, allow_bulk_delete=True),
)

Gives you POST /invoices/bulk and DELETE /invoices/bulk.

The registry

Every registered Resource is tracked by the ResourceRegistry. The registry is what powers /admin/schema, cascade resolution, and audit logging — you do not interact with it directly in most cases.

from craft_easy.core.crud import ResourceRegistry

# Inspect all registered resources (e.g. in a startup hook)
for resource in ResourceRegistry.all():
    print(resource.name, resource.path)

When not to use Resources

Resources are the right default. Use a plain FastAPI APIRouter when you need:

  • An RPC-style endpoint that does not correspond to a single document (e.g. /reports/monthly).
  • An integration callback (e.g. /webhooks/stripe) — see Webhooks.
  • A long-running batch operation — use Jobs instead.

Source

  • craft_easy/core/crud/resource.py — the Resource base class.
  • craft_easy/core/crud/registry.py — the ResourceRegistry.
  • craft_easy/core/crud/auto_hooks.py — default BEFORE/AFTER hooks.
  • craft_easy/core/models.pyBaseDocument, TenantScopedDocument.