Object storage — MinIO / S3¶
AsyncMinIOClient é uma fachada async sobre o pacote oficial minio. Cobre o que serviço FastAPI típico precisa: bucket (ensure/exists/list/remove), object I/O (put/get/stream/stat/list/remove/copy) e presigned URLs (GET/PUT). Operações avançadas (versioning, lifecycle XML, SSE-KMS, multipart fine-tuning) ficam acessíveis via atributo .client.
Por que esse wrapper existe
minio-py é síncrono. Chamar client.put_object(...) direto dentro de uma rota FastAPI bloqueia o event loop durante o upload inteiro. O wrapper envolve cada chamada em asyncio.to_thread, então o loop continua respondendo enquanto a operação roda no executor.
Instalação¶
O pacote minio é lazy-loaded — só carrega quando AsyncMinIOClient é instanciado. Projetos sem storage não precisam do extra.
Configuração via settings mixin¶
from tempest_fastapi_sdk import (
BaseAppSettings,
ServerSettings,
StorageSettings,
)
class Settings(
ServerSettings,
StorageSettings,
BaseAppSettings,
):
"""Service settings — herda os defaults de storage (MinIO local)."""
.env:
STORAGE_ENDPOINT=minio.internal:9000
STORAGE_ACCESS_KEY=...
STORAGE_SECRET_KEY=...
STORAGE_SECURE=true
STORAGE_REGION=us-east-1
STORAGE_DEFAULT_BUCKET=uploads
O mixin fala com qualquer storage S3-compatível (AWS S3, MinIO, R2, B2,
Wasabi, Spaces) — o MinIO de AsyncMinIOClient é a biblioteca que o
cliente usa, não exigência sobre o servidor. Por isso as variáveis se chamam
STORAGE_*.
Vindo de MinIOSettings¶
Até aqui o mixin se chamava MinIOSettings e as variáveis eram MINIO_*.
Nada quebra na troca:
- As variáveis antigas continuam valendo. Cada campo lê
STORAGE_Xprimeiro e cai paraMINIO_Xquando o novo não está definido — no ambiente e no.env. MinIOSettingscontinua importável, como subclasse deprecada deStorageSettings: compor ou instanciar emiteDeprecationWarning, e os atributossettings.MINIO_*continuam respondendo.minio_kwargs()continua existindo e devolve o mesmo dicionário questorage_kwargs().
| Variável antiga | Variável nova |
|---|---|
MINIO_ENDPOINT |
STORAGE_ENDPOINT |
MINIO_ACCESS_KEY |
STORAGE_ACCESS_KEY |
MINIO_SECRET_KEY |
STORAGE_SECRET_KEY |
MINIO_SECURE |
STORAGE_SECURE |
MINIO_REGION |
STORAGE_REGION |
MINIO_DEFAULT_BUCKET |
STORAGE_DEFAULT_BUCKET |
MINIO_PUBLIC_ENDPOINT |
STORAGE_PUBLIC_ENDPOINT |
MINIO_PUBLIC_SECURE |
STORAGE_PUBLIC_SECURE |
Os dois nomes com valores diferentes derrubam o boot
Com STORAGE_ENDPOINT=minio:9000 no ambiente e um MINIO_ENDPOINT=localhost:9000
esquecido no .env, a construção do settings levanta SettingsError
nomeando o par (STORAGE_ENDPOINT and MINIO_ENDPOINT), sem imprimir valor.
Sem essa checagem, o AliasChoices ficaria com o nome novo em silêncio e o
antigo pareceria configurado. Os dois nomes com o mesmo valor são
aceitos, para a janela de migração. Remova o nome antigo para resolver.
Isso vale também para quem regenerar só o docker-compose.yaml com o
tempest: o bloco gerado agora escreve STORAGE_ENDPOINT, e um
MINIO_ENDPOINT diferente no .env lido pelo container é recusado.
Em produção
Com EnvironmentSettings
e ENV=production, o boot recusa as chaves minioadmin do default,
STORAGE_SECURE=false e STORAGE_PUBLIC_SECURE=false explícito.
Wiring no create_app()¶
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from fastapi import FastAPI
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
# settings.storage_kwargs() mapeia os STORAGE_* -> endpoint,
# access_key, secret_key, default_bucket, secure, region, public_endpoint,
# public_secure, accel_redirect e accel_prefix: nada a repetir campo a campo.
storage = AsyncMinIOClient(**settings.storage_kwargs())
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
"""Garante que o bucket padrão existe antes de servir tráfego."""
await storage.ensure_bucket()
yield
def create_app() -> FastAPI:
"""Build the configured FastAPI instance."""
return FastAPI(lifespan=lifespan)
Receitas¶
Upload de UploadFile (FastAPI)¶
from fastapi import APIRouter, UploadFile
from src.api.app import storage
router = APIRouter()
@router.post("/files")
async def upload_file(file: UploadFile) -> dict[str, str]:
"""Persiste o arquivo recebido no bucket padrão."""
body = await file.read()
etag = await storage.put_object(
file.filename or "unnamed",
body,
content_type=file.content_type or "application/octet-stream",
metadata={"original-name": file.filename or ""},
)
return {"key": file.filename or "unnamed", "etag": etag}
Streaming de download¶
Quando o download precisa passar pelo backend — arquivo com autorização, ou
bucket que o navegador não alcança —, download_response faz stat + stream
numa chamada só, chunk a chunk, sem carregar o objeto na memória. O
DownloadUtils(minio) embrulha a mesma chamada.
Passe o request. É ele que deixa o <video> avançar, o download retomar e
a imagem ser revalidada em vez de baixada de novo:
from fastapi import APIRouter, Request
from starlette.responses import Response
from src.api.app import storage
router = APIRouter()
@router.get("/files/{key}")
async def download_file(key: str, request: Request) -> Response:
"""Stream do objeto no bucket padrão, com Range e revalidação."""
return await storage.download_response(
key,
request=request,
as_attachment=False,
cache_control="private, max-age=3600",
)
Toda resposta leva Accept-Ranges: bytes, o ETag do objeto (entre aspas)
e o Last-Modified, mais os headers de segurança de todo download
(nosniff, a CSP sandbox e o CORP same-site). O as_attachment=False
só vira inline quando o tipo guardado no objeto está em
INLINE_SAFE_MEDIA_TYPES — um text/html sai como attachment. Detalhe em
Downloads. Com o request em mãos, a rota responde assim:
| O cliente manda | Resposta |
|---|---|
| nada de especial | 200, objeto inteiro |
Range: bytes=1000-1999, bytes=1000- ou bytes=-300 |
206 com Content-Range, lendo só o trecho no bucket |
Range que começa no fim do objeto ou depois |
416 com Content-Range: bytes */<tamanho> |
Range: bytes=0-1,5-6 (vários trechos) |
200, objeto inteiro |
If-None-Match com o ETag atual |
304, sem corpo e sem ler o objeto |
If-Modified-Since não mais antigo que o objeto (sem If-None-Match) |
304 |
Range + If-Range que não bate mais com o objeto |
200, objeto inteiro |
Cada linha da tabela é um teste contra um MinIO de verdade em container
(tests/storage/test_download_live.py, make test-docker). O 206 passa
offset/length ao get_object do minio-py, que manda o Range ao
bucket — o teste espiona essa chamada e confere que foi só o trecho.
Por que vários trechos viram 200
O RFC 9110 permite ignorar o Range e responder o objeto inteiro. A
alternativa, um corpo multipart/byteranges, não é o que <video>,
<audio> ou gerenciador de download pedem — eles mandam um trecho só.
If-Range protege a retomada
O gerenciador de download que retoma manda If-Range com o ETag que
tinha. Se o arquivo mudou no meio, o SDK ignora o Range e manda o
arquivo novo inteiro, em vez de emendar bytes de duas versões.
O lado do navegador não precisa de nada além da tag:
Medido num Chromium (Playwright) com um MP4 de 60 s e 30 033 093 bytes servido
pela rota acima: o player abriu com Range: bytes=0- (206) e, ao pular para
os 50 s, pediu bytes=24969216-30033092, respondido com 206; o vídeo parou
em currentTime == 50, sem erro de mídia.
Sem request, é o comportamento antigo
download_response(key) sem request não lê header nenhum da requisição
e responde sempre 200 com o objeto inteiro — só os headers
Accept-Ranges/ETag/Last-Modified são novos. Nesse modo o <video>
não avança.
Quer só os bytes, sem montar resposta? stream_object(key, offset=, length=)
devolve o iterador do trecho.
Entrega pelo nginx — X-Accel-Redirect¶
No download_response, todo byte passa pelo processo Python. Funciona, mas
para vídeo e tráfego alto você quer outra divisão de trabalho: o backend só
autoriza, e o nginx entrega.
A rota confere a permissão e responde uma resposta vazia com um header:
O nginx intercepta esse header, segue para uma location interna, busca o
arquivo no bucket pela rede privada e faz o streaming para o cliente. O
backend não carrega bytes, o bucket continua privado e o cliente só vê o
domínio do backend.
Quando escolher cada modo¶
Proxy (download_response) |
Redirect (accel_redirect_response) |
|
|---|---|---|
| Quem move os bytes | o processo Python | o nginx |
| Precisa de nginx na frente | não | sim, com a location interna abaixo |
206 / 304 |
respondidos pelo SDK | respondidos pelo bucket, atrás do nginx |
| Bom para | arquivo pequeno, dev local, deploy sem nginx | vídeo, arquivo grande, tráfego alto |
Liga por configuração¶
Os dois modos saem da mesma rota. serve_object escolhe pelo que o
cliente recebeu no construtor, e o StorageSettings mapeia duas variáveis
novas:
# .env
STORAGE_ENDPOINT=bucket:9000 # endpoint INTERNO: é contra ele que o SDK assina
STORAGE_ACCEL_REDIRECT=true # false (default) = proxy pelo app
STORAGE_ACCEL_PREFIX=/_bucket/ # a location interna do nginx
from fastapi import APIRouter, Request
from starlette.responses import Response
from tempest_fastapi_sdk import AsyncMinIOClient, guess_media_type
from src.core.settings import settings
router = APIRouter()
storage = AsyncMinIOClient(**settings.storage_kwargs())
@router.get("/files/{key:path}")
async def download_file(key: str, request: Request) -> Response:
"""Autoriza e entrega — pelo app ou pelo nginx, conforme o .env."""
return await storage.serve_object(
key,
request=request,
media_type=guess_media_type(key),
as_attachment=False,
)
No modo redirect, inline exige media_type=
O accel_redirect_response não faz stat, então não sabe com que tipo o
bucket vai responder. Sem media_type=, o as_attachment=False sai
attachment. Com ele, o tipo é assinado na URL (o bucket responde com
esse, não com o guardado) e só vira inline se estiver em
INLINE_SAFE_MEDIA_TYPES. O guess_media_type(key) acima resolve pela
extensão da chave: aula.mp4 vai inline, pagina.html vira download.
O default do accel_redirect_response é as_attachment=True.
Com STORAGE_ACCEL_REDIRECT=false, é o download_response da seção
anterior. Com true, é o accel_redirect_response, e o request não é
usado: o nginx repassa sozinho o Range e o If-None-Match do cliente ao
bucket.
accel_redirect_response direto
Quer o redirect numa rota só, sem depender da flag? Chame
storage.accel_redirect_response(key, internal_prefix="/_bucket/",
expires=timedelta(minutes=5), filename=..., media_type=...,
as_attachment=False, cache_control=...). Ele nunca usa o
STORAGE_PUBLIC_ENDPOINT: a URL é assinada contra o STORAGE_ENDPOINT, que é
o host que o nginx vai chamar.
O bloco do nginx¶
upstream app {
server app:8000;
}
server {
listen 80;
server_name exemplo.com;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
}
location /_bucket/ {
internal;
proxy_pass http://bucket:9000/;
proxy_set_header Host bucket:9000;
proxy_hide_header X-Content-Type-Options;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
add_header Cross-Origin-Resource-Policy "same-site" always;
}
}
As linhas da location /_bucket/ têm, cada uma, um porquê:
internal;— só o nginx entra aqui, seguindo umX-Accel-Redirect. O cliente que pede/_bucket/...direto recebe404.proxy_pass http://bucket:9000/;com a barra final — com a barra, o nginx troca o prefixo/_bucket/por/, e o bucket recebe/media/aula.mp4?X-Amz-..., exatamente o path que foi assinado. Sem a barra, o prefixo vai junto, o MinIO lê_bucketcomo nome do bucket e responde400 InvalidBucketName.proxy_set_header Host bucket:9000;— a assinatura SigV4 cobre oHost. Ele precisa ser oSTORAGE_ENDPOINTque assinou a URL. O perigo é a herança:proxy_set_headerdefinido noserver(oHost $hostque quase todo config tem para o app) é herdado pelalocationque não define nenhum. Aí o bucket recebeHost: exemplo.come responde403 SignatureDoesNotMatch— o sintoma é "o arquivo não carrega".- Os
add_header ... always— são os headers de segurança de todo download, e aqui é o único lugar onde eles chegam ao cliente. O nginx responde com a resposta do bucket, não com a resposta vazia do app: um header que o app pusesse nela não passaria (medido — por isso o SDK nem põe). Sem oalways, o nginx só acrescenta o header a uma parte dos status — é como a documentação doadd_headero define. proxy_hide_header X-Content-Type-Options;— o MinIO já mandanosniff; sem esconder o dele, o cliente recebenosniff, nosniff(medido). Esconder e reemitir deixa um valor só, qualquer que seja o storage atrás.
O Content-Type e o Content-Disposition vão dentro da URL
O SDK não põe esses headers na resposta vazia: ele os assina na URL como
os overrides response-content-type, response-content-disposition e
response-cache-control do S3, e o bucket os devolve junto com os bytes.
Medido no nginx 1.27.5: o Content-Type do bucket vence o que o app
define, e um Content-Disposition definido nos dois chega duplicado no
cliente.
Sem nginx na frente, a flag vaza a URL assinada
Com STORAGE_ACCEL_REDIRECT=true e nenhum nginx interceptando, o cliente
recebe um 200 vazio com o X-Accel-Redirect — path e assinatura
incluídos. A URL aponta para o host interno e vale por expires (5
minutos por padrão). Ligue a flag só no ambiente que tem o bloco acima.
O que foi medido¶
tests/storage/test_accel_redirect_live.py (make test-docker) sobe um
MinIO e um nginx em container e o app com uvicorn, e baixa pela rota. O
mesmo arquivo passou inteiro no nginx 1.22.1, 1.27.5 e 1.29.8:
| Requisição | Resultado |
|---|---|
GET /files/clip.mp4 |
200, bytes do objeto, Content-Type guardado no bucket |
GET /files/clip.mp4 com Range: bytes=100-199 |
206, Content-Range: bytes 100-199/1048576 |
GET /files/clip.mp4 com If-None-Match: <etag> |
304 |
chave com espaço, acento e + |
200 |
GET /_bucket/media/clip.mp4 direto do cliente |
404 |
location que herda Host $host |
403 SignatureDoesNotMatch |
proxy_pass sem a barra final |
400 InvalidBucketName |
GET /files/clip.mp4, location com os add_header |
X-Content-Type-Options, CSP e CORP, um valor cada |
location sem os add_header |
200 sem CSP nem CORP, mesmo com o app pondo os dois na resposta vazia |
as_attachment=False sem media_type= |
Content-Disposition: attachment |
mesma rota com STORAGE_ACCEL_REDIRECT=false e Range: bytes=-10 |
206 pelo app |
No teste, bucket, app e nginx rodam na rede do host, então o endpoint
interno é 127.0.0.1:<porta> em vez de bucket:9000. O que importa é o
mesmo: o Host que o nginx manda é o host que assinou a URL.
Arquivo privado com URL assinada da própria rota¶
Quando o frontend só fala com o backend, um arquivo privado precisa de uma URL
do backend que o navegador abra sozinho. <img src>, <video src> e um
link de download não mandam Authorization: Bearer, e cookie de sessão entre
o domínio do app e o da API esbarra em SameSite. A URL presignada do bucket
não serve: ela leva o navegador ao bucket, não à sua rota.
A saída tem o mesmo formato da URL presignada, só que aponta para a rota do
app. O mapper que já autorizou o caller gera
/api/files/<key>?expires=<ts>&signature=<mac> com sign_path, e a rota
confere a assinatura com make_signed_path_dependency antes de fazer o
streaming:
from datetime import timedelta
from fastapi import Depends, FastAPI, Request
from starlette.responses import Response
from tempest_fastapi_sdk import (
AsyncMinIOClient,
BaseSchema,
make_signed_path_dependency,
register_exception_handlers,
sign_path,
)
FILES_SECRET: str = "troque-pelo-settings.JWT_SECRET"
FILES_PURPOSE: str = "files"
storage = AsyncMinIOClient(
endpoint="minio.internal:9000",
access_key="minioadmin",
secret_key="minioadmin",
default_bucket="uploads",
)
signed_file = make_signed_path_dependency(secret=FILES_SECRET, purpose=FILES_PURPOSE)
class AttachmentResponseSchema(BaseSchema):
"""Anexo como o frontend recebe: a chave e a URL que o navegador abre."""
key: str
url: str
def to_attachment_response(key: str) -> AttachmentResponseSchema:
"""Mapeia a chave guardada para o schema, com a URL já assinada."""
return AttachmentResponseSchema(
key=key,
url=sign_path(
f"/api/files/{key}",
secret=FILES_SECRET,
expires_in=timedelta(minutes=15),
purpose=FILES_PURPOSE,
),
)
app = FastAPI()
register_exception_handlers(app)
@app.get("/api/attachments/{key:path}")
async def get_attachment(key: str) -> AttachmentResponseSchema:
"""A sua autorização entra aqui: JWT, dono do recurso, papel."""
return to_attachment_response(key)
@app.get("/api/files/{key:path}", dependencies=[Depends(signed_file)])
async def download_file(key: str, request: Request) -> Response:
"""Só chega aqui com URL assinada e dentro da validade."""
return await storage.download_response(
key, request=request, as_attachment=False
)
O url que o frontend recebe para a chave aulas/aula 01.mp4, assinado às
12:00 UTC de 1º de outubro de 2026, é:
/api/files/aulas/aula%2001.mp4?expires=1790856900&signature=e9vPzlJkaaLXY6fJ0dSsV6RS6D3pOElruFdeskdikck
E vai direto na tag, sem header nenhum:
<video src="/api/files/aulas/aula%2001.mp4?expires=1790856900&signature=e9vPzlJkaaLXY6fJ0dSsV6RS6D3pOElruFdeskdikck" controls></video>
Pedaço por pedaço:
sign_pathno mapper. Ele roda depois da autorização, então só quem pôde ver o anexo recebe a URL. Quem tem a URL abre o arquivo até oexpires— é uma capacidade, como a URL presignada, e por isso a validade é curta.make_signed_path_dependencyna rota. A dependency lêexpiresesignatureda query e confere contra o path decodificado da requisição. O handler só roda com assinatura válida e dentro da validade: o bucket nem é consultado numa URL recusada.download_response(..., request=)continua igual. ORangepassa pela assinatura, então o<video>avança e o download retoma do mesmo jeito. Trocando porserve_object, a mesma dependency guarda o modoX-Accel-Redirectda seção anterior.
tests/storage/test_signed_url_live.py (make test-docker) monta essa rota
contra um MinIO em container e mede: 200 com o objeto inteiro pela URL
assinada, 206 com Range: bytes=1000-1999 e só o trecho, e 403 numa URL
adulterada ou sem assinatura, sem nenhum stat_object no bucket. Com
serve_object e accel_redirect=True, a URL assinada responde o
X-Accel-Redirect e a adulterada responde 403 sem ele.
O que a assinatura cobre¶
- O path e o
expires. A assinatura éHMAC-SHA256(chave, "<expires>\n<path>"). Mudar a chave do objeto ou esticar oexpiresquebra o MAC. Outros parâmetros de query não entram. - O
purpose. A chave do MAC é derivada do segredo com opurpose(HMAC-SHA256(secret, "tempest-fastapi-sdk.signed-url.v1\0" + purpose)), e nunca é o segredo cru. Por isso reusar oJWT_SECRETé seguro, e uma URL assinada para"files"não vale numa rota protegida com"email-link". - Comparação em tempo constante, com
hmac.compare_digest.
| A requisição | Resposta |
|---|---|
| URL assinada, dentro da validade | o handler roda |
expires alterado |
403, code: "SIGNED_URL_INVALID" |
| path alterado | 403, code: "SIGNED_URL_INVALID" |
assinada com outro purpose ou outro segredo |
403, code: "SIGNED_URL_INVALID" |
sem expires/signature, ou expires que não é número |
403, code: "SIGNED_URL_INVALID" (nunca 422) |
autêntica, mas no segundo do expires ou depois |
403, code: "SIGNED_URL_EXPIRED" |
Cada linha é um teste de tests/api/test_signed_path_dependency.py. O corpo
segue o envelope do SDK:
Por que 403, e não 401
Uma URL assinada é uma capacidade, não um login: nenhuma credencial que o
cliente mande conserta a URL. O S3 responde 403 para presignada vencida
ou adulterada pelo mesmo motivo. E 401 dispararia o interceptor de
"sessão expirada, volte ao login" que quase todo frontend tem, com a
sessão do usuário intacta. Os dois code existem para o frontend separar
"peça uma URL nova ao backend" (SIGNED_URL_EXPIRED) de "este link foi
adulterado" (SIGNED_URL_INVALID).
A regra de encoding do path¶
sign_path recebe o path decodificado, do jeito que a rota vai vê-lo em
request.scope["path"] (é o que o {key:path} recebe), e devolve a URL já
com percent-encoding. O Starlette decodifica o path antes de rotear, então os
dois lados assinam a mesma string:
aula 01.mp4viaja comoaula%2001.mp4,100%.pdfcomo100%25.pdf, eá.txtcomo%C3%A1.txt; a rota recebe a chave original;/api/files/a%2Fb.pdfe/api/files/a/b.pdfchegam à rota como o mesmo path, com a mesma chavea/b.pdf, e valem com a mesma assinatura.
Medido no Starlette 0.46.0 (o piso que fastapi>=0.141.1 aceita) e no 1.6.0.
Passe o path decodificado, nunca um já codificado
sign_path("/api/files/aula%2001.mp4", ...) assina a chave literal
aula%2001.mp4, que vira aula%252001.mp4 na URL. Monte o path com a
chave como ela está guardada.
App montado e root_path
Sob app.mount("/v1", sub_app), a rota vê /v1/api/files/..., então
assine com o prefixo do mount. Já o root_path de proxy (o prefixo que
o proxy reverso tira antes de repassar) não aparece em
request.scope["path"]: assine sem ele e acrescente o prefixo na URL que
o navegador recebe.
Presigned URL — upload direto do browser¶
Padrão recomendado pra arquivos grandes: o cliente faz PUT direto no MinIO/S3, os bytes não passam pelo FastAPI.
from datetime import timedelta
from uuid import uuid4
from fastapi import APIRouter
from pydantic import BaseModel
from src.api.app import storage
router = APIRouter()
class PresignedUploadResponse(BaseModel):
key: str
url: str
@router.post("/uploads/presign")
async def presign_upload() -> PresignedUploadResponse:
"""Devolve URL temporária pro cliente fazer PUT direto."""
key = f"uploads/{uuid4().hex}"
url = await storage.presigned_put_url(key, expires=timedelta(minutes=15))
return PresignedUploadResponse(key=key, url=url)
Cliente JS:
const { key, url } = await fetch("/uploads/presign", { method: "POST" }).then(r => r.json());
await fetch(url, { method: "PUT", body: file });
Presigned URL — download temporário¶
Para servir arquivos privados sem rotear bytes pela API:
from datetime import timedelta
from fastapi import APIRouter
from src.api.app import storage
router = APIRouter()
@router.get("/files/{key}/url")
async def get_download_url(key: str) -> dict[str, str]:
"""URL de download válida por 1 hora."""
url = await storage.presigned_get_url(key, expires=timedelta(hours=1))
return {"url": url}
Endpoint público separado para presigned URLs (v0.88.0+)¶
Cenário comum em produção: o backend fala com o MinIO por uma rede privada rápida (servus-storage:9000, sem TLS), mas o browser não alcança esse host — precisa de um host público com HTTPS. Se você assinar a presigned URL com o endpoint interno, o link vem com servus-storage:9000 e o navegador não abre.
Solução: STORAGE_PUBLIC_ENDPOINT. As presigned URLs (presigned_get_url / presigned_put_url) passam a ser assinadas contra o host público, enquanto todas as operações servidor→MinIO continuam no endpoint interno.
# .env
STORAGE_ENDPOINT=servus-storage:9000 # rede interna Docker (ops)
STORAGE_SECURE=false
STORAGE_PUBLIC_ENDPOINT=https://storage.example.com # browser (presigned)
# STORAGE_PUBLIC_SECURE=true # opcional; https:// já implica true
Por que dois clients e não um replace de host
A presigned URL é assinada (SigV4) incluindo o header Host. Trocar o host depois de assinar invalida a assinatura. Por isso o SDK mantém um segundo minio.Minio (mesmas credenciais) só para assinar contra o host público — o AsyncMinIOClient.client interno segue fazendo put/get/stat/ensure_bucket pela rede privada.
Sem STORAGE_PUBLIC_ENDPOINT
Comportamento inalterado: presigned URLs são assinadas com STORAGE_ENDPOINT (modo endpoint único). O split é 100% opt-in.
O proxy do host público precisa rotear para a API S3 do MinIO (porta 9000) com TLS e repassar o Host correto (a assinatura valida o host).
Operações em lote — presign / upload / download (v0.133.0+)¶
Endpoints de listagem normalmente precisam resolver uma chave por linha — uma página de perfis, cada um com sua foto. Fazer isso num for com await presigned_get_url(...) serializa os N hops de thread (cada chamada do minio roda em asyncio.to_thread). Os três métodos batch disparam o fan-out de uma vez, com um teto de concorrência:
presigned_get_urls(keys)→dict[str, str](chave → URL)put_objects(items)→dict[str, str](chave → ETag)get_objects_bytes(keys)→dict[str, bytes](chave → payload)
from fastapi import APIRouter
from src.api.app import storage
router = APIRouter()
@router.post("/files/urls")
async def sign_many(keys: list[str]) -> dict[str, str]:
"""Assina uma página de chaves de uma vez, em vez de uma por request."""
return await storage.presigned_get_urls(keys)
Chaves duplicadas são deduplicadas (cada objeto é assinado/baixado uma vez), e o retorno é um dict — faça o lookup por chave com result.get(row.key).
No serviço: file_urls para páginas
Se o serviço usa StoredFileServiceMixin, prefira file_urls([...]) — o par em lote de file_url. Ele descarta chaves None/vazias e devolve dict, ideal pra montar uma página de respostas:
Semântica fail-fast
Os três métodos são fail-fast: a primeira falha aborta o lote e propaga (asyncio.gather padrão) — mesmo comportamento de rodar as operações uma a uma. Precisa tolerar falha parcial? Rode os itens individualmente e trate cada exceção.
Teto de concorrência (max_concurrency, padrão 16)
Cada operação vai pra um thread do executor default. Agendar milhares de uma vez satura o pool e estoura memória. Um asyncio.Semaphore limita quantas rodam ao mesmo tempo, preservando a ordem. Ajuste via max_concurrency= (mínimo 1; 0 ou negativo levanta ValueError).
Upload em lote usa PutObjectItem, que espelha os argumentos por-objeto de put_object (content-type, metadata, length para streams):
import asyncio
from tempest_fastapi_sdk import AsyncMinIOClient, PutObjectItem
from src.core.settings import settings
storage = AsyncMinIOClient(**settings.storage_kwargs())
# No seu código estes vêm do disco (`Path(...).read_bytes()`) ou do upload.
thumb_a = b"\xff\xd8\xff\xdb bytes do primeiro JPEG"
thumb_b = b"\xff\xd8\xff\xdb bytes do segundo JPEG"
async def main() -> None:
"""Run this example."""
etags = await storage.put_objects(
[
PutObjectItem(key="thumbs/a.jpg", data=thumb_a, content_type="image/jpeg"),
PutObjectItem(key="thumbs/b.jpg", data=thumb_b, content_type="image/jpeg"),
]
)
asyncio.run(main())
get_objects_bytes carrega tudo em memória
Como get_object_bytes, o lote é para objetos pequenos — cada payload vira bytes na RAM. Para arquivos grandes, faça streaming individual com stream_object.
Listar objetos por prefixo¶
from fastapi import APIRouter
from src.api.app import storage
router = APIRouter()
@router.get("/files")
async def list_files(prefix: str = "") -> list[str]:
"""Lista chaves no bucket padrão sob ``prefix``."""
return await storage.list_objects(prefix)
list_objects devolve [] quando nada bate — em linha com a convenção do SDK ("nenhum match não é erro").
Remover em lote¶
remove_objects apaga muitas chaves com o DeleteObjects do S3 — uma
requisição por 1000 chaves, em vez de um DELETE por objeto — e devolve só o
que o storage recusou:
import asyncio
from tempest_fastapi_sdk import AsyncMinIOClient
storage = AsyncMinIOClient(
endpoint="localhost:9000",
access_key="minioadmin",
secret_key="minioadmin",
default_bucket="uploads",
)
async def main() -> None:
"""Apaga tudo sob um prefixo."""
keys = await storage.list_objects("tmp/")
errors = await storage.remove_objects(keys)
for error in errors:
print(error.key, error.code, error.message)
asyncio.run(main())
Lista vazia é sucesso. Para apagar os arquivos de um titular (LGPD), use
SubjectObjectStorage.delete_all — veja
Exportação e exclusão de titular.
Copiar / mover¶
import asyncio
from tempest_fastapi_sdk import AsyncMinIOClient
from src.core.settings import settings
storage = AsyncMinIOClient(**settings.storage_kwargs())
async def main() -> None:
"""Run this example."""
await storage.copy_object("uploads/draft-1", "uploads/final-1")
await storage.remove_object("uploads/draft-1")
asyncio.run(main())
Quando NÃO usar AsyncMinIOClient¶
- Quando você precisa de operações fora das listadas (SSE-KMS, ACLs S3 v2, bucket replication). Use
storage.client.<método>direto —minio-pycontinua acessível. - Para uploads gigantes (> 5 GiB) com retomada —
minio-pyfaz multipart automático mas não suportatusou resume. Consideretus.ioseparadamente.
Recap¶
AsyncMinIOClienté fachada async sobre o pacote oficialminio, no extra[minio]: bucket, objeto e URL pré-assinada, que é o que um serviço FastAPI costuma precisar.- A configuração vem do settings mixin, e o cliente é montado no
lifespandocreate_app()— uma instância por processo, não uma por request. - URL pré-assinada é a forma de entregar arquivo privado sem passar os bytes pelo seu processo.
- Quando os bytes precisam passar pelo backend,
download_response(key, request=request)responde206,304e416— sem orequest, é sempre200inteiro. - Para o nginx entregar no lugar do app,
serve_objectcomSTORAGE_ACCEL_REDIRECT=truerespondeX-Accel-Redirect; alocationinterna precisa deproxy_passcom barra final,Hostigual aoSTORAGE_ENDPOINTe osadd_headerde segurança — o nginx não repassa os do app. - Para o navegador abrir arquivo privado pela rota do app, assine o
path no mapper com
sign_pathe proteja a rota commake_signed_path_dependency: a assinatura cobre path,expiresepurpose, e a recusa é403(SIGNED_URL_INVALIDouSIGNED_URL_EXPIRED). - Operação fora da fachada não é bloqueada: chame
storage.client.<método>e use ominio-pydireto, em vez de esperar que a fachada cresça. - Para alternar disco local e MinIO por configuração, o backend pluggável de upload é o caminho — a fachada é para quem já decidiu usar MinIO.
Próximos passos¶
- O backend pluggable de upload
MinIOUploadStorageestá disponível desde a v0.24.0 — para o pipeline que alterna disco local ↔ MinIO/S3 via flag de settings, veja a receita de uploads.