Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5. File Uploads

A book needs a cover image. In this part we add a file field to the Book model, configure local storage for development, and learn how the same code handles a swap to S3 in production.

Adding the file field

Edit models/book.go:

type Book struct {
    maniflex.BaseModel
    Title       string  `json:"title"        mfx:"required,filterable,sortable"`
    ISBN        string  `json:"isbn"         mfx:"required,filterable,unique"`
    Price       float64 `json:"price"        mfx:"required,min:0,filterable,sortable"`
    Stock       int64   `json:"stock"        mfx:"required,min:0,filterable"`
    PublishedAt string  `json:"published_at" mfx:"filterable,sortable"`

    AuthorID string `json:"author_id" mfx:"required,filterable,relation:Author;onDelete:cascade"`
    Author   Author `json:"author,omitempty"`

    Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/png|image/jpeg"`

    Genres  []Genre  `json:"genres,omitempty"  mfx:"through:BookGenre"`
    Reviews []Review `json:"reviews,omitempty"`
}

The cover field is a string in Go and a string in the database, but the mfx:"file" tag opts the model into multipart uploads. The column stores the storage key — a path under whichever backend you have configured.

max_size and accept are enforced in the framework, before the upload reaches storage. A 5 MB JPEG or a application/pdf is rejected with 400 BAD_REQUEST and never written.

Configuring storage

For development we use local disk. The maniflex/storage package ships a ready implementation:

import "github.com/xaleel/maniflex/storage"

fs, err := storage.NewLocalStorage("./uploads")
if err != nil {
    log.Fatal(err)
}

server := maniflex.New(maniflex.Config{
    Port:        8080,
    PathPrefix:  "/api",
    AutoMigrate: true,
    FileStorage: fs,
})

./uploads is created if it doesn’t exist. Every uploaded file lands under uploads/<uuid>/<sanitised-filename> so collisions are impossible.

Uploading a cover

There are two ways to attach a cover, both supported out of the box.

1. Multipart upload alongside create

The client sends multipart/form-data with one part per field:

curl -X POST localhost:8080/api/books \
  -H "Authorization: Bearer $TOKEN" \
  -F 'title=The Dispossessed' \
  -F 'isbn=9780061054884' \
  -F 'price=12.99' \
  -F 'stock=10' \
  -F "author_id=$AUTH" \
  -F 'cover=@./covers/dispossessed.jpg;type=image/jpeg'

The framework parses the multipart envelope, streams cover into the storage backend, writes the resulting key into the column, and persists the row. The response is the usual JSON envelope:

{
  "data": {
    "id": "...",
    "title": "The Dispossessed",
    "cover": "uploads/3f2b.../dispossessed.jpg",
    ...
  }
}

2. Two-step upload + reference

For large files or out-of-band uploads, hit the standalone /files endpoint first:

KEY=$(curl -s -X POST localhost:8080/files \
  -H "Authorization: Bearer $TOKEN" \
  -F 'file=@./covers/dispossessed.jpg;type=image/jpeg' \
  | jq -r .data.key)

curl -X POST localhost:8080/api/books \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"title\":\"The Dispossessed\",\"isbn\":\"9780061054884\",\"price\":12.99,\"stock\":10,\"author_id\":\"$AUTH\",\"cover\":\"$KEY\"}"

The file field accepts a plain string in JSON — the storage key returned by /files. The framework recognises that the value is already a key (not a new upload) and stores it as-is.

Downloading a cover

Storage keys are served at /files/{key...}:

curl 'localhost:8080/files/uploads/3f2b.../dispossessed.jpg' --output cover.jpg

The handler sets Content-Type, Content-Disposition: inline, and Content-Length from the metadata stored alongside the file.

For a permission layer in front of downloads — say, only registered users can fetch covers — add Auth middleware to the file route just as you would for any model route.

Automatic cleanup

The framework tracks the row that owns each key. A file is deleted from storage when:

  • the owning row is hard-deleted, or
  • the field is overwritten by a PATCH that supplies a new file or key.

Book does not embed WithDeletedAt, so a delete is a hard-delete and the cover goes away too. If you want covers to outlive book deletions (for an audit trail), tag the field with auto_delete:false:

Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/*,auto_delete:false"`

Swapping in S3

FileStorage is a four-method interface — Store, Retrieve, Delete, Exists. A drop-in S3 implementation looks like:

type S3Storage struct{ client *s3.Client; bucket string }

func (s *S3Storage) Store(ctx context.Context, key string, r io.Reader, meta maniflex.FileMeta) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket:      &s.bucket,
        Key:         &key,
        Body:        r,
        ContentType: &meta.ContentType,
    })
    return err
}

// Retrieve, Delete, Exists similarly.

Swap storage.NewLocalStorage(...) for the new type in main.go and nothing else changes. The same model code, the same endpoints, the same multipart parser. The model never knows.

What we built

CapabilityHow
File field on Bookmfx:"file,max_size:...,accept:..."
Local storage backendstorage.NewLocalStorage("./uploads")
Multipart uploadThe framework auto-detects multipart/form-data on create/update
Pre-uploaded key referencePlain string in the JSON body
Standalone uploadPOST /files, returns a key
Backend-agnosticmaniflex.FileStorage interface — swap to S3 with no model change

Next

In Part 6 — Filtering, Sorting & Pagination we build a catalogue browser: lookup books by title, sort by price or publication date, paginate the results, and combine includes with filters.