Free Ebook cover Django Fundamentals: From First App to a Complete Backend

Django Fundamentals: From First App to a Complete Backend

New course

12 pages

From Django Backend to Django REST Framework: Exposing Data Pragmatically

Capítulo 12

Estimated reading time: 9 minutes

+ Exercise

Adding Django REST Framework (DRF) to an Existing Django Project

When you already have Django models powering your backend, Django REST Framework lets you expose that data as JSON endpoints with minimal disruption. The pragmatic goal is: reuse your existing models and business rules, serialize them safely, and publish endpoints that are authenticated, permissioned, paginated, and filterable.

Install and enable DRF

Add DRF (and optional filtering support) to your environment:

pip install djangorestframework django-filter

Then enable it in settings.py:

INSTALLED_APPS = [    # ...    "rest_framework",    "django_filters",]

Configure DRF defaults so every endpoint is not accidentally public and unpaginated. This is a good baseline for a real backend:

REST_FRAMEWORK = {    "DEFAULT_AUTHENTICATION_CLASSES": [        "rest_framework.authentication.SessionAuthentication",        "rest_framework.authentication.TokenAuthentication",    ],    "DEFAULT_PERMISSION_CLASSES": [        "rest_framework.permissions.IsAuthenticated",    ],    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",    "PAGE_SIZE": 20,    "DEFAULT_FILTER_BACKENDS": [        "django_filters.rest_framework.DjangoFilterBackend",        "rest_framework.filters.SearchFilter",        "rest_framework.filters.OrderingFilter",    ],}

Why these defaults? IsAuthenticated prevents data leaks by default; pagination avoids returning thousands of rows; filter backends enable practical querying without custom code for every list endpoint.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Token authentication (practical option for APIs)

If you want token auth (common for mobile/SPA clients), add the token app and migrate:

INSTALLED_APPS = [    # ...    "rest_framework.authtoken",]
python manage.py migrate

To obtain tokens, you can use DRF’s built-in endpoint:

# project/urls.py (or api/urls.py)from rest_framework.authtoken.views import obtain_auth_tokenurlpatterns = [    # ...    path("api/auth/token/", obtain_auth_token),]

Clients then send Authorization: Token <token>. (Session auth can remain for internal/admin usage and browsable API convenience.)

Serializers: Turning Existing Models into Safe JSON

A serializer defines what fields are exposed, how relations are represented, and what validation happens on writes. Start with ModelSerializer to map directly from your existing models.

Example models (assumed to already exist)

Assume you already have something like Customer, Order, and OrderItem models in an app called sales. You will not change the models; you will only add API code around them.

Create serializers

Create sales/api/serializers.py:

from rest_framework import serializersfrom sales.models import Customer, Order, OrderItemclass CustomerSerializer(serializers.ModelSerializer):    class Meta:        model = Customer        fields = ["id", "email", "full_name", "is_active", "created_at"]        read_only_fields = ["id", "created_at"]class OrderItemSerializer(serializers.ModelSerializer):    class Meta:        model = OrderItem        fields = ["id", "product_name", "unit_price", "quantity"]        read_only_fields = ["id"]class OrderSerializer(serializers.ModelSerializer):    customer = CustomerSerializer(read_only=True)    customer_id = serializers.PrimaryKeyRelatedField(        source="customer", queryset=Customer.objects.all(), write_only=True    )    items = OrderItemSerializer(many=True, read_only=True)    class Meta:        model = Order        fields = [            "id", "status", "total", "created_at",            "customer", "customer_id", "items",        ]        read_only_fields = ["id", "created_at", "total"]

Practical patterns used here:

  • customer is nested and read-only for convenient responses.
  • customer_id is write-only so clients can create/update orders without sending nested customer objects.
  • items is read-only to keep the first iteration simple; you can add item creation later via a dedicated endpoint.
  • total is read-only if it is computed server-side.

Expose only what you mean to expose

Do not use fields = "__all__" for production APIs. It can leak internal fields (flags, notes, cost price, audit fields) and makes future schema changes risky.

API Views: Start with ViewSets for CRUD, Add Custom Actions Only When Needed

For typical CRUD endpoints, ModelViewSet is the pragmatic default: list, retrieve, create, update, delete in one class. You can still lock down methods and add filtering/pagination.

Permissions that match real backend needs

Most backends need different access rules for different resources. Examples:

  • Customers: staff-only or limited to internal roles.
  • Orders: authenticated users can see their own orders; staff can see all.

Create sales/api/permissions.py:

from rest_framework.permissions import BasePermission, SAFE_METHODSclass IsStaffOrReadOnly(BasePermission):    def has_permission(self, request, view):        if request.method in SAFE_METHODS:            return request.user and request.user.is_authenticated        return request.user and request.user.is_staffclass IsOwnerOrStaff(BasePermission):    def has_object_permission(self, request, view, obj):        if request.user and request.user.is_staff:            return True        # assumes Order has a customer with a user relation or email mapping        # adapt to your domain model        return hasattr(obj, "user") and obj.user_id == request.user.id

If your Order is linked to a Customer which is linked to a User, adjust the ownership check accordingly (for example obj.customer.user_id).

Create viewsets with filtering, searching, ordering

Create sales/api/views.py:

from django_filters.rest_framework import FilterSet, filtersfrom rest_framework import viewsetsfrom rest_framework.permissions import IsAuthenticatedfrom sales.models import Customer, Orderfrom .serializers import CustomerSerializer, OrderSerializerfrom .permissions import IsStaffOrReadOnly, IsOwnerOrStaffclass OrderFilter(FilterSet):    status = filters.CharFilter(field_name="status", lookup_expr="exact")    created_from = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")    created_to = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte")    class Meta:        model = Order        fields = ["status", "created_from", "created_to"]class CustomerViewSet(viewsets.ModelViewSet):    queryset = Customer.objects.all().order_by("-created_at")    serializer_class = CustomerSerializer    permission_classes = [IsStaffOrReadOnly]    search_fields = ["email", "full_name"]    ordering_fields = ["created_at", "email"]class OrderViewSet(viewsets.ModelViewSet):    serializer_class = OrderSerializer    permission_classes = [IsAuthenticated, IsOwnerOrStaff]    filterset_class = OrderFilter    search_fields = ["id"]    ordering_fields = ["created_at", "total"]    def get_queryset(self):        qs = Order.objects.select_related("customer").prefetch_related("items").order_by("-created_at")        user = self.request.user        if user.is_staff:            return qs        # adapt to your domain model; example if Order has a direct user FK:        return qs.filter(user=user)

Notes:

  • select_related/prefetch_related keeps list endpoints fast when you include nested data.
  • get_queryset() is the most common place to enforce “users can only see their own data”. Do not rely only on serializer logic for access control.
  • filterset_class gives you stable, explicit filters rather than ad-hoc query params.

Wire Up URLs with a Router

Routers generate consistent REST-style URLs for viewsets. Create sales/api/urls.py:

from rest_framework.routers import DefaultRouterfrom .views import CustomerViewSet, OrderViewSetrouter = DefaultRouter()router.register(r"customers", CustomerViewSet, basename="customer")router.register(r"orders", OrderViewSet, basename="order")urlpatterns = router.urls

Include these URLs in your project’s main URL configuration:

from django.urls import include, pathurlpatterns = [    # ...    path("api/", include("sales.api.urls")),]

This yields endpoints like:

  • GET /api/orders/ (list)
  • POST /api/orders/ (create)
  • GET /api/orders/{id}/ (retrieve)
  • PATCH /api/orders/{id}/ (partial update)

Pagination: Practical Defaults and Client Usage

With PageNumberPagination enabled globally, list endpoints return a structure like:

{  "count": 125,  "next": "https://example.com/api/orders/?page=2",  "previous": null,  "results": [    { "id": 1, "status": "paid", ... }  ]}

Clients request pages with ?page=2. If you need different page sizes per endpoint, you can create a custom pagination class and set pagination_class on a viewset.

Filtering, Searching, Ordering: What to Support and How to Use It

Filtering and ordering are where APIs become genuinely useful without custom endpoints for every query.

Filtering (explicit fields)

With the OrderFilter above, clients can do:

  • GET /api/orders/?status=paid
  • GET /api/orders/?created_from=2026-01-01T00:00:00Z&created_to=2026-01-31T23:59:59Z

Search (text-like queries)

With search_fields set, clients can do:

  • GET /api/customers/?search=alice

Keep search fields limited and indexed where possible; search can be expensive on large tables.

Ordering

With ordering_fields set, clients can do:

  • GET /api/orders/?ordering=-created_at
  • GET /api/orders/?ordering=total

Authentication and Permissions: A Practical Setup

Choose an authentication strategy per client type

ClientPragmatic choiceWhy
Internal admin users in browserSessionAuthenticationUses existing login session; easy for staff tools
Mobile app / SPATokenAuthentication (or JWT)Simple header-based auth; decoupled from cookies
Server-to-serverTokenAuthentication + scoped permissionsEasy rotation; can restrict endpoints

If you later need JWT, you can swap token auth for a JWT package and keep your viewsets/permissions mostly unchanged.

Lock down write operations

Even if reads are allowed for authenticated users, writes often need stricter rules. Two common approaches:

  • Per-view permissions: set permission_classes on each viewset.
  • Per-action permissions: override get_permissions() to require staff for destructive actions.

Example per-action permissions:

from rest_framework.permissions import IsAuthenticated, IsAdminUserclass CustomerViewSet(viewsets.ModelViewSet):    # ...    def get_permissions(self):        if self.action in ["create", "update", "partial_update", "destroy"]:            return [IsAdminUser()]        return [IsAuthenticated()]

Extending the API Without Rewriting It

Versioning approach (pick one and apply consistently)

Versioning is easiest when you decide early how URLs and clients will evolve. A pragmatic default is URL path versioning:

  • /api/v1/orders/
  • /api/v2/orders/

To implement, you can namespace your API URLs:

# project/urls.pyurlpatterns = [    # ...    path("api/v1/", include("sales.api.urls")),]

When you need a breaking change, create a new module (for example sales/api_v2/) and include it under /api/v2/. This keeps old clients working while you migrate.

Documentation options (practical choices)

Once endpoints exist, documentation becomes a product feature. Common options:

  • OpenAPI/Swagger generation: add an OpenAPI schema generator and serve Swagger UI/Redoc for interactive docs.
  • Human-written docs for critical flows: short “how to authenticate + key endpoints” pages for your frontend/mobile team.

Even with generated docs, keep a small set of examples for the most important endpoints (auth, list orders with filters, create order).

Where to add tests to protect critical endpoints

API tests should focus on access control, serialization shape, and key workflows. A practical structure:

  • sales/api/tests/test_orders_api.py
  • sales/api/tests/test_customers_api.py

Use DRF’s test tools to validate authentication, permissions, and pagination:

from django.urls import reversefrom rest_framework.test import APITestCasefrom rest_framework import statusclass OrderApiTests(APITestCase):    def test_list_requires_auth(self):        url = reverse("order-list")  # from router basename        resp = self.client.get(url)        self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)    def test_user_sees_only_own_orders(self):        # create two users + orders, authenticate as one, assert only own returned        pass    def test_pagination_shape(self):        # create many orders, authenticate, assert resp.data has count/results keys        pass

Prioritize tests for:

  • Unauthorized access returns 401/403 correctly.
  • Non-staff users cannot access other users’ objects (object-level permission).
  • List endpoints are paginated and filterable as expected.
  • Write endpoints reject invalid payloads and do not allow writing read-only fields.

Now answer the exercise about the content:

In a DRF serializer pattern for an Order that belongs to a Customer, what is the main purpose of exposing both a nested read-only customer field and a write-only customer_id field?

You are right! Congratulations, now go to the next page

You missed! Try again.

A nested read-only customer is useful for responses, while a write-only customer_id lets clients create/update an order by sending just the related customer primary key. This avoids requiring nested customer objects in write requests.

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.