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. 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.
keyis optional inAssetSaveRequest. When omitted, the repository auto-generates a collision-free key using a UUID folder structure (see Auto-generated Key Layout below).- 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 — explicit key
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"},
))
# Auto-generated key — omit ``key`` and supply ``filename``
result = repo.save(AssetSaveRequest(
source=thumbnail_bytes,
content_type="image/jpeg",
filename="photo.jpg",
visibility=AssetVisibility.PUBLIC,
))
print(result.key)
# e.g. "3b105bc5-6056-4a52-b03b-7d953644c826/3b105bc5-6056-4a52-b03b-7d953644c826.jpg"
# Prefix-only key — pass a path without extension to place the file inside
# a named folder; granite-assets appends the UUID subfolder automatically
result = repo.save(AssetSaveRequest(
key="private/3b105bc5-6056-4a52-b03b-7d953644c826",
source=data,
content_type="image/png",
filename="photo.png",
))
print(result.key)
# "private/3b105bc5-.../3b105bc5-....png"
# 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
))
Auto-generated Key Layout
When key is omitted from AssetSaveRequest, both backends enforce a
collision-free, privacy-safe storage layout:
{uuid}/{uuid}.{ext}
The same UUID is used for the folder and the filename. This means:
The folder name is the stable identifier for the asset — you can sign a CloudFront wildcard resource for
{uuid}/*to grant access to all representations of that asset (thumbnail, original, HLS segments …) with a single policy.Physical paths are never guessable or enumerable.
No collision is possible regardless of original filename.
Three key resolution rules are applied by save():
``key`` is ``None`` → generate
{uuid}/{uuid}.extusing a new UUID.``key`` has no file extension → treat it as a folder prefix and append
/{last_segment}.ext. Useful when the caller pre-allocates a UUID and wants to delegate the path construction to the repository.``key`` has a file extension → use it unchanged (backward-compatible).
The extension is derived from filename; when filename is also absent,
no extension is appended ({uuid}/{uuid}).
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 — by default the library sets
ACL='public-read'for public objects when saving. If your bucket has Object Ownership set toBucketOwnerEnforced(ACLs disabled), setuse_object_acl=FalseonS3AssetRepositoryConfig. Visibility is then controlled entirely via bucket policy or CloudFront OAC.config = S3AssetRepositoryConfig( bucket="my-bucket", region="eu-west-1", use_object_acl=False, # required when ACLs are disabled on the bucket )
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.
CloudFront Signed URLs for Streaming
For HLS/DASH streaming or any multi-file asset (e.g. thumbnails alongside an
original), use build_folder_signed_url() to issue a single custom-policy
signed URL that grants access to every file under the asset’s UUID folder:
# The asset was saved at: private/3b105bc5-.../3b105bc5-....mp4
# HLS segments live alongside it: 3b105bc5-....m3u8, seg-0.ts, seg-1.ts …
stream_url = repo.build_folder_signed_url(
key="private/3b105bc5-6056-4a52-b03b-7d953644c826/3b105bc5-....mp4",
entry_filename="master.m3u8",
ttl_seconds=3600,
)
# stream_url.url points to: https://cdn.example.com/private/3b105bc5-.../master.m3u8
# The CloudFront policy resource covers: https://cdn.example.com/private/3b105bc5-.../*
# so all segment files are accessible with the same signed credentials.
This requires cf_key_id and cf_private_key to be set in
S3AssetRepositoryConfig.