Django Rest Framework OpenAPI 3 support

OpenAPI 3 support in Django Rest Framework is still a work in progress. Things are moving quickly so there's not a lot of up to date info about this topic. It's not clear which features of OpenAPI 3 spec are supported in DRF and researching this info on your own means wasting a lot of time.

In this article I'll go over the sections of the OpenAPI spec and talk about its support in DRF. If you don't know how to generate an OpenAPI 3 spec in DRF you can read about it here.

Since things change quickly I'll try to keep this post up to date (DRF version tested in this post: 3.10.2).

Overview

Here's a bird's-eye view of an OpenAPI spec:

  • info (general API info like title, license, etc)
  • servers (basically a base url for your API service)
  • paths (this is your actual application)
    • path (url to a DRF view)
      • operation (get, post etc)
        • parameters (url/query, headers and cookies parameters)
        • request
          • media types (e.g application/json)
            • body schema
        • response
          • status code (e.g. 200 or 500)
            • media types
              • body schema
  • components (chunks of schema that can be reused in this spec)

The ideal scenario is where DRF generates an OpenAPI schema by inferring as much info from the application code as possible. It is not difficult to populate the info and servers parts, so we are not really interested in them. Ideally, DRF should generate the paths section of the spec, as this is where the actual application is described. components section allows to keep schema readable and short by defining and reusing certain spec parts, however DRF doesn't use it at all. If DRF doesn't generate something automatically, it is still possible to customize the process by overriding a SchemaGenerator or AutoSchema (view inspector).

Paths and Operations

In DRF a path is an endpoint URL and an operation is an actual view (for CBVs it is a method that handles an HTTP verb, e.g. GET). Paths and operations spec is generated automatically in DRF. It can infer supported HTTP verbs for both FBVs and CBVs.

# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework.views import APIView


class CBView(APIView):
    def get(self, request, *args, **kwargs):
        return Response()
    def post(self, request, *args, **kwargs):
        return Response()

@api_view(['GET', 'POST'])
def fb_view(request):
    return Response()

# urls.py
urlpatterns = [
    path('api/cb_view/', views.CBView.as_view()),
    path('api/fb_view/', views.fb_view),
]

The above code would result in the following OpenAPI spec (I only included a paths section since this is the one we're mostly interested in):

paths:
  /api/cb_view/:
    get:
      operationId: ListCBs
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}
    post:
      operationId: CreateCB
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}
  /api/fb_view/:
    get:
      operationId: Listfb_views
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}
    post:
      operationId: Createfb_view
      parameters: []
      responses:
        '200':
          content:
            application/json:
              schema: {}

Besides weird operationId values, all urls and views are handled correctly. I also tested ViewSets and Generic Views and they all produced correct paths and operations spec. Finally, DRF metadata spec generation is not supported out of the box, however this is not an important feature.

Parameters

There are 4 kinds of parameters in OA3 spec: path, query, header and cookie. In DRF parameters are automatically inferred from urls, builtin filters and pagination backends.

URL parameters

# views.py
from rest_framework.response import Response
from rest_framework.views import APIView

class Record(APIView):
    def get(self, request, *args, **kwargs):
        return Response()


# urls.py
urlpatterns = [
    path('api/records/<int:pk>/<uuid:uuid>', views.Record.as_view()),
]
paths:
  /api/records/{id}/{uuid}:
    get:
      operationId: RetrieveRecord
      parameters:
      - name: uuid
        in: path
        required: true
        description: ''
        schema:
          type: string
      - name: id
        in: path
        required: true
        description: ''
        schema:
          type: string # should have been an integer
      responses:
        '200':
          content:
            application/json:
              schema: {}

As you can see, DRF infers multiple parameters in URLs, however it doesn't support automatic path converters to schema type mapping, so all path parameters are treated as a string. So all the essential functionality is there, which is great! Unfortunately, you can't customize various parameter attributes (e.g. required, description, etc) without overriding the whole view inspector class.

Builtin filters, pagination and custom parameters

Builtin DRF filters and pagination backends come with their own parameters. Fortunately, DRF includes them in a spec automatically. If your endpoint accepts custom parameters and you'd like to include them in a spec, you should define them via custom filters by overriding get_schema_operation_parameters:

from rest_framework.views import APIView
from rest_framework.filters import BaseFilterBackend, OrderingFilter

class CustomFilter(BaseFilterBackend):
    def filter_queryset(self, request, queryset, view):
        return queryset

    def get_schema_operation_parameters(self, view):
        return [
            {
                'name': 'q',
                'required': False,
                'in': 'query',
                'schema': {
                    'type': 'string',
                },
            },
            {
                'name': 'X-Param',
                'required': False,
                'in': 'header',
                'schema': {
                    'type': 'string',
                },
            },
        ]

class BookList(APIView):
    pagination_class = PageNumberPagination
    filter_backends = [OrderingFilter, CustomFilter]

    def get(self, request, *args, **kwargs):
        pass
paths:
  /api/books/:
    get:
      operationId: ListBooks
      parameters:
      # builtin parameters
      - name: page
        required: false
        in: query
        description: A page number within the paginated result set.
        schema:
          type: integer
      - name: ordering
        required: false
        in: query
        description: Which field to use when ordering the results.
        schema:
          type: string
      # custom parameters
      - name: q
        required: false
        in: query
        schema:
          type: string
      - name: X-Param
        required: false
        in: header
        schema:
          type: string
      responses:
        '200':
          content:
            application/json:
              schema: {}

Specifying parameters via custom filters each time might be an overkill but it is probably a good code pattern to follow anyway. Here's what DRF doesn't support: inferring parameters from builtin authentication classes, versioning, format suffixes are somewhat broken (don't work with ViewSets, produce duplicate operationId, etc).

Request and Response body

OA3 allows to specify body media types supported by an endpoint. DRF has a concept of parsers and renderers, where the former handles the request body and the latter – the response body. By default, each DRF view is configured to support 3 request parsers: JSONParser, FormParser and MultiPartParser. Those parsers handle the following media types: application/json, application/x-www-form-urlencoded and  multipart/form-data. This means that you can submit the same contents using different request body formats and DRF would handle them (e.g. {"title": "Example"} is the same as title=Example).

When it comes to OA3 spec generation, DRF doesn't infer anything from the view's parsers or renderers to generate appropriate media types, it produces application/json in every scenario. This is a problem and I've already opened a PR which addresses this.

There is no support for multiple response codes: at the moment every response spec is generated with a 200 status code and there is no way to specify other responses without overriding a view inspector. To customize any request or response attributes (e.g. description, required, response headers, etc), you'd need to override a view inspector too.

Schema

An actual body schema is produced by inferring the fields and its attributes from DRF serializer. As I said in the beginning, DRF doesn't utilize the components section, that's why it doesn't put the generated schema in this section. This means the body spec is duplicated for request and response. Also, even though this is mostly a DRF limitation, you can't use distinct schemas (via oneOf, anyOf, etc) for request, response or both.

When it comes to actual mapping of serializer fields to OA3 schema fields, DRF does a pretty good job. The majority of keywords and attributes are supported correctly. Here's a sample model and a serializer where I tried to include as much various field and attribute combinations as I could to demo the functionality:

# models.py

from django.db import models
from django.core.validators import MinLengthValidator


class Author(models.Model):
    first_name = models.CharField(max_length=200)

class Book(models.Model):
    KINDS = [ 
        ('hc', 'Hardcover'),
        ('sc', 'Softcover')
    ]   
    title = models.CharField(max_length=200)
    kind = models.CharField(max_length=2, choices=KINDS)
    description = models.CharField(max_length=300, validators=[MinLengthValidator(10)])
    pages = models.IntegerField(null=True)
    status = models.BooleanField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE)


# serializers.py

from rest_framework import serializers
from .models import Author, Book


class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = '__all__'

class NestedSerializer(serializers.Serializer):
    title = serializers.CharField()

class BookSerializer(serializers.ModelSerializer):
    author = AuthorSerializer()
    nested_test = NestedSerializer()
    char_test = serializers.CharField(default='value', required=False, help_text='Test field')
    email_test = serializers.EmailField()
    ip_test = serializers.IPAddressField('IPv4')
    class Meta:
        model = Book
        fields = '__all__'


# views.py

from rest_framework import generics


class BookDetail(generics.UpdateAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    allowed_methods = ['put']
paths:
  /api/books/{id}/:
    put:
      operationId: UpdateBook
      parameters:
      - name: id
        in: path
        required: true
        description: A unique integer value identifying this book.
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              required:
              - author
              - nested_test
              - email_test
              - ip_test
              - title
              - kind
              - description
              - status
              properties:
                author:
                  required:
                  - first_name
                  properties:
                    id:
                      type: integer
                      readOnly: true
                    first_name:
                      type: string
                      maxLength: 200
                  type: object
                nested_test:
                  required:
                  - title
                  properties:
                    title:
                      type: string
                  type: object
                char_test:
                  type: string
                  default: value
                  description: Test field
                email_test:
                  type: string
                  format: email
                ip_test:
                  type: string
                  format: ipv4
                title:
                  type: string
                  maxLength: 200
                kind:
                  enum:
                  - hc
                  - sc
                description:
                  type: string
                  maxLength: 300
                  minLength: 10
                pages:
                  type: integer
                  nullable: true
                status:
                  type: boolean
      responses:
        '200':
          content:
            application/json:
              schema:
                required:
                - author
                - nested_test
                - email_test
                - ip_test
                - title
                - kind
                - description
                - status
                properties:
                  id:
                    type: integer
                    readOnly: true
                  author:
                    required:
                    - first_name
                    properties:
                      id:
                        type: integer
                        readOnly: true
                      first_name:
                        type: string
                        maxLength: 200
                    type: object
                  nested_test:
                    required:
                    - title
                    properties:
                      title:
                        type: string
                    type: object
                  char_test:
                    type: string
                    default: value
                    description: Test field
                  email_test:
                    type: string
                    format: email
                  ip_test:
                    type: string
                    format: ipv4
                  title:
                    type: string
                    maxLength: 200
                  kind:
                    enum:
                    - hc
                    - sc
                  description:
                    type: string
                    maxLength: 300
                    minLength: 10
                  pages:
                    type: integer
                    nullable: true
                  status:
                    type: boolean

As you can see DRF supports a lot of OA3 schema fields features. There are still some bugs or unsupported parts but it's not a big deal. Here's a rough list of what is not supported (this is what I've been able to find, it's not an exhaustive list):

  • ListField
    • min_length & max_length attributes don't map to minItems & maxItem schema attributes
    • ignores the attributes of a child's field
  • Related fields
    • PrimaryKeyRelatedField results in type: string (should be integer)
    • HyperlinkedRelatedField, HyperlinkedIdentityField could have included format: uri.
  • DictField: ignores the attributes of a child's field
  • FileField: doesn't add a format: binary to a field's schema
  • read_only fields are not included in request body, write_only fields are not included in response body. Not sure about this one, but I think it should include the same set of fields in both request & response with relevant attributes

These are not big issues, so the field mapping support is still pretty good.

Unreleased but solved issues

In other words issues that are already fixed but not yet released. Here's a list of new commits since the latest release, most of them are about OpenAPI schema generation.

There is also a number of OpenAPI related PRs that are either completed but not merged or still in progress:

Summary

As you can see the OpenAPI 3 support in DRF is still far from complete but the majority of the functionality is there. Improved API for spec customization would also be nice (e.g. overriding a method to customize a schema attribute rather than a whole view inspector). This is a work in progress, so hopefully the issues will be fixed within the next couple releases. For now, the best approach to work with a spec would be to generate a static version and then modifying the relevant parts manually.