Documentation previewThese docs are actively being built. Some pages may change as the framework and examples are finalized.
Skip to content

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.Service validates and writes files.
  • uploads.Controller binds request input and returns the stored path.
  • provideUploadsService selects 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

text
.env

Uploads feature

text
internal/uploads/service.go
internal/uploads/service_test.go
internal/uploads/controller.go

HTTP registration

text
app/wire/inject_http_controllers_app.go
app/routes.go

App wiring

text
app/wire/inject_services_app.go

The storage generator updates:

text
internal/storages/accessors_gen.go
internal/storages/manager_gen.go

Do 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:

dotenv
STORAGE_UPLOADS_DRIVER=local
STORAGE_UPLOADS_ROOT=storage/app/uploads
STORAGE_UPLOADS_PREFIX=

Update .env so it includes:

dotenv
STORAGE_SUPPORTED_DRIVERS=local,memory
bash
forj build

Step 2: Scaffold the Controller

Start with the real make command. It creates the uploads controller, wires the constructor, and registers its routes.

bash
forj make:controller uploads

Step 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:

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:

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:

go
"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:

go
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:

go
// 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:

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

bash
forj build
bash
go test ./...
bash
forj route:list

Expected output includes:

  • /api/v1/uploads

Try the Route

Run the HTTP server:

bash
forj api

Send a small upload:

bash
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:

json
{"path":"incoming/YYYYMMDD/hello.txt","content_type":"text/plain","size":5}

The date segment uses the current UTC date.

Operations

Operational notes:

  • uploads is 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:

dotenv
STORAGE_SUPPORTED_DRIVERS=local,s3
STORAGE_UPLOADS_DRIVER=s3
STORAGE_UPLOADS_BUCKET=my-app-uploads
STORAGE_UPLOADS_REGION=us-east-1
STORAGE_UPLOADS_PREFIX=uploads

Then run:

bash
forj build

Business 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 build after adding STORAGE_UPLOADS_*.
  • Do not store ownership, authorization, or lifecycle rules only in object paths.

Next Steps