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.
- Hooked —
BEFORE_*/AFTER_*hooks fire for your custom logic. - Schema-exposed — appears in
/admin/schemafor 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— theResourcebase class.craft_easy/core/crud/registry.py— theResourceRegistry.craft_easy/core/crud/auto_hooks.py— default BEFORE/AFTER hooks.craft_easy/core/models.py—BaseDocument,TenantScopedDocument.