File Upload to Storage
Verified Scenario
This page is generated from an executable spec. An automated suite renders a fresh App from the current GoForj templates, applies every step below in order, and runs every verification command. If any step fails, the page does not ship.
Scenario 3 of 7 in the verified path. Plan on about 20 minutes.
This scenario adds a POST /api/v1/uploads endpoint that writes a file to a named uploads storage disk.
The example uses a small JSON payload so the page can focus on the GoForj storage boundary. Multipart parsing, streaming uploads, and large object handling are separate HTTP concerns.
What You Will Build
STORAGE_UPLOADS_*defines a named storage disk.app.Storage().Uploads()exposes the generated disk accessor.uploads.Servicevalidates and writes files.uploads.Controllerbinds request input and returns the stored path.provideUploadsServiceselects the named disk while Wire provides the service.- A service test uses memory storage and does not start HTTP.
Prerequisites
Start from the App used in the previous scenarios.
The generated App should have HTTP and storage support enabled.
Golden Path State
Before this scenario, the App can read user profiles through a service, repository, and named cache.
After this scenario, the App also has an upload endpoint and a named uploads storage disk. File content moves through an upload service; ownership, metadata, and path discipline stay explicit application concerns.
Files
This scenario edits or creates:
Configuration
.envUploads feature
internal/uploads/service.go
internal/uploads/service_test.go
internal/uploads/controller.goHTTP registration
app/wire/inject_http_controllers_app.go
app/routes.goApp wiring
app/wire/inject_services_app.goThe storage generator updates:
internal/storages/accessors_gen.go
internal/storages/manager_gen.goDo not edit generated storage files by hand.
Step 1: Add a Named Storage Disk
Add a named uploads disk to .env, then run the build pipeline so the generated App exposes app.Storage().Uploads().
Append to .env:
STORAGE_UPLOADS_DRIVER=local
STORAGE_UPLOADS_ROOT=storage/app/uploads
STORAGE_UPLOADS_PREFIX=Update .env so it includes:
STORAGE_SUPPORTED_DRIVERS=local,memoryforj buildStep 2: Scaffold the Controller
Start with the real make command. It creates the uploads controller, wires the constructor, and registers its routes.
forj make:controller uploadsStep 3: Add the Service
Create internal/uploads/service.go.
The service receives storage.Storage, not a local filesystem or S3 client.
Create or replace internal/uploads/service.go:
// Package uploads keeps upload policy independent of the configured storage driver.
package uploads
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/goforj/storage"
)
const maxUploadBytes = 2 * 1024 * 1024
var (
// ErrFilenameRequired prevents uploads from being stored without a safe final path segment.
ErrFilenameRequired = errors.New("filename is required")
// ErrBodyRequired prevents empty objects from being persisted as successful uploads.
ErrBodyRequired = errors.New("body is required")
// ErrBodyInvalid keeps malformed transport encoding distinguishable from storage failures.
ErrBodyInvalid = errors.New("body must be valid base64")
// ErrUploadTooLarge keeps memory use and storage writes within the endpoint's documented limit.
ErrUploadTooLarge = errors.New("upload is too large")
)
// Service keeps upload validation independent of the selected storage driver.
type Service struct {
disk storage.Storage
}
// StoreInput keeps transport fields separate from the storage driver's byte-oriented contract.
type StoreInput struct {
Filename string
ContentType string
BodyBase64 string
}
// StoredUpload returns the bounded metadata callers need without exposing driver-specific details.
type StoredUpload struct {
Path string `json:"path"`
ContentType string `json:"content_type"`
Size int `json:"size"`
}
// NewService requires the named disk at construction so invalid wiring fails before requests arrive.
func NewService(disk storage.Storage) *Service {
return &Service{disk: disk}
}
// Store validates one upload before writing it beneath the application-owned incoming prefix.
func (s *Service) Store(ctx context.Context, input StoreInput) (StoredUpload, error) {
filename := safeFilename(input.Filename)
if filename == "" {
return StoredUpload{}, ErrFilenameRequired
}
encodedBody := strings.TrimSpace(input.BodyBase64)
if encodedBody == "" {
return StoredUpload{}, ErrBodyRequired
}
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encodedBody))
body, err := io.ReadAll(io.LimitReader(decoder, maxUploadBytes+1))
if err != nil {
return StoredUpload{}, fmt.Errorf("%w: %v", ErrBodyInvalid, err)
}
if len(body) == 0 {
return StoredUpload{}, ErrBodyRequired
}
if len(body) > maxUploadBytes {
return StoredUpload{}, ErrUploadTooLarge
}
storedPath := path.Join("incoming", time.Now().UTC().Format("20060102"), filename)
if err := s.disk.WithContext(ctx).Put(storedPath, body); err != nil {
return StoredUpload{}, fmt.Errorf("store upload: %w", err)
}
contentType := strings.TrimSpace(input.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
}
return StoredUpload{
Path: storedPath,
ContentType: contentType,
Size: len(body),
}, nil
}
// safeFilename reduces user-controlled names to one segment so they cannot escape the managed prefix.
func safeFilename(name string) string {
name = strings.TrimSpace(name)
name = path.Base(strings.ReplaceAll(name, "\\", "/"))
name = strings.Trim(name, ".")
return name
}Step 4: Replace the Starter Controller
Replace internal/uploads/controller.go.
The controller owns request binding and HTTP status decisions. The service owns storage behavior.
Create or replace internal/uploads/controller.go:
// Package uploads keeps HTTP translation independent from portable storage behavior.
package uploads
import (
"errors"
"net/http"
"github.com/goforj/web"
)
// Controller keeps HTTP binding and response policy outside the storage service.
type Controller struct {
service *Service
}
// StoreRequest represents the JSON boundary without coupling it to storage driver types.
type StoreRequest struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
BodyBase64 string `json:"body_base64"`
}
// NewController requires the upload service so route registration cannot hide incomplete wiring.
func NewController(service *Service) *Controller {
return &Controller{service: service}
}
// Routes keeps the upload endpoint owned by the feature that handles it.
func (c *Controller) Routes() []web.Route {
return []web.Route{
web.NewRoute(http.MethodPost, "/uploads", c.Store),
}
}
// Store translates HTTP input failures while allowing unexpected storage failures to reach middleware.
func (c *Controller) Store(ctx web.Context) error {
var req StoreRequest
if err := ctx.Bind(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid payload",
})
}
upload, err := c.service.Store(ctx.Context(), StoreInput{
Filename: req.Filename,
ContentType: req.ContentType,
BodyBase64: req.BodyBase64,
})
switch {
case errors.Is(err, ErrFilenameRequired),
errors.Is(err, ErrBodyRequired),
errors.Is(err, ErrBodyInvalid),
errors.Is(err, ErrUploadTooLarge):
return ctx.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
case err != nil:
return err
}
return ctx.JSON(http.StatusCreated, upload)
}Step 5: Add Upload Imports
Add imports for the generated storage manager and uploads package.
Update app/wire/inject_services_app.go so it includes:
"your/module/internal/runtime"
"your/module/internal/storages"
"your/module/internal/uploads"Step 6: Add Upload Providers
Add the upload service provider, which selects its named disk at the composition root.
Update app/wire/inject_services_app.go so it includes:
provideUploadsService,
provideUserProfileCache,Step 7: Add the Upload Service Provider
provideUploadsService keeps named disk selection out of application behavior without exporting an ambiguous storage.Storage to Wire.
Update app/wire/inject_services_app.go so it includes:
// provideUploadsService selects the named disk where dependencies are composed instead of inside upload behavior.
func provideUploadsService(manager *storages.Manager) *uploads.Service {
return uploads.NewService(manager.Uploads())
}
// provideUserRepository preserves the service boundary while Wire composes its concrete cache-aside implementation.
func provideUserRepository(source *users.MemoryUserRepository, profileCache *cache.Cache) users.UserRepository {Step 8: Add a Service Test
Create internal/uploads/service_test.go.
The test uses memory storage. It does not create local files and does not require S3.
Create or replace internal/uploads/service_test.go:
// Package uploads keeps storage behavior testable through the portable driver contract.
package uploads
import (
"context"
"encoding/base64"
"errors"
"strings"
"testing"
"github.com/goforj/storage"
"github.com/goforj/storage/driver/memorystorage"
)
// serviceFixture keeps the service and its observable memory disk together for behavior-focused tests.
type serviceFixture struct {
service *Service
disk storage.Storage
}
// newServiceFixture creates an isolated in-memory boundary so tests never depend on local files or cloud services.
func newServiceFixture(t *testing.T) serviceFixture {
t.Helper()
disk, err := storage.Build(memorystorage.Config{})
if err != nil {
t.Fatalf("build storage: %v", err)
}
return serviceFixture{
service: NewService(disk),
disk: disk,
}
}
// TestServiceStoresUpload proves path sanitization and persistence through the portable storage contract.
func TestServiceStoresUpload(t *testing.T) {
ctx := context.Background()
fixture := newServiceFixture(t)
upload, err := fixture.service.Store(ctx, StoreInput{
Filename: "../hello.txt",
ContentType: "text/plain",
BodyBase64: "aGVsbG8=",
})
if err != nil {
t.Fatalf("store upload: %v", err)
}
if !strings.HasSuffix(upload.Path, "/hello.txt") {
t.Fatalf("upload path = %q, want a sanitized hello.txt path", upload.Path)
}
if upload.ContentType != "text/plain" {
t.Fatalf("content type = %q, want %q", upload.ContentType, "text/plain")
}
if upload.Size != 5 {
t.Fatalf("upload size = %d, want 5", upload.Size)
}
body, err := fixture.disk.WithContext(ctx).Get(upload.Path)
if err != nil {
t.Fatalf("read upload: %v", err)
}
if string(body) != "hello" {
t.Fatalf("body = %q, want %q", string(body), "hello")
}
}
// TestServiceRejectsInvalidUpload keeps validation cases explicit without repeating storage setup.
func TestServiceRejectsInvalidUpload(t *testing.T) {
oversizedBody := base64.StdEncoding.EncodeToString(make([]byte, maxUploadBytes+1))
tests := []struct {
name string
input StoreInput
wantErr error
}{
{
name: "missing filename",
input: StoreInput{BodyBase64: "aGVsbG8="},
wantErr: ErrFilenameRequired,
},
{
name: "missing body",
input: StoreInput{Filename: "hello.txt"},
wantErr: ErrBodyRequired,
},
{
name: "invalid body",
input: StoreInput{
Filename: "hello.txt",
BodyBase64: "not base64",
},
wantErr: ErrBodyInvalid,
},
{
name: "body exceeds limit",
input: StoreInput{
Filename: "hello.txt",
BodyBase64: oversizedBody,
},
wantErr: ErrUploadTooLarge,
},
}
fixture := newServiceFixture(t)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := fixture.service.Store(context.Background(), test.input)
if !errors.Is(err, test.wantErr) {
t.Fatalf("Store() error = %v, want %v", err, test.wantErr)
}
})
}
}Build and Verify
forj buildgo test ./...forj route:listExpected output includes:
/api/v1/uploads
Try the Route
Run the HTTP server:
forj apiSend a small upload:
curl -X POST http://localhost:3000/api/v1/uploads \
-H 'Content-Type: application/json' \
-d '{"filename":"hello.txt","content_type":"text/plain","body_base64":"aGVsbG8="}'Expected response shape:
{"path":"incoming/YYYYMMDD/hello.txt","content_type":"text/plain","size":5}The date segment uses the current UTC date.
Operations
Operational notes:
uploadsis a named storage resource and appears in generated storage accessors.- Storage operation metrics, inspect records, and Lighthouse views can use the named disk.
- Store ownership, metadata, content type, and retention policy in durable application state when those values matter.
Swap the Driver
To use S3 in production, compile S3 support and select it for the named disk:
STORAGE_SUPPORTED_DRIVERS=local,s3
STORAGE_UPLOADS_DRIVER=s3
STORAGE_UPLOADS_BUCKET=my-app-uploads
STORAGE_UPLOADS_REGION=us-east-1
STORAGE_UPLOADS_PREFIX=uploadsThen run:
forj buildBusiness code does not change. The service still receives storage.Storage.
Common Mistakes
Common mistakes
- Do not trust raw user filenames as storage paths.
- Do not import S3, GCS, or local driver packages into
uploads.Service. - Do not edit generated storage accessors by hand.
- Do not forget
forj buildafter addingSTORAGE_UPLOADS_*. - Do not store ownership, authorization, or lifecycle rules only in object paths.
Next Steps
- Next, publish a
users.createdevent and handle it with Users Created Event.
