Lánzate a Python Django REST Framework: tu primer paso concreto hoy

Python Django REST Framework : lo esencial en un artículo — código real, diagramas y pasos concretos, extractos de un curso de 24 lecciones.

Lánzate a Python Django REST Framework: tu primer paso concreto hoy

La mejor forma de aprender Python Django REST Framework es practicando. Este artículo te ayuda a empezar con extractos prácticos extraídos de un curso de 24 lecciones — para obtener un primer resultado ya hoy.

tl;dr
  • Setup DRF
  • Serializers
  • Views
  • Auth y permisos
  • Filtros y paginación
~$ cat ./parcours.md # Python Django REST Framework — 8 capítulos
01
Setup DRF
→ REST vs RPC vs GraphQL→ Instalación y primer endpoint+ 1 más lecciones
02
Serializers
→ Serializer y ModelSerializer→ Validación custom+ 1 más lecciones
03
Views
→ APIView y generics→ ViewSets y Routers+ 1 más lecciones
04
Auth y permisos
→ Token, Session, JWT→ Permisos builtin y custom+ 1 más lecciones
05
Filtros y paginación
→ Filters y search→ Pagination+ 1 más lecciones
06
Documentación OpenAPI
→ drf-spectacular→ Swagger UI y Redoc+ 1 más lecciones
07
Tests y performance
→ APITestCase y factories→ Optimización N+1+ 1 más lecciones
08
Proyecto final API SaaS
→ Arquitectura multi-tenant→ Endpoints y tests+ 1 más lecciones
🏁
Proyecto final
→ Te vas con un proyecto concreto y demostrable

REST vs RPC vs GraphQL

REST : recursos y verbos

output
GET    /api/articles/        # listar
GET    /api/articles/42/     # detalle
POST   /api/articles/        # crear
PUT    /api/articles/42/     # reemplazar
PATCH  /api/articles/42/     # actualización parcial
DELETE /api/articles/42/     # eliminar

# Verbos HTTP + URIs = CRUD universal

Comparación

AspectoRESTGraphQLRPC (gRPC)
FormatoJSON/XMLJSONProtobuf
TransporteHTTPHTTPHTTP/2
EsquemaOpenAPISDL (tipado).proto
Versionadov1/v2campos obsoletoscompatibilidad hacia atrás
Over-fetchNoNo
Caché HTTPFácilDifícilNo
HerramientasExcelenteBuenoEspecífico de la tecnología
Móvil/IoTOKExcelenteExcelente

Cuándo elegir qué

NOTEREST — APIs públicas, CRUD simple, web/móvil clásico, cacheable. Opción por defecto en el 90 % de los casos.
NOTEGraphQL — Móvil complejo con pantallas que agregan 5+ recursos, frontends múltiples con necesidades distintas.
NOTEgRPC — Microservicios internos, latencia crítica, streaming bidireccional, múltiples lenguajes.

¿Por qué DRF?

Alternativas a DRF

Anatomía de una buena API REST

output
# Buenas prácticas
GET    /api/v1/users/                  # lista
GET    /api/v1/users/42/               # detalle
GET    /api/v1/users/42/orders/        # subrecurso
POST   /api/v1/users/                  # 201 Created + Location
PATCH  /api/v1/users/42/               # parcial
DELETE /api/v1/users/42/               # 204 No Content

# Filtros / paginación
GET    /api/v1/users/?role=admin&page=2&page_size=20

# Códigos de estado
200 OK, 201 Created, 204 No Content
400 Bad Request, 401 Unauthorized, 403 Forbidden
404 Not Found, 409 Conflict, 422 Unprocessable
500 Server Error, 503 Unavailable

Formato de respuesta estándar

output
{
  "data": [...],
  "meta": {
    "count": 150,
    "page": 2,
    "page_size": 20
  },
  "links": {
    "next": "/api/v1/users/?page=3",
    "prev": "/api/v1/users/?page=1"
  }
}

# Errores
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": {"email": ["This field is required"]}
  }
}

Resumen

NOTEPara recordar
  • REST = recursos + verbos HTTP estandarizados
  • GraphQL para móvil complejo, REST para el resto
  • DRF = estándar de Django desde 2014
  • v1/v2 en la URL, formato JSON, códigos HTTP correctos

Arquitectura multi-tenant

Requisitos

NOTEProyecto — API SaaS de gestión de proyectos/tareas multi-tenant con planes de precios (Free/Pro/Enterprise), facturación con Stripe, webhooks y OpenAPI público.

Modelos

output
# workspaces/models.py
class Workspace(models.Model):
    class Plan(models.TextChoices):
        FREE = "free", "Free"
        PRO = "pro", "Pro"
        ENTERPRISE = "enterprise", "Enterprise"
    
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    plan = models.CharField(max_length=20, choices=Plan.choices, default=Plan.FREE)
    stripe_customer_id = models.CharField(max_length=100, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

class Membership(models.Model):
    class Role(models.TextChoices):
        OWNER = "owner"
        ADMIN = "admin"
        MEMBER = "member"
        GUEST = "guest"
    
    workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="memberships")
    user = models.ForeignKey("accounts.User", on_delete=models.CASCADE)
    role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
    invited_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ["workspace", "user"]

# projects/models.py
class Project(models.Model):
    workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="projects")
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT)
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        indexes = [models.Index(fields=["workspace", "-created_at"])]

class Task(models.Model):
    class Status(models.TextChoices):
        TODO = "todo"
        IN_PROGRESS = "in_progress"
        DONE = "done"
    
    project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks")
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.TODO)
    assigned_to = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL)
    due_date = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Tenant mediante subdominio

output
# workspaces/middleware.py
class WorkspaceMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        host = request.get_host().split(":")[0]
        subdomain = host.split(".")[0]
        
        try:
            request.workspace = Workspace.objects.get(slug=subdomain)
        except Workspace.DoesNotExist:
            request.workspace = None
        
        return self.get_response(request)

# settings.py
MIDDLEWARE += ["workspaces.middleware.WorkspaceMiddleware"]

# URLs : acme.api.example.com -> workspace "acme"

Permiso multi-tenant

output
# workspaces/permissions.py
from rest_framework.permissions import BasePermission

class IsWorkspaceMember(BasePermission):
    def has_permission(self, request, view):
        if not request.user.is_authenticated:
            return False
        if not request.workspace:
            return False
        return Membership.objects.filter(
            workspace=request.workspace, user=request.user
        ).exists()

class IsWorkspaceAdmin(BasePermission):
    def has_permission(self, request, view):
        return Membership.objects.filter(
            workspace=request.workspace,
            user=request.user,
            role__in=["owner", "admin"]
        ).exists()

Mixin para queryset con filtro automático

output
class WorkspaceQuerysetMixin:
    """Filtro automático por workspace actual."""
    workspace_field = "workspace"
    
    def get_queryset(self):
        qs = super().get_queryset()
        if self.request.workspace:
            return qs.filter(**{self.workspace_field: self.request.workspace})
        return qs.none()
    
    def perform_create(self, serializer):
        serializer.save(workspace=self.request.workspace, created_by=self.request.user)

class ProjectViewSet(WorkspaceQuerysetMixin, viewsets.ModelViewSet):
    queryset = Project.objects.all()
    serializer_class = ProjectSerializer
    permission_classes = [IsAuthenticated, IsWorkspaceMember]
    
    def get_queryset(self):
        return super().get_queryset().select_related("created_by")

class TaskViewSet(WorkspaceQuerysetMixin, viewsets.ModelViewSet):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticated, IsWorkspaceMember]
    workspace_field = "project__workspace"
    
    def get_queryset(self):
        return super().get_queryset().select_related("project", "assigned_to")

Cuotas por plan

output
# workspaces/quotas.py
PLAN_LIMITS = {
    "free": {"max_projects": 3, "max_members": 3, "api_rate": "100/day"},
    "pro": {"max_projects": None, "max_members": 20, "api_rate": "10000/day"},
    "enterprise": {"max_projects": None, "max_members": None, "api_rate": None},
}

def check_project_quota(workspace):
    limit = PLAN_LIMITS[workspace.plan]["max_projects"]
    if limit is None:
        return
    current = workspace.projects.count()
    if current >= limit:
        raise serializers.ValidationError(
            f"Límite alcanzado: {limit} proyectos para el plan {workspace.plan}. "
            f"Actualiza a Pro."
        )

# En el serializer
class ProjectSerializer(serializers.ModelSerializer):
    def create(self, validated_data):
        check_project_quota(validated_data["workspace"])
        return super().create(validated_data)

Throttle por plan

output
class PlanRateThrottle(SimpleRateThrottle):
    scope = "plan"
    
    def allow_request(self, request, view):
        if not request.workspace:
            return True
        
        rate = PLAN_LIMITS[request.workspace.plan]["api_rate"]
        if rate is None:
            return True          # enterprise
        
        self.num_requests, self.duration = self.parse_rate(rate)
        self.request = request
        return super().allow_request(request, view)
    
    def get_cache_key(self, request, view):
        return f"plan_{request.workspace.id}"

Almacenamiento en caché de respuestas

Configurar la caché

output
# settings.py - backend Redis
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": env("REDIS_URL"),
        "TIMEOUT": 300,                # 5 min por defecto
        "OPTIONS": {
            "db": 1,
            "max_connections": 50,
        }
    }
}

# Prueba rápida con memoria local (desarrollo)
CACHES = {
    "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
}

Caché de vista completa

output
from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator

class PopularBooksView(generics.ListAPIView):
    queryset = Book.objects.filter(popular=True)
    serializer_class = BookSerializer
    
    @method_decorator(cache_page(60 * 15))      # 15 min
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

# O con vary_on_headers
from django.views.decorators.vary import vary_on_headers

@method_decorator(cache_page(600), name="dispatch")
@method_decorator(vary_on_headers("Authorization"), name="dispatch")
class MyView(generics.ListAPIView):
    # Caché diferente por token
    ...

Caché de bajo nivel

output
from django.core.cache import cache

class StatsView(APIView):
    def get(self, request):
        cache_key = f"stats_{request.user.id}"
        data = cache.get(cache_key)
        
        if data is None:
            # Cálculo costoso
            data = compute_expensive_stats(request.user)
            cache.set(cache_key, data, timeout=3600)
        
        return Response(data)

# get_or_set en 1 línea
data = cache.get_or_set(f"stats_{user.id}", lambda: compute_stats(user), 3600)

# Multi-caché
cache.set_many({"a": 1, "b": 2}, timeout=60)
values = cache.get_many(["a", "b"])

# Invalidación
cache.delete("stats_42")
cache.delete_many(["stats_42", "stats_43"])
cache.clear()                                # borrar todo

Invalidación de caché mediante signals

output
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver

@receiver([post_save, post_delete], sender=Book)
def invalidate_books_cache(sender, instance, **kwargs):
    cache.delete("popular_books")
    cache.delete(f"book_{instance.id}")
    cache.delete(f"books_by_author_{instance.author_id}")

Caché HTTP : ETag

output
from rest_framework.response import Response
import hashlib

class BookDetailView(generics.RetrieveAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    
    def retrieve(self, request, *args, **kwargs):
        book = self.get_object()
        etag = hashlib.md5(str(book.updated_at).encode()).hexdigest()
        
        if request.headers.get("If-None-Match") == etag:
            return Response(status=304)        # Not Modified
        
        response = Response(BookSerializer(book).data)
        response["ETag"] = etag
        response["Cache-Control"] = "public, max-age=300"
        return response

# El cliente envía automáticamente If-None-Match: "abc..."
# El servidor puede responder 304 sin body -> muy rápido

Cache-Control por endpoint

output
from django.views.decorators.cache import cache_control
from django.utils.decorators import method_decorator

class PublicCatalogView(generics.ListAPIView):
    queryset = Product.objects.filter(public=True)
    serializer_class = ProductSerializer
    
    @method_decorator(cache_control(public=True, max_age=3600))
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

# Header : Cache-Control: public, max-age=3600
# CDN y navegador lo cachearán

class UserDataView(APIView):
    @method_decorator(cache_control(private=True, max_age=60))
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)
    
    # private = sin caché CDN, pero sí en el navegador
va-plus-loin

Este artículo cubre los extractos más útiles — el curso completo Python Django REST Framework (8 capítulos, 24 lecciones, ejercicios corregidos y proyecto final) te lleva hasta el final.

./acceder-al-curso-completo curso gratuito : Vibe Coding

FAQ

¿Cuánto tiempo se necesita para aprender Python Django REST Framework?
Con una progresión estructurada (8 capítulos, 24 lecciones cortas y prácticas), se alcanza un nivel operativo en unas semanas dedicando 30 a 60 minutos al día. Lo importante es practicar cada concepto de inmediato.
¿Se necesitan requisitos previos?
Basta con nociones básicas de informática. Si sabes usar una terminal y leer código sencillo, estás listo.
¿Por dónde empezar de forma concreta?
Reproduce los comandos de este artículo y luego sigue el curso completo Python Django REST Framework: encadena las 24 lecciones en orden, con ejercicios y proyecto final.

📬 ¿Quieres recibir este tipo de guía cada semana? Suscríbete gratis — código real, cero palabrería.