├── .github ├── CODEOWNERS.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── css │ └── extra.css └── index.md ├── manage.py ├── mkdocs.yml ├── pytest.ini ├── requirements.txt ├── rest_framework_transforms ├── __init__.py ├── exceptions.py ├── parsers.py ├── serializers.py ├── transforms.py └── utils.py ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── actual_tests.py ├── conftest.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── test_parsers.py ├── test_serializers.py └── test_transforms.py └── tox.ini /.github/CODEOWNERS.md: -------------------------------------------------------------------------------- 1 | * @mrhwick 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: mrhwick 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Versions** 14 | Python: 15 | Django: 16 | Django Rest Framework: 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: help wanted 6 | assignees: mrhwick 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # High-Level Description 2 | 3 | # Changelog: 4 | 5 | - 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | MANIFEST 13 | 14 | bin/ 15 | include/ 16 | lib/ 17 | local/ 18 | 19 | !.gitignore 20 | !.travis.yml 21 | 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | 26 | # C extensions 27 | *.so 28 | 29 | # Distribution / packaging 30 | .Python 31 | env/ 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *,cover 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | .venv 75 | .venv/ 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | .idea/ 83 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | env: 6 | - TOX_ENV=py34-flake8 7 | - TOX_ENV=py34-docs 8 | - TOX_ENV=py27-django1.6-drf3.0 9 | - TOX_ENV=py27-django1.6-drf3.1 10 | - TOX_ENV=py27-django1.6-drf3.2 11 | - TOX_ENV=py27-django1.7-drf3.0 12 | - TOX_ENV=py27-django1.7-drf3.1 13 | - TOX_ENV=py27-django1.7-drf3.2 14 | - TOX_ENV=py27-django1.8-drf3.0 15 | - TOX_ENV=py27-django1.8-drf3.1 16 | - TOX_ENV=py27-django1.8-drf3.2 17 | - TOX_ENV=py34-django1.6-drf3.0 18 | - TOX_ENV=py34-django1.6-drf3.1 19 | - TOX_ENV=py34-django1.6-drf3.2 20 | - TOX_ENV=py34-django1.7-drf3.0 21 | - TOX_ENV=py34-django1.7-drf3.1 22 | - TOX_ENV=py34-django1.7-drf3.2 23 | - TOX_ENV=py34-django1.8-drf3.0 24 | - TOX_ENV=py34-django1.8-drf3.1 25 | - TOX_ENV=py34-django1.8-drf3.2 26 | 27 | install: 28 | - pip install tox 29 | 30 | script: 31 | - tox -e $TOX_ENV 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Matt Hardwick 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-exclude * __pycache__ 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | djangorestframework-version-transforms 2 | ====================================== 3 | 4 | [![build-status-image](https://secure.travis-ci.org/mrhwick/django-rest-framework-version-transforms.svg?branch=master)](http://travis-ci.org/mrhwick/django-rest-framework-version-transforms?branch=master) 5 | [![pypi-version](https://img.shields.io/pypi/v/djangorestframework-version-transforms.svg)](https://pypi.python.org/pypi/djangorestframework-version-transforms) 6 | [![read-the-docs](https://readthedocs.org/projects/django-rest-framework-version-transforms/badge/?version=latest)](http://django-rest-framework-version-transforms.readthedocs.org/en/latest/?badge=latest) 7 | 8 | # Overview 9 | 10 | A library to enable the use of functional transforms for versioning of [Django Rest Framework] API representations. 11 | 12 | ## API Change Management - State of the Art 13 | 14 | Unfortunately for API developers, changes in API schema are inevitable for any significant web service. 15 | 16 | If developers cannot avoid changing their API representations, then the next best option is to manage these changes without making sacrifices to software quality. Managing API changes often requires a developer to define and maintain multiple versions of resource representations for their API. Django Rest Framework makes some code quality sacrifices in its default support for version definition. 17 | 18 | Using the default versioning support in DRF, API developers are required to manage version differences within their endpoint code. Forcing the responsibility of version compatibility into this layer of your API increases the complexity of endpoints. As the number of supported versions increases, the length, complexity, and duplication of version compatibility boilerplate will increase, leading to ever-increasing difficulty when making subsequent changes. 19 | 20 | We can do better than duplicating code and maintaining ever-increasing boilerplate within our APIs. 21 | 22 | ## Representation Version Transforms 23 | 24 | `djangorestframework-version-transforms` empowers DRF users to forgo the introduction of unecessary boilerplate into their endpoint code. 25 | 26 | Version compability is instead implemented as version transform functions that translate from one version of a resource to another. The general concept of a version transform should already be familiar to Django users, since it is derived from the frequently-used migration tool and uses similar patterns. Developers need only write version compatibility code once per version change, and need only maintain their endpoint code at the latest version. 27 | 28 | Version transforms encapsulate the necessary changes to promote or demote a resource representation between versions, and a stack of version transforms can be used as a promotion or demotion pipeline when needed. With the correct stack of version transforms in place, endpoint logic should only be concerned with the latest (or current) version of the resource. 29 | 30 | When backwards incompatible changes are required, the endpoint can be upgraded to work against the new version. Then a single version transform is introduced that converts between the now outdated version and the newly created "current" version that the endpoint code expects. 31 | 32 | ## Requirements 33 | 34 | - Python (2.7, 3.4) 35 | - Django (1.6, 1.7, 1.8) 36 | - Django REST Framework (2.4, 3.0, 3.1) 37 | 38 | ## Installation 39 | 40 | Install using ```pip```... 41 | 42 | ```bash 43 | $ pip install djangorestframework-version-transforms 44 | ``` 45 | 46 | ## Usage 47 | 48 | ### Creating Version Transforms 49 | 50 | Transforms are defined as python subclasses of the `BaseTransform` class. They are expected to implement two methods (`forwards` and `backwards`) which describe the necessary transformations for forward (request) and backward (response) conversion between two versions of the resource. The base version number for a transform is appended to the name. 51 | 52 | For example: 53 | 54 | ```python 55 | # Notice that this is a subclass of the `BaseTransform` class 56 | class MyFirstTransform0001(BaseTransform): 57 | 58 | # .forwards() is used to promote request data 59 | def forwards(self, data, request): 60 | if 'test_field_one' in data: 61 | data['new_test_field'] = data.get('test_field_one') 62 | data.pop('test_field_one') 63 | return data 64 | 65 | # .backwards() is used to demote response data 66 | def backwards(self, data, request, instance): 67 | data['test_field_one'] = data.get('new_test_field') 68 | data.pop('new_test_field') 69 | return data 70 | ``` 71 | 72 | In this example transform, the `.forwards()` method would be used to change a v1 representation into a v2 representation by substituting the field key `new_test_field` for the previous key `test_field_one`. This transform indicates that it will be used to convert between v1 and v2 by appending a numerical indicator of the version it is based upon, `0001`, to the transform name. The `.backwards()` method simply does the swap operation in reverse, replacing the original field key that is expected in v1. 73 | 74 | To define a second transform that would enable conversion between a v2 and v3, we would simply use the same prefix and increment the base version number to `0002`. 75 | 76 | ```python 77 | # Again, subclassing `BaseTransform`. 78 | # The postfix integer indicates the base version. 79 | class MyFirstTransform0002(BaseTransform): 80 | 81 | def forwards(self, data, request): 82 | data['new_related_object_id_list'] = [1, 2, 3, 4, 5] 83 | return data 84 | 85 | def backwards(self, data, request, instance): 86 | data.pop('new_related_object_id_list') 87 | return data 88 | ``` 89 | 90 | In this second example transform, the `.forwards()` method adds a newly required field with some default values onto the representation. The `.backwards()` method simply removes the new field, since v2 does not require it. 91 | 92 | ### Whole-API vs. Per-Endpoint Versioning 93 | 94 | There are two general strategies for introducing new API versions, and this library supports either version strategy. 95 | 96 | #### Whole-API Versioning 97 | 98 | In the Whole-API versioning strategy, any backwards-incompatible change to any endpoint within the API introduces a new API version for all endpoints. Clients are expected to maintain knowledge of the various changes particular to any resources affected by a given version change. 99 | 100 | In this strategy, changes to resources will be bundled together as a new version alongside any unchanged resources. 101 | 102 | Whole-API versioning offers convenience for client-side developers at runtime, since the client must only interact with one version of an API at a time. One drawback is that the client must be made to support all changes to endpoints included in each new version of the API. 103 | 104 | ##### Usage 105 | 106 | For example, assume you have two resources `User` and `Profile`. 107 | 108 | In the course of development, you must make several backwards incompatible changes over time: 109 | 110 | - v1 - Some initial version of `Profile` and `User`. 111 | - v2 - The `Profile` resource changes in some incompatible way. 112 | - v3 - The `User` resource changes in some incompatible way. 113 | - v4 - Both `Profile` and `User` resources change in some incompatible way at the same time. 114 | 115 | In order to support these version changes, you would define these transforms: 116 | 117 | ```python 118 | class ProfileTransform0002(BaseTransform): 119 | """ 120 | Targets v2 of the profile representation. 121 | Will convert forwards and backwards for requests at v1. 122 | """ 123 | 124 | class UserTransform0003(BaseTransform): 125 | """ 126 | Targets v3 of the user representation. 127 | Will convert forwards and backwards for requests at v1 or v2. 128 | """ 129 | 130 | class ProfileTransform0004(BaseTransform): 131 | """ 132 | Targets v4 of the profile representation. 133 | Will convert forwards and backwards for requests at v1, v2, or v3. 134 | """ 135 | 136 | class UserTransform0004(BaseTransform): 137 | """ 138 | Targets v4 of the user representation. 139 | Will convert forwards and backwards for requests at v1, v2, or v3. 140 | """ 141 | ``` 142 | 143 | In the Whole-API strategy, each transform targets the version to which it promotes a resource. Using this pattern, the transforms "opt in" to a particular version number. 144 | 145 | In this example: 146 | 147 | - `ProfileTransform0002` targets `v2`. 148 | - `UserTransform0003` targets `v3`. 149 | - `ProfileTransform0004` and `UserTransform0004` both target `v4`. 150 | 151 | #### Per-Endpoint Versioning 152 | 153 | Per-Endpoint API versioning requires a client to maintain knowledge of the various versions of each endpoint. The client will access each endpoint at its associated version, and can expect to independently change the version number for each endpoint. This allows for finer-grained control for the client to manage which resource versions with which it expects to interact. 154 | 155 | In this strategy, changes to resources are made independently of each other. Unchanged resources stay at the same version number no matter how many new versions of other resources are created. 156 | 157 | Per-Endpoint versioning offers convenience for client developers in that they can improve a single resource interaction at a time. One major drawback of this strategy is that the client must maintain a mapping of which resource versions are to be used at runtime. 158 | 159 | ##### Usage 160 | 161 | For example, assume you have two resources `User` and `Profile`. 162 | 163 | In the course of development, you must make several backwards incompatible changes over time: 164 | 165 | Some changes to the `Profile` endpoint: 166 | 167 | - v1 `Profile` - Some initial version of `Profile`. 168 | - v2 `Profile` - The `Profile` resource changes in some incompatible way. 169 | 170 | Some changes to the `User` endpoint: 171 | 172 | - v1 `User` - Some initial version of `User`. 173 | - v2 `User` - The `User` resource changes in some incompatible way. 174 | - v3 `User` - The `User` resource changes in some incompatible way. 175 | - v4 `User` - The `User` resource changes in some incompatible way. 176 | 177 | In order to support these versions, you would define these transforms: 178 | 179 | ```python 180 | class ProfileTransform0002(BaseTransform): 181 | """ 182 | Targets v2 of the profile representation. 183 | Will convert forwards and backwards for requests at v1. 184 | """ 185 | 186 | class UserTransform0002(BaseTransform): 187 | """ 188 | Targets v2 of the user representation. 189 | Will convert forwards and backwards for requests at v1. 190 | """ 191 | 192 | class UserTransform0003(BaseTransform): 193 | """ 194 | Targets v3 of the user representation. 195 | Will convert forwards and backwards for requests at v1 or v2. 196 | """ 197 | 198 | class UserTransform0004(BaseTransform): 199 | """ 200 | Targets v4 of the user representation. 201 | Will convert forwards and backwards for requests at v1, v2, or v3. 202 | """ 203 | ``` 204 | 205 | In this example, the `User` and `Profile` resources are versioned independently from one another. 206 | 207 | The `User` resource supports `v1`, `v2`, `v3`, and `v4`. Three transforms are defined, with each stating their targeted version after promotion by the postfix integer in their names. 208 | 209 | The `Profile` resource supports `v1` and `v2`. One transform is defined to enable this support, and that transform states that it targets `v2` after promotion of the representation. 210 | 211 | Using this strategy, the client-side interactions can target a different version for each of the resources independently from one another. 212 | 213 | ### Parsers 214 | 215 | Parsers are useful in Django Rest Framework for defining content-types for your RESTful API resources. 216 | 217 | Using this library, custom parsers can also be used to ensure that the representation parsed out of a request match the latest version of that resource. This relieves the endpoint from the burden of maintaining knowledge of previous resource versions. 218 | 219 | When using a custom parser, inbound representations at lower-than-latest versions will be converted into the latest version during parsing. 220 | 221 | To make use of version transforms in custom parsers, define a subclass of `BaseVersioningParser`: 222 | 223 | ```python 224 | # Notice that this is a subclass of the provided `BaseVersioningParser` 225 | class MyFirstVersioningParser(BaseVersioningParser): 226 | media_type = 'application/vnd.test.testtype+json' 227 | transform_base = 'my_version_transforms.MyFirstTransform' 228 | ``` 229 | 230 | The `media_type` property must be defined, but can be defined simply as `application/json` if no custom content type is desired. 231 | 232 | The `transform_base` property can be defined for use with this library. This parser will now automatically retrieve transform classes from the specified module that are prefixed with the base transform name. 233 | 234 | In this example, the full module name is `'my_version_transforms'`, which indicates the module from which the transform classes will be loaded. The base transform name in this example is `'MyFirstTransform'`, which indicates a prefix to be used for pattern matching to find the version transforms associated with this parser. 235 | 236 | The VersioningParser will automatically discover the transforms from the provided module that match the given base transform name. Then, the parser will use the version being requested to identify which transform to run first. The parser then creates a pipeline from the `.forwards()` methods of each later transform in ascending order. After this promotion pipeline is complete, the parser provides the request representation at the latest version for handling by the endpoint logic. 237 | 238 | ### Serializers 239 | 240 | Serializers are useful in Django Rest Framework for consistently returning well-formated responses to the client. 241 | 242 | Using this library, custom serializers can also be used to ensure that responses match the version which the client originally requested. A response representation is automatically demoted back to the requested version during serialization. This again relieves endpoints from the burden of maintaining knowledge of previous versions. 243 | 244 | To make use of transforms in serializers, define a subclass of `BaseVersioningSerializer`: 245 | 246 | ```python 247 | from rest_framework import serializers 248 | 249 | # using a plain serializer 250 | class MyFirstVersioningSerializer(BaseVersioningSerializer, serializers.Serializer): 251 | transform_base = 'my_version_transforms.MyFirstTransform' 252 | 253 | test_field_two = serializers.CharField() 254 | 255 | # using model serializer 256 | class MyFirstVersioningSerializer(BaseVersioningSerializer, serializers.ModelSerializer): 257 | transform_base = 'my_version_transforms.MyFirstTransform' 258 | 259 | class Meta: 260 | model = TestModelV3 261 | fields = ( 262 | 'test_field_two', 263 | 'test_field_three', 264 | 'test_field_four', 265 | 'test_field_five', 266 | 'new_test_field', 267 | 'new_related_object_id_list', 268 | ) 269 | ``` 270 | 271 | The `transform_base` property is defined in the same manner as with parsers, using the first portions of the definition to identify from which module to load transforms, and the last part to identify the transforms to be used. 272 | 273 | The versioning serializer will automatically discover the transforms from the provided module that match the base transform name. Then the serializer builds a pipeline of transforms to be used for demotion down to the requested version of the resource. The pipeline is run in sequence by executing the `.backwards()` methods on each transform in descending order until the requested version is reached. 274 | 275 | ## Development 276 | 277 | ### Testing 278 | 279 | Install testing requirements. 280 | 281 | ```bash 282 | $ pip install -r requirements.txt 283 | ``` 284 | 285 | Run with runtests. 286 | 287 | ```bash 288 | $ ./runtests.py 289 | ``` 290 | 291 | You can also use the excellent [tox] testing tool to run the tests 292 | against all supported versions of Python and Django. Install tox globally, and then simply run: 293 | 294 | ```bash 295 | $ tox 296 | ``` 297 | 298 | ### Documentation 299 | 300 | To build the documentation, you’ll need to install ```mkdocs```. 301 | 302 | ```bash 303 | $ pip install mkdocs 304 | ``` 305 | 306 | To preview the documentation: 307 | 308 | ```bash 309 | $ mkdocs serve 310 | Running at: http://127.0.0.1:8000/ 311 | ``` 312 | 313 | To build the documentation: 314 | 315 | ```bash 316 | $ mkdocs build 317 | ``` 318 | 319 | [Django Rest Framework]: https://github.com/tomchristie/django-rest-framework 320 | [tox]: http://tox.readthedocs.org/en/latest/ 321 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | body.homepage div.col-md-9 h1:first-of-type { 2 | text-align: center; 3 | font-size: 60px; 4 | font-weight: 300; 5 | margin-top: 0; 6 | } 7 | 8 | body.homepage div.col-md-9 p:first-of-type { 9 | text-align: center; 10 | } 11 | 12 | body.homepage .badges { 13 | text-align: right; 14 | } 15 | 16 | body.homepage .badges a { 17 | display: inline-block; 18 | } 19 | 20 | body.homepage .badges a img { 21 | padding: 0; 22 | margin: 0; 23 | } 24 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | djangorestframework-version-transforms 11 | ====================================== 12 | 13 | # Overview 14 | 15 | A library to enable the use of functional transforms for versioning of [Django Rest Framework] API representations. 16 | 17 | ## API Change Management - State of the Art 18 | 19 | Unfortunately for API developers, changes in API schema are inevitable for any significant web service. 20 | 21 | If developers cannot avoid changing their API representations, then the next best option is to manage these changes without making sacrifices to software quality. Managing API changes often requires a developer to define and maintain multiple versions of resource representations for their API. Django Rest Framework makes some code quality sacrifices in its default support for version definition. 22 | 23 | Using the default versioning support in DRF, API developers are required to manage version differences within their endpoint code. Forcing the responsibility of version compatibility into this layer of your API increases the complexity of endpoints. As the number of supported versions increases, the length, complexity, and duplication of version compatibility boilerplate will increase, leading to ever-increasing difficulty when making subsequent changes. 24 | 25 | We can do better than duplicating code and maintaining ever-increasing boilerplate within our APIs. 26 | 27 | ## Representation Version Transforms 28 | 29 | `djangorestframework-version-transforms` empowers DRF users to forgo the introduction of unecessary boilerplate into their endpoint code. 30 | 31 | Version compability is instead implemented as version transform functions that translate from one version of a resource to another. The general concept of a version transform should already be familiar to Django users, since it is derived from the frequently-used migration tool and uses similar patterns. Developers need only write version compatibility code once per version change, and need only maintain their endpoint code at the latest version. 32 | 33 | Version transforms encapsulate the necessary changes to promote or demote a resource representation between versions, and a stack of version transforms can be used as a promotion or demotion pipeline when needed. With the correct stack of version transforms in place, endpoint logic should only be concerned with the latest (or current) version of the resource. 34 | 35 | When backwards incompatible changes are required, the endpoint can be upgraded to work against the new version. Then a single version transform is introduced that converts between the now outdated version and the newly created "current" version that the endpoint code expects. 36 | 37 | ## Requirements 38 | 39 | - Python (2.7, 3.4) 40 | - Django (1.6, 1.7, 1.8) 41 | - Django REST Framework (2.4, 3.0, 3.1) 42 | 43 | ## Installation 44 | 45 | Install using ```pip```... 46 | 47 | ```bash 48 | $ pip install djangorestframework-version-transforms 49 | ``` 50 | 51 | ## Usage 52 | 53 | ### Creating Version Transforms 54 | 55 | Transforms are defined as python subclasses of the `BaseTransform` class. They are expected to implement two methods (`forwards` and `backwards`) which describe the necessary transformations for forward (request) and backward (response) conversion between two versions of the resource. The base version number for a transform is appended to the name. 56 | 57 | For example: 58 | 59 | ```python 60 | # Notice that this is a subclass of the `BaseTransform` class 61 | class MyFirstTransform0001(BaseTransform): 62 | 63 | # .forwards() is used to promote request data 64 | def forwards(self, data, request): 65 | if 'test_field_one' in data: 66 | data['new_test_field'] = data.get('test_field_one') 67 | data.pop('test_field_one') 68 | return data 69 | 70 | # .backwards() is used to demote response data 71 | def backwards(self, data, request, instance): 72 | data['test_field_one'] = data.get('new_test_field') 73 | data.pop('new_test_field') 74 | return data 75 | ``` 76 | 77 | In this example transform, the `.forwards()` method would be used to change a v1 representation into a v2 representation by substituting the field key `new_test_field` for the previous key `test_field_one`. This transform indicates that it will be used to convert between v1 and v2 by appending a numerical indicator of the version it is based upon, `0001`, to the transform name. The `.backwards()` method simply does the swap operation in reverse, replacing the original field key that is expected in v1. 78 | 79 | To define a second transform that would enable conversion between a v2 and v3, we would simply use the same prefix and increment the base version number to `0002`. 80 | 81 | ```python 82 | # Again, subclassing `BaseTransform`. 83 | # The postfix integer indicates the base version. 84 | class MyFirstTransform0002(BaseTransform): 85 | 86 | def forwards(self, data, request): 87 | data['new_related_object_id_list'] = [1, 2, 3, 4, 5] 88 | return data 89 | 90 | def backwards(self, data, request, instance): 91 | data.pop('new_related_object_id_list') 92 | return data 93 | ``` 94 | 95 | In this second example transform, the `.forwards()` method adds a newly required field with some default values onto the representation. The `.backwards()` method simply removes the new field, since v2 does not require it. 96 | 97 | ### Whole-API vs. Per-Endpoint Versioning 98 | 99 | There are two general strategies for introducing new API versions, and this library supports either version strategy. 100 | 101 | #### Whole-API Versioning 102 | 103 | In the Whole-API versioning strategy, any backwards-incompatible change to any endpoint within the API introduces a new API version for all endpoints. Clients are expected to maintain knowledge of the various changes particular to any resources affected by a given version change. 104 | 105 | In this strategy, changes to resources will be bundled together as a new version alongside any unchanged resources. 106 | 107 | Whole-API versioning offers convenience for client-side developers at runtime, since the client must only interact with one version of an API at a time. One drawback is that the client must be made to support all changes to endpoints included in each new version of the API. 108 | 109 | ##### Usage 110 | 111 | For example, assume you have two resources `User` and `Profile`. 112 | 113 | In the course of development, you must make several backwards incompatible changes over time: 114 | 115 | - v1 - Some initial version of `Profile` and `User`. 116 | - v2 - The `Profile` resource changes in some incompatible way. 117 | - v3 - The `User` resource changes in some incompatible way. 118 | - v4 - Both `Profile` and `User` resources change in some incompatible way at the same time. 119 | 120 | In order to support these version changes, you would define these transforms: 121 | 122 | ```python 123 | class ProfileTransform0002(BaseTransform): 124 | """ 125 | Targets v2 of the profile representation. 126 | Will convert forwards and backwards for requests at v1. 127 | """ 128 | 129 | class UserTransform0003(BaseTransform): 130 | """ 131 | Targets v3 of the user representation. 132 | Will convert forwards and backwards for requests at v1 or v2. 133 | """ 134 | 135 | class ProfileTransform0004(BaseTransform): 136 | """ 137 | Targets v4 of the profile representation. 138 | Will convert forwards and backwards for requests at v1, v2, or v3. 139 | """ 140 | 141 | class UserTransform0004(BaseTransform): 142 | """ 143 | Targets v4 of the user representation. 144 | Will convert forwards and backwards for requests at v1, v2, or v3. 145 | """ 146 | ``` 147 | 148 | In the Whole-API strategy, each transform targets the version to which it promotes a resource. Using this pattern, the transforms "opt in" to a particular version number. 149 | 150 | In this example: 151 | 152 | - `ProfileTransform0002` targets `v2`. 153 | - `UserTransform0003` targets `v3`. 154 | - `ProfileTransform0004` and `UserTransform0004` both target `v4`. 155 | 156 | #### Per-Endpoint Versioning 157 | 158 | Per-Endpoint API versioning requires a client to maintain knowledge of the various versions of each endpoint. The client will access each endpoint at its associated version, and can expect to independently change the version number for each endpoint. This allows for finer-grained control for the client to manage which resource versions with which it expects to interact. 159 | 160 | In this strategy, changes to resources are made independently of each other. Unchanged resources stay at the same version number no matter how many new versions of other resources are created. 161 | 162 | Per-Endpoint versioning offers convenience for client developers in that they can improve a single resource interaction at a time. One major drawback of this strategy is that the client must maintain a mapping of which resource versions are to be used at runtime. 163 | 164 | ##### Usage 165 | 166 | For example, assume you have two resources `User` and `Profile`. 167 | 168 | In the course of development, you must make several backwards incompatible changes over time: 169 | 170 | Some changes to the `Profile` endpoint: 171 | 172 | - v1 `Profile` - Some initial version of `Profile`. 173 | - v2 `Profile` - The `Profile` resource changes in some incompatible way. 174 | 175 | Some changes to the `User` endpoint: 176 | 177 | - v1 `User` - Some initial version of `User`. 178 | - v2 `User` - The `User` resource changes in some incompatible way. 179 | - v3 `User` - The `User` resource changes in some incompatible way. 180 | - v4 `User` - The `User` resource changes in some incompatible way. 181 | 182 | In order to support these versions, you would define these transforms: 183 | 184 | ```python 185 | class ProfileTransform0002(BaseTransform): 186 | """ 187 | Targets v2 of the profile representation. 188 | Will convert forwards and backwards for requests at v1. 189 | """ 190 | 191 | class UserTransform0002(BaseTransform): 192 | """ 193 | Targets v2 of the user representation. 194 | Will convert forwards and backwards for requests at v1. 195 | """ 196 | 197 | class UserTransform0003(BaseTransform): 198 | """ 199 | Targets v3 of the user representation. 200 | Will convert forwards and backwards for requests at v1 or v2. 201 | """ 202 | 203 | class UserTransform0004(BaseTransform): 204 | """ 205 | Targets v4 of the user representation. 206 | Will convert forwards and backwards for requests at v1, v2, or v3. 207 | """ 208 | ``` 209 | 210 | In this example, the `User` and `Profile` resources are versioned independently from one another. 211 | 212 | The `User` resource supports `v1`, `v2`, `v3`, and `v4`. Three transforms are defined, with each stating their targeted version after promotion by the postfix integer in their names. 213 | 214 | The `Profile` resource supports `v1` and `v2`. One transform is defined to enable this support, and that transform states that it targets `v2` after promotion of the representation. 215 | 216 | Using this strategy, the client-side interactions can target a different version for each of the resources independently from one another. 217 | 218 | ### Parsers 219 | 220 | Parsers are useful in Django Rest Framework for defining content-types for your RESTful API resources. 221 | 222 | Using this library, custom parsers can also be used to ensure that the representation parsed out of a request match the latest version of that resource. This relieves the endpoint from the burden of maintaining knowledge of previous resource versions. 223 | 224 | When using a custom parser, inbound representations at lower-than-latest versions will be converted into the latest version during parsing. 225 | 226 | To make use of version transforms in custom parsers, define a subclass of `BaseVersioningParser`: 227 | 228 | ```python 229 | # Notice that this is a subclass of the provided `BaseVersioningParser` 230 | class MyFirstVersioningParser(BaseVersioningParser): 231 | media_type = 'application/vnd.test.testtype+json' 232 | transform_base = 'my_version_transforms.MyFirstTransform' 233 | ``` 234 | 235 | The `media_type` property must be defined, but can be defined simply as `application/json` if no custom content type is desired. 236 | 237 | The `transform_base` property can be defined for use with this library. This parser will now automatically retrieve transform classes from the specified module that are prefixed with the base transform name. 238 | 239 | In this example, the full module name is `'my_version_transforms'`, which indicates the module from which the transform classes will be loaded. The base transform name in this example is `'MyFirstTransform'`, which indicates a prefix to be used for pattern matching to find the version transforms associated with this parser. 240 | 241 | The VersioningParser will automatically discover the transforms from the provided module that match the given base transform name. Then, the parser will use the version being requested to identify which transform to run first. The parser then creates a pipeline from the `.forwards()` methods of each later transform in ascending order. After this promotion pipeline is complete, the parser provides the request representation at the latest version for handling by the endpoint logic. 242 | 243 | ### Serializers 244 | 245 | Serializers are useful in Django Rest Framework for consistently returning well-formated responses to the client. 246 | 247 | Using this library, custom serializers can also be used to ensure that responses match the version which the client originally requested. A response representation is automatically demoted back to the requested version during serialization. This again relieves endpoints from the burden of maintaining knowledge of previous versions. 248 | 249 | To make use of transforms in serializers, define a subclass of `BaseVersioningSerializer`: 250 | 251 | ```python 252 | from rest_framework import serializers 253 | 254 | # using a plain serializer 255 | class MyFirstVersioningSerializer(BaseVersioningSerializer, serializers.Serializer): 256 | transform_base = 'my_version_transforms.MyFirstTransform' 257 | 258 | test_field_two = serializers.CharField() 259 | 260 | # using model serializer 261 | class MyFirstVersioningSerializer(BaseVersioningSerializer, serializers.ModelSerializer): 262 | transform_base = 'my_version_transforms.MyFirstTransform' 263 | 264 | class Meta: 265 | model = TestModelV3 266 | fields = ( 267 | 'test_field_two', 268 | 'test_field_three', 269 | 'test_field_four', 270 | 'test_field_five', 271 | 'new_test_field', 272 | 'new_related_object_id_list', 273 | ) 274 | ``` 275 | 276 | The `transform_base` property is defined in the same manner as with parsers, using the first portions of the definition to identify from which module to load transforms, and the last part to identify the transforms to be used. 277 | 278 | The versioning serializer will automatically discover the transforms from the provided module that match the base transform name. Then the serializer builds a pipeline of transforms to be used for demotion down to the requested version of the resource. The pipeline is run in sequence by executing the `.backwards()` methods on each transform in descending order until the requested version is reached. 279 | 280 | ## Development 281 | 282 | ### Testing 283 | 284 | Install testing requirements. 285 | 286 | ```bash 287 | $ pip install -r requirements.txt 288 | ``` 289 | 290 | Run with runtests. 291 | 292 | ```bash 293 | $ ./runtests.py 294 | ``` 295 | 296 | You can also use the excellent [tox] testing tool to run the tests 297 | against all supported versions of Python and Django. Install tox globally, and then simply run: 298 | 299 | ```bash 300 | $ tox 301 | ``` 302 | 303 | ### Documentation 304 | 305 | To build the documentation, you’ll need to install ```mkdocs```. 306 | 307 | ```bash 308 | $ pip install mkdocs 309 | ``` 310 | 311 | To preview the documentation: 312 | 313 | ```bash 314 | $ mkdocs serve 315 | Running at: http://127.0.0.1:8000/ 316 | ``` 317 | 318 | To build the documentation: 319 | 320 | ```bash 321 | $ mkdocs build 322 | ``` 323 | 324 | [Django Rest Framework]: https://github.com/tomchristie/django-rest-framework 325 | [tox]: http://tox.readthedocs.org/en/latest/ 326 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | from tests.conftest import pytest_configure 7 | 8 | if __name__ == "__main__": 9 | 10 | pytest_configure() 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: djangorestframework-version-transforms 2 | pages: 3 | - Home: index.md 4 | site_description: A library to enable the use of delta transformations for versioning of Django Rest Framwork API representations. 5 | repo_url: https://github.com/mrhwick/django-rest-framework-version-transforms 6 | site_dir: html 7 | 8 | theme: readthedocs -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = actual_tests.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Minimum Django and REST framework version 2 | Django>=1.6 3 | djangorestframework>=2.4.3 4 | ipdb>=0.8 5 | 6 | # Test requirements 7 | pytest-django==2.8 8 | pytest==2.5.2 9 | pytest-cov==1.6 10 | flake8==2.2.2 11 | mock==1.3.0 12 | 13 | # wheel for PyPI installs 14 | wheel==0.24.0 15 | 16 | # MkDocs for documentation previews/deploys 17 | mkdocs==0.14.0 18 | -------------------------------------------------------------------------------- /rest_framework_transforms/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.5.0' 2 | -------------------------------------------------------------------------------- /rest_framework_transforms/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class TransformBaseNotDeclaredException(Exception): 4 | pass 5 | -------------------------------------------------------------------------------- /rest_framework_transforms/parsers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from rest_framework.parsers import JSONParser 4 | from rest_framework_transforms.exceptions import TransformBaseNotDeclaredException 5 | from rest_framework_transforms.utils import get_transform_classes 6 | 7 | 8 | class BaseVersioningParser(JSONParser): 9 | """ 10 | A base class for parsers that automatically promote resource representations 11 | according to provided transform classes for that resource. 12 | """ 13 | media_type = None 14 | transform_base = None 15 | 16 | def parse(self, stream, media_type=None, parser_context=None): 17 | """ 18 | Parses the incoming bytestream as JSON and executes any available version transforms against the 19 | parsed representation to convert the requested version of this content type into the 20 | highest supported version of the content type. 21 | 22 | :returns: A dictionary of upconverted request data in the most recent supported version of the content type. 23 | """ 24 | if not self.transform_base: 25 | raise TransformBaseNotDeclaredException("VersioningParser cannot correctly promote incoming resources with no transform classes.") 26 | 27 | json_data_dict = super(BaseVersioningParser, self).parse(stream, media_type, parser_context) 28 | request = parser_context['request'] 29 | 30 | if hasattr(request, 'version'): 31 | for transform in get_transform_classes(self.transform_base, base_version=request.version, reverse=False): 32 | json_data_dict = transform().forwards(data=json_data_dict, request=request) 33 | 34 | return json_data_dict 35 | -------------------------------------------------------------------------------- /rest_framework_transforms/serializers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from rest_framework_transforms.exceptions import TransformBaseNotDeclaredException 4 | from rest_framework_transforms.utils import get_transform_classes 5 | 6 | 7 | class BaseVersioningSerializer(object): 8 | """ 9 | A base class for serializers that automatically demote resource representations 10 | according to provided transform classes for the resource. 11 | """ 12 | transform_base = None 13 | 14 | def to_representation(self, instance): 15 | """ 16 | Serializes the outgoing data as JSON and executes any available version transforms in backwards 17 | order against the serialized representation to convert the highest supported version into the 18 | requested version of the resource. 19 | """ 20 | if not self.transform_base: 21 | raise TransformBaseNotDeclaredException("VersioningParser cannot correctly promote incoming resources with no transform classes.") 22 | 23 | data = super(BaseVersioningSerializer, self).to_representation(instance) 24 | if instance: 25 | request = self.context.get('request') 26 | 27 | if request and hasattr(request, 'version'): 28 | # demote data until we've run the transform just above the requested version 29 | 30 | for transform in get_transform_classes(self.transform_base, base_version=request.version, reverse=True): 31 | data = transform().backwards(data, request, instance) 32 | 33 | return data 34 | -------------------------------------------------------------------------------- /rest_framework_transforms/transforms.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class BaseTransform(object): 4 | """ 5 | All transforms should extend 'BaseTransform', overriding the two 6 | methods '.forwards()' and '.backwards()' to provide forwards and backwards 7 | conversions between representation versions. 8 | """ 9 | def forwards(self, data, request): 10 | """ 11 | Converts from this transform's base version to the targeted version of the representation. 12 | 13 | :returns: Dictionary with the correct structure for the targeted version of the representation. 14 | """ 15 | raise NotImplementedError(".forwards() must be overridden.") 16 | 17 | def backwards(self, data, request, instance): 18 | """ 19 | Converts from the targeted version back to this transform's base version of the representation. 20 | 21 | :returns: Dictionary with the correct structure for the base version of the representation. 22 | """ 23 | raise NotImplementedError(".backwards() must be overridden.") 24 | -------------------------------------------------------------------------------- /rest_framework_transforms/utils.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | import inspect 3 | import re 4 | from rest_framework_transforms.transforms import BaseTransform 5 | 6 | 7 | def get_transform_classes(transform_base=None, base_version=1, reverse=False): 8 | """ 9 | Compiles a list of transform classes between the provided 'base_version' and the highest version supported. 10 | 11 | :param reverse: Specifies the order in which the transform classes are returned. 12 | 13 | Running the '.forwards()' method of the returned transform classes (in ascending order) over 14 | a dictionary of resource representation data will promote the dictionary from the given 'base_version' to the 15 | highest supported version. 16 | 17 | Running the '.backwards()' method of the returned transform classes (in descending order) over 18 | a dictionary of resource representation data will demote the dictionary from the highest supported version to the 19 | given 'base_version'. 20 | """ 21 | module, base = transform_base.rsplit('.', 1) 22 | mod = import_module(module) 23 | 24 | transform_classes_dict = {} 25 | 26 | for name, transform_class in inspect.getmembers(mod): 27 | if name.startswith(base) and issubclass(transform_class, BaseTransform): 28 | transform_index_match = re.search('\d+$', name) 29 | if transform_index_match: 30 | int_transform_index = int(transform_index_match.group(0)) 31 | if base_version < int_transform_index: 32 | transform_classes_dict[int_transform_index] = transform_class 33 | 34 | ordered_transform_classes_list = [ 35 | transform_classes_dict[key] 36 | for key 37 | in sorted(transform_classes_dict, reverse=reverse) 38 | ] 39 | 40 | return ordered_transform_classes_list 41 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import pytest 5 | import sys 6 | import os 7 | import subprocess 8 | 9 | 10 | PYTEST_ARGS = { 11 | 'default': ['tests'], 12 | 'fast': ['tests', '-q'], 13 | } 14 | 15 | FLAKE8_ARGS = ['rest_framework_transforms', 'tests', '--ignore=E501'] 16 | 17 | 18 | sys.path.append(os.path.dirname(__file__)) 19 | 20 | 21 | def exit_on_failure(ret, message=None): 22 | if ret: 23 | sys.exit(ret) 24 | 25 | 26 | def flake8_main(args): 27 | print('Running flake8 code linting') 28 | ret = subprocess.call(['flake8'] + args) 29 | print('flake8 failed' if ret else 'flake8 passed') 30 | return ret 31 | 32 | 33 | def split_class_and_function(string): 34 | class_string, function_string = string.split('.', 1) 35 | return "%s and %s" % (class_string, function_string) 36 | 37 | 38 | def is_function(string): 39 | # `True` if it looks like a test function is included in the string. 40 | return string.startswith('test_') or '.test_' in string 41 | 42 | 43 | def is_class(string): 44 | # `True` if first character is uppercase - assume it's a class name. 45 | return string[0] == string[0].upper() 46 | 47 | 48 | if __name__ == "__main__": 49 | try: 50 | sys.argv.remove('--nolint') 51 | except ValueError: 52 | run_flake8 = True 53 | else: 54 | run_flake8 = False 55 | 56 | try: 57 | sys.argv.remove('--lintonly') 58 | except ValueError: 59 | run_tests = True 60 | else: 61 | run_tests = False 62 | 63 | try: 64 | sys.argv.remove('--fast') 65 | except ValueError: 66 | style = 'default' 67 | else: 68 | style = 'fast' 69 | run_flake8 = False 70 | 71 | if len(sys.argv) > 1: 72 | pytest_args = sys.argv[1:] 73 | first_arg = pytest_args[0] 74 | if first_arg.startswith('-'): 75 | # `runtests.py [flags]` 76 | pytest_args = ['tests'] + pytest_args 77 | elif is_class(first_arg) and is_function(first_arg): 78 | # `runtests.py TestCase.test_function [flags]` 79 | expression = split_class_and_function(first_arg) 80 | pytest_args = ['tests', '-k', expression] + pytest_args[1:] 81 | elif is_class(first_arg) or is_function(first_arg): 82 | # `runtests.py TestCase [flags]` 83 | # `runtests.py test_function [flags]` 84 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] 85 | else: 86 | pytest_args = PYTEST_ARGS[style] 87 | 88 | if run_tests: 89 | print("running tests") 90 | exit_on_failure(pytest.main(pytest_args)) 91 | if run_flake8: 92 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 93 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import os 5 | import sys 6 | from setuptools import setup 7 | 8 | 9 | name = 'djangorestframework-version-transforms' 10 | package = 'rest_framework_transforms' 11 | description = 'A library to enable the use of delta transformations for versioning of Django Rest Framwork API representations.' 12 | url = 'https://github.com/mrhwick/django-rest-framework-version-transforms' 13 | author = 'Matt Hardwick' 14 | author_email = 'MatthewRHardwick@gmail.com' 15 | license = 'MIT' 16 | 17 | 18 | def get_version(package): 19 | """ 20 | Return package version as listed in `__version__` in `init.py`. 21 | """ 22 | init_py = open(os.path.join(package, '__init__.py')).read() 23 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", 24 | init_py, re.MULTILINE).group(1) 25 | 26 | 27 | def get_packages(package): 28 | """ 29 | Return root package and all sub-packages. 30 | """ 31 | return [dirpath 32 | for dirpath, dirnames, filenames in os.walk(package) 33 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 34 | 35 | 36 | def get_package_data(package): 37 | """ 38 | Return all files under the root package, that are not in a 39 | package themselves. 40 | """ 41 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 42 | for dirpath, dirnames, filenames in os.walk(package) 43 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 44 | 45 | filepaths = [] 46 | for base, filenames in walk: 47 | filepaths.extend([os.path.join(base, filename) 48 | for filename in filenames]) 49 | return {package: filepaths} 50 | 51 | 52 | version = get_version(package) 53 | 54 | 55 | if sys.argv[-1] == 'publish': 56 | if os.system("pip freeze | grep wheel"): 57 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.") 58 | sys.exit() 59 | os.system("python setup.py sdist upload") 60 | os.system("python setup.py bdist_wheel upload") 61 | print("You probably want to also tag the version now:") 62 | print(" git tag -a {0} -m 'version {0}'".format(version)) 63 | print(" git push --tags") 64 | sys.exit() 65 | 66 | 67 | setup( 68 | name=name, 69 | version=version, 70 | url=url, 71 | license=license, 72 | description=description, 73 | author=author, 74 | author_email=author_email, 75 | packages=get_packages(package), 76 | package_data=get_package_data(package), 77 | install_requires=[], 78 | classifiers=[ 79 | 'Development Status :: 2 - Pre-Alpha', 80 | 'Environment :: Web Environment', 81 | 'Framework :: Django', 82 | 'Intended Audience :: Developers', 83 | 'License :: OSI Approved :: BSD License', 84 | 'Operating System :: OS Independent', 85 | 'Natural Language :: English', 86 | 'Programming Language :: Python :: 2', 87 | 'Programming Language :: Python :: 2.7', 88 | 'Programming Language :: Python :: 3', 89 | 'Programming Language :: Python :: 3.3', 90 | 'Programming Language :: Python :: 3.4', 91 | 'Topic :: Internet :: WWW/HTTP', 92 | ] 93 | ) 94 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrhwick/django-rest-framework-version-transforms/1811653350b136efe02c3142dfdbe6c6e77a43d4/tests/__init__.py -------------------------------------------------------------------------------- /tests/actual_tests.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | from unittest import TestCase 4 | import pytest 5 | from rest_framework.parsers import JSONParser 6 | from rest_framework_transforms.utils import get_transform_classes 7 | 8 | try: 9 | from unittest.mock import MagicMock, patch 10 | except ImportError: 11 | from mock import MagicMock, patch 12 | from rest_framework.test import APIRequestFactory 13 | from rest_framework_transforms.exceptions import TransformBaseNotDeclaredException 14 | from tests.models import TestModel, TestModelV3 15 | from tests.test_parsers import TestParser 16 | from tests.test_serializers import ( 17 | TestSerializer, MatchingSerializer, TestSerializerV3, 18 | TestModelSerializer, MatchingModelSerializer, TestModelSerializerV3) 19 | from tests.test_transforms import TestModelTransform0002, TestModelTransform0003 20 | 21 | 22 | @patch('rest_framework_transforms.utils.inspect.getmembers') 23 | @patch('rest_framework_transforms.utils.import_module') 24 | class GetTransformClassesUnitTests(TestCase): 25 | def test_calls_inspect_getmembers_with_module(self, import_module_mock, getmembers_mock): 26 | get_transform_classes(transform_base='some_package.some_module.SomeTransformBase') 27 | import_module_mock.assert_called_once_with('some_package.some_module') 28 | getmembers_mock.assert_called_once_with(import_module_mock.return_value) 29 | 30 | def test_adds_transforms_to_list(self, import_module_mock, getmembers_mock): 31 | getmembers_mock.return_value = { 32 | 'TestModelTransform0002': TestModelTransform0002, 33 | 'TestModelTransform0003': TestModelTransform0003, 34 | }.items() 35 | returned_classes = get_transform_classes( 36 | transform_base='some_package.some_module.TestModelTransform', 37 | ) 38 | self.assertEqual(TestModelTransform0002, returned_classes[0]) 39 | self.assertEqual(TestModelTransform0003, returned_classes[1]) 40 | self.assertEqual(2, len(returned_classes)) 41 | 42 | def test_adds_transforms_to_list_in_reverse_order(self, import_module_mock, getmembers_mock): 43 | getmembers_mock.return_value = { 44 | 'TestModelTransform0002': TestModelTransform0002, 45 | 'TestModelTransform0003': TestModelTransform0003, 46 | }.items() 47 | returned_classes = get_transform_classes( 48 | transform_base='some_package.some_module.TestModelTransform', 49 | reverse=True, 50 | ) 51 | self.assertEqual(TestModelTransform0002, returned_classes[1]) 52 | self.assertEqual(TestModelTransform0003, returned_classes[0]) 53 | self.assertEqual(2, len(returned_classes)) 54 | 55 | def test_adds_only_transforms_matching_base_to_list(self, import_module_mock, getmembers_mock): 56 | getmembers_mock.return_value = { 57 | 'TestModelTransform0002': TestModelTransform0002, 58 | 'AnotherModelTransform0002': TestModelTransform0003, 59 | }.items() 60 | returned_classes = get_transform_classes( 61 | transform_base='some_package.some_module.TestModelTransform', 62 | ) 63 | self.assertEqual(TestModelTransform0002, returned_classes[0]) 64 | self.assertNotIn(TestModelTransform0003, returned_classes) 65 | self.assertEqual(1, len(returned_classes)) 66 | 67 | def test_adds_only_transforms_above_base_version_number_to_list(self, import_module_mock, getmembers_mock): 68 | getmembers_mock.return_value = { 69 | 'TestModelTransform0002': TestModelTransform0002, 70 | 'TestModelTransform0003': TestModelTransform0003, 71 | }.items() 72 | returned_classes = get_transform_classes( 73 | transform_base='some_package.some_module.TestModelTransform', 74 | base_version=2, 75 | ) 76 | self.assertNotIn(TestModelTransform0002, returned_classes) 77 | self.assertEqual(TestModelTransform0003, returned_classes[0]) 78 | self.assertEqual(1, len(returned_classes)) 79 | 80 | 81 | class VersioningParserUnitTests(TestCase): 82 | def setUp(self): 83 | self.request = APIRequestFactory().get('') 84 | self.request.version = 1 85 | self.parser = TestParser() 86 | self.json_string = json.dumps( 87 | { 88 | 'test_field_one': 'value_one', 89 | 'test_field_two': 'value_two', 90 | 'test_field_three': 'value_three', 91 | 'test_field_four': 'value_four', 92 | 'test_field_five': 'value_five', 93 | }, 94 | ) 95 | self.json_string_data = io.BytesIO(str.encode(self.json_string)) 96 | 97 | def test_parse_raises_error_when_no_transform_base_specified(self): 98 | self.parser.transform_base = None 99 | with self.assertRaises(TransformBaseNotDeclaredException): 100 | self.parser.parse(stream=None) 101 | 102 | def test_unversioned_request_returns_default_parsed_data(self): 103 | self.request = APIRequestFactory().get('') 104 | data_dict = self.parser.parse( 105 | stream=self.json_string_data, 106 | media_type='application/vnd.test.testtype+json', 107 | parser_context={ 108 | 'request': self.request, 109 | }, 110 | ) 111 | self.json_string_data = io.BytesIO(str.encode(self.json_string)) 112 | default_data_dict = JSONParser().parse( 113 | stream=self.json_string_data, 114 | media_type='application/vnd.test.testtype+json', 115 | parser_context={ 116 | 'request': self.request, 117 | }, 118 | ) 119 | self.assertEqual(data_dict, default_data_dict) 120 | 121 | @patch('rest_framework_transforms.parsers.get_transform_classes') 122 | def test_parse_gets_transform_classes_with_version_specified(self, get_transform_classes_mock): 123 | self.parser.parse( 124 | stream=self.json_string_data, 125 | media_type='application/vnd.test.testtype+json', 126 | parser_context={ 127 | 'request': self.request, 128 | }, 129 | ) 130 | self.assertTrue(get_transform_classes_mock.called) 131 | get_transform_classes_mock.assert_called_once_with( 132 | 'tests.test_transforms.TestModelTransform', 133 | base_version=self.request.version, 134 | reverse=False, 135 | ) 136 | 137 | @patch('rest_framework_transforms.parsers.get_transform_classes') 138 | def test_parse_doesnt_get_transform_classes_with_no_version_specified(self, get_transform_classes_mock): 139 | self.request = APIRequestFactory().get('') 140 | self.parser.parse( 141 | stream=self.json_string_data, 142 | media_type='application/vnd.test.testtype+json', 143 | parser_context={ 144 | 'request': self.request, 145 | }, 146 | ) 147 | self.assertFalse(get_transform_classes_mock.called) 148 | 149 | @patch('rest_framework_transforms.parsers.get_transform_classes') 150 | def test_parse_calls_forwards_on_transform_classes(self, get_transform_classes_mock): 151 | transform_one = MagicMock() 152 | transform_two = MagicMock() 153 | get_transform_classes_mock.return_value = [ 154 | transform_one, 155 | transform_two, 156 | ] 157 | 158 | self.parser.parse( 159 | stream=self.json_string_data, 160 | media_type='application/vnd.test.testtype+json', 161 | parser_context={ 162 | 'request': self.request, 163 | }, 164 | ) 165 | 166 | self.json_string_data = io.BytesIO(str.encode(self.json_string)) 167 | self.assertTrue(transform_one.return_value.forwards.called) 168 | transform_one.return_value.forwards.assert_called_once_with( 169 | data=JSONParser().parse( 170 | stream=self.json_string_data, 171 | media_type='application/vnd.test.testtype+json', 172 | parser_context={ 173 | 'request': self.request, 174 | }, 175 | ), 176 | request=self.request, 177 | ) 178 | 179 | self.assertTrue(transform_two.return_value.forwards.called) 180 | transform_two.return_value.forwards.assert_called_once_with( 181 | data=transform_one.return_value.forwards.return_value, 182 | request=self.request, 183 | ) 184 | 185 | 186 | class VersioningParserIntegrationTests(TestCase): 187 | def setUp(self): 188 | self.request = APIRequestFactory().get('') 189 | self.request.version = 1 190 | self.parser = TestParser() 191 | self.json_string = json.dumps( 192 | { 193 | 'test_field_one': 'value_one', 194 | 'test_field_two': 'value_two', 195 | 'test_field_three': 'value_three', 196 | 'test_field_four': 'value_four', 197 | 'test_field_five': 'value_five', 198 | }, 199 | ) 200 | self.json_string_data = io.BytesIO(str.encode(self.json_string)) 201 | 202 | def test_parsing_does_forward_conversion_v1_to_v3(self): 203 | data_dict = self.parser.parse( 204 | stream=self.json_string_data, 205 | media_type='application/vnd.test.testtype+json', 206 | parser_context={ 207 | 'request': self.request, 208 | }, 209 | ) 210 | self.assertTrue('new_test_field' in data_dict) 211 | self.assertEqual(data_dict['new_test_field'], 'value_one') 212 | self.assertTrue('new_related_object_id_list' in data_dict) 213 | self.assertEqual(data_dict['new_related_object_id_list'], [1, 2, 3, 4, 5]) 214 | 215 | 216 | class VersioningSerializerUnitTests(TestCase): 217 | def setUp(self): 218 | self.request = APIRequestFactory().get('') 219 | self.request.version = 1 220 | self.serializer = TestSerializer(context={'request': self.request}) 221 | self.model_serializer = TestModelSerializer(context={'request': self.request}) 222 | 223 | def test_to_representation_raises_error_when_no_transform_base_specified_with_instance(self): 224 | self.serializer.transform_base = None 225 | with self.assertRaises(TransformBaseNotDeclaredException): 226 | self.serializer.to_representation(instance=TestModel()) 227 | 228 | def test_to_representation_raises_error_when_no_transform_base_specified_without_instance(self): 229 | self.serializer.transform_base = None 230 | with self.assertRaises(TransformBaseNotDeclaredException): 231 | self.serializer.to_representation(instance=None) 232 | 233 | @patch('rest_framework_transforms.serializers.get_transform_classes') 234 | def test_to_representation_gets_transform_classes_with_instance(self, get_transform_classes_mock): 235 | self.serializer.to_representation(instance=TestModel()) 236 | self.assertTrue(get_transform_classes_mock.called) 237 | get_transform_classes_mock.assert_called_once_with( 238 | 'tests.test_transforms.TestModelTransform', 239 | base_version=self.request.version, 240 | reverse=True, 241 | ) 242 | 243 | @patch('rest_framework_transforms.serializers.get_transform_classes') 244 | def test_to_representation_calls_backwards_on_transform_classes_with_instance(self, get_transform_classes_mock): 245 | instance = TestModel( 246 | test_field_one='test_one', 247 | test_field_two='test_two', 248 | test_field_three='test_three', 249 | test_field_four='test_four', 250 | test_field_five='test_five', 251 | ) 252 | transform_one = MagicMock() 253 | transform_two = MagicMock() 254 | get_transform_classes_mock.return_value = [ 255 | transform_one, 256 | transform_two, 257 | ] 258 | 259 | self.serializer.to_representation(instance=instance) 260 | 261 | self.assertTrue(transform_one.return_value.backwards.called) 262 | transform_one.return_value.backwards.assert_called_once_with( 263 | MatchingSerializer().to_representation(instance), 264 | self.request, 265 | instance, 266 | ) 267 | self.assertTrue(transform_two.return_value.backwards.called) 268 | transform_two.return_value.backwards.assert_called_once_with( 269 | transform_one.return_value.backwards.return_value, 270 | self.request, 271 | instance, 272 | ) 273 | 274 | def test_to_representation_returns_default_serialization_if_no_request(self): 275 | self.serializer = TestSerializer(context={'request': None}) 276 | instance = TestModel( 277 | test_field_one='test_one', 278 | test_field_two='test_two', 279 | test_field_three='test_three', 280 | test_field_four='test_four', 281 | test_field_five='test_five', 282 | ) 283 | data = self.serializer.to_representation(instance=instance) 284 | self.assertEqual(data, MatchingSerializer().to_representation(instance)) 285 | 286 | def test_to_representation_returns_default_serialization_if_no_request_version(self): 287 | self.request = APIRequestFactory().get('') 288 | self.serializer = TestSerializer(context={'request': self.request}) 289 | instance = TestModel( 290 | test_field_one='test_one', 291 | test_field_two='test_two', 292 | test_field_three='test_three', 293 | test_field_four='test_four', 294 | test_field_five='test_five', 295 | ) 296 | data = self.serializer.to_representation(instance=instance) 297 | self.assertEqual(data, MatchingSerializer().to_representation(instance)) 298 | 299 | def test_to_representation_returns_empty_serialization_if_no_instance(self): 300 | data = self.serializer.to_representation(instance=None) 301 | self.assertEqual(data, MatchingSerializer().to_representation(None)) 302 | 303 | @patch('rest_framework_transforms.serializers.get_transform_classes') 304 | def test_to_representation_doesnt_get_transform_classes_without_instance(self, get_transform_classes_mock): 305 | self.serializer.to_representation(instance=None) 306 | self.assertFalse(get_transform_classes_mock.called) 307 | 308 | @patch('rest_framework_transforms.serializers.get_transform_classes') 309 | def test_to_representation_doesnt_get_transform_classes_without_version(self, get_transform_classes_mock): 310 | self.request = APIRequestFactory().get('') 311 | self.serializer = TestSerializer(context={'request': self.request}) 312 | self.serializer.to_representation(instance=TestModel()) 313 | self.assertFalse(get_transform_classes_mock.called) 314 | 315 | @patch('rest_framework_transforms.serializers.get_transform_classes') 316 | def test_model_to_representation_gets_transform_classes_with_instance(self, get_transform_classes_mock): 317 | self.model_serializer.to_representation(instance=TestModel()) 318 | self.assertTrue(get_transform_classes_mock.called) 319 | get_transform_classes_mock.assert_called_once_with( 320 | 'tests.test_transforms.TestModelTransform', 321 | base_version=self.request.version, 322 | reverse=True, 323 | ) 324 | 325 | @patch('rest_framework_transforms.serializers.get_transform_classes') 326 | def test_model_to_representation_calls_backwards_on_transform_classes_with_instance(self, get_transform_classes_mock): 327 | instance = TestModel( 328 | test_field_one='test_one', 329 | test_field_two='test_two', 330 | test_field_three='test_three', 331 | test_field_four='test_four', 332 | test_field_five='test_five', 333 | ) 334 | transform_one = MagicMock() 335 | transform_two = MagicMock() 336 | get_transform_classes_mock.return_value = [ 337 | transform_one, 338 | transform_two, 339 | ] 340 | 341 | self.model_serializer.to_representation(instance=instance) 342 | 343 | self.assertTrue(transform_one.return_value.backwards.called) 344 | transform_one.return_value.backwards.assert_called_once_with( 345 | MatchingModelSerializer().to_representation(instance), 346 | self.request, 347 | instance, 348 | ) 349 | self.assertTrue(transform_two.return_value.backwards.called) 350 | transform_two.return_value.backwards.assert_called_once_with( 351 | transform_one.return_value.backwards.return_value, 352 | self.request, 353 | instance, 354 | ) 355 | 356 | def test_model_to_representation_returns_default_serialization_if_no_request(self): 357 | self.model_serializer = TestModelSerializer(context={'request': None}) 358 | instance = TestModel( 359 | test_field_one='test_one', 360 | test_field_two='test_two', 361 | test_field_three='test_three', 362 | test_field_four='test_four', 363 | test_field_five='test_five', 364 | ) 365 | data = self.model_serializer.to_representation(instance=instance) 366 | self.assertEqual(data, MatchingModelSerializer().to_representation(instance)) 367 | 368 | def test_model_to_representation_returns_default_serialization_if_no_request_version(self): 369 | self.request = APIRequestFactory().get('') 370 | self.model_serializer = TestModelSerializer(context={'request': self.request}) 371 | instance = TestModel( 372 | test_field_one='test_one', 373 | test_field_two='test_two', 374 | test_field_three='test_three', 375 | test_field_four='test_four', 376 | test_field_five='test_five', 377 | ) 378 | data = self.model_serializer.to_representation(instance=instance) 379 | self.assertEqual(data, MatchingModelSerializer().to_representation(instance)) 380 | 381 | def test_model_to_representation_returns_empty_serialization_if_no_instance(self): 382 | data = self.model_serializer.to_representation(instance=None) 383 | self.assertEqual(data, MatchingModelSerializer().to_representation(None)) 384 | 385 | @patch('rest_framework_transforms.serializers.get_transform_classes') 386 | def test_model_to_representation_doesnt_get_transform_classes_without_instance(self, get_transform_classes_mock): 387 | self.model_serializer.to_representation(instance=None) 388 | self.assertFalse(get_transform_classes_mock.called) 389 | 390 | @patch('rest_framework_transforms.serializers.get_transform_classes') 391 | def test_model_to_representation_doesnt_get_transform_classes_without_version(self, get_transform_classes_mock): 392 | self.request = APIRequestFactory().get('') 393 | self.model_serializer = TestModelSerializer(context={'request': self.request}) 394 | self.model_serializer.to_representation(instance=TestModel()) 395 | self.assertFalse(get_transform_classes_mock.called) 396 | 397 | 398 | class VersioningSerializerIntegrationTests(TestCase): 399 | def setUp(self): 400 | self.request = APIRequestFactory().get('') 401 | self.request.version = 1 402 | self.serializer = TestSerializerV3(context={'request': self.request}) 403 | self.model_serializer = TestModelSerializerV3(context={'request': self.request}) 404 | self.instance = TestModelV3.objects.create( 405 | test_field_two='value_two', 406 | test_field_three='value_three', 407 | test_field_four='value_four', 408 | test_field_five='value_five', 409 | new_test_field='SOME TEST VALUE', 410 | ) 411 | self.instance.new_related_object_id_list.create() 412 | self.instance.new_related_object_id_list.create() 413 | self.instance.new_related_object_id_list.create() 414 | self.instance.new_related_object_id_list.create() 415 | self.instance = TestModelV3.objects.get(id=self.instance.id) 416 | 417 | @pytest.mark.django_db 418 | def test_serialization_does_backwards_conversion_v3_to_v1(self): 419 | data = self.serializer.to_representation(instance=self.instance) 420 | self.assertTrue('test_field_one' in data) 421 | self.assertEqual(data['test_field_one'], 'SOME TEST VALUE') 422 | self.assertFalse('new_related_object_id_list' in data) 423 | 424 | @pytest.mark.django_db 425 | def test_serialization_does_backwards_conversion_v3_to_v2(self): 426 | self.request.version = 2 427 | self.serializer = TestSerializerV3(context={'request': self.request}) 428 | data = self.serializer.to_representation(instance=self.instance) 429 | self.assertFalse('test_field_one' in data) 430 | self.assertFalse('new_related_object_id_list' in data) 431 | 432 | @pytest.mark.django_db 433 | def test_serialization_does_nothing_on_v3_to_v3(self): 434 | self.request.version = 3 435 | self.serializer = TestSerializerV3(context={'request': self.request}) 436 | data = self.serializer.to_representation(instance=self.instance) 437 | self.assertFalse('test_field_one' in data) 438 | self.assertTrue('new_related_object_id_list' in data) 439 | self.assertEqual(data['new_related_object_id_list'], [1, 2, 3, 4]) 440 | 441 | @pytest.mark.django_db 442 | def test_model_serialization_does_backwards_conversion_v3_to_v1(self): 443 | data = self.model_serializer.to_representation(instance=self.instance) 444 | self.assertTrue('test_field_one' in data) 445 | self.assertEqual(data['test_field_one'], 'SOME TEST VALUE') 446 | self.assertFalse('new_related_object_id_list' in data) 447 | 448 | @pytest.mark.django_db 449 | def test_model_serialization_does_backwards_conversion_v3_to_v2(self): 450 | self.request.version = 2 451 | self.model_serializer = TestModelSerializerV3(context={'request': self.request}) 452 | data = self.model_serializer.to_representation(instance=self.instance) 453 | self.assertFalse('test_field_one' in data) 454 | self.assertFalse('new_related_object_id_list' in data) 455 | 456 | @pytest.mark.django_db 457 | def test_model_serialization_does_nothing_on_v3_to_v3(self): 458 | self.request.version = 3 459 | self.model_serializer = TestModelSerializerV3(context={'request': self.request}) 460 | data = self.model_serializer.to_representation(instance=self.instance) 461 | self.assertFalse('test_field_one' in data) 462 | self.assertTrue('new_related_object_id_list' in data) 463 | self.assertEqual(data['new_related_object_id_list'], [1, 2, 3, 4]) 464 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | print("configuring settings") 5 | settings.configure( 6 | DEBUG_PROPAGATE_EXCEPTIONS=True, 7 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': ':memory:'}}, 9 | SITE_ID=1, 10 | SECRET_KEY='not very secret in tests', 11 | USE_I18N=True, 12 | USE_L10N=True, 13 | STATIC_URL='/static/', 14 | ROOT_URLCONF='tests.urls', 15 | TEMPLATE_LOADERS=( 16 | 'django.template.loaders.filesystem.Loader', 17 | 'django.template.loaders.app_directories.Loader', 18 | ), 19 | MIDDLEWARE_CLASSES=( 20 | 'django.middleware.common.CommonMiddleware', 21 | 'django.contrib.sessions.middleware.SessionMiddleware', 22 | 'django.middleware.csrf.CsrfViewMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | 'django.contrib.messages.middleware.MessageMiddleware', 25 | ), 26 | INSTALLED_APPS=( 27 | 'django.contrib.auth', 28 | 'django.contrib.contenttypes', 29 | 'django.contrib.sessions', 30 | 'django.contrib.sites', 31 | 'django.contrib.messages', 32 | 'django.contrib.staticfiles', 33 | 34 | 'rest_framework', 35 | 'rest_framework.authtoken', 36 | 'tests', 37 | ), 38 | PASSWORD_HASHERS=( 39 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 40 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 41 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 42 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 43 | 'django.contrib.auth.hashers.MD5PasswordHasher', 44 | 'django.contrib.auth.hashers.CryptPasswordHasher', 45 | ), 46 | ) 47 | 48 | # python_files = ['tests.py'] 49 | 50 | try: 51 | import oauth_provider # NOQA 52 | import oauth2 # NOQA 53 | except ImportError: 54 | pass 55 | else: 56 | settings.INSTALLED_APPS += ( 57 | 'oauth_provider', 58 | ) 59 | 60 | try: 61 | import provider # NOQA 62 | except ImportError: 63 | pass 64 | else: 65 | settings.INSTALLED_APPS += ( 66 | 'provider', 67 | 'provider.oauth2', 68 | ) 69 | 70 | # guardian is optional 71 | try: 72 | import guardian # NOQA 73 | except ImportError: 74 | pass 75 | else: 76 | settings.ANONYMOUS_USER_ID = -1 77 | settings.AUTHENTICATION_BACKENDS = ( 78 | 'django.contrib.auth.backends.ModelBackend', 79 | 'guardian.backends.ObjectPermissionBackend', 80 | ) 81 | settings.INSTALLED_APPS += ( 82 | 'guardian', 83 | ) 84 | 85 | try: 86 | import django 87 | django.setup() 88 | except AttributeError: 89 | pass 90 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='OtherTestModel', 15 | fields=[ 16 | ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), 17 | ], 18 | ), 19 | migrations.CreateModel( 20 | name='TestModel', 21 | fields=[ 22 | ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), 23 | ('test_field_one', models.CharField(max_length=20)), 24 | ('test_field_two', models.CharField(max_length=20)), 25 | ('test_field_three', models.CharField(max_length=20)), 26 | ('test_field_four', models.CharField(max_length=20)), 27 | ('test_field_five', models.CharField(max_length=20)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='TestModelV3', 32 | fields=[ 33 | ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)), 34 | ('test_field_two', models.CharField(max_length=20)), 35 | ('test_field_three', models.CharField(max_length=20)), 36 | ('test_field_four', models.CharField(max_length=20)), 37 | ('test_field_five', models.CharField(max_length=20)), 38 | ('new_test_field', models.CharField(max_length=20)), 39 | ], 40 | ), 41 | migrations.AddField( 42 | model_name='othertestmodel', 43 | name='some_foreign_key', 44 | field=models.ForeignKey(related_name='new_related_object_id_list', to='tests.TestModelV3'), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrhwick/django-rest-framework-version-transforms/1811653350b136efe02c3142dfdbe6c6e77a43d4/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Model 3 | 4 | 5 | class TestModel(Model): 6 | test_field_one = models.CharField(max_length=20) 7 | test_field_two = models.CharField(max_length=20) 8 | test_field_three = models.CharField(max_length=20) 9 | test_field_four = models.CharField(max_length=20) 10 | test_field_five = models.CharField(max_length=20) 11 | 12 | 13 | class TestModelV3(Model): 14 | test_field_two = models.CharField(max_length=20) 15 | test_field_three = models.CharField(max_length=20) 16 | test_field_four = models.CharField(max_length=20) 17 | test_field_five = models.CharField(max_length=20) 18 | new_test_field = models.CharField(max_length=20) 19 | 20 | 21 | class OtherTestModel(Model): 22 | some_foreign_key = models.ForeignKey(TestModelV3, related_name='new_related_object_id_list') 23 | -------------------------------------------------------------------------------- /tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | from rest_framework_transforms.parsers import BaseVersioningParser 2 | 3 | 4 | class TestParser(BaseVersioningParser): 5 | media_type = 'application/vnd.test.testtype+json' 6 | transform_base = 'tests.test_transforms.TestModelTransform' 7 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework_transforms.serializers import BaseVersioningSerializer 3 | from tests.models import TestModel, TestModelV3 4 | 5 | 6 | class TestModelSerializer(BaseVersioningSerializer, serializers.ModelSerializer): 7 | transform_base = 'tests.test_transforms.TestModelTransform' 8 | 9 | class Meta: 10 | model = TestModel 11 | exclude = tuple() 12 | 13 | 14 | class MatchingModelSerializer(serializers.ModelSerializer): 15 | class Meta: 16 | model = TestModel 17 | exclude = tuple() 18 | 19 | 20 | class TestModelSerializerV3(BaseVersioningSerializer, serializers.ModelSerializer): 21 | transform_base = 'tests.test_transforms.TestModelTransform' 22 | 23 | class Meta: 24 | model = TestModelV3 25 | fields = ( 26 | 'test_field_two', 27 | 'test_field_three', 28 | 'test_field_four', 29 | 'test_field_five', 30 | 'new_test_field', 31 | 'new_related_object_id_list', 32 | ) 33 | 34 | 35 | class TestSerializer(BaseVersioningSerializer, serializers.Serializer): 36 | transform_base = 'tests.test_transforms.TestModelTransform' 37 | 38 | test_field_one = serializers.CharField() 39 | test_field_two = serializers.CharField() 40 | test_field_three = serializers.CharField() 41 | test_field_four = serializers.CharField() 42 | test_field_five = serializers.CharField() 43 | 44 | 45 | class MatchingSerializer(serializers.Serializer): 46 | test_field_one = serializers.CharField() 47 | test_field_two = serializers.CharField() 48 | test_field_three = serializers.CharField() 49 | test_field_four = serializers.CharField() 50 | test_field_five = serializers.CharField() 51 | 52 | 53 | class TestSerializerV3(BaseVersioningSerializer, serializers.Serializer): 54 | transform_base = 'tests.test_transforms.TestModelTransform' 55 | 56 | test_field_two = serializers.CharField() 57 | test_field_three = serializers.CharField() 58 | test_field_four = serializers.CharField() 59 | test_field_five = serializers.CharField() 60 | new_test_field = serializers.CharField() 61 | new_related_object_id_list = serializers.PrimaryKeyRelatedField(many=True, read_only=True) 62 | -------------------------------------------------------------------------------- /tests/test_transforms.py: -------------------------------------------------------------------------------- 1 | from rest_framework_transforms.transforms import BaseTransform 2 | 3 | 4 | class TestModelTransform0002(BaseTransform): 5 | def forwards(self, data, request): 6 | if 'test_field_one' in data: 7 | data['new_test_field'] = data.get('test_field_one') 8 | data.pop('test_field_one') 9 | return data 10 | 11 | def backwards(self, data, request, instance): 12 | data['test_field_one'] = data.get('new_test_field') 13 | data.pop('new_test_field') 14 | return data 15 | 16 | 17 | class TestModelTransform0003(BaseTransform): 18 | def forwards(self, data, request): 19 | data['new_related_object_id_list'] = [1, 2, 3, 4, 5] 20 | return data 21 | 22 | def backwards(self, data, request, instance): 23 | data.pop('new_related_object_id_list') 24 | return data 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py34-{flake8,docs}, 4 | {py27,py33,py34}-django{1.6,1.7,1.8}-drf{3.0,3.1,3.2} 5 | 6 | [testenv] 7 | commands = ./runtests.py --fast 8 | setenv = 9 | PYTHONDONTWRITEBYTECODE=1 10 | PYTHONPATH = {toxinidir}:{toxinidir}/django-rest-framework-version-transforms 11 | deps = 12 | django1.6: Django>=1.6, <1.7 13 | django1.7: Django>=1.7, <1.8 14 | django1.8: Django>=1.8, <1.9 15 | drf3.0: djangorestframework>=3.0, <3.1 16 | drf3.1: djangorestframework>=3.1, <3.2 17 | drf3.2: djangorestframework>=3.2, <3.3 18 | pytest-django>=2.8, <2.9 19 | ipdb>=0.8 20 | mock>=1.3.0, <1.4 21 | 22 | [testenv:py34-flake8] 23 | commands = ./runtests.py --lintonly 24 | deps = 25 | pytest>=2.7, <2.8 26 | flake8>=2.4, <2.5 27 | 28 | [testenv:py34-docs] 29 | commands = mkdocs build 30 | deps = 31 | mkdocs>=0.14, <0.15 32 | --------------------------------------------------------------------------------