Django REST Framework skill for Codex
DRF endpoints scoped, paginated and N+1 free. Codex writes DRF APIs that default to IsAuthenticated, scope every queryset to the caller so other users' objects return 404, and list serializer fields explicitly. Read and write serializers are split, owners are set server-side, list views use select_related, prefetch and annotations to keep query counts flat, and tests assert both permissions and a query ceiling.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- django-rest-framework Installs to ~/.agents/skills/django-rest-framework/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install django-rest-framework The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project django-rest-framework Installs to .agents/skills/django-rest-framework/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/django-rest-framework.zipunzip -o django-rest-framework.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) django-rest-framework PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $django-rest-framework, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Django REST Framework APIs that are secure and fast by default - explicit serializer fields, separate read and write serializers, queryset scoping in get_queryset, object-level permissions, ViewSets with routers and custom actions, cursor pagination, django-filter, throttling, consistent exception handling, drf-spectacular schemas and APIClient tests. Use when adding DRF endpoints, fixing slow serializers or N+1 queries in list views, tightening permissions, or adding pagination and filtering to a Django API.
Use it when
- Adding or reviewing DRF serializers, viewsets or permissions
- A DRF list endpoint is slow or its query count grows with page size
- Users can reach objects they should not via the API
- An API needs pagination, filters, throttling or an OpenAPI schema
Use something else when
- You are building server-rendered Django views, models or admin: use django-apps
- You are designing URLs, status codes and error formats independent of DRF: use rest-api-design
How to check the result
APIClient tests show another user's object returns 404, invalid payloads return 400, and list views stay under a fixed query count; spectacular --validate passes.
The mistake it prevents
Relying on has_object_permission to protect list endpoints; it never runs for lists, so every user's rows leak unless get_queryset filters them.
Done well means: an endpoint returns only rows the caller may see (enforced in the queryset, not only in permissions), serializers list fields explicitly, list views run a fixed number of queries regardless of page size, every list is paginated, and the OpenAPI schema matches what the code returns.
Inspect first
- Read
AGENTS.md, then theREST_FRAMEWORKdict in settings. NoteDEFAULT_PERMISSION_CLASSES(DRF’s own default isAllowAny), authentication classes, pagination, throttles, exception handler and schema class. - Find viewsets and serializers:
rg -n "ViewSet|APIView|Serializer\)" apps. - Check installed helpers:
django-filter,drf-spectacular,djangorestframework-simplejwt.
Project defaults
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
# or rest_framework_simplejwt.authentication.JWTAuthentication
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.CursorPagination",
"PAGE_SIZE": 50,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"user": "1000/hour", "anon": "50/hour"},
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"EXCEPTION_HANDLER": "apps.core.api.exception_handler",
}
Secure by default: public endpoints opt out with permission_classes = [AllowAny],
never the other way round.
Serializers
class OrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = OrderItem
fields = ["id", "sku", "qty", "unit_price_cents"]
class OrderReadSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
customer_email = serializers.EmailField(source="customer.email", read_only=True)
class Meta:
model = Order
fields = ["id", "status", "total_cents", "customer_email", "items", "created_at"]
class OrderWriteSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ["shipping_address", "notes"]
def validate_notes(self, value: str) -> str:
if len(value) > 2000:
raise serializers.ValidationError("Too long.")
return value
- Always list
fieldsexplicitly."__all__"andexcludeexpose new model columns the day they are added. - Split read and write serializers when shapes differ; it removes most
read_only/write_onlyjuggling and keeps writable surface small. - Never accept owner, tenant, status or price from the client. Set them in the view
(
serializer.save(customer=request.user)) or withHiddenField(default=CurrentUserDefault()). - Writable nested serializers require custom
create()/update(); DRF refuses nested writes otherwise. Prefer separate endpoints for child resources. SerializerMethodFieldthat queries the DB runs once per row: an N+1 in disguise. Annotate the value in the queryset and read it as a plain field.
ViewSets
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsOrderOwnerOrStaff]
filterset_fields = ["status"]
ordering_fields = ["created_at", "total_cents"]
ordering = ["-created_at"]
http_method_names = ["get", "post", "patch", "head", "options"] # no PUT/DELETE
def get_queryset(self):
qs = (Order.objects
.select_related("customer")
.prefetch_related("items")
.annotate(item_count=Count("items")))
if self.request.user.is_staff:
return qs
return qs.filter(customer=self.request.user)
def get_serializer_class(self):
return OrderWriteSerializer if self.action in {"create", "partial_update"} else OrderReadSerializer
def perform_create(self, serializer):
serializer.save(customer=self.request.user)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
order = self.get_object() # runs object permissions
services.cancel_order(order, by=request.user)
return Response(OrderReadSerializer(order).data)
router = DefaultRouter()
router.register("orders", OrderViewSet, basename="order")
urlpatterns = [path("api/v1/", include(router.urls))]
| Need | Use |
|---|---|
| Standard CRUD on a model | ModelViewSet, trimmed with http_method_names or mixins |
| Read-only resource | ReadOnlyModelViewSet |
| A verb on a resource (cancel, refund) | @action(detail=True, methods=["post"]) |
| Non-model endpoint (report, webhook) | APIView or @api_view |
Permissions
has_object_permissionruns only when the view callsget_object(): retrieve, update, destroy and custom detail actions. It does not filter list views. Scoping inget_querysetis what protects lists, and it also turns other users’ ids into 404.- Custom actions that load objects manually (
Order.objects.get(pk=pk)) skip both. Useself.get_object().
class IsOrderOwnerOrStaff(BasePermission):
def has_object_permission(self, request, view, obj):
return request.user.is_staff or obj.customer_id == request.user.id
Pagination and filtering
| Pagination | Choose when |
|---|---|
CursorPagination | Feeds and large or fast-changing tables; requires a stable, unique-ish ordering |
PageNumberPagination | Admin-style UIs needing page numbers and totals on modest tables |
LimitOffsetPagination | Clients that need arbitrary offsets; slow at deep offsets |
- Set
ordering_fieldsexplicitly; leaving it unset lets clients order by any serializer field, including unindexed ones. django-filterFilterSetclasses for anything beyond equality on a few fields.PageNumberPaginationrunsCOUNT(*)each request; on big tables use cursors.
Errors
Return one error shape. A custom handler wraps DRF’s default:
from rest_framework.views import exception_handler as drf_handler
def exception_handler(exc, context):
response = drf_handler(exc, context)
if response is not None:
response.data = {"error": {"status": response.status_code, "detail": response.data}}
return response # None -> Django 500; make sure it is logged
Raise ValidationError, PermissionDenied, NotFound from views and services called by
views; map domain exceptions in the handler.
Schema
python manage.py spectacular --file schema.yml --validate generates and validates the
OpenAPI document. Annotate custom actions and APIViews with @extend_schema so the
schema matches reality; check the diff of schema.yml in review.
Testing
from rest_framework.test import APIClient
def test_user_cannot_see_others_orders(db, user, other_user_order):
client = APIClient()
client.force_authenticate(user)
r = client.get(f"/api/v1/orders/{other_user_order.id}/")
assert r.status_code == 404
def test_list_query_count(db, user, django_assert_max_num_queries, orders_with_items):
client = APIClient(); client.force_authenticate(user)
with django_assert_max_num_queries(5):
assert client.get("/api/v1/orders/").status_code == 200
Per endpoint test: anonymous (401/403), another user’s object (404), invalid payload (400 with field errors), happy path, and a query-count ceiling on list views with several rows.
Verification
python manage.py check
pytest -q apps/orders # or manage.py test
python manage.py spectacular --file /tmp/schema.yml --validate
Also hit the list endpoint as a non-staff user and confirm only their rows return.
Gotchas
- Throttling stores counters in the Django cache; with
LocMemCacheand several processes each process counts separately. Use Redis. partial_updatewithrequired=Truefields still validates only sent fields; model constraints are the backstop.- Serializer
UniqueTogetherValidatormakes the involved fields required, surprising clients; set the owner withHiddenFieldso it participates in validation. SessionAuthenticationenforces CSRF for unsafe methods; SPA clients must send the CSRF token. Token or JWT auth does not.serializer.dataaftersave()reflects the instance; if the read shape differs, return the read serializer explicitly.
Output template
## DRF change: <summary>
- Endpoints: <method path -> serializer, permission, status codes>
- Queryset scoping: <filter applied>
- Query count on list: <n>
- Pagination/filters: <class, ordering, filter fields>
- Schema: regenerated and validated: yes/no
- Tests: <cases> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Django REST Framework"
short_description: "DRF endpoints scoped, paginated and N+1 free"
default_prompt: "$django-rest-framework Add a DRF orders endpoint where users only see their own orders" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.