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_checkableProtocol).- Configuration dataclasses
LocalNginxAssetRepositoryConfigandS3AssetRepositoryConfigare frozen-like dataclasses that hold all wiring details. Pass one tobuild_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
AssetSaveRequestand is reflected inAssetSaveResultandAssetDescriptor.- AssetSaveRequest
The input model for
save(). Thesourcefield accepts either aBinaryIOstream orbytes. Callrequest.open_source()to always get a stream regardless of which was provided.- AssetSaveResult
Returned by
save(). Contains thekey, a backend-specificbackend_ref(e.g. S3 ETag, absolute file path),content_length, andchecksum.- AssetDescriptor
Returned by
get_descriptor(). Provides metadata without downloading the asset body — equivalent to an HTTP HEAD request.- AssetAccessUrl
Returned by
build_public_url()andbuild_download_url(). Theurlfield is always populated.expires_atisNonefor permanent public URLs. Checkurl.is_permanentas a convenience.- UploadUrlResult
Returned by
build_upload_url(). Contains theurl, the HTTPmethod("PUT"for S3,"POST"for tus), requiredheaders,expires_at, and thekeythat 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 setpublic_base_urlto your CDN domain.Custom endpoint — set
endpoint_urlfor MinIO, LocalStack, or any S3-compatible store.Credentials — pass
access_key_id/secret_access_keydirectly or leave them asNoneto use the standard boto3 credential chain (environment variables, instance roles,~/.aws/credentials).Key prefix —
key_prefixis prepended to the logical key before writing to S3 and stripped when reading back. Application code always works with the unprefixed logical key.