Skip to content

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.

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:

MethodPathWhat it does
GET/articles/:id/coverDownload the file
POST/articles/:id/coverUpload 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.

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.

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).

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.

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:

MethodPathDescription
GET/articles/:id/coverDownload cover at original size
GET/articles/:id/cover/smDownload cover at 400x400
GET/articles/:id/cover/mdDownload cover at 800x800
POST/articles/:id/coverUpload cover via multipart form

Gallery items are accessed by their array index in the resource path.

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"];

Upload a cover image using a standard multipart form POST:

Terminal window
curl -X POST http://localhost:9090/articles/000000000000000000000001/cover \
-H "Authorization: Bearer <token>" \
-F "file=@photo.jpg"

Include the image inline when creating or updating an article:

Terminal window
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.

Terminal window
# Original size
curl http://localhost:9090/articles/000000000000000000000001/cover
# Resized to 400x400
curl http://localhost:9090/articles/000000000000000000000001/cover/sm

The response includes Content-Type, ETag, and Cache-Control headers. Conditional requests with If-None-Match return 304 when the file hasn’t changed.

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.

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.

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.

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.

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.