Skip to content

Commit

Permalink
OpenAPI Schema Generation (#223)
Browse files Browse the repository at this point in the history
* add openapi schema generation for filters

* improve schema generation for filters

* add code to generate schema from serializer

* rename some variables

* override schema of pagination

* handle GeometrySerializerMethodField

* fix invalid schema for id field

* add test models

* add test serializer

* add initial test case for schema generation

* add model migration

* rename files

* fix handling of GeometrySerializerMethodField

* handle DRF version >= 3.12

* add DEFAULT_SCHEMA_CLASS

* upgrade djangorestframework to 3.11

* fix _map_serializer

* revert DRF version to 3.10

* fix schema for polygon

* fix enum for polygon

* add test cases for polygon

* remove min items and change example generation logic

* add test cases for multi polygon

* add test case for multi line string model

* add test case for multi point model

* add test cases for pagination schema

* add test cases for filter schemas

* do not handle source attribute

* add missing dependency

* add number as type of array member for bbox

* add test case fox auto bbox

* fix bbox_geo_field handling

* add test case for schema generation of bbox_geo_field

* add support for DRF 3.12

* change DRF version to 3.12

* fix schema generation for 3.12

* remove support for DRF < 3.12

* fix support for distance to point filter

* fix linting issues

* add name to AUTHORS

* fix linting issues

* fix linting issues using black

* [fix] Conflicts between black and flake8

* [fix] Postgresql Setup

* [fix] revert unnecessary changes made by black

* [fix] Wrong call to _map_serializer

* [change] Update documentation

* add packaging dependency

* fix has_geometry_distance

* run schema generation tests only if DRF >= 3.12

* remove unnecessary check for DRF < 3.9

* [fix] Fix tox.ini

* [fix] Fix .travis.yml

* [fix] Fix linting issues

* [fix] revert unnecessary changes.

* [fix] Linting issues

* [fix] Support for Django 2.1

* [fix] travis env

* [fix] Fix tox.ini

* [Merge] Merge remote-tracking branch 'upstream/master'

* [ci] Fix .travis.yml

* [code] Add support for GeometryField and GeometryCollectionField

* [docs] improve docs

Co-authored-by: Federico Capoano <federico.capoano@gmail.com>

* [ci] Remove support for Django 2.1 and support for Python 3.9

* [lint] Fix linting issues

Co-authored-by: Federico Capoano <federico.capoano@gmail.com>
  • Loading branch information
dhaval-mehta and nemesifier committed Jan 10, 2021
1 parent 496d958 commit 4be23ef
Show file tree
Hide file tree
Showing 14 changed files with 1,229 additions and 22 deletions.
28 changes: 17 additions & 11 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,22 @@ addons:

matrix:
include:
- { python: "3.6", env: TOXENV=py36-django21 }
- { python: "3.7", env: TOXENV=py37-django21 }
- { python: "3.6", env: TOXENV=py36-django22 }
- { python: "3.7", env: TOXENV=py37-django22 }
- { python: "3.6", env: TOXENV=py36-django30 }
- { python: "3.7", env: TOXENV=py37-django30 }
- { python: "3.6", env: TOXENV=py36-django31 }
- { python: "3.7", env: TOXENV=py37-django31 }
- { python: "3.8", env: TOXENV=py38-django30 }
- { python: "3.8", env: TOXENV=py38-django31 }
- { python: "3.6", env: TOXENV=py36-django22-djangorestframework310 }
- { python: "3.7", env: TOXENV=py37-django22-djangorestframework310 }
- { python: "3.8", env: TOXENV=py38-django22-djangorestframework310 }
- { python: "3.9", env: TOXENV=py39-django22-djangorestframework310 }
- { python: "3.6", env: TOXENV=py36-django22-djangorestframework312 }
- { python: "3.7", env: TOXENV=py37-django22-djangorestframework312 }
- { python: "3.8", env: TOXENV=py38-django22-djangorestframework312 }
- { python: "3.9", env: TOXENV=py39-django22-djangorestframework312 }
- { python: "3.6", env: TOXENV=py36-django30-djangorestframework312 }
- { python: "3.7", env: TOXENV=py37-django30-djangorestframework312 }
- { python: "3.8", env: TOXENV=py38-django30-djangorestframework312 }
- { python: "3.9", env: TOXENV=py39-django30-djangorestframework312 }
- { python: "3.6", env: TOXENV=py36-django31-djangorestframework312 }
- { python: "3.7", env: TOXENV=py37-django31-djangorestframework312 }
- { python: "3.8", env: TOXENV=py38-django31-djangorestframework312 }
- { python: "3.9", env: TOXENV=py39-django31-djangorestframework312 }

branches:
only:
Expand All @@ -48,5 +54,5 @@ before_script:
script:
# check is done here to allow travisbuddy to include summary in case of failure
- ./run-qa-checks
- tox -e travis
- tox
- python setup.py check -r -s
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ Federico Capoano https://github.com/nemesisdesign/
Shanto https://github.com/Shanto
Eric Theise https://github.com/erictheise
Asif Saifuddin https://github.com/auvipy
Dhaval Mehta https://github.com/dhaval-mehta
20 changes: 20 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,26 @@ will order the results by the distance to the point (-122.4862, 37.7694).
We can also reverse the order of the results by passing ``order=desc``:
``/location/?point=-122.4862,37.7694&order=desc&format=json``

Schema Generation
-----------------

Note: Schema generation support is available only for DRF >= 3.12.

Simplest Approach would be, change ``DEFAULT_SCHEMA_CLASS`` to ``rest_framework_gis.schema.GeoFeatureAutoSchema``:

.. code-block:: python
REST_FRAMEWORK = {
...
'DEFAULT_SCHEMA_CLASS': 'rest_framework_gis.schema.GeoFeatureAutoSchema',
...
}
If you do not want to change default schema generator class:

- You can pass this class as an argument to ``get_schema_view`` function `[Ref] <https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview>`__.
- You can pass this class as an argument to the ``generateschema`` command `[Ref] <https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command>`__.

Running the tests
-----------------

Expand Down
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ django-filter>=2.0
contexttimer
# QA checks
openwisp-utils[qa]~=0.6
packaging~=20.4
78 changes: 77 additions & 1 deletion rest_framework_gis/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,25 @@ def filter_queryset(self, request, queryset, view):
return queryset
return queryset.filter(Q(**{'%s__%s' % (filter_field, geoDjango_filter): bbox}))

def get_schema_operation_parameters(self, view):
return [
{
"name": self.bbox_param,
"required": False,
"in": "query",
"description": "Specify a bounding box as filter: in_bbox=min_lon,min_lat,max_lon,max_lat",
"schema": {
"type": "array",
"items": {"type": "float"},
"minItems": 4,
"maxItems": 4,
"example": [0, 0, 10, 10],
},
"style": "form",
"explode": False,
},
]


# backward compatibility
InBBOXFilter = InBBoxFilter
Expand Down Expand Up @@ -111,7 +130,7 @@ def __new__(cls, *args, **kwargs):


class TMSTileFilter(InBBoxFilter):
tile_param = 'tile' # The URL query paramater which contains the tile address
tile_param = 'tile' # The URL query parameter which contains the tile address

def get_filter_bbox(self, request):
tile_string = request.query_params.get(self.tile_param, None)
Expand All @@ -128,6 +147,17 @@ def get_filter_bbox(self, request):
bbox = Polygon.from_bbox(tile_edges(x, y, z))
return bbox

def get_schema_operation_parameters(self, view):
return [
{
"name": self.tile_param,
"required": False,
"in": "query",
"description": "Specify a bounding box filter defined by a TMS tile address: tile=Z/X/Y",
"schema": {"type": "string", "example": "12/56/34"},
},
]


class DistanceToPointFilter(BaseFilterBackend):
dist_param = 'dist'
Expand Down Expand Up @@ -209,6 +239,34 @@ def filter_queryset(self, request, queryset, view):
Q(**{'%s__%s' % (filter_field, geoDjango_filter): (point, dist)})
)

def get_schema_operation_parameters(self, view):
return [
{
"name": self.dist_param,
"required": False,
"in": "query",
"schema": {"type": "number", "format": "float", "default": 1000},
"description": f"Represents **Distance** in **Distance to point** filter. "
f"Default value is used only if ***{self.point_param}*** is passed.",
},
{
"name": self.point_param,
"required": False,
"in": "query",
"description": "Point represented in **x,y** format. "
"Represents **point** in **Distance to point filter**",
"schema": {
"type": "array",
"items": {"type": "float"},
"minItems": 2,
"maxItems": 2,
"example": [0, 10],
},
"style": "form",
"explode": False,
},
]


class DistanceToPointOrderingFilter(DistanceToPointFilter):
srid = 4326
Expand All @@ -232,3 +290,21 @@ def filter_queryset(self, request, queryset, view):
return queryset.order_by(-GeometryDistance(filter_field, point))
else:
return queryset.order_by(GeometryDistance(filter_field, point))

def get_schema_operation_parameters(self, view):
params = super().get_schema_operation_parameters(view)
params.append(
{
"name": self.order_param,
"required": False,
"in": "query",
"description": "",
"schema": {
"type": "enum",
"items": {"type": "string", "enum": ["asc", "desc"]},
"example": "desc",
},
"style": "form",
"explode": False,
}
)
9 changes: 9 additions & 0 deletions rest_framework_gis/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ def get_paginated_response(self, data):
]
)
)

def get_paginated_response_schema(self, view):
schema = super().get_paginated_response_schema(view)
schema["properties"]["features"] = schema["properties"].pop("results")
schema["properties"] = {
"type": {"type": "string", "enum": ["FeatureCollection"]},
**schema["properties"],
}
return schema
169 changes: 169 additions & 0 deletions rest_framework_gis/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import warnings

from django.contrib.gis.db import models
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.utils import model_meta

from rest_framework_gis.fields import GeometrySerializerMethodField
from rest_framework_gis.serializers import (
GeoFeatureModelListSerializer,
GeoFeatureModelSerializer,
)


class GeoFeatureAutoSchema(AutoSchema):
COORDINATES_SCHEMA_FOR_POINT = {
"type": "array",
"items": {"type": "number", "format": "float"},
"example": [12.9721, 77.5933],
"minItems": 2,
"maxItems": 3,
}

COORDINATES_SCHEMA_FOR_LINE_STRING = {
"type": "array",
"items": COORDINATES_SCHEMA_FOR_POINT,
"example": [[22.4707, 70.0577], [12.9721, 77.5933]],
"minItems": 2,
}

GEO_FIELD_TO_SCHEMA = {
models.PointField: {
"type": {"type": "string", "enum": ["Point"]},
"coordinates": COORDINATES_SCHEMA_FOR_POINT,
},
models.LineStringField: {
"type": {"type": "string", "enum": ["LineString"]},
"coordinates": COORDINATES_SCHEMA_FOR_LINE_STRING,
},
models.PolygonField: {
"type": {"type": "string", "enum": ["Polygon"]},
"coordinates": {
"type": "array",
"items": {**COORDINATES_SCHEMA_FOR_LINE_STRING, "minItems": 4},
"example": [
[0.0, 0.0],
[0.0, 50.0],
[50.0, 50.0],
[50.0, 0.0],
[0.0, 0.0],
],
},
},
}

GEO_FIELD_TO_SCHEMA[models.GeometryField] = {
'type': {'type': 'string'},
'coordinates': {
'oneOf': [ # If you have custom subclass of GeometryField, Override `oneOf` property.
GEO_FIELD_TO_SCHEMA[models.PointField],
GEO_FIELD_TO_SCHEMA[models.LineStringField],
GEO_FIELD_TO_SCHEMA[models.PolygonField],
],
'example': GEO_FIELD_TO_SCHEMA[models.PolygonField]['coordinates'][
'example'
],
},
}

MULTI_FIELD_MAPPING = {
models.PointField: models.MultiPointField,
models.LineStringField: models.MultiLineStringField,
models.PolygonField: models.MultiPolygonField,
models.GeometryField: models.GeometryCollectionField,
}

for singular_field, multi_field in MULTI_FIELD_MAPPING.items():
GEO_FIELD_TO_SCHEMA[multi_field] = {
"type": {"type": "string", "enum": [multi_field.geom_class.__name__]},
"coordinates": {
"type": "array",
"items": GEO_FIELD_TO_SCHEMA[singular_field]["coordinates"],
"example": [
GEO_FIELD_TO_SCHEMA[singular_field]["coordinates"]["example"]
],
},
}

def _map_geo_field(self, serializer, geo_field_name):
field = serializer.fields[geo_field_name]
if isinstance(field, GeometrySerializerMethodField):
warnings.warn(
"Geometry generation for GeometrySerializerMethodField is not supported."
)
return {}

model_field_name = geo_field_name

geo_field = model_meta.get_field_info(serializer.Meta.model).fields[
model_field_name
]
try:
return self.GEO_FIELD_TO_SCHEMA[geo_field.__class__]
except KeyError:
warnings.warn(
"Geometry generation for {field} is not supported.".format(field=field)
)
return {}

def map_field(self, field):
if isinstance(field, GeoFeatureModelListSerializer):
return self._map_geo_feature_model_list_serializer(field)

return super().map_field(field)

def _map_geo_feature_model_list_serializer(self, serializer):
return {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["FeatureCollection"]},
"features": {
"type": "array",
"items": self.map_serializer(serializer.child),
},
},
}

def _map_geo_feature_model_serializer(self, serializer):
schema = super().map_serializer(serializer)

geo_json_schema = {
"type": "object",
"properties": {"type": {"type": "string", "enum": ["Feature"]}},
}

if serializer.Meta.id_field:
geo_json_schema["properties"]["id"] = schema["properties"].pop(
serializer.Meta.id_field
)

geo_field = serializer.Meta.geo_field
geo_json_schema["properties"]["geometry"] = {
"type": "object",
"properties": self._map_geo_field(serializer, geo_field),
}
schema["properties"].pop(geo_field)

if serializer.Meta.auto_bbox or serializer.Meta.bbox_geo_field:
geo_json_schema["properties"]["bbox"] = {
"type": "array",
"items": {"type": "number"},
"minItems": 4,
"maxItems": 4,
"example": [12.9721, 77.5933, 12.9721, 77.5933],
}
if serializer.Meta.bbox_geo_field in schema["properties"]:
schema["properties"].pop(serializer.Meta.bbox_geo_field)

geo_json_schema["properties"]["properties"] = schema

return geo_json_schema

def map_serializer(self, serializer):
if isinstance(serializer, GeoFeatureModelListSerializer):
return self._map_geo_feature_model_list_serializer(serializer)

if isinstance(serializer, GeoFeatureModelSerializer):
return self._map_geo_feature_model_serializer(serializer)

return super().map_serializer(serializer)

0 comments on commit 4be23ef

Please sign in to comment.