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 a405 Method Not Allowed
. - If there is a mismatch in
Security
we send a401 Unauthorized
- If there is a mismatch in
Content-Type
we return a415 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.