Bucket Module
Domain: File Storage & Content Management
Type: Backbone domain module (library crate)
Architecture: Domain-Driven Design — 4 layers (domain / application / infrastructure / presentation)
Source of truth: YAML schemas under schema/models/
Most of this crate is generated from the schemas. Hand-written
additions live behind // <<< CUSTOM markers (for generated files) or
in sibling *_custom.rs files that the generator never touches.
Table of contents
- What you get
- Module setup
- Bucket usage — step by step
- File upload (multipart HTTP)
- File serving
- Configuration
- Architecture
- Testing
- Regeneration & custom code
- Documentation index
What you get
| Surface | Description | Where it lives |
|---|---|---|
| CRUD router | 12+ standard endpoints per entity (list, create, get, update, patch, soft delete, bulk, upsert, trash, restore, empty, count) | BucketModule::crud_router() |
| Upload router | multipart/form-data HTTP entry — single-shot + resumable (initiate → parts → complete) |
BucketModule::upload_router::<A>() |
| Serving router | Auth-aware delivery: 302 to presigned URL, byte stream, or JSON | BucketModule::serving_router::<A>() |
gRPC (feature grpc) |
Mirror of the CRUD surface | presentation::grpc |
13 generated entities: Bucket, StoredFile, FileVersion, FileShare, FileLock, FileComment, ContentHash, UploadSession, ConversionJob, ProcessingJob, Thumbnail, UserQuota, AccessLog.
7 custom services (regen-safe): LockingService,
DeduplicationService, MultipartUploadService, ConversionService,
CdnService, VideoThumbnailService, DocumentPreviewService.
Module setup
use Arc;
use PgPool;
use ;
use Url;
use Duration;
// 1. Database
let pool: PgPool = connect.await?;
// 2. Storage backend — LocalStorage for dev, S3Storage in prod
let storage: = new;
// 3. Bucket-module runtime config
let config = BucketConfig ;
// 4. Build the module
let bucket = builder
.with_database
.with_storage
.with_config
.build?;
// 5a. Simple wiring — one merged router (CRUD + upload + serving).
// `MyUser` must implement Axum's `FromRequestParts` (so it's an
// `AuthExtractor`) AND `HasOwnerId` (so `owner_id` is derived from
// auth, never trusted from the request body).
let policy = new;
let opts = new;
let app = new
.nest;
// 5b. Advanced wiring — mount each surface independently when you need
// different prefixes, middleware, or auth policies per router.
let app = new
.nest
.nest
.nest;
Loading config from the environment
For first-time wiring, prefer BucketConfig::from_env() over
hand-building the struct:
// Reads BUCKET_STORAGE_BACKEND, BUCKET_STORAGE_ROOT, BUCKET_BASE_URL, …
// (S3 path) BUCKET_S3_ENDPOINT, BUCKET_S3_REGION, BUCKET_S3_PRIVATE_BUCKET, …
// See `BucketConfig::from_env` rustdoc for the full list.
let config = from_env?;
BucketConfig::default() produces a LocalStorage dev shape rooted at
/tmp/bucket with redirect-mode serving — useful for tests and the
example project.
Bucket usage — step by step
All examples use BASE = http://localhost:3000/api/v1/bucket (matches
the nest above). Swap in your own prefix. Auth is whatever your
AuthExtractor requires; the examples assume Authorization: Bearer <jwt>.
Bucket fields (cheat sheet)
| Field | Type | Notes |
|---|---|---|
name |
string (req, ≤ 255) | Display name |
slug |
string (req, unique, ≤ 255, lowercased) | URL-safe identifier — how you usually look it up |
description |
string? | Free text |
owner_id |
uuid (req) | FK → User |
bucket_type |
enum | user (default) / shared / system / temp |
status |
enum | active (default) / readonly / locked / archived / deleted |
storage_backend |
enum | local (default) / s3 / … |
root_path |
string (req, ≤ 1024) | Path inside the backend |
max_file_size |
int64? | bytes; null = no limit |
allowed_mime_types |
string[] | empty = all |
auto_delete_after_days |
int? | for temp buckets |
enable_cdn / enable_versioning / enable_deduplication |
bool | toggles |
Full schema: schema/models/bucket.model.yaml.
1. Create a bucket
Response: 201 Created + the persisted bucket (UUID id, audit metadata, defaults filled in).
2. Read a bucket
By id:
By slug (no dedicated endpoint — slug is unique, so filter the list):
3. List & search buckets
The list endpoint accepts these query parameters (all optional):
| Param | Default | Meaning |
|---|---|---|
page |
1 | 1-based page index |
page_size |
20 | Items per page (≤ 100) |
sort_by |
created_at |
Column name |
sort_direction |
desc |
asc / desc |
search |
— | Substring match against text fields (name, slug, description) |
status |
— | Filter by status enum |
tags |
— | Comma-separated tag filter |
created_by |
— | Filter by creator user id |
# Page 2, 50 per page, alphabetical by name
# All active 'shared' buckets owned by a user, name contains "image"
Response shape:
4. Update a bucket
Full update (PUT — send the whole representation):
Partial update (PATCH — send only the changed fields):
5. Soft-delete, restore, empty trash
# Soft delete — sets metadata.deleted_at; bucket disappears from list
# Browse the trash
# Bring it back
# Permanent delete one bucket from trash
# Permanent delete EVERYTHING in trash
6. State transitions (lock / unlock / archive / …)
The Bucket state machine adds explicit transitions on top of CRUD.
They live under /buckets/:id/transitions/<action>:
| Action | Effect |
|---|---|
lock |
active → locked (no writes) |
unlock |
locked → active |
archive |
active → archived (read-only, hidden from default list) |
restore |
archived → active |
delete |
Triggers state-machine-managed delete (audited) |
7. Bulk & upsert
# Bulk create (one round-trip, transactional)
# Upsert by slug (or whatever unique key the schema declares)
8. Counts
The same 12-endpoint shape applies to every other entity: just swap
bucketsforstored-files,file-shares,file-locks,upload-sessions, etc. See the OpenAPI spec (docs/openapi/bucket-v2.yaml) for the per-entity field lists.
File upload (multipart HTTP)
Two flows, both mounted by upload_router::<A>(UploadConfig). A must
implement AuthExtractor + HasOwnerId — the authenticated identity
supplies owner_id (never trusted from the request body).
Single-shot (one request)
Body limit defaults to 256 MiB (override via UploadConfig::single_shot_limit).
Resumable (initiate → parts → complete)
# 1. Initiate
SESSION=
# 2. Upload parts (1-based, in any order)
# ... part 2, 3, …
# 3. Finalize — assembles, persists stored_files row, deletes staged parts
# Or abort
Chunk limit defaults to 16 MiB; tune via UploadConfig::chunk_limit.
Full OpenAPI: see the /uploads/* operations in
docs/openapi/bucket-v2.yaml.
File serving
Mount serving_router to deliver bytes under pretty URLs:
let policy = new;
let app = app.nest;
Three response strategies, configured via ServingConfig::default_mode:
| Mode | What the handler returns |
|---|---|
Redirect (default) |
302 to a short-lived presigned URL |
Stream |
200 with the bytes proxied through the service |
SignedUrl |
200 {"url": "..."} JSON |
Full reference (storage backends, key naming, public-prefix routing, auth slots): docs/serving.md.
Configuration
application.yml
database:
url: postgresql://root:password@localhost:5432/bucket
max_connections: 20
min_connections: 5
bucket:
enabled: true
storage:
backend: local # or s3
root: /var/data/bucket
base_url: http://localhost:3000/cdn/
signing_secret_env: BUCKET_SIGNING_SECRET
serving:
default_mode: redirect # redirect | stream | signed_url
public_prefix: "public/"
presigned_ttl_secs: 300
Environment
| Variable | Purpose |
|---|---|
DATABASE_URL |
Overrides the configured database URL |
BUCKET_SIGNING_SECRET |
HMAC secret for LocalStorage presigned URLs (name is configurable) |
AWS_* |
Picked up by S3Storage via aws-config |
Knobs and their defaults are documented in docs/CONFIGURATION_THRESHOLDS.md.
Architecture
bucket/
├── schema/ SOURCE OF TRUTH
│ ├── models/*.model.yaml entity DSL (generated → everything else)
│ ├── hooks/ lifecycle hooks
│ ├── workflows/ saga / flow definitions
│ └── openapi/ generated index
│
├── src/
│ ├── domain/ entities, value objects, state machines, repos (traits)
│ ├── application/
│ │ ├── service/ {entity}_service.rs (type alias) + {entity}_service_custom.rs
│ │ ├── workflows/ multipart upload, file processing, share creation, …
│ │ └── triggers/ validator/ usecases/ events/
│ ├── infrastructure/
│ │ └── persistence/ newtype repos over GenericCrudRepository
│ ├── presentation/
│ │ └── http/
│ │ ├── {entity}_handler.rs BackboneCrudHandler-driven CRUD
│ │ ├── serving.rs custom — mode-B file delivery
│ │ └── upload.rs custom — multipart HTTP upload
│ ├── storage/ ObjectStorage trait + Local/S3 backends (custom)
│ ├── auth/ AuthExtractor / AuthzPolicy / HasOwnerId (custom)
│ ├── config/ BucketConfig (custom)
│ ├── bucket_module.rs custom — crud_router / upload_router / serving_router
│ └── lib.rs generated re-exports + custom `// <<<` block
│
├── migrations/ <timestamp>_*.up.sql / .down.sql (sqlx convention)
├── docs/ human-maintained docs (this README + serving.md + bucket-spec.md …)
├── tests/ integration + workflow + bench tests
└── examples/serving/ runnable wiring example
Why this shape: the project CLAUDE.md has the non-negotiable rules and naming conventions.
Testing
# Unit tests (lib)
# Unit + handler-level tests for the upload module
# HTTP integration suite — points reqwest at a running server
API_BASE_URL=http://localhost:3000 \
API_AUTH_TOKEN= \
BUCKET_TEST_BUCKET_ID=00000000-0000-0000-0000-000000000001 \
Integration tests skip (rather than fail) when API_BASE_URL is
unreachable, so they are safe to leave on in CI without a deployed
backend.
In-process tests via InMemoryStorage
Consumers writing their own tests against the upload / serving routers
should avoid LocalStorage (filesystem) and S3 (credentials). Turn on
the test-utils feature instead — it ships an InMemoryStorage that
implements ObjectStorage against a DashMap:
# consumer Cargo.toml
[]
= { = "*", = ["test-utils"] }
use InMemoryStorage;
use Arc;
let storage: = new;
// ... wire into BucketModule::builder().with_storage(storage)
InMemoryStorage::len(), .contains_key(k), and .clear() are
available for assertions and per-test isolation. Presigned URLs are
synthetic (memory://...) and never call out to the network.
Regeneration & custom code
Two rules keep custom code safe across regeneration:
- Inside generated files, put hand-written code between
// <<< CUSTOMand// END CUSTOMmarkers. Everything outside is overwritten bymetaphor schema schema generate. - For larger surfaces, create a sibling file like
account_service_custom.rs— files that don't match the generated filename pattern are never touched.
The custom-safe surfaces in this module today:
src/bucket_module.rs—crud_router,upload_router,serving_routersrc/presentation/http/upload.rs— multipart HTTP handlersrc/presentation/http/serving.rs— mode-B serving handlersrc/storage/,src/auth/,src/config/application/service/*_custom.rsfilestests/integration/tests/upload_multipart_test.rs(registered via a// <<< CUSTOMblock intests/integration/tests/mod.rs)
Regenerate everything:
Documentation index
| File | What's inside |
|---|---|
docs/README.md |
Doc map |
docs/brd.md |
Business requirements (917 lines) |
docs/bucket-spec.md |
Module specification |
docs/bucket-plan.md |
Implementation plan |
docs/domain.md |
Entity reference (generated from schemas) |
docs/serving.md |
File serving — storage + auth + URL shapes |
docs/CONFIGURATION_THRESHOLDS.md |
Tunable defaults |
docs/MIGRATION_V2.md |
V1 → V2 migration notes |
docs/code-quality.md |
Quality / lint conventions |
docs/TRAIT_ABSTRACTION_ANALYSIS.md |
Design rationale for the trait surface |
docs/openapi/bucket-v2.yaml |
Authoritative OpenAPI 3.0 spec (CRUD + uploads) |
schema/models/ |
Per-entity schema YAML (the actual source of truth) |
CHANGELOG.md |
Version history |
CLAUDE.md |
Conventions / rules for AI-assisted edits |
License
Part of the Backbone Framework.