Django Ninja 1.3.0 - PatchDict
Publicado em 12/11/2024.
Testado com: Django 5.0.1, Django Ninja 1.3 e Python 3.10 ou superior.
Release: https://github.com/vitalik/django-ninja/releases/tag/v1.3.0
Github do projeto: https://github.com/rg3915/alpinejs-django-ninja-crud
A versão 1.3.0 do Django Ninja trouxe uma novidade pequena e muito útil: o PatchDict. Com ele, um endpoint PATCH aceita só os campos que você quer alterar, mesmo que o schema de entrada tenha campos obrigatórios.
Neste tutorial vamos montar a API de despesas do projeto alpinejs-django-ninja-crud (models, schemas e rotas CRUD), adicionar uma relação nova com Person, ver o problema que aparece na edição e resolver com PatchDict. No fim, escrevemos os testes com pytest-django.
PUT x PATCH
Antes do código, a diferença entre os dois verbos HTTP:
- PUT substitui o recurso inteiro pelo dado enviado. Se faltar um campo, ele é considerado ausente.
- PATCH aplica uma alteração parcial: atualiza só os campos enviados e mantém o resto.
O problema clássico é usar o mesmo schema do POST (com campos obrigatórios) no PATCH. Aí o cliente é obrigado a mandar tudo de novo, o que não é uma atualização parcial de verdade.
Pré-requisitos
- Python 3.10 ou superior (o projeto usa Django 5.0, que não roda no Python 3.9).
- Noções de Django (models, migrations) e de API REST.
- Django Ninja 1.3.0 ou superior.
Clonando e rodando o projeto
git clone https://github.com/rg3915/alpinejs-django-ninja-crud.git
cd alpinejs-django-ninja-crud
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# cria o arquivo .env com SECRET_KEY, DEBUG e ALLOWED_HOSTS
python contrib/env_gen.py
python manage.py migrate
python manage.py runserver
O requirements.txt do repositório já está com a versão nova do Django Ninja:
# requirements.txt
Django==5.0.1
django-extensions==3.2.3
django-ninja==1.3.0
python-decouple==3.8
ruff==0.1.9
Se você está atualizando um projeto antigo (no vídeo o projeto estava na 1.1.0), rode:
pip install -U django-ninja
pip freeze | grep ninja
A tela inicial é um CRUD de despesas feito com AlpineJS que consome a API. A documentação interativa da API fica em http://localhost:8000/api/v1/docs.
Os models
A app expense tem dois models: Person (a pessoa responsável pela despesa) e Expense (a despesa). O Person foi adicionado depois que o projeto já tinha dados, por isso a chave estrangeira é opcional (null=True, blank=True): registros antigos ficam sem pessoa e a migração não quebra.
# backend/expense/models.py
from datetime import datetime
from django.db import models
class Person(models.Model):
name = models.CharField('nome', max_length=100, unique=True)
class Meta:
ordering = ('name',)
verbose_name = 'pessoa'
verbose_name_plural = 'pessoas'
def __str__(self):
return f'{self.name}'
class Expense(models.Model):
description = models.CharField('descrição', max_length=120)
value = models.DecimalField('valor', max_digits=7, decimal_places=2)
date_payment = models.DateField('data', null=True, blank=True)
person = models.ForeignKey(
Person,
on_delete=models.SET_NULL,
verbose_name='pessoa',
related_name='expenses',
null=True,
blank=True
)
created = models.DateTimeField(auto_now=True)
class Meta:
ordering = ('-created',)
verbose_name = 'despesa'
verbose_name_plural = 'despesas'
def __str__(self):
return f'{self.description}'
def date_payment_display(self):
if self.date_payment:
date_str = self.date_payment.isoformat()
iso_date = datetime.fromisoformat(date_str)
return iso_date.strftime('%d/%m/%Y')
return ''
Pontos importantes:
on_delete=models.SET_NULL: se a pessoa for apagada, a despesa continua existindo, só que sem pessoa.related_name='expenses': permite fazerperson.expenses.all().date_payment_display()devolve a data no formato brasileiro. Esse método vira um campo extra na resposta da API.
Depois de mexer no model, gere e aplique a migração:
python manage.py makemigrations
python manage.py migrate
Registrando a API
O NinjaAPI é criado num arquivo próprio e recebe o router da app expense:
# backend/api.py
from ninja import NinjaAPI
api = NinjaAPI()
api.add_router('', 'backend.expense.api.router')
E as URLs do projeto expõem a API em /api/v1/:
# backend/urls.py
from django.contrib import admin
from django.urls import include, path
from .api import api
urlpatterns = [
path('', include('backend.core.urls', namespace='core')),
path('expense/', include('backend.expense.urls', namespace='expense')),
path('api/v1/', api.urls),
path('admin/', admin.site.urls),
]
Schemas e rotas CRUD
Este é o arquivo principal do tutorial, já com o PatchDict:
# backend/expense/api.py
from http import HTTPStatus
from django.shortcuts import get_object_or_404
from ninja import PatchDict, ModelSchema, Router
from ninja.orm import create_schema
from .models import Expense
router = Router(tags=['Expenses'])
ExpenseSchema = create_schema(
Expense, custom_fields=(('date_payment_display', str, None),)
)
class ExpenseCreateSchema(ModelSchema):
person_id: int
# https://github.com/vitalik/django-ninja/issues/444#issuecomment-1148543491
class Meta:
model = Expense
fields = (
'description',
'value',
'date_payment',
)
@router.get('expenses', response=list[ExpenseSchema])
def list_expense(request):
return Expense.objects.all()
@router.get('expenses/{pk}', response=ExpenseSchema)
def detail_expense(request, pk: int):
return get_object_or_404(Expense, pk=pk)
@router.post('expenses', response={HTTPStatus.CREATED: ExpenseSchema})
def create_expense(request, payload: ExpenseCreateSchema):
return Expense.objects.create(**payload.dict())
@router.patch('expenses/{pk}', response=ExpenseSchema)
def update_expense(request, pk: int, payload: PatchDict[ExpenseCreateSchema]):
instance = get_object_or_404(Expense, pk=pk)
# data = payload.dict()
for attr, value in payload.items():
setattr(instance, attr, value)
instance.save()
return instance
@router.delete('expenses/{pk}')
def delete_expense(request, pk: int):
instance = get_object_or_404(Expense, pk=pk)
instance.delete()
return {'success': True}
O que cada parte faz:
ExpenseSchema(saída): gerado comcreate_schemaa partir do model. Ocustom_fieldsacrescenta o campodate_payment_display(do tipostr), que o Ninja preenche chamando o método de mesmo nome no model.ExpenseCreateSchema(entrada): umModelSchemacomdescription,valueedate_payment, maisperson_id: int. Declarar a chave estrangeira com o sufixo_idfaz o Ninja aceitar o id da pessoa, e oExpense.objects.create(**payload.dict())grava direto na colunaperson_id. A dica é do próprio criador do Ninja, na issue citada no comentário.list_expenseedetail_expense:GETda lista e de um item. Oget_object_or_404devolve 404 se o id não existir.create_expense:POSTque devolve status 201 (HTTPStatus.CREATED).delete_expense: apaga e devolve{'success': True}, que o front-end usa para remover a linha da tabela.
O problema na edição
Antes da 1.3.0, a rota de edição recebia payload: ExpenseCreateSchema. Como person_id é declarado como int (obrigatório), ao editar uma despesa antiga, que não tem pessoa, a API respondia com erro 422 dizendo que person_id é um campo obrigatório. O mesmo acontece com value, que é obrigatório no model: se o cliente não enviar, a validação falha.
A saída seria criar um segundo schema com todos os campos opcionais, só para o PATCH. É exatamente isso que o PatchDict faz por você.
A solução com PatchDict
Basta envolver o schema de entrada:
from ninja import PatchDict
@router.patch('expenses/{pk}', response=ExpenseSchema)
def update_expense(request, pk: int, payload: PatchDict[ExpenseCreateSchema]):
...
Com PatchDict[ExpenseCreateSchema]:
- todos os campos do schema passam a ser opcionais;
- o
payloadé um dicionário (não mais um objeto Pydantic) contendo somente os campos que o cliente enviou.
Por isso não se usa mais payload.dict(), e sim payload.items(). O laço com setattr copia para a instância apenas o que veio na requisição; o resto fica como estava no banco. Por fim, instance.save() grava e a função devolve a instância, serializada com ExpenseSchema.
Um exemplo de requisição que agora funciona, alterando só a descrição:
curl -X PATCH http://localhost:8000/api/v1/expenses/1 \
-H 'Content-Type: application/json' \
-d '{"description": "Livro de Python"}'
O lado do front-end
O CRUD em AlpineJS já envia PATCH na edição. Ele remove o id do objeto e manda o resto no corpo:
// backend/core/static/js/expense.js (trecho)
updateItem(url) {
const id = this.editItem.id
// Remove o id e associa o restante a bodyData
const { id: _, ...bodyData } = this.editItem
axios.patch(`${url}/${id}`, bodyData, { headers })
.then(response => {
this.updateItemInList(response.data)
this.resetForm()
})
},
Com o PatchDict, editar uma despesa sem pessoa passa a funcionar sem erro de validação.
Testes com pytest-django
Update parcial é o tipo de coisa que precisa de teste. Instale o pytest-django (ele não está no requirements.txt do repositório):
pip install pytest-django
Crie o arquivo de configuração na raiz do projeto:
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = backend.settings
python_files = tests.py test_*.py *_tests.py
addopts = -p no:warnings
DJANGO_SETTINGS_MODULEdiz ao pytest qual settings usar.python_filesdefine quais arquivos contêm testes (incluindo otests.pypadrão das apps).-p no:warningsesconde os avisos para deixar a saída limpa.
Agora os testes:
# backend/expense/tests.py
from http import HTTPStatus
import pytest
from .models import Expense
@pytest.mark.django_db
def test_atualiza_despesa_sem_person(client):
Expense.objects.create(description='Livro', value=90)
payload = dict(
description='Livro de Python',
value=50
)
response = client.patch('/api/v1/expenses/1', payload, content_type='application/json')
assert response.status_code == HTTPStatus.OK
@pytest.mark.django_db
def test_atualiza_despesa_sem_person_e_sem_value(client):
Expense.objects.create(description='Livro', value=90)
payload = dict(
description='Livro de Python'
)
response = client.patch('/api/v1/expenses/1', payload, content_type='application/json')
assert response.status_code == HTTPStatus.OK
Como funciona:
@pytest.mark.django_dblibera o acesso ao banco de testes (cada teste roda numa transação desfeita no final).clienté a fixture do pytest-django com o cliente de testes do Django.- O primeiro teste cria uma despesa sem pessoa e manda um
PATCHsó comdescriptionevalue. - O segundo manda apenas
description, semperson_ide semvalue, que são obrigatórios no schema original. - O
content_type='application/json'é necessário para o cliente serializar o dicionário como JSON.
Rode:
pytest
# ou, com mais detalhes e permitindo parar em breakpoint():
pytest -vv -s
Os dois testes passam. Sem o PatchDict, ambos falhariam com status 422, porque nenhum deles envia person_id.
Duas melhorias que ficam como lição de casa:
- em vez de fixar o id
1na URL, guarde a despesa criada (expense = Expense.objects.create(...)) e usef'/api/v1/expenses/{expense.pk}'; - confira também o JSON da resposta e o registro no banco, por exemplo
expense.refresh_from_db()seguido deassert expense.description == 'Livro de Python'eassert expense.value == 90no segundo teste, provando que o valor não enviado foi mantido.
Cuidados
O PatchDict aceita qualquer subconjunto de campos, então ele não avisa se o cliente esqueceu um campo que deveria ter mandado. Também deixa alterar campos sensíveis com facilidade: trocar a pessoa ligada a uma despesa, por exemplo, é só enviar outro person_id. Use com consciência do que a rota permite editar e, se necessário, restrinja o schema de entrada aos campos que realmente podem mudar.
Resumo
PUTsubstitui o recurso;PATCHaltera só o que foi enviado.- No Django Ninja 1.3.0,
PatchDict[Schema]torna todos os campos opcionais e entrega um dicionário só com o que veio na requisição. - Percorra
payload.items()comsetattre salve a instância. - Garanta o comportamento com testes que mandam payloads parciais.
Documentação do Django Ninja: https://django-ninja.dev/
Capítulos
- 00:00 Intro
- 01:24 Clonando e rodando o projeto
- 04:33 PatchDict
- 06:07 pytest