Obsah článku / Contents
Django REST Framework: Professional API za 20 minút

Django REST Framework: Professional API za 20 minút

"Potrebujem production-ready REST API s dokumentáciou!" 🔥

Django REST Framework (DRF) je odpoveď.

Prečo Django REST Framework?

DRF je professional choice pre REST APIs v Pythone.

DRF features:

  • 🔥 Industry standard (Instagram, Mozilla, Red Hat...)
  • 📚 Auto-generated browsable API
  • 🔐 Built-in authentication (Token/Session/JWT)
  • ✅ Automatic validation with serializers
  • 🎨 Test endpoints directly in browser!
  • 🚀 Production-ready

Poďme na to!


Quick Start: 20 minút od nuly po API

Krok 1: Installation (2 minúty)

bash
# Virtual environment
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate  # Windows

# Install Django + DRF
pip install django djangorestframework

# Verify
python -c "import rest_framework; print(rest_framework.__version__)"
# Output: 3.14.0

Krok 2: Project Setup (2 minúty)

bash
# Create Django project
django-admin startproject myapi
cd myapi

# Create app for API
python manage.py startapp products

# Štruktúra:
# myapi/
#   manage.py
#   myapi/
#     settings.py
#     urls.py
#   products/
#     models.py
#     views.py
#     serializers.py  <- Will create this
#     urls.py         <- Will create this

Krok 3: Configuration (2 minúty)

python
# myapi/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # Third party
    'rest_framework',  # <- Add DRF
    
    # Your apps
    'products',  # <- Your app
]

# DRF Configuration
REST_FRAMEWORK = {
    # Pagination
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
    
    # Default permissions (we'll customize later)
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',  # For now
    ],
}

Krok 4: Models (3 minúty)

python
# products/models.py

from django.db import models

class Category(models.Model):
    """Product category"""
    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    
    class Meta:
        verbose_name_plural = 'categories'
        ordering = ['name']
    
    def __str__(self):
        return self.name

class Product(models.Model):
    """Product model"""
    # Basic info
    name = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    description = models.TextField(blank=True)
    
    # Pricing
    price = models.DecimalField(max_digits=10, decimal_places=2)
    
    # Inventory
    stock = models.IntegerField(default=0)
    
    # Category
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE,
        related_name='products'
    )
    
    # Status
    active = models.BooleanField(default=True)
    
    # Timestamps
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return self.name

Krok 5: Serializers (3 minúty)

python
# products/serializers.py (create this file)

from rest_framework import serializers
from .models import Category, Product

class CategorySerializer(serializers.ModelSerializer):
    """Serialize Category model"""
    product_count = serializers.IntegerField(read_only=True)
    
    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'description', 'product_count']
        read_only_fields = ['id']

class ProductSerializer(serializers.ModelSerializer):
    """Serialize Product model"""
    # Nested category (read)
    category_detail = CategorySerializer(source='category', read_only=True)
    
    # Simple category ID (write)
    category_id = serializers.IntegerField(write_only=True)
    
    class Meta:
        model = Product
        fields = [
            'id',
            'name',
            'slug',
            'description',
            'price',
            'stock',
            'category_id',      # For writing
            'category_detail',  # For reading
            'active',
            'created_at',
            'updated_at'
        ]
        read_only_fields = ['id', 'created_at', 'updated_at']
    
    def validate_price(self, value):
        """Custom validation for price"""
        if value < 0:
            raise serializers.ValidationError("Price cannot be negative")
        return value
    
    def validate_stock(self, value):
        """Custom validation for stock"""
        if value < 0:
            raise serializers.ValidationError("Stock cannot be negative")
        return value

Serializers handle:

  • ✅ Model → JSON (serialization)
  • ✅ JSON → Model (deserialization)
  • ✅ Validation
  • ✅ Nested relationships

Krok 6: Views (3 minúty)

python
# products/views.py

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Count
from .models import Category, Product
from .serializers import CategorySerializer, ProductSerializer

class CategoryViewSet(viewsets.ModelViewSet):
    """
    ViewSet for Category model
    
    Provides:
    - list: GET /api/categories/
    - create: POST /api/categories/
    - retrieve: GET /api/categories/1/
    - update: PUT /api/categories/1/
    - partial_update: PATCH /api/categories/1/
    - destroy: DELETE /api/categories/1/
    """
    queryset = Category.objects.annotate(
        product_count=Count('products')
    )
    serializer_class = CategorySerializer

class ProductViewSet(viewsets.ModelViewSet):
    """
    ViewSet for Product model
    
    All CRUD operations + custom actions
    """
    queryset = Product.objects.select_related('category').all()
    serializer_class = ProductSerializer
    
    def get_queryset(self):
        """
        Filter queryset by query parameters
        
        Examples:
            ?active=true
            ?category=1
            ?search=laptop
        """
        queryset = super().get_queryset()
        
        # Filter by active
        active = self.request.query_params.get('active')
        if active is not None:
            queryset = queryset.filter(active=active.lower() == 'true')
        
        # Filter by category
        category = self.request.query_params.get('category')
        if category:
            queryset = queryset.filter(category_id=category)
        
        # Simple search
        search = self.request.query_params.get('search')
        if search:
            queryset = queryset.filter(name__icontains=search)
        
        return queryset
    
    @action(detail=False, methods=['get'])
    def low_stock(self, request):
        """
        Custom action: Get products with low stock
        
        GET /api/products/low_stock/
        """
        threshold = int(request.query_params.get('threshold', 10))
        products = self.get_queryset().filter(stock__lt=threshold)
        
        serializer = self.get_serializer(products, many=True)
        return Response(serializer.data)
    
    @action(detail=True, methods=['post'])
    def restock(self, request, pk=None):
        """
        Custom action: Add stock to product
        
        POST /api/products/1/restock/
        Body: {"amount": 50}
        """
        product = self.get_object()
        amount = request.data.get('amount', 0)
        
        try:
            amount = int(amount)
            if amount < 0:
                return Response(
                    {'error': 'Amount must be positive'},
                    status=status.HTTP_400_BAD_REQUEST
                )
            
            product.stock += amount
            product.save()
            
            return Response({
                'status': 'restocked',
                'product': product.name,
                'new_stock': product.stock
            })
        
        except ValueError:
            return Response(
                {'error': 'Invalid amount'},
                status=status.HTTP_400_BAD_REQUEST
            )

ViewSets provide:

  • ✅ All CRUD operations automatically
  • ✅ Custom actions (@action decorator)
  • ✅ Filtering
  • ✅ Permissions (add later)

Krok 7: URLs (2 minúty)

python
# products/urls.py (create this file)

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CategoryViewSet, ProductViewSet

# Router automatically generates URL patterns
router = DefaultRouter()
router.register(r'categories', CategoryViewSet, basename='category')
router.register(r'products', ProductViewSet, basename='product')

# Generated URLs:
# /categories/          GET, POST
# /categories/1/        GET, PUT, PATCH, DELETE
# /products/            GET, POST
# /products/1/          GET, PUT, PATCH, DELETE
# /products/low_stock/  GET (custom action)
# /products/1/restock/  POST (custom action)

urlpatterns = [
    path('', include(router.urls)),
]
python
# myapi/urls.py (update this)

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('products.urls')),
    
    # DRF browsable API login
    path('api-auth/', include('rest_framework.urls')),
]

Krok 8: Migrate and Run (2 minúty)

bash
# Create migrations
python manage.py makemigrations

# Apply migrations
python manage.py migrate

# Create superuser (optional, for admin)
python manage.py createsuperuser
# Username: admin
# Password: ********

# Run server
python manage.py runserver

# Open browser:
# http://127.0.0.1:8000/api/
# 
# 🎉 BROWSABLE API! 🎉
# You can see AND test endpoints in browser!

You'll see:

API Root
────────
{
    "categories": "http://localhost:8000/api/categories/",
    "products": "http://localhost:8000/api/products/"
}

Click around! You can test API directly in browser! 🎨

Krok 9: Test API (2 minúty)

bash
# CREATE CATEGORY
curl -X POST http://localhost:8000/api/categories/ \
  -H "Content-Type: application/json" \
  -d '{
    "name":"Electronics",
    "slug":"electronics",
    "description":"Electronic devices"
  }'
# Response: {"id":1,"name":"Electronics",...}

# CREATE PRODUCT
curl -X POST http://localhost:8000/api/products/ \
  -H "Content-Type: application/json" \
  -d '{
    "name":"Laptop",
    "slug":"laptop",
    "description":"Gaming laptop",
    "price":"999.99",
    "stock":5,
    "category_id":1
  }'
# Response: {"id":1,"name":"Laptop",...}

# LIST PRODUCTS
curl http://localhost:8000/api/products/
# Response: {"count":1,"next":null,"previous":null,"results":[...]}

# GET ONE PRODUCT
curl http://localhost:8000/api/products/1/
# Response: {"id":1,"name":"Laptop",...}

# UPDATE PRODUCT
curl -X PATCH http://localhost:8000/api/products/1/ \
  -H "Content-Type: application/json" \
  -d '{"price":"899.99"}'
# Response: {"id":1,"price":"899.99",...}

# FILTER PRODUCTS
curl http://localhost:8000/api/products/?active=true
curl http://localhost:8000/api/products/?category=1
curl http://localhost:8000/api/products/?search=laptop

# CUSTOM ACTION: Low stock
curl http://localhost:8000/api/products/low_stock/?threshold=10

# CUSTOM ACTION: Restock
curl -X POST http://localhost:8000/api/products/1/restock/ \
  -H "Content-Type: application/json" \
  -d '{"amount":50}'
# Response: {"status":"restocked","new_stock":55}

# DELETE PRODUCT
curl -X DELETE http://localhost:8000/api/products/1/
# Response: 204 No Content

# All working! 🎉

Total time: 20 minút!


Advanced Features

Authentication & Permissions

python
# myapi/settings.py - Update REST_FRAMEWORK

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.TokenAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
}

# Add to INSTALLED_APPS
INSTALLED_APPS += [
    'rest_framework.authtoken',
]
bash
# Migrate to create token tables
python manage.py migrate

# Create token for user
python manage.py drf_create_token admin
# Generated token for user admin: abc123def456...
bash
# Now API requires authentication for write operations

# READ (works without auth)
curl http://localhost:8000/api/products/

# WRITE (needs auth)
curl -X POST http://localhost:8000/api/products/ \
  -H "Authorization: Token abc123def456..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Mouse","price":"29.99",...}'

# Token auth working! 🔐

Custom Permissions

python
# products/permissions.py (create this)

from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    """
    Custom permission:
    - Read: Anyone
    - Write: Only authenticated users
    - Delete: Only superusers
    """
    
    def has_permission(self, request, view):
        # Read permissions (GET, HEAD, OPTIONS)
        if request.method in permissions.SAFE_METHODS:
            return True
        
        # Write permissions: authenticated users
        return request.user and request.user.is_authenticated
    
    def has_object_permission(self, request, view, obj):
        # Read: anyone
        if request.method in permissions.SAFE_METHODS:
            return True
        
        # Update: authenticated users
        if request.method in ['PUT', 'PATCH']:
            return request.user and request.user.is_authenticated
        
        # Delete: only superusers
        if request.method == 'DELETE':
            return request.user and request.user.is_superuser

# products/views.py - Use custom permission

from .permissions import IsOwnerOrReadOnly

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [IsOwnerOrReadOnly]  # Apply permission
bash
# Install django-filter
pip install django-filter
python
# myapi/settings.py

INSTALLED_APPS += ['django_filters']

REST_FRAMEWORK = {
    # ... existing config ...
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}

# products/views.py - Add filtering

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    
    # Filtering
    filterset_fields = ['category', 'active']  # Exact match
    search_fields = ['name', 'description']    # Search
    ordering_fields = ['price', 'created_at', 'stock']  # Ordering
    ordering = ['-created_at']  # Default ordering

Now you can:

bash
# Filter by exact match
curl http://localhost:8000/api/products/?category=1
curl http://localhost:8000/api/products/?active=true

# Search in name/description
curl http://localhost:8000/api/products/?search=laptop

# Order by price
curl http://localhost:8000/api/products/?ordering=price
curl http://localhost:8000/api/products/?ordering=-price  # Descending

# Combine!
curl "http://localhost:8000/api/products/?category=1&search=gaming&ordering=-price"

# Powerful filtering! 🎯

Statistics Endpoint

python
# products/views.py

from django.db.models import Count, Avg, Sum, Min, Max

class ProductViewSet(viewsets.ModelViewSet):
    # ... existing code ...
    
    @action(detail=False, methods=['get'])
    def statistics(self, request):
        """
        GET /api/products/statistics/
        
        Returns various statistics about products
        """
        queryset = self.get_queryset()
        
        stats = {
            'total_products': queryset.count(),
            'active_products': queryset.filter(active=True).count(),
            'total_stock': queryset.aggregate(Sum('stock'))['stock__sum'] or 0,
            'avg_price': float(queryset.aggregate(Avg('price'))['price__avg'] or 0),
            'min_price': float(queryset.aggregate(Min('price'))['price__min'] or 0),
            'max_price': float(queryset.aggregate(Max('price'))['price__max'] or 0),
            'by_category': list(
                queryset.values('category__name')
                .annotate(count=Count('id'))
                .order_by('-count')
            )
        }
        
        return Response(stats)

# Test:
# curl http://localhost:8000/api/products/statistics/

Throttling (Rate Limiting)

python
# myapi/settings.py

REST_FRAMEWORK = {
    # ... existing config ...
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle'
    ],
    'DEFAULT_THROTTLE_RATES': {
        'anon': '100/day',    # Anonymous users
        'user': '1000/day',   # Authenticated users
    }
}

# Now API is rate-limited! ⚡
# Anonymous: 100 requests/day
# Authenticated: 1000 requests/day

Versioning

python
# myapi/settings.py

REST_FRAMEWORK = {
    # ... existing config ...
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
}

# myapi/urls.py

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('products.urls')),  # Version 1
    path('api-auth/', include('rest_framework.urls')),
]

# Now API is versioned!
# http://localhost:8000/api/v1/products/

Testing

python
# products/tests.py

from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth.models import User
from .models import Category, Product

class ProductAPITestCase(APITestCase):
    def setUp(self):
        """Setup test data"""
        self.user = User.objects.create_user(
            username='testuser',
            password='testpass123'
        )
        
        self.category = Category.objects.create(
            name='Test Category',
            slug='test-category'
        )
        
        self.product = Product.objects.create(
            name='Test Product',
            slug='test-product',
            price=99.99,
            stock=10,
            category=self.category
        )
    
    def test_list_products(self):
        """Test listing products"""
        response = self.client.get('/api/products/')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['count'], 1)
    
    def test_get_product(self):
        """Test getting single product"""
        response = self.client.get(f'/api/products/{self.product.id}/')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['name'], 'Test Product')
    
    def test_create_product_authenticated(self):
        """Test creating product (authenticated)"""
        self.client.force_authenticate(user=self.user)
        
        data = {
            'name': 'New Product',
            'slug': 'new-product',
            'price': '49.99',
            'stock': 5,
            'category_id': self.category.id
        }
        
        response = self.client.post('/api/products/', data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Product.objects.count(), 2)
    
    def test_create_product_unauthenticated(self):
        """Test creating product (unauthenticated) - should fail"""
        data = {
            'name': 'New Product',
            'slug': 'new-product',
            'price': '49.99',
            'stock': 5,
            'category_id': self.category.id
        }
        
        response = self.client.post('/api/products/', data)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
    
    def test_update_product(self):
        """Test updating product"""
        self.client.force_authenticate(user=self.user)
        
        data = {'price': '79.99'}
        response = self.client.patch(
            f'/api/products/{self.product.id}/',
            data
        )
        
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.product.refresh_from_db()
        self.assertEqual(float(self.product.price), 79.99)
    
    def test_filter_by_category(self):
        """Test filtering by category"""
        response = self.client.get(
            f'/api/products/?category={self.category.id}'
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['count'], 1)
    
    def test_search_products(self):
        """Test searching products"""
        response = self.client.get('/api/products/?search=Test')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['count'], 1)

# Run tests:
# python manage.py test products

Production Deployment

python
# myapi/settings.py - Production settings

import os

# Security
DEBUG = False
SECRET_KEY = os.environ.get('SECRET_KEY')
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

# HTTPS
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Database (example with PostgreSQL)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

# CORS (if needed for frontend)
# pip install django-cors-headers
INSTALLED_APPS += ['corsheaders']
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware'] + MIDDLEWARE
CORS_ALLOWED_ORIGINS = [
    'https://yourdomain.com',
    'https://www.yourdomain.com',
]
bash
# Install production requirements
pip install gunicorn psycopg2-binary django-cors-headers

# Collect static files
python manage.py collectstatic

# Run with gunicorn
gunicorn myapi.wsgi:application --bind 0.0.0.0:8000

# Production ready! 🚀

Záver

DRF features v 20 minútach:

  • ✅ Browsable API (test in browser!)
  • ✅ Auto validation
  • ✅ Authentication ready
  • ✅ Permissions system
  • ✅ Filtering & search
  • ✅ Pagination
  • ✅ Throttling
  • ✅ Versioning
  • ✅ Testing tools

Setup za 20 minút. Production ready. Industry standard.

DRF používajú: Instagram, Mozilla, Red Hat, Eventbrite...


Tutorial napísal developer ktorý používa DRF v produkcii. Sometimes paying 20 minút setup saves weeks of manual work. DRF je proof. 🔥

Všetky články