Arquivo no serviço — StoredFileServiceMixin¶
Uma entidade quase sempre carrega uma chave de storage: o avatar do usuário, o banner de um evento, a capa de um produto, um anexo. E todo serviço que cuida disso reescreve a mesma dança na mão:
- resolver a entidade,
- subir o arquivo novo e apagar o antigo,
- gravar a chave nova no model,
- dar
commit, - devolver uma URL temporária de download.
O StoredFileServiceMixin faz esse fluxo uma vez, parametrizado pelo
nome do campo — então um mesmo serviço cuida de vários campos de arquivo
sem repetição. Ele monta por cima do UploadUtils (upload +
validação) e do AsyncMinIOClient (presigned URL); requer os
extras [upload] e [minio].
Escopo: o caso comum
Cobre uma chave por campo → URL presigned. Thumbnails, variantes
(S/M/L), bucket público/CDN e galerias (um-para-muitos) ficam de fora —
pra esses, componha o UploadUtils direto.
Misturando no seu serviço¶
O mixin não constrói nada: ele lê dois colaboradores de self —
upload_utils e storage. Quem mistura continua dono da configuração
(tamanho, tipos aceitos, bucket):
from tempest_fastapi_sdk import (
AsyncMinIOClient,
BaseService,
StoredFileServiceMixin,
UploadUtils,
)
from src.db.models import UserModel
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(
BaseService[UserRepository, UserResponseSchema],
StoredFileServiceMixin[UserModel],
):
def __init__(
self,
repository: UserRepository,
storage: AsyncMinIOClient,
upload_utils: UploadUtils,
) -> None:
super().__init__(repository)
self.storage = storage
self.upload_utils = upload_utils
A ordem das bases importa: BaseService traz o repository; o mixin só
adiciona os métodos de arquivo por cima.
Como a herança funciona¶
O serviço herda de duas bases genéricas ao mesmo tempo — é composição por herança múltipla, e cada peça tem um papel:
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import UserModel
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(
BaseService[UserRepository, UserResponseSchema], # (1) estado + CRUD
StoredFileServiceMixin[UserModel], # (2) métodos de arquivo
):
...
BaseService[Repo, Response]vem primeiro na MRO (method resolution order). É ela que define o__init__(repository)e guarda oself.repository— por isso o seusuper().__init__(repository)cai nela. Os dois parâmetros genéricos amarram o tipo do repositório e o schema de resposta.StoredFileServiceMixin[Model]vem depois. Ela não tem__init__nem estado próprio — só empilhaset_file/file_url/file_urls/clear_filepor cima. O único genérico (Model) mantém o retorno deset_file/clear_filepreciso (UserModel, não umAnyqualquer).
Por que o mixin não constrói nada
Um mixin que criasse o storage/upload_utils roubaria do serviço a
configuração (bucket, tamanho máximo, tipos aceitos). Em vez disso ele
lê os colaboradores de self via structural typing (Protocols
SupportsUpload e SupportsPresign): qualquer objeto com os métodos certos
serve. Consequência prática: importar o mixin não puxa os extras
[upload]/[minio] — eles só entram quando você de fato instancia um
UploadUtils / AsyncMinIOClient.
repository: Any no mixin — e o mypy
O mixin declara repository: Any só como anotação. Sem isso, o mypy
reclamaria de campo repository conflitante entre as duas bases (a
BaseService tipa ele como RepositoryT). Com Any no mixin, a base
concreta vence e os métodos públicos continuam precisos via Model — nada
de # type: ignore no seu serviço.
Trocar o arquivo — set_file¶
from uuid import UUID
from fastapi import UploadFile
from src.db.models import UserModel
from src.schemas import UserResponseSchema
async def update_profile_picture(
self, user: UUID | UserModel, image: UploadFile
) -> UserResponseSchema:
"""Sobe a foto nova, apaga a antiga e devolve o perfil com a URL."""
updated = await self.set_file(
user, image, field="profile_picture", subdir="profiles"
)
response = await self._map_to_response(updated)
response.profile_picture_url = await self.file_url(updated.profile_picture)
return response
Foi isso. Comparado às ~13 linhas manuais, o set_file resolve a entidade,
chama replace (grava a nova antes de apagar a antiga), grava a chave e
dá commit — tudo num passo.
Seguro com o usuário autenticado
O set_file re-resolve a entidade na sessão do request via
repository.resolve(). Se você passar o UserModel que veio de
get_current_user (que em apps mal-fiados vinha detached), o resolve
o reanexa antes da escrita — sem o
InvalidRequestError: Instance is not persistent within this Session.
Servir a URL — file_url¶
from datetime import timedelta
from fastapi import UploadFile
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import UserModel
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(
BaseService[UserRepository, UserResponseSchema],
StoredFileServiceMixin[UserModel],
):
async def profile_picture_url(self, user: UserModel) -> str | None:
"""Assina a chave guardada — 1h de validade por padrão."""
url = await self.file_url(user.profile_picture)
url = await self.file_url(user.profile_picture, expires=timedelta(minutes=5))
return url
Devolve None quando a chave é vazia, então você pode jogar o resultado
direto num campo do schema de resposta sem if:
from datetime import timedelta
from fastapi import UploadFile
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import UserModel
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(
BaseService[UserRepository, UserResponseSchema],
StoredFileServiceMixin[UserModel],
):
async def to_response(self, updated: UserModel) -> UserResponseSchema:
"""Monta a resposta já com a URL assinada — sem ``if``."""
response = await self._map_to_response(updated)
response.profile_picture_url = await self.file_url(updated.profile_picture)
return response
Uma página inteira — file_urls (v0.133.0+)¶
Endpoint de listagem precisa resolver uma chave por linha — uma página
de candidatos, cada um com sua foto. Fazer isso num for com
await self.file_url(...) serializa os hops de thread (cada presign do
minio roda em asyncio.to_thread). O file_urls é o par em lote do
file_url: dispara o fan-out de uma vez e devolve um dict indexado pela
chave.
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import CandidateModel
from src.db.repositories import CandidateRepository
from src.schemas import CandidateResponseSchema
class CandidateService(
BaseService[CandidateRepository, CandidateResponseSchema],
StoredFileServiceMixin[CandidateModel],
):
async def _load_profile_picture_from_users(
self, candidates: list[CandidateResponseSchema]
) -> None:
"""Preenche ``profile_picture_url`` de cada candidato numa tacada só."""
users = [c.user for c in candidates if c.user is not None]
urls = await self.file_urls([user.profile_picture for user in users])
for user in users:
user.profile_picture_url = urls.get(user.profile_picture)
Chaves None/vazias são descartadas e duplicatas deduplicadas, então o
dict tem uma entrada por chave não-vazia distinta. Faça o lookup com
urls.get(row.key) — uma linha cuja chave era vazia devolve None, sem if.
Teto de concorrência (max_concurrency, padrão 16)
Cada presign vai pra um thread do executor default. O file_urls limita
quantos rodam ao mesmo tempo com um asyncio.Semaphore, preservando a
ordem — uma página grande não satura o pool. Ajuste via
file_urls(keys, max_concurrency=32).
Fail-fast
Se um presign falha, o lote inteiro aborta e propaga (asyncio.gather
padrão) — mesmo comportamento de assinar uma a uma.
Remover o arquivo — clear_file¶
from datetime import timedelta
from fastapi import UploadFile
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import UserModel
from src.db.repositories import UserRepository
from src.schemas import UserResponseSchema
class UserService(
BaseService[UserRepository, UserResponseSchema],
StoredFileServiceMixin[UserModel],
):
async def remove_profile_picture(self, user: UserModel) -> UserModel:
"""Apaga o objeto do storage e zera o campo."""
return await self.clear_file(user, field="profile_picture")
Apaga o objeto do storage e zera o campo. Quando o campo já está vazio, é
um no-op: a entidade volta sem commit e sem chamar o storage.
Vários campos? Mesmo mixin¶
field= é só um argumento — o mesmo serviço cuida de quantos campos quiser:
from fastapi import UploadFile
from tempest_fastapi_sdk import BaseService, StoredFileServiceMixin
from src.db.models import EventModel
from src.db.repositories import EventRepository
from src.schemas import EventResponseSchema
class EventService(
BaseService[EventRepository, EventResponseSchema],
StoredFileServiceMixin[EventModel],
):
async def set_images(
self, event: EventModel, cover: UploadFile, banner: UploadFile
) -> None:
"""Dois campos de arquivo, um mixin só."""
await self.set_file(event, cover, field="cover_image", subdir="events/covers")
await self.set_file(event, banner, field="banner_image", subdir="events/banners")
Recapitulando¶
- Misture
StoredFileServiceMixin[Model]no serviço (depois deBaseService[Repo, Response]na MRO) e exponhaupload_utils+storage. O mixin não tem estado próprio: lê os colaboradores deselfvia Protocol, então importá-lo não puxa os extras[upload]/[minio]. set_file(ref, file, *, field, subdir=...)→ sobe, troca a antiga, persiste. Detach-safe.file_url(key, *, expires=...)→ URL presigned ouNone.file_urls(keys, *, expires=..., max_concurrency=16)→dictchave→URL para uma página inteira; descarta chaves vazias, dedup, fail-fast.clear_file(ref, *, field)→ apaga + zera (no-op se já vazio).- Caso comum (uma chave + presigned). Pra resize/variantes/galeria, use o
UploadUtilsdireto.