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:

  • ReadOnlyModelViewSet expone solo list y retrieve. Métodos POST, PUT, DELETE quedan cerrados sin tener que listarlos. En la versión inicial del código tenía ModelViewSet con http_method_names=['get','post']: una trampa que veremos en el post de la auditoría.
  • prefetch_related evita 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 = 1 serializa 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.