Skip to content

Storage - Usage guide

This guide shows you how to use the Storage service in your FastEdgy application.

Configuration

Set the storage path in your environment file (.env):

DATA_PATH=./storage
IMAGE_QUALITY=80

S3 object storage

Set STORAGE_ADAPTER=s3 to keep the files in an S3-compatible bucket instead of on disk:

STORAGE_ADAPTER=s3
S3_BUCKET=my-project-prod
S3_REGION=gra
S3_ENDPOINT=https://s3.gra.io.cloud.ovh.net
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PREFIX=data
S3_STORAGE_CLASS=EXPRESS_ONEZONE

S3_STORAGE_CLASS is the class every write asks for. Left empty, the bucket's default applies. On OVHcloud, EXPRESS_ONEZONE is the High Performance class of the 1-AZ regions such as Gravelines, on NVMe drives, and STANDARD the default one.

Changing the class only applies to the files written afterwards. To move the files already stored, copied in place by the server without going through the application:

kt storage set-class EXPRESS_ONEZONE --dry-run   # count what would move
kt storage set-class EXPRESS_ONEZONE

A file over 5 GiB, the most a single copy accepts, is reported and left in its class.

Image cache

The optimized images live in their own adapter, STORAGE_CACHE_ADAPTER, under cache_optimized_images/. With s3 they go to the same bucket and prefix as the files, in the same storage class, and the server disk holds no file at all:

STORAGE_ADAPTER=s3
STORAGE_CACHE_ADAPTER=s3
STORAGE_CACHE_MAX_AGE_DAYS=30

The cleanup-image-cache scheduled task runs every night at 03:00 and deletes the optimized images older than STORAGE_CACHE_MAX_AGE_DAYS, 30 days by default, 0 keeping them forever. On the filesystem the age is the last read, since every download refreshes the file's date: only the images nobody reads go. S3 keeps no read date, so the age is the creation: an image still in use goes too, and is rebuilt on its next download.

Storage usage

The report-storage-usage scheduled task measures the store every hour and writes one JSON line to the standard output, the files and the image cache apart, with the numeric suffixes OVHcloud Logs Data Platform requires:

{"message": "storage 5420 files 12.42 GiB, cache 830 files 0.31 GiB", "logger": "storage-metrics", "metric": "storage", "storage_data_objects_int": 5420, "storage_data_gib_float": 12.42, "storage_cache_objects_int": 830, "storage_cache_gib_float": 0.31}

It bypasses logging, so LOG_LEVEL never filters it out. The same measure is at hand on the command line:

kt storage usage          # files and image cache, objects and GiB
kt storage usage --json   # the line the task writes

Both tasks are FastEdgy's own: DISABLED_SCHEDULED_TASKS turns either off, and a project task of the same name replaces it.

File organization

Files are organized based on the directory_path you provide in your upload calls:

  • Workspace storage (global_storage=False): {DATA_PATH}/{workspace_id}/{directory_path}/
  • Global storage (global_storage=True): {DATA_PATH}/{directory_path}/

Example with DATA_PATH=./storage:

storage/
├── 123/                # workspace_id=123 files
│   ├── photos/         # directory_path="photos"
│   │   └── image.jpg
│   └── avatars/        # directory_path="avatars"
│       └── avatar.png
├── 456/                # workspace_id=456 files
│   └── documents/      # directory_path="documents"
│       └── file.pdf
├── media/              # global_storage=True, directory_path="media"
│   └── shared.jpg
└── public/             # global_storage=True, directory_path="public"
    └── logo.png

File upload

Basic upload

from fastedgy.app import FastEdgy
from fastedgy.dependencies import Inject
from fastedgy.storage import Storage
from fastapi import UploadFile, File

app = FastEdgy()


@app.post("/upload")
async def upload_file(file: UploadFile = File(...), directory: str = "photos", storage: Storage = Inject(Storage)):
    # Upload to workspace-specific directory
    file_path = await storage.upload(file=file, directory_path=directory)
    return {"path": file_path, "filename": file.filename}

Upload with custom filename

@app.post("/upload-avatar")
async def upload_avatar(file: UploadFile = File(...), storage: Storage = Inject(Storage)):
    file_path = await storage.upload(
        file=file,
        directory_path="avatars",
        filename="avatar.{ext}",  # {ext} is replaced with file extension
    )
    return {"avatar_path": file_path}

Global storage

@app.post("/upload-global")
async def upload_global(file: UploadFile = File(...), directory: str = "shared", storage: Storage = Inject(Storage)):
    # Upload to global directory (shared across workspaces)
    file_path = await storage.upload(file=file, directory_path=directory, global_storage=True)
    return {"path": file_path}

Attachment upload

POST /api/storage/upload/attachments stores one or many files and creates an Attachment record for each. Every form entry holding a file is uploaded, and its field name is used as the file's key:

curl -X POST /api/storage/upload/attachments \
  -F "brief.pdf=@brief.pdf" \
  -F "photo.jpg=@photo.jpg"

Setting attachment values at upload time

An optional meta form field (a JSON object) carries values applied to the created records. It avoids the follow-up PATCH that associating an attachment would otherwise require — which matters to an offline client, where a single request is a single outbox operation instead of two that can fail independently:

# Flat form: applied to every file of the request
curl -X POST /api/storage/upload/attachments \
  -F "brief.pdf=@brief.pdf" \
  -F 'meta={"record": {"model": "flow", "id": 42}}'

# Keyed by file field name: applied per file
curl -X POST /api/storage/upload/attachments \
  -F "a.pdf=@a.pdf" \
  -F "b.pdf=@b.pdf" \
  -F 'meta={"a.pdf": {"record": {"model": "flow", "id": 42}}, "b.pdf": {"record": {"model": "flow", "id": 43}}}'

The values are validated against the Attachment PATCH schema, so a polymorphic record reference goes through the same coercion as a regular PATCH (both the {"model": ...} write form and the {"$model": ...} spelling a read returns are accepted). Values describing the stored bytes — storage_path, size_bytes, is_global — are always computed and cannot be overridden.

Errors are not swallowed: an invalid reference target (or any rejected value) returns a 422, no Attachment is created, and the bytes already written are removed rather than left orphaned.

meta keys must be either all file field names or none of them; a mix is ambiguous and returns a 422. Values that resolve to no writable field also return a 422 rather than being dropped in silence — the common case being a misspelled per-file key, read as a flat object whose keys match no Attachment field.

An unknown replication mode is caught at startup, not on the first request:

@api_route_model(sync={"mode": "partiel"})   # ValueError at boot

Model field upload

Use the built-in API endpoints to upload directly to model fields:

from fastedgy.orm import Model, fields
from fastedgy.api_route_model import api_route_model


@api_route_model()
class User(Model):
    name = fields.CharField(max_length=100)
    avatar = fields.CharField(max_length=255, null=True)

    class Meta:
        tablename = "users"

Upload to the model field:

# Upload avatar for user ID 123
POST /storage/upload/user/123/avatar
Content-Type: multipart/form-data

file: [image file]

Download files

The built-in endpoint handles file serving:

# Download file
GET /storage/download/photos/image.jpg

# Force download (with Content-Disposition header)
GET /storage/download/photos/image.jpg?force_download=true

Signed download urls

A <video> or an <audio> reads its file by itself, range after range as it plays, and a link the browser follows downloads by itself too: neither can send the Authorization header the download routes require. A signed url carries the authorization in its path instead:

# Authenticated: the same checks and transformers as the download, answers {"url": "https://.../signed/<token>"}
GET /storage/download-url/videos/clip.mp4
GET /storage/download-url/attachments/42

# Public: the token is the whole authorization, ranges included
GET /storage/signed/<token>
GET /storage/signed/<token>?force_download=true

On the Vue side, useStorage() asks for one with signedFileUrl(path, { params }) or signedAttachmentUrl(id), and adds the params to the url it gets: { force_download: true } turns it into a download link.

The token names the one stored file, resolved in the caller's workspace when it was signed, and lasts six hours. It is signed with AUTH_SECRET_KEY under a type of its own, so it never stands for an access token. The signing routes come with router and attachments_router, under the prefix the project gives them; the public one is signed_router, which the project mounts with its public routes:

from fastedgy.api import storage

public_router.include_router(storage.signed_router)

Without it, the signing routes answer 404, and v-fetcher-src reads a video whole instead.

Image optimization

FastEdgy automatically optimizes images when you add URL parameters. Optimized images are cached for better performance.

Resize images

# Resize by width (maintains aspect ratio)
GET /storage/download/photos/image.jpg?w=300

# Resize by height (maintains aspect ratio)
GET /storage/download/photos/image.jpg?h=200

# Resize to fit within 800x600 box
GET /storage/download/photos/image.jpg?w=800&h=600

Resize modes

Control how images are resized when both width and height are specified:

# Contain: fit inside box (no cropping, may have empty space)
GET /storage/download/photos/image.jpg?w=300&h=200&m=contain

# Cover: fill entire box (crops if needed for perfect fit)
GET /storage/download/photos/image.jpg?w=300&h=200&m=cover

Format conversion

Convert images to modern formats for better performance:

# Convert to WebP (smaller file size)
GET /storage/download/photos/image.jpg?e=webp

# Convert to PNG
GET /storage/download/photos/image.jpg?e=png

# Resize and convert in one request
GET /storage/download/photos/image.jpg?w=500&e=webp

Common use cases

User avatars (square thumbnails):

GET /storage/download/avatars/user-123.jpg?w=150&h=150&m=cover&e=webp

Responsive images (different sizes for mobile/desktop):

# Mobile
GET /storage/download/photos/hero.jpg?w=400&e=webp

# Desktop
GET /storage/download/photos/hero.jpg?w=1200&e=webp

Product thumbnails (consistent grid layout):

GET /storage/download/products/456.jpg?w=250&h=250&m=cover

URL parameters

  • w: Width in pixels
  • h: Height in pixels
  • m: Resize mode (contain or cover)
  • e: Output format (jpg, png, webp)
  • force_download: Force file download instead of display

Upload from URL

@app.post("/upload-from-url")
async def upload_from_url(url: str, directory: str = "external", storage: Storage = Inject(Storage)):
    file_path = await storage.download_and_upload(file_url=url, directory_path=directory)
    return {"path": file_path}

Upload from base64

@app.post("/upload-base64")
async def upload_base64(
    data: str,  # base64-encoded image
    directory: str = "images",
    storage: Storage = Inject(Storage),
):
    file_path = await storage.upload_from_base64(data=data, directory_path=directory)
    return {"path": file_path}

Upload from an open file

upload_from_file stores a binary file that is already open without reading it whole: the filesystem adapter copies it block by block, and the S3 adapter sends a large file in parts. upload takes the same path for an UploadFile, so a large upload that Starlette spooled to disk never sits in memory.

import tempfile

@app.post("/export")
async def export(storage: Storage = Inject(Storage)):
    with tempfile.TemporaryFile() as file:
        file.write(b"id,name\n1,Acme\n")
        file.seek(0)

        file_path = await storage.upload_from_file(
            file,
            directory_path="exports",
            filename="export.{ext}",
            mime_type="text/csv",
            extension="csv",
        )

    return {"path": file_path}

Error handling

The Storage service validates files automatically:

  • File type: Only images are accepted
  • Extensions: JPG, JPEG, PNG, GIF, WEBP
  • Filename: Must be provided
@app.post("/safe-upload")
async def safe_upload(file: UploadFile = File(...), directory: str = "files", storage: Storage = Inject(Storage)):
    try:
        file_path = await storage.upload(file=file, directory_path=directory)
        return {"success": True, "path": file_path}
    except ValueError as e:
        return {"success": False, "error": str(e)}

Back to Overview