In this post we go over the development of a Django middleware which validates the incoming requests and returning responses against an OpenAPI specification. The spec document succinctly defines an OpenAPI specification as follows.

The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection. When properly defined, a consumer can understand and interact with the remote service with a minimal amount of implementation logic. - swagger.io

Moreover there are variety of tools available which can read the spec and generate a user-friendly documentation and even API SDKs for majority of known frameworks and languages.

Motivation

The advantages of having an OpenAPI spec for your API are clearly evident. It empowers your teams to define and communicate the behavior clearly. With the use of mock-servers it is easier to get the API-consumer feedback early. There are several articles (like this) which talk about benefits of using an OpenAPI-driven approach.

It also presents a challenge for the API maintainer. Generally the Spec is defined “out-of-code”, i.e. being language agnostic the specification is not tightly coupled with the API code that is being written. And hence there is a threat that the spec being provided to the consumer may not match the code being implemented.

It is important to address this issue as this discrepancy may have adverse impact on usability of the spec as well as confidence of consumers that the documentation given will actually work against a live API.

One way to address this problem is to validate the request and response against the specification. If the validation fails return a well-defined error response to the client. We can also drop the requests not defined in the spec (with a 404). This prevents “unknown” capabilities in the system and also force engineers to think through the “design” first rather than jumping into code.

Implementation

Let’s get our hands dirty. We will be writing a Django middleware which will intercept the requests and validate it against an OpenAPI spec. It will also validate the response generated by the application. We will be using openapi-core pacakge to parse the spec and run the validations. The package can be installed using pip. You will also need pyyaml to read the YAML specification.

pip install openapi-core

Lets first setup some conventions for the middleware behaviour.

  • If there is any validation error we return the error immediately. i.e. we do not call our API code in case of error.
  • If API path is not defined in the spec we return a 404 Not Found.
  • If API method (GET/POST) is not defined we return a 405 Method Not Allowed.
  • If there is a mismatch in Security we send a 401 Unauthorized
  • If there is a mismatch in Content-Type we return a 415 Unsupported Media Type.
  • In case of any other request validation error we return 422 Unprocessable Entity.
  • When response validation fails we return a custom 522 Response Validation Failed.

The scaffold of our middleware looks like following:

# imports

# load the OpenAPI specification in file scope
# TODO: load the OpenAPI spec from yaml file

class OpenAPIMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        # validate request and return on error
        # TODO: request validation code

        # pass on request to next handler if validation
        # succeeds
        response = self.get_response(request)

        # validate response and return 522 on error
        # TODO: response validation code

        # return application response if validation
        # succeeds
        return response

We will start filling in the TODO sections in this code now. Assuming that the spec file resides in <project_root>/openapi-spec/api.yaml, we first read the file and load the spec. We also assume that the middleware is implemented in file <project_root>/app/middlewares/openapi_middleware.py.

from openapi_core import create_spec
from pathlib import Path
import yaml


# load the OpenAPI specification in file scope
_oas3_file = Path(__file__).resolve().parent.parent.parent / "openapi-spec" / "api.yaml"
_oas3_spec = {}
with _oas3_file.open('rb') as f:
    _oas3_spec = yaml.load(f, Loader=yaml.SafeLoader)
    _oas3_spec = create_spec(_oas3_spec)

Next we add the request validation code. We leverage the built-in Django integration in openapi-core. We introduce a new method _get_req_validation_response which we will implement later. This method will return an appropriate error response depending on the errors raised.

from django.http import HttpRequest
from openapi_core.contrib.django import DjangoOpenAPIRequest
from openapi_core.validation.request.validators import RequestValidator

# ... other code ...

class OpenAPIMiddleware:
    # ... other code ...

    def __call__(self, request: HttpRequest):
        # validate request and return on error
        openapi_request = DjangoOpenAPIRequest(request)
        validator = RequestValidator(_oas3_spec)
        result = validator.validate(openapi_request)
        if result.errors:
            return self._get_req_validation_response(result.errors)

        # ... other code ...

    @staticmethod
    def _get_req_validation_response(errors):
        # TODO: generate error responses depending on `errors`
        pass

The response validation is also simple. We initialize the Django response and validators and let openapi-core do its job. First we put a condition to skip validation of 5XX responses generated downstream. If we encounter errors in validation we return a 522 Response Validation Failed response.

from django.http import HttpResponse
from openapi_core.validation.response.validators import ResponseValidator
from openapi_core.contrib.django import DjangoOpenAPIResponse

# ... other code ...

class OpenAPIMiddleware:
    # ... other code ...

    def __call__(self, request: HttpRequest):
        # ... other code ...

        # validate response and return 522 on error
        if response.status_code >= 500:
            # Do not validate 5XX responses
            return response

        openapi_response = DjangoOpenAPIResponse(response)
        validator = ResponseValidator(_oas3_spec)
        result = validator.validate(openapi_request, openapi_response)
        if result.errors:
            # return 522 error
            return HttpResponse(status=522, reason="Response Validation Failed")

        # ... other code ...

Finally we finish the _get_req_validation_response method.

from openapi_core.schema.media_types.exceptions import InvalidContentType
from openapi_core.templating.paths.exceptions import (
    PathNotFound,
    OperationNotFound,
    ServerNotFound
)
from openapi_core.validation.exceptions import InvalidSecurity


# ... other code ...

class OpenAPIMiddleware:
    # ... other code ...

    @staticmethod
    def _get_req_validation_response(errors):
        for error in errors:
            if isinstance(error, PathNotFound):
                return HttpResponse(status=404)
            if isinstance(error, ServerNotFound):
                return HttpResponse(status=404)
        for error in errors:
            if isinstance(error, OperationNotFound):
                return HttpResponse(status=405)
        for error in errors:
            if isinstance(error, InvalidSecurity):
                return HttpResponse(status=401)
        for error in errors:
            if isinstance(error, InvalidContentType):
                return HttpResponse(status=415)
        return HttpResponse(status=422)

Summing it up, the final middleware file looks like following.

from django.http import HttpRequest, HttpResponse
from openapi_core import create_spec
from openapi_core.contrib.django import DjangoOpenAPIRequest, DjangoOpenAPIResponse
from openapi_core.validation.response.validators import ResponseValidator
from openapi_core.validation.request.validators import RequestValidator
from openapi_core.schema.media_types.exceptions import InvalidContentType
from openapi_core.templating.paths.exceptions import (
    PathNotFound,
    OperationNotFound,
    ServerNotFound
)
from openapi_core.validation.exceptions import InvalidSecurity
from pathlib import Path
import yaml


# load the OpenAPI specification in file scope
_oas3_file = Path(__file__).resolve().parent.parent.parent / "openapi-spec" / "api.yaml"
_oas3_spec = {}
with _oas3_file.open('rb') as f:
    _oas3_spec = yaml.load(f, Loader=yaml.SafeLoader)
    _oas3_spec = create_spec(_oas3_spec)


class OpenAPIMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        # validate request and return on error
        openapi_request = DjangoOpenAPIRequest(request)
        validator = RequestValidator(_oas3_spec)
        result = validator.validate(openapi_request)
        if result.errors:
            return self._get_req_validation_response(result.errors

        # pass on request to next handler if validation
        # succeeds
        response = self.get_response(request)

        # validate response and return 522 on error
        if response.status_code >= 500:
            # Do not validate 5XX responses
            return response

        openapi_response = DjangoOpenAPIResponse(response)
        validator = ResponseValidator(_oas3_spec)
        result = validator.validate(openapi_request, openapi_response)
        if result.errors:
            # return 522 error
            return HttpResponse(status=522, reason="Response Validation Failed")

        # return application response if validation
        # succeeds
        return response

    @staticmethod
    def _get_req_validation_response(errors):
        for error in errors:
            if isinstance(error, PathNotFound):
                return HttpResponse(status=404)
            if isinstance(error, ServerNotFound):
                return HttpResponse(status=404)
        for error in errors:
            if isinstance(error, OperationNotFound):
                return HttpResponse(status=405)
        for error in errors:
            if isinstance(error, InvalidSecurity):
                return HttpResponse(status=401)
        for error in errors:
            if isinstance(error, InvalidContentType):
                return HttpResponse(status=415)
        return HttpResponse(status=422)

Once finished you can add this middleware to your settings.py and rest assured that your implementation is in line with your OpenAPI spec.