Kiedy tworzymy proste aplikacje CRUD w Django Rest Framework, najczęściej wykorzystujemy zaledwie dwa endpointy: jeden do akcji list / create, drugi do wszystkich pozostałych: retrieve / update / destroy.
Implementacja tylko jednego widoku dla wielu akcji teoretycznie oznacza stworzenie tylko jednego serializera, ale czy zawsze mniej kodu znaczy lepiej?
W wielu sytuacjach może okazać się, że API powinno zwracać dane w zupełnie innej postaci w zależności od wywoływanej akcji. Co to oznacza w praktyce?
W tym poście postaram się pokazać Wam różne podejścia i możliwości rozwiązania tego problemu.
🇺🇸 Angielską wersję tego artykułu możecie znaleźć tutaj.
Krótkie wprowadzenie
Załóżmy, że mamy pizzerię (przecież wszyscy kochają pizzę 🍕) i musimy stworzyć aplikację, dzięki której klienci będą mogli składać zamówienia, a kuchnia zacznie realizować je szybko i sprawnie.
Spójrzmy na modele, którymi operujemy:
# models.py
from django.db import models
class Ingredient(models.Model):
name = models.TextField(max_length=20)
class Sauce(models.Model):
name = models.TextField(max_length=20)
class Pizza(models.Model):
name = models.TextField(max_length=20)
price = models.DecimalField(decimal_places=2, max_digits=4)
ingredients = models.ManyToManyField(Ingredient)
sauce = models.ForeignKey(Sauce, null=True, blank=True, on_delete=models.SET_NULL)
Model Pizza
składa się z pól:
name
– nazwa,price
– cena (wyjątkowo nieistotna w tym przypadku 😆),ingredients
– składniki (pole typuManyToManyField
, gdyż pizza składa się z wielu składników,sauce
– sos (tutaj zakładamy, że mamy do wyboru tylko jeden sos, stąd relacjaForeignKey
).
Zaimplementujmy więc prosty widok PizzaAPIView
dziedziczący z generycznego widoku DRF – generics.ListCreateAPIview
, który obsłuży akcje list (lista wszystkich pizz) i create (tworzenie nowej):
# views.py
class PizzaAPIView(generics.ListCreateAPIView):
serializer_class = PizzaSerializer
queryset = Pizza.objects.all()
Dodajmy do tego klasyczny serializer modelu:
# serializers.py
class PizzaSerializer(serializers.ModelSerializer):
class Meta:
model = Pizza
fields = '__all__'
Jeszcze jedna linijka kodu definiująca url:
# urls.py
urlpatterns = [
url(r'^pizza/$', views.PizzaAPIView.as_view()),
]
I szybkie zapoznanie się z zawartością tabel Ingredients
i Sauce
, żeby wiedzieć, jak dobrze zaopatrzona jest nasza kuchnia.


Czas stworzyć najlepszą pizzę na świecie 🤩. Json do zapytania POST powinien wyglądać następująco:
{
"name": "Hawaiian",
"price": 29.99,
"sauce": 1,
"ingredients": [1,2,5]
}
Następnie wyślijmy zapytanie typu GET, żeby pobrać listę dostępnych pizz. Powinniśmy ujrzeć na niej stworzoną przed chwilą Hawajską.

Hmm, niby wszystko działa, ale… gdzie jest mój ananas? Jako klient nie chcę widzieć na liście numerów id składników i sosów, ale ich nazwy 🤔.
Jak więc osiągnć założony efekt bez większego wysiłku i utrzymując czysty kod?
Wiele widoków – wiele serializerów
Pierwszym rozwiązaniem jest rozdzielenie naszego widoku na dwa oddzielne:
# views.py
class PizzaCreateAPIView(generics.CreateAPIView):
serializer_class = PizzaSerializer
class PizzaListAPIView(generics.ListAPIView):
serializer_class = PizzaListSerializer
queryset = Pizza.objects.all()
W ten sposób możemy stworzyć też nowy serializer, który obsłuży widok PizzaListAPIView
:
# serializers.py
class PizzaListSerializer(serializers.ModelSerializer):
sauce = serializers.CharField(source="sauce.name")
class Meta:
model = Pizza
fields = '__all__'
depth = 1
Nadpisałam tutaj pole sauce
dodając mu parametr source
, dzięki czemu będzie ono zwracało nazwę sosu. Dodałam też właściwość depth = 1
, która pozwoli na zwrócenie pełnych obiektu powiązanych z modelem.
Zostało nam jeszcze dodanie nowego endpointu:
# urls.py
urlpatterns = [
url(r'^pizza/create$', views.PizzaCreateAPIView.as_view()),
url(r'^pizza/list$', views.PizzaListAPIView.as_view()),
]
Teraz zapytanie GET zwróci nam następujące dane:

Pizza z ananasem🍍. To lubię!
Niestety powyższe rozwiązanie wymagało stworzenia dodatkowego widoku i endpointu. Może jest jakiś lepszy sposób?
Jeden widok – jeden serializer
A może by tak spróbować połączyć wszystko razem i zmieścić się w jednym widoku i jednym serializerze? Powróćmy do początkowego widoku PizzaAPIView
i zmodyfikujmy nieco wywoływany w nim PizzaSerializer
:
# serializers.py
class PizzaSerializer(serializers.ModelSerializer):
selected_sauce = serializers.CharField(source="sauce.name", read_only=True)
selected_ingredients = IngredientSerializer(many=True, source="ingredients", read_only=True)
class Meta:
model = Pizza
fields = '__all__'
extra_kwargs = {
'sauce': {'write_only': True},
'ingredients': {'write_only': True}}
Tym razem dodałam dwa dodatkowe pola: selected_sauce
oraz selected_ingredients
, które mają zwracać powiązane obiekty w odpowiednim formacie. Przypisałam im też parametr read_only = True
, dzięki czemu będą one dostępne tylko w odpowiedzi API na zapytania.
Z kolei pola sauce
i ingredients
otrzymały parametr write_only = True
. W ten sposób mamy pewność, że będą one wymagane podczas wysylania zapytania POST, jednak nie zostaną wyświetlone w odpowiedzi.
Takie podejście umożliwia nam obsłużenie dwóch akcji wykorzystując tylko jeden widok i jeden serializer. Jednak czy jest to najlepsze rozwiązanie? To już zależy od Ciebie.
W moim odczuciu dodawanie nadmiarowych pól o innych nazwach sprawia, że kod jest nieco zagmatwany. Dodatkowo świadomość, że muszę dokładnie sprawdzić, które pole jest do odczytu, a które do zapisu, przyprawia mnie o lekki ból głowy i mam wrażenie, że ten kod, mimo że nie zajmuje dużo miejsca, nie jest do końca czysty.
Z tego powodu zaczęłam szukać jakiejś alternatywy i udało mi się znaleźć trzecie rozwiązanie.
Jeden widok – wiele serializerów
Wróćmy jeszcze raz do wyjściowego widoku i zmodyfikujmy go. Django Rest Framework zapewnia nam dostęp do wielu ciekawych metod, które możemy łatwo nadpisać i uzyskać w ten sposób to, czego potrzebujemy.
Jedną z takich metod jest get_serializer_class()
. Jest ona wywoływana w momencie, gdy wysyłamy request, a Django szuka odpowiedniego serializera. Domyślnie jest tu zwracana wartość pola serializer_class
zdefiniowanego w widoku, ale jeśli sami nadpiszemy tą metodę, zadzieje się prawdziwa magia…
# views.py
class PizzaListCreateAPIView(generics.ListCreateAPIView):
queryset = Pizza.objects.all()
def get_serializer_class(self):
if self.request.method == 'POST':
return PizzaCreateSerializer
return PizzaListSerializer
Co się tutaj stało? Jedno proste sprawdzenie, czy wysłany request jest typu POST, czy nie. W pierwszym przypadku (czyli podczas tworzenia nowego obiektu), zmuszamy Django do użycia PizzaCreateSerializer
, w pozostałych – PizzaListSerializer
.
Stwórzmy więc oba serializery:
# serializers.py
class PizzaListSerializer(serializers.ModelSerializer):
sauce = serializers.CharField(source="sauce.name")
class Meta:
model = Pizza
fields = '__all__'
depth = 1
class PizzaCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Pizza
fields = '__all__'
I zaktualizujmy endpointy:
# urls.py
urlpatterns = [
url(r'^pizza/$', views.PizzaListCreateAPIView.as_view()),
]
Wow, udało się! Wykorzystaliśmy właśnie dwa serializery w jednym widoku. A możemy pójść jeszcze dalej i użyć ich tyle, ile tylko chcemy. Wszystko zależy od logiki, którą zawrzemy w naszej implementacji get_serializer_class()
.
Teraz kod wygląda dużo czyściej i każdy serializer jest odpowiedzialny tylko za jedną akcję.
Podsumowanie
Opisany przykład pokazuje, że praktycznie w każdej sytuacji mamy wiele rozwiązań i w zależności od kontekstu każde z nich może okazać się dobre. Warto więc znać różne podejścia i używać ich zamiennie w zależności od aktualnych potrzeb.
Nie bójcie się eksperymentować ze swoim kodem – jest to najlepsza metoda do odkrycia czegoś nowego i (miejmy nadzieję) także lepszego 🔥.
P.S. Mam nadzieję, że nie znienawidzicie mnie za tą pizzę z ananasem, ale naprawdę ją uwielbiam❤️.
P.S.2 A jakie rozwiązanie jest Twoim zdaniem najlepsze? A może znasz inne podejście? Podziel się w komentarzu.