Tutorial: File Uploads & Images
This tutorial continues from Build a Complete API. You will add a cover image and a gallery to the Article model using Crate’s resource system. By the end, your API will handle file uploads, serve images in multiple sizes, and clean up files when articles are deleted.
How Resources Work
Section titled “How Resources Work”When a model struct contains a field that implements CrateResource, the framework automatically generates upload and download routes for it. You don’t register these routes manually — they appear as soon as the field exists on the struct.
For a model Article with a resource field cover, Crate generates:
| Method | Path | What it does |
|---|---|---|
| GET | /articles/:id/cover | Download the file |
| POST | /articles/:id/cover | Upload via multipart form |
If the resource is a CrateImageCollectionResource with size presets, additional sub-routes are generated for each size (e.g. /articles/:id/cover/sm, /articles/:id/cover/md).
Files can also be uploaded inline as base64 in a JSON create/update request — the framework detects the data: prefix and stores the file automatically.
Storage Backends
Section titled “Storage Backends”Crate ships two file storage backends:
CrateFileResource stores files on the local filesystem. Good for development and simple deployments.
GridFsResource stores files as chunks in MongoDB (GridFS). Good for production — files live alongside your data and scale with your database.
Both implement the same CrateResource interface, so switching backends is a type alias change.
Setting Up GridFS for Pictures
Section titled “Setting Up GridFS for Pictures”First, define a settings struct and type aliases for your picture storage. Create packages/articles/source/blog_api/articles/picture.d:
import crate.resource.gridfs;import crate.resource.image;
struct PictureSettings { static { string baseUrl; @optional { IGridFsCollection files; IGridFsCollection chunks; size_t chunkSize = 255 * 1024; } }}
alias PictureStorage = GridFsResource!PictureSettings;PictureSettings is a static struct — the framework reads its fields at runtime to know where to store files. You configure it once at startup (shown below).
Adding Image Sizes
Section titled “Adding Image Sizes”Wrap the storage in CrateImageCollectionResource to get automatic resizing. The framework uses ImageMagick (convert) under the hood:
import crate.resource.image;
enum PictureSizes = [ ImageSettings("xs", "100", "100"), ImageSettings("sm", "400", "400"), ImageSettings("md", "800", "800"), ImageSettings("lg", "1600", "1600"),];
alias PictureFile = CrateImageCollectionResource!(PictureStorage, PictureSizes);This generates sub-routes for each size. A GET to /articles/:id/cover/sm returns a 400x400 version, resized on first request and cached in GridFS for subsequent requests.
Adding Cover and Gallery to the Model
Section titled “Adding Cover and Gallery to the Model”Update your Article struct to include a cover image and a gallery array:
struct Article { string _id; string title; string content;
@optional { string[] tags; string status; SysTime publishedAt; PictureFile cover; PictureFile[] gallery; }}cover is a single image. gallery is an array of images. Both are @optional — articles can exist without pictures.
With this struct in place, Crate automatically generates:
| Method | Path | Description |
|---|---|---|
| GET | /articles/:id/cover | Download cover at original size |
| GET | /articles/:id/cover/sm | Download cover at 400x400 |
| GET | /articles/:id/cover/md | Download cover at 800x800 |
| POST | /articles/:id/cover | Upload cover via multipart form |
Gallery items are accessed by their array index in the resource path.
Configuring Storage at Startup
Section titled “Configuring Storage at Startup”In your service class or central api.d, configure the static settings before registering routes:
void setupArticleApi(T)(CrateRouter!T crateRouter, Crate!Article articleCrate, IGridFsCollection pictureFiles, IGridFsCollection pictureChunks, string baseUrl) { PictureSettings.files = pictureFiles; PictureSettings.chunks = pictureChunks; PictureSettings.baseUrl = baseUrl;
// ... register routes}The GridFS collections come from your MongoDB connection. In the service class:
auto client = configurations.getMongoClient;auto db = client.getDatabase("blog");
auto pictureFiles = db["pictures.files"];auto pictureChunks = db["pictures.chunks"];Uploading Files
Section titled “Uploading Files”Via Multipart Form
Section titled “Via Multipart Form”Upload a cover image using a standard multipart form POST:
curl -X POST http://localhost:9090/articles/000000000000000000000001/cover \ -H "Authorization: Bearer <token>" \ -F "file=@photo.jpg"Via Base64 in JSON
Section titled “Via Base64 in JSON”Include the image inline when creating or updating an article:
curl -X POST http://localhost:9090/articles \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{ "article": { "title": "My Post", "content": "Hello world", "cover": "data:image/jpeg;base64,/9j/4AAQ..." } }'The framework detects the data: prefix, decodes the base64 content, and stores it in GridFS. The stored value becomes the GridFS ObjectId.
Downloading
Section titled “Downloading”# Original sizecurl http://localhost:9090/articles/000000000000000000000001/cover
# Resized to 400x400curl http://localhost:9090/articles/000000000000000000000001/cover/smThe response includes Content-Type, ETag, and Cache-Control headers. Conditional requests with If-None-Match return 304 when the file hasn’t changed.
Cleaning Up Pictures on Delete
Section titled “Cleaning Up Pictures on Delete”When an article is deleted, its cover and gallery images still exist in GridFS. You need middleware to clean them up. Create packages/articles/source/blog_api/articles/middleware/cleanup.d:
class PictureCleanupMiddleware { private Crate!Article articleCrate;
this(Crate!Article articleCrate) { this.articleCrate = articleCrate; }
@delete_ void deleteCover(HTTPServerRequest req) { auto item = articleCrate.getItem(req.params["id"]).exec.front;
removePicture(item, "cover"); removePictureArray(item, "gallery"); }
private void removePicture(Json item, string field) { if (field !in item || item[field].type != Json.Type.string) return;
auto pictureId = item[field].to!string; if (pictureId == "" || pictureId == "null") return;
try { auto resource = PictureStorage.fromString(pictureId); resource.remove(); } catch (Exception e) { logError("Failed to delete %s resource: %s", field, e.msg); } }
private void removePictureArray(Json item, string field) { if (field !in item || item[field].type != Json.Type.array) return;
foreach (entry; item[field]) { removePicture(entry, "picture"); } }}The @delete_ annotation runs this before the article record is removed from the database. It fetches the article, extracts the cover and gallery picture IDs, and removes each file from GridFS. Errors are logged but don’t block the delete — a missing file shouldn’t prevent removing the record.
Handling Cover Replacement
Section titled “Handling Cover Replacement”When an article’s cover is replaced via PATCH or PUT, the old cover should be deleted. Add a method for that:
@patch @replace void replaceCover(HTTPServerRequest req) { if ("cover" !in req.json["article"]) return;
auto newCover = req.json["article"]["cover"].to!string; auto item = articleCrate.getItem(req.params["id"]).exec.front; auto oldCover = item["cover"].to!string;
if (oldCover != newCover) { removePicture(item, "cover"); } }This compares the incoming cover ID with the stored one. If they differ, the old file is deleted before the new one is saved.
Wiring the Cleanup Middleware
Section titled “Wiring the Cleanup Middleware”Add the cleanup middleware to your route registration in api.d:
void setupArticleApi(T)(CrateRouter!T crateRouter, Crate!Article articleCrate, /* ... */) { auto cleanupMiddleware = new PictureCleanupMiddleware(articleCrate); auto tagFilter = new TagFilter;
crateRouter .prepare(articleCrate) .and(cleanupMiddleware) .and(tagFilter);}The cleanup middleware should be added early in the chain so it runs before other middleware that might depend on the article still existing.
Shared Pictures
Section titled “Shared Pictures”In a real application, the same picture might be used by multiple articles. Deleting one article shouldn’t destroy a picture used elsewhere. Add a reference count check:
private void removePicture(Json item, string field) { if (field !in item || item[field].type != Json.Type.string) return;
auto pictureId = item[field].to!string; if (pictureId == "" || pictureId == "null") return;
// Check if other articles reference this picture auto refCount = articleCrate.get .where(field).equal(pictureId).and .size;
if (refCount > 1) return;
try { auto resource = PictureStorage.fromString(pictureId); resource.remove(); } catch (Exception e) { logError("Failed to delete %s resource: %s", field, e.msg); } }This queries the collection to count how many articles reference the same picture. If more than one, the file is kept.
Using Filesystem Storage Instead
Section titled “Using Filesystem Storage Instead”For development or simpler deployments, swap GridFS for filesystem storage:
import crate.resource.file;
struct LocalPictureSettings { static string path = "uploads/pictures/"; static string baseUrl = "http://localhost:9090";}
alias PictureStorage = CrateFileResource!LocalPictureSettings;alias PictureFile = CrateImageCollectionResource!(PictureStorage, PictureSizes);The rest of the code stays the same — models, middleware, upload handling, and cleanup all work identically because both backends implement CrateResource.
Next Steps
Section titled “Next Steps”- Models & CRUD — field types,
@optional, and nested structs - Middleware — full reference for
@delete_,@mapper, and other annotations - Custom Operations — add non-CRUD endpoints like bulk delete