User Guide

This guide covers the core concepts, the full API surface, and integration patterns for both the local and S3 backends.

Architecture Overview

Granite Assets has three layers:

IAssetRepository (protocol)

The structural interface your application depends on. No inheritance required — any class implementing the methods qualifies at runtime (@runtime_checkable Protocol).

Configuration dataclasses

LocalNginxAssetRepositoryConfig and S3AssetRepositoryConfig are frozen-like dataclasses that hold all wiring details. Pass one to build_asset_repository() and you get the right implementation back.

Concrete repositories

LocalNginxAssetRepository — writes files to a directory tree served by any static HTTP server. S3AssetRepository — reads/writes from AWS S3 (or any S3-compatible store).

All methods are synchronous. Use them inside a thread pool (e.g. asyncio.to_thread / run_in_executor) when calling from an async context such as FastAPI.

Key Concepts

Asset key

A forward-slash separated path that uniquely identifies an asset within the repository (e.g. "invoices/2024/inv-001.pdf"). Keys must not start with a leading slash. The key is the stable identifier you store in your database — the physical location (filesystem path, S3 key with prefix) is an internal detail of the repository.

AssetVisibility

PUBLIC — the asset is accessible via a stable, non-expiring URL. PRIVATE — the asset requires a time-limited signed URL.

Visibility is set at write time in AssetSaveRequest and is reflected in AssetSaveResult and AssetDescriptor.

AssetSaveRequest

The input model for save(). The source field accepts either a BinaryIO stream or bytes. Call request.open_source() to always get a stream regardless of which was provided.

AssetSaveResult

Returned by save(). Contains the key, a backend-specific backend_ref (e.g. S3 ETag, absolute file path), content_length, and checksum.

AssetDescriptor

Returned by get_descriptor(). Provides metadata without downloading the asset body — equivalent to an HTTP HEAD request.

AssetAccessUrl

Returned by build_public_url() and build_download_url(). The url field is always populated. expires_at is None for permanent public URLs. Check url.is_permanent as a convenience.

UploadUrlResult

Returned by build_upload_url(). Contains the url, the HTTP method ("PUT" for S3, "POST" for tus), required headers, expires_at, and the key that will be created after a successful upload. See Upload and Download URLs for the full upload flow.

Saving Assets

import io
from granite_assets import AssetSaveRequest, AssetVisibility

# From an open file
with open("report.pdf", "rb") as f:
    result = repo.save(AssetSaveRequest(
        key="reports/q1-2024.pdf",
        source=f,
        content_type="application/pdf",
        visibility=AssetVisibility.PRIVATE,
        filename="q1-2024.pdf",
        metadata={"uploader": "user-123"},
    ))

# From bytes
result = repo.save(AssetSaveRequest(
    key="thumbnails/user-42.jpg",
    source=thumbnail_bytes,
    content_type="image/jpeg",
    visibility=AssetVisibility.PUBLIC,
))

# Prevent overwriting an existing asset
result = repo.save(AssetSaveRequest(
    key="config/settings.json",
    source=json_bytes,
    content_type="application/json",
    overwrite=False,   # raises AssetError if the key already exists
))

Reading Asset Metadata

# Check existence cheaply
if repo.exists("reports/q1-2024.pdf"):
    desc = repo.get_descriptor("reports/q1-2024.pdf")
    print(desc.content_type)
    print(desc.content_length)
    print(desc.last_modified)
    print(desc.visibility)

Copy and Move

Copy and move are cheap server-side operations — the library never downloads the asset body just to re-upload it.

# Copy to another key
repo.copy("reports/q1-2024.pdf", "archive/2024/q1.pdf")

# Move (rename)
repo.move("reports/draft.pdf", "reports/final.pdf")

# Both accept overwrite control
repo.copy("src.jpg", "dst.jpg", overwrite=False)

Deleting Assets

repo.delete("thumbnails/user-42.jpg")  # raises AssetNotFoundError if missing

FastAPI Integration

Since all repository methods are synchronous, wrap them in asyncio.to_thread inside async endpoints:

import asyncio
from fastapi import FastAPI, UploadFile
from granite_assets import AssetSaveRequest, AssetVisibility

app = FastAPI()

@app.post("/upload")
async def upload_file(file: UploadFile) -> dict:
    content = await file.read()
    result = await asyncio.to_thread(
        repo.save,
        AssetSaveRequest(
            key=f"uploads/{file.filename}",
            source=content,
            content_type=file.content_type or "application/octet-stream",
            visibility=AssetVisibility.PRIVATE,
            filename=file.filename,
        ),
    )
    dl_url = await asyncio.to_thread(
        repo.build_download_url, result.key, 600
    )
    return {"url": dl_url.url, "expires_at": dl_url.expires_at}

Local Nginx Backend Details

Files are organised under two sub-directories inside storage_path:

  • <storage_path>/<public_prefix>/<key> — publicly served.

  • <storage_path>/<private_prefix>/<key> — private assets (Nginx-protected).

Public URLs are constructed by joining base_url, the relevant prefix, and the logical key.

Signed download URLs (secure_link)

Set secure_link_secret to enable time-limited download URLs for private assets. The token algorithm matches ngx_http_secure_link_module:

import os
config = LocalNginxAssetRepositoryConfig(
    storage_path="/srv/assets",
    base_url="https://media.example.com/assets",
    secure_link_secret=os.environ["SECURE_LINK_SECRET"],
    secure_link_ttl_seconds=3600,   # default TTL; override per-call
)

Without secure_link_secret, calling build_download_url() on a private asset raises AssetAccessNotSupportedError — you must proxy downloads through your application layer.

Resumable upload URLs (tus / tusd)

Set tusd_url and upload_secret to enable build_upload_url(). The method returns a tus creation URL (method="POST") with signed Upload-Metadata. The tusd pre-create hook must verify the upload-token field using the same upload_secret:

config = LocalNginxAssetRepositoryConfig(
    storage_path="/srv/assets",
    base_url="https://media.example.com/assets",
    secure_link_secret=os.environ["SECURE_LINK_SECRET"],
    tusd_url="http://localhost:1080",
    upload_secret=os.environ["UPLOAD_SECRET"],
    upload_ttl_seconds=3600,
)

See Upload and Download URLs for the full upload flow and hook implementation, and Infrastructure Setup for docker-compose and Nginx configuration.

Example Nginx configuration:

location /assets/public/ {
    alias /var/www/assets/public/;
}

location /assets/private/ {
    # Validate secure_link token
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri YOUR_SECRET_HERE";
    if ($secure_link = "")  { return 403; }
    if ($secure_link = "0") { return 410; }  # expired
    alias /var/www/assets/private/;
}

S3 Backend Details

  • ACLs — the library sets ACL='public-read' for public objects when saving. Ensure your bucket allows ACLs or manage access via bucket policy instead and set public_base_url to your CDN domain.

  • Custom endpoint — set endpoint_url for MinIO, LocalStack, or any S3-compatible store.

  • Credentials — pass access_key_id / secret_access_key directly or leave them as None to use the standard boto3 credential chain (environment variables, instance roles, ~/.aws/credentials).

  • Key prefixkey_prefix is prepended to the logical key before writing to S3 and stripped when reading back. Application code always works with the unprefixed logical key.