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.

Your 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 your 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:

text
/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 ​