Backend Latam Radio #5
Publicado el 27/05/2026
La API REST: lo que ve la app móvil
Con la base lista y poblada, toca el otro lado del backend: la interfaz que consume el cliente. Para una API de solo lectura, DRF es casi un placer. Es de esas piezas donde el framework te quita el ruido y te deja escribir solo la intención.
Los endpoints
GET /api/v1/stations/ → lista paginada con filtros
GET /api/v1/stations/{id}/ → detalle de una estación
POST /api/v1/stations/by-uuid/ → varias estaciones por UUID
GET /api/v1/countries/ → códigos de país disponibles
GET /api/v1/tags/ → tags únicos
GET /docs/ → swagger UI
Toda la API es pública y de solo lectura. El único POST es by-uuid, y no escribe nada: recibe una lista de UUIDs en el body porque pasarlos por query string es feo y susceptible al límite de URL.
El viewset: ReadOnlyModelViewSet
# api/viewsets/station_viewset.py
class StationViewSet(viewsets.ReadOnlyModelViewSet):
queryset = (
Station.objects.filter(lastcheckok=True)
.prefetch_related("tags", "languages", "language_codes")
)
serializer_class = StationSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = StationFilter
ordering_fields = ["name", "country_code", "bitrate", "last_check_ok_time"]
ordering = ["country_code", "name"]
@swagger_auto_schema(
method="post",
request_body=StationUUIDSerializer,
responses={200: StationSerializer(many=True)},
)
@action(detail=False, methods=["post"], url_path="by-uuid")
def post_by_ids(self, request):
serializer = StationUUIDSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
uuids = serializer.validated_data["uuids"]
stations = (
Station.objects.filter(uuid__in=uuids, lastcheckok=True)
.prefetch_related("tags", "languages", "language_codes")
)
return Response(self.get_serializer(stations, many=True).data)
Cosas que merece detallar:
ReadOnlyModelViewSetexpone sololistyretrieve. MétodosPOST,PUT,DELETEquedan cerrados sin tener que listarlos. En la versión inicial del código teníaModelViewSetconhttp_method_names=['get','post']: una trampa que veremos en el post de la auditoría.prefetch_relatedevita N+1: sin él, listar 20 emisoras dispara 1 + 60 queries (3 M2M × 20). Con él, 4 queries totales.@action(detail=False, methods=["post"], url_path="by-uuid")crea el endpoint/stations/by-uuid/como acción del viewset, no como router separado.
Serializers: explícitos, nested, sin sorpresas
El primer impulso al escribir un serializer de DRF es fields = '__all__' y depth = 1. Es rápido y horrible:
__all__expone campos internos no deseados.depth = 1serializa todas las FKs/M2M con todos sus campos, sin filtro.
Mi versión explícita:
# api/serializers/output/station_serializer.py
class _NameSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["name"]
class _LangSerializer(serializers.ModelSerializer):
class Meta:
model = Language
fields = ["lang"]
class _LangCodeSerializer(serializers.ModelSerializer):
class Meta:
model = LanguageCode
fields = ["lang_codes", "name"]
class StationSerializer(serializers.ModelSerializer):
location = GeometryField(required=False, allow_null=True)
tags = _NameSerializer(many=True, read_only=True)
languages = _LangSerializer(many=True, read_only=True)
language_codes = _LangCodeSerializer(many=True, read_only=True)
class Meta:
model = Station
fields = [
"uuid", "name", "country_code", "state", "codec", "bitrate",
"url", "url_resolved", "favicon", "homepage", "location",
"languages", "language_codes", "tags", "lastcheckok",
"last_check_ok_time", "last_check_time", "lastchange_time",
]
Listar campos parece más trabajo, y lo es: tres líneas. A cambio: - Saber exactamente qué se expone. - Que la app móvil reciba un contrato estable. - Que añadir un campo al modelo no rompa la API por defecto.
Filtros: lookups explícitos, no autogenerated
django-filter permite autogenerar filtros desde el modelo, pero por defecto usa lookup exact y respeta case. Para una UI donde el usuario teclea "rock" buscando "Rock Nacional", no sirve.
# api/filtersets/station_filterset.py
class StationFilter(django_filters.FilterSet):
name = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
country_code = django_filters.CharFilter(
field_name="country_code", lookup_expr="iexact",
)
country_code_in = django_filters.BaseInFilter(
field_name="country_code", lookup_expr="in",
)
tag = django_filters.CharFilter(field_name="tags__name", lookup_expr="icontains")
language_code = django_filters.CharFilter(
field_name="language_codes__lang_codes", lookup_expr="iexact",
)
class Meta:
model = Station
fields = ["name", "country_code", "country_code_in", "tag", "language_code"]
country_code_in usa BaseInFilter para que el cliente pueda pedir varios países en una sola query: ?country_code_in=AR,BR,CL. Útil para una vista "radios de la región andina".
Paginación con techo
DRF acepta ?limit=X con LimitOffsetPagination. Sin tope, un cliente puede pedir ?limit=99999 y poner el servidor de rodillas serializando todo el dataset. Solución:
# api/pagination.py
class CappedLimitOffsetPagination(LimitOffsetPagination):
default_limit = 20
max_limit = 100
Y en settings.py:
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "api.pagination.CappedLimitOffsetPagination",
"PAGE_SIZE": 20,
# ...
}
max_limit = 100 significa que aunque el cliente pida ?limit=10000, DRF sirve 100. Cap silencioso, sin error.
El input de by-uuid: tamaño acotado
Un endpoint que recibe una lista en el body necesita un tamaño máximo. Si no, es DoS a punta de payload grande:
# api/serializers/input/station_uuid_serializer.py
MAX_UUIDS_PER_REQUEST = 100
class StationUUIDSerializer(serializers.Serializer):
uuids = serializers.ListField(
child=serializers.UUIDField(),
min_length=1,
max_length=MAX_UUIDS_PER_REQUEST,
)
Si la app móvil necesita más de 100 estaciones de un golpe, hace varias requests. La regla del 80/20: nunca pide tantas para una sola pantalla.
Con esto, la API está lista para servir al cliente. Pero falta la parte más interesante del proyecto: lo que pasó cuando le pedí a dos subagentes que auditaran el código antes de publicarlo. Próximo post.