├── .circleci └── config.yml ├── .github └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── README.txt ├── manage.py ├── pypi_submit.py ├── requirements.txt ├── rest_flex_fields ├── __init__.py ├── filter_backends.py ├── serializers.py ├── utils.py └── views.py ├── setup.py └── tests ├── __init__.py ├── settings.py ├── test_flex_fields_model_serializer.py ├── test_serializer.py ├── test_utils.py ├── test_views.py ├── testapp ├── __init__.py ├── apps.py ├── models.py ├── serializers.py └── views.py └── urls.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | working_directory: ~/drf-flex-fields 5 | docker: 6 | - image: circleci/python:3.7.3 7 | 8 | steps: 9 | - checkout 10 | - run: sudo chown -R circleci:circleci /usr/local/bin 11 | - run: sudo chown -R circleci:circleci /usr/local/lib/python3.7/site-packages 12 | - restore_cache: 13 | key: deps9-{{ .Branch }}-{{ checksum "requirements.txt" }} 14 | - run: 15 | name: Install Python dependencies 16 | command: | 17 | pip install -r requirements.txt --user 18 | - save_cache: 19 | key: deps9-{{ .Branch }}-{{ checksum "requirements.txt" }} 20 | paths: 21 | - ".venv" 22 | - "/usr/local/bin" 23 | - "/usr/local/lib/python3.7/site-packages" 24 | - run: 25 | name: Run Tests 26 | command: | 27 | python manage.py test -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | Test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 4 12 | matrix: 13 | python-version: [ 3.7, 3.8, 3.9 ] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install Dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r requirements.txt 25 | - name: Run Tests 26 | run: | 27 | python manage.py test 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | local-development.txt 4 | local-dev.txt 5 | dist/ 6 | MANIFEST 7 | .mypy_cache/ 8 | .idea/ 9 | .vscode/ 10 | drf_flex_fields.egg-info/ 11 | venv.sh 12 | .venv 13 | venv/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 rsinger86 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include README.txt 4 | recursive-include tests *.py 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django REST - FlexFields 2 | 3 | [![Package version](https://badge.fury.io/py/drf-flex-fields.svg)](https://pypi.python.org/pypi/drf-flex-fields) 4 | [![Python versions](https://img.shields.io/pypi/status/drf-flex-fields.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) 5 | 6 | Flexible, dynamic fields and nested models for Django REST Framework serializers. 7 | 8 | # Overview 9 | 10 | FlexFields (DRF-FF) for [Django REST Framework](https://django-rest-framework.org) is a package designed to provide a common baseline of functionality for dynamically setting fields and nested models within DRF serializers. This package is designed for simplicity, with minimal magic and entanglement with DRF's foundational classes. 11 | 12 | Key benefits: 13 | 14 | - Easily set up fields that be expanded to their fully serialized counterparts via query parameters (`users/?expand=organization,friends`) 15 | - Select a subset of fields by either: 16 | - specifying which ones should be included (`users/?fields=id,first_name`) 17 | - specifying which ones should be excluded (`users/?omit=id,first_name`) 18 | - Use dot notation to dynamically modify fields at arbitrary depths (`users/?expand=organization.owner.roles`) 19 | - Flexible API - options can also be passed directly to a serializer: `UserSerializer(obj, expand=['organization'])` 20 | 21 | # Quick Start 22 | 23 | ```python 24 | from rest_flex_fields import FlexFieldsModelSerializer 25 | 26 | class StateSerializer(FlexFieldsModelSerializer): 27 | class Meta: 28 | model = State 29 | fields = ('id', 'name') 30 | 31 | class CountrySerializer(FlexFieldsModelSerializer): 32 | class Meta: 33 | model = Country 34 | fields = ('id', 'name', 'population', 'states') 35 | expandable_fields = { 36 | 'states': (StateSerializer, {'many': True}) 37 | } 38 | 39 | class PersonSerializer(FlexFieldsModelSerializer): 40 | class Meta: 41 | model = Person 42 | fields = ('id', 'name', 'country', 'occupation') 43 | expandable_fields = {'country': CountrySerializer} 44 | ``` 45 | 46 | ``` 47 | GET /people/142/ 48 | ``` 49 | 50 | ```json 51 | { 52 | "id": 142, 53 | "name": "Jim Halpert", 54 | "country": 1 55 | } 56 | ``` 57 | 58 | ``` 59 | GET /people/142/?expand=country.states 60 | ``` 61 | 62 | ```json 63 | { 64 | "id": 142, 65 | "name": "Jim Halpert", 66 | "country": { 67 | "id": 1, 68 | "name": "United States", 69 | "states": [ 70 | { 71 | "id": 23, 72 | "name": "Ohio" 73 | }, 74 | { 75 | "id": 2, 76 | "name": "Pennsylvania" 77 | } 78 | ] 79 | } 80 | } 81 | ``` 82 | 83 | # Table of Contents: 84 | 85 | - [Django REST - FlexFields](#django-rest---flexfields) 86 | - [Overview](#overview) 87 | - [Quick Start](#quick-start) 88 | - [Table of Contents:](#table-of-contents) 89 | - [Setup](#setup) 90 | - [Usage](#usage) 91 | - [Dynamic Field Expansion](#dynamic-field-expansion) 92 | - [Deferred Fields](#deferred-fields) 93 | - [Deep, Nested Expansion](#deep-nested-expansion) 94 | - [Field Expansion on "List" Views ](#field-expansion-on-list-views-) 95 | - [Expanding a "Many" Relationship ](#expanding-a-many-relationship-) 96 | - [Dynamically Setting Fields (Sparse Fields) ](#dynamically-setting-fields-sparse-fields-) 97 | - [Reference serializer as a string (lazy evaluation) ](#reference-serializer-as-a-string-lazy-evaluation-) 98 | - [Increased re-usability of serializers ](#increased-re-usability-of-serializers-) 99 | - [Serializer Options](#serializer-options) 100 | - [Advanced](#advanced) 101 | - [Customization](#customization) 102 | - [Serializer Introspection](#serializer-introspection) 103 | - [Use Wildcards to Match Multiple Fields](#wildcards) 104 | - [Combining Sparse Fields and Field Expansion ](#combining-sparse-fields-and-field-expansion-) 105 | - [Utility Functions ](#utility-functions-) 106 | - [rest_flex_fields.is_expanded(request, field: str)](#rest_flex_fieldsis_expandedrequest-field-str) 107 | - [rest_flex_fields.is_included(request, field: str)](#rest_flex_fieldsis_includedrequest-field-str) 108 | - [Query optimization (experimental)](#query-optimization-experimental) 109 | - [Changelog ](#changelog-) 110 | - [Testing](#testing) 111 | - [License](#license) 112 | 113 | # Setup 114 | 115 | First install: 116 | 117 | ``` 118 | pip install drf-flex-fields 119 | ``` 120 | 121 | Then have your serializers subclass `FlexFieldsModelSerializer`: 122 | 123 | ```python 124 | from rest_flex_fields import FlexFieldsModelSerializer 125 | 126 | class StateSerializer(FlexFieldsModelSerializer): 127 | class Meta: 128 | model = Country 129 | fields = ('id', 'name') 130 | 131 | class CountrySerializer(FlexFieldsModelSerializer): 132 | class Meta: 133 | model = Country 134 | fields = ('id', 'name', 'population', 'states') 135 | expandable_fields = { 136 | 'states': (StateSerializer, {'many': True}) 137 | } 138 | ``` 139 | 140 | Alternatively, you can add the `FlexFieldsSerializerMixin` mixin to a model serializer. 141 | 142 | # Usage 143 | 144 | ## Dynamic Field Expansion 145 | 146 | To define expandable fields, add an `expandable_fields` dictionary to your serializer's `Meta` class. Key the dictionary with the name of the field that you want to dynamically expand, and set its value to either the expanded serializer or a tuple where the first element is the serializer and the second is a dictionary of options that will be used to instantiate the serializer. 147 | 148 | ```python 149 | class CountrySerializer(FlexFieldsModelSerializer): 150 | class Meta: 151 | model = Country 152 | fields = ['name', 'population'] 153 | 154 | 155 | class PersonSerializer(FlexFieldsModelSerializer): 156 | country = serializers.PrimaryKeyRelatedField(read_only=True) 157 | 158 | class Meta: 159 | model = Person 160 | fields = ['id', 'name', 'country', 'occupation'] 161 | 162 | expandable_fields = { 163 | 'country': CountrySerializer 164 | } 165 | ``` 166 | 167 | If the default serialized response is the following: 168 | 169 | ```json 170 | { 171 | "id": 13322, 172 | "name": "John Doe", 173 | "country": 12, 174 | "occupation": "Programmer" 175 | } 176 | ``` 177 | 178 | When you do a `GET /person/13322?expand=country`, the response will change to: 179 | 180 | ```json 181 | { 182 | "id": 13322, 183 | "name": "John Doe", 184 | "country": { 185 | "name": "United States", 186 | "population": 330000000 187 | }, 188 | "occupation": "Programmer" 189 | } 190 | ``` 191 | 192 | ## Deferred Fields 193 | 194 | Alternatively, you could treat `country` as a "deferred" field by not defining it among the default fields. To make a field deferred, only define it within the serializer's `expandable_fields`. 195 | 196 | ## Deep, Nested Expansion 197 | 198 | Let's say you add `StateSerializer` as a serializer nested inside the country serializer above: 199 | 200 | ```python 201 | class StateSerializer(FlexFieldsModelSerializer): 202 | class Meta: 203 | model = State 204 | fields = ['name', 'population'] 205 | 206 | 207 | class CountrySerializer(FlexFieldsModelSerializer): 208 | class Meta: 209 | model = Country 210 | fields = ['name', 'population'] 211 | 212 | expandable_fields = { 213 | 'states': (StateSerializer, {'many': True}) 214 | } 215 | 216 | class PersonSerializer(FlexFieldsModelSerializer): 217 | country = serializers.PrimaryKeyRelatedField(read_only=True) 218 | 219 | class Meta: 220 | model = Person 221 | fields = ['id', 'name', 'country', 'occupation'] 222 | 223 | expandable_fields = { 224 | 'country': CountrySerializer 225 | } 226 | ``` 227 | 228 | Your default serialized response might be the following for `person` and `country`, respectively: 229 | 230 | ```json 231 | { 232 | "id" : 13322, 233 | "name" : "John Doe", 234 | "country" : 12, 235 | "occupation" : "Programmer", 236 | } 237 | 238 | { 239 | "id" : 12, 240 | "name" : "United States", 241 | "states" : "http://www.api.com/countries/12/states" 242 | } 243 | ``` 244 | 245 | But if you do a `GET /person/13322?expand=country.states`, it would be: 246 | 247 | ```json 248 | { 249 | "id": 13322, 250 | "name": "John Doe", 251 | "occupation": "Programmer", 252 | "country": { 253 | "id": 12, 254 | "name": "United States", 255 | "states": [ 256 | { 257 | "name": "Ohio", 258 | "population": 11000000 259 | } 260 | ] 261 | } 262 | } 263 | ``` 264 | 265 | Please be kind to your database, as this could incur many additional queries. Though, you can mitigate this impact through judicious use of `prefetch_related` and `select_related` when defining the queryset for your viewset. 266 | 267 | ## Field Expansion on "List" Views 268 | 269 | If you request many objects, expanding fields could lead to many additional database queries. Subclass `FlexFieldsModelViewSet` if you want to prevent expanding fields by default when calling a ViewSet's `list` method. Place those fields that you would like to expand in a `permit_list_expands` property on the ViewSet: 270 | 271 | ```python 272 | from rest_flex_fields import is_expanded 273 | 274 | class PersonViewSet(FlexFieldsModelViewSet): 275 | permit_list_expands = ['employer'] 276 | serializer_class = PersonSerializer 277 | 278 | def get_queryset(self): 279 | queryset = models.Person.objects.all() 280 | if is_expanded(self.request, 'employer'): 281 | queryset = queryset.select_related('employer') 282 | return queryset 283 | ``` 284 | 285 | Notice how this example is using the `is_expanded` utility method as well as `select_related` and `prefetch_related` to efficiently query the database if the field is expanded. 286 | 287 | ## Expanding a "Many" Relationship 288 | 289 | Set `many` to `True` in the serializer options to make sure "to many" fields are expanded correctly. 290 | 291 | ```python 292 | class StateSerializer(FlexFieldsModelSerializer): 293 | class Meta: 294 | model = State 295 | fields = ['name', 'population'] 296 | 297 | 298 | class CountrySerializer(FlexFieldsModelSerializer): 299 | class Meta: 300 | model = Country 301 | fields = ['name', 'population'] 302 | 303 | expandable_fields = { 304 | 'states': (StateSerializer, {'many': True}) 305 | } 306 | ``` 307 | 308 | A request to `GET /countries?expand=states` will return: 309 | 310 | ```python 311 | { 312 | "id" : 12, 313 | "name" : "United States", 314 | "states" : [ 315 | { 316 | "name" : "Alabama", 317 | "population": 11000000 318 | }, 319 | //... more states ... // 320 | { 321 | "name" : "Ohio", 322 | "population": 11000000 323 | } 324 | ] 325 | } 326 | ``` 327 | 328 | ## Dynamically Setting Fields (Sparse Fields) 329 | 330 | You can use either the `fields` or `omit` keywords to declare only the fields you want to include or to specify fields that should be excluded. 331 | 332 | Consider this as a default serialized response: 333 | 334 | ```json 335 | { 336 | "id": 13322, 337 | "name": "John Doe", 338 | "country": { 339 | "name": "United States", 340 | "population": 330000000 341 | }, 342 | "occupation": "Programmer", 343 | "hobbies": ["rock climbing", "sipping coffee"] 344 | } 345 | ``` 346 | 347 | To whittle down the fields via URL parameters, simply add `?fields=id,name,country` to your requests to get back: 348 | 349 | ```json 350 | { 351 | "id": 13322, 352 | "name": "John Doe", 353 | "country": { 354 | "name": "United States", 355 | "population": 330000000 356 | } 357 | } 358 | ``` 359 | 360 | Or, for more specificity, you can use dot-notation, `?fields=id,name,country.name`: 361 | 362 | ```json 363 | { 364 | "id": 13322, 365 | "name": "John Doe", 366 | "country": { 367 | "name": "United States" 368 | } 369 | } 370 | ``` 371 | 372 | Or, if you want to leave out the nested country object, do `?omit=country`: 373 | 374 | ```json 375 | { 376 | "id": 13322, 377 | "name": "John Doe", 378 | "occupation": "Programmer", 379 | "hobbies": ["rock climbing", "sipping coffee"] 380 | } 381 | ``` 382 | 383 | ## Reference serializer as a string (lazy evaluation) 384 | 385 | To avoid circular import problems, it's possible to lazily evaluate a string reference to you serializer class using this syntax: 386 | 387 | ```python 388 | expandable_fields = { 389 | 'record_set': ('.RelatedSerializer', {'many': True}) 390 | } 391 | ``` 392 | 393 | **Note**: 394 | Prior to version `0.9.0`, it was assumed your serializer classes would be in a module with the following path: 395 | `.serializers`. 396 | 397 | This import style will still work, but you can also now specify fully-qualified import paths to any locations. 398 | 399 | ## Increased re-usability of serializers 400 | 401 | The `omit` and `fields` options can be passed directly to serializers. Rather than defining a separate, slimmer version of a regular serializer, you can re-use the same serializer and declare which fields you want. 402 | 403 | ```python 404 | from rest_flex_fields import FlexFieldsModelSerializer 405 | 406 | class CountrySerializer(FlexFieldsModelSerializer): 407 | class Meta: 408 | model = Country 409 | fields = ['id', 'name', 'population', 'capital', 'square_miles'] 410 | 411 | class PersonSerializer(FlexFieldsModelSerializer): 412 | country = CountrySerializer(fields=['id', 'name']) 413 | 414 | class Meta: 415 | model = Person 416 | fields = ['id', 'name', 'country'] 417 | 418 | 419 | serializer = PersonSerializer(person) 420 | print(serializer.data) 421 | 422 | >>>{ 423 | "id": 13322, 424 | "name": "John Doe", 425 | "country": { 426 | "id": 1, 427 | "name": "United States", 428 | } 429 | } 430 | ``` 431 | 432 | # Serializer Options 433 | 434 | Dynamic field options can be passed in the following ways: 435 | 436 | - from the request's query parameters; separate multiple values with a commma 437 | - as keyword arguments directly to the serializer class when its constructed 438 | - from a dictionary placed as the second element in a tuple when defining `expandable_fields` 439 | 440 | Approach #1 441 | 442 | ``` 443 | GET /people?expand=friends.hobbies,employer&omit=age 444 | ``` 445 | 446 | Approach #2 447 | 448 | ```python 449 | serializer = PersonSerializer( 450 | person, 451 | expand=["friends.hobbies", "employer"], 452 | omit="friends.age" 453 | ) 454 | ``` 455 | 456 | Approach #3 457 | 458 | ```python 459 | 460 | class PersonSerializer(FlexFieldsModelSerializer): 461 | // Your field definitions 462 | 463 | class Meta: 464 | model = Person 465 | fields = ["age", "hobbies", "name"] 466 | expandable_fields = { 467 | 'friends': ( 468 | 'serializer.FriendSerializer', 469 | {'many': True, "expand": ["hobbies"], "omit": ["age"]} 470 | ) 471 | } 472 | ``` 473 | 474 | | Option | Description | 475 | | ------ | :--------------------------------------------------------------------------: | 476 | | expand | Fields to expand; must be configured in the serializer's `expandable_fields` | 477 | | fields | Fields that should be included; all others will be excluded | 478 | | omit | Fields that should be excluded; all others will be included | 479 | 480 | # Advanced 481 | 482 | ## Customization 483 | 484 | Parameter names and wildcard values can be configured within a Django setting, named `REST_FLEX_FIELDS`. 485 | 486 | | Option | Description | Default | 487 | |-------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|-----------------| 488 | | EXPAND_PARAM | The name of the parameter with the fields to be expanded | `"expand"` | 489 | | MAXIMUM_EXPANSION_DEPTH | The max allowed expansion depth. By default it's unlimited. Expanding `state.towns` would equal a depth of 2 | `None` | 490 | | FIELDS_PARAM | The name of the parameter with the fields to be included (others will be omitted) | `"fields"` | 491 | | OMIT_PARAM | The name of the parameter with the fields to be omitted | `"omit"` | 492 | | RECURSIVE_EXPANSION_PERMITTED | If `False`, an exception is raised when a recursive pattern is found | `True` | 493 | | WILDCARD_VALUES | List of values that stand in for all field names. Can be used with the `fields` and `expand` parameters.

When used with `expand`, a wildcard value will trigger the expansion of all `expandable_fields` at a given level.

When used with `fields`, all fields are included at a given level. For example, you could pass `fields=name,state.*` if you have a city resource with a nested state in order to expand only the city's name field and all of the state's fields.

To disable use of wildcards, set this setting to `None`. | `["*", "~all"]` | 494 | 495 | For example, if you want your API to work a bit more like [JSON API](https://jsonapi.org/format/#fetching-includes), you could do: 496 | 497 | ```python 498 | REST_FLEX_FIELDS = {"EXPAND_PARAM": "include"} 499 | ``` 500 | 501 | ### Defining Expansion and Recursive Limits on Serializer Classes 502 | 503 | A `maximum_expansion_depth` integer property can be set on a serializer class. 504 | 505 | `recursive_expansion_permitted` boolean property can be set on a serializer class. 506 | 507 | Both settings raise `serializers.ValidationError` when conditions are met but exceptions can be customized by overriding the `recursive_expansion_not_permitted` and `expansion_depth_exceeded` methods. 508 | 509 | 510 | ## Serializer Introspection 511 | 512 | When using an instance of `FlexFieldsModelSerializer`, you can examine the property `expanded_fields` to discover which fields, if any, have been dynamically expanded. 513 | 514 | ## Use of Wildcard to Match All Fields 515 | 516 | You can pass `expand=*` ([or another value of your choosing](#customization)) to automatically expand all fields that are available for expansion at a given level. To refer to nested resources, you can use dot-notation. For example, requesting `expand=menu.sections` for a restaurant resource would expand its nested `menu` resource, as well as that menu's nested `sections` resource. 517 | 518 | Or, when requesting sparse fields, you can pass `fields=*` to include only the specified fields at a given level. To refer to nested resources, you can use dot-notation. For example, if you have an `order` resource, you could request all of its fields as well as only two fields on its nested `restaurant` resource with the following: `fields=*,restaurent.name,restaurant.address&expand=restaurant`. 519 | 520 | ## Combining Sparse Fields and Field Expansion 521 | 522 | You may be wondering how things work if you use both the `expand` and `fields` option, and there is overlap. For example, your serialized person model may look like the following by default: 523 | 524 | ```json 525 | { 526 | "id": 13322, 527 | "name": "John Doe", 528 | "country": { 529 | "name": "United States" 530 | } 531 | } 532 | ``` 533 | 534 | However, you make the following request `HTTP GET /person/13322?include=id,name&expand=country`. You will get the following back: 535 | 536 | ```json 537 | { 538 | "id": 13322, 539 | "name": "John Doe" 540 | } 541 | ``` 542 | 543 | The `fields` parameter takes precedence over `expand`. That is, if a field is not among the set that is explicitly alllowed, it cannot be expanded. If such a conflict occurs, you will not pay for the extra database queries - the expanded field will be silently abandoned. 544 | 545 | ## Utility Functions 546 | 547 | ### rest_flex_fields.is_expanded(request, field: str) 548 | 549 | Checks whether a field has been expanded via the request's query parameters. 550 | 551 | **Parameters** 552 | 553 | - **request**: The request object 554 | - **field**: The name of the field to check 555 | 556 | ### rest_flex_fields.is_included(request, field: str) 557 | 558 | Checks whether a field has NOT been excluded via either the `omit` parameter or the `fields` parameter. 559 | 560 | **Parameters** 561 | 562 | - **request**: The request object 563 | - **field**: The name of the field to check 564 | 565 | ## Query optimization (experimental) 566 | 567 | An experimental filter backend is available to help you automatically reduce the number of SQL queries and their transfer size. _This feature has not been tested thorougly and any help testing and reporting bugs is greatly appreciated._ You can add FlexFieldFilterBackend to `DEFAULT_FILTER_BACKENDS` in the settings: 568 | 569 | ```python 570 | # settings.py 571 | 572 | REST_FRAMEWORK = { 573 | 'DEFAULT_FILTER_BACKENDS': ( 574 | 'rest_flex_fields.filter_backends.FlexFieldsFilterBackend', 575 | # ... 576 | ), 577 | # ... 578 | } 579 | ``` 580 | 581 | It will automatically call `select_related` and `prefetch_related` on the current QuerySet by determining which fields are needed from many-to-many and foreign key-related models. For sparse fields requests (`?omit=fieldX,fieldY` or `?fields=fieldX,fieldY`), the backend will automatically call `only(*field_names)` using only the fields needed for serialization. 582 | 583 | **WARNING:** The optimization currently works only for one nesting level. 584 | 585 | # Changelog 586 | 587 | ## 1.0.2 (March 2023) 588 | 589 | - Adds control over whether recursive expansions are allowed and allows setting the max expansion depth. Thanks @andruten! 590 | 591 | ## 1.0.1 (March 2023) 592 | 593 | - Various bug fixes. Thanks @michaelschem, @andruten, and @erielias! 594 | 595 | ## 1.0.0 (August 2022) 596 | 597 | - Improvements to the filter backends for generic foreign key handling and docs generation. Thanks @KrYpTeD974 and @michaelschem! 598 | 599 | ## 0.9.9 (July 2022) 600 | 601 | - Fixes bug in `FlexFieldsFilterBackend`. Thanks @michaelschem! 602 | - Adds `FlexFieldsDocsFilterBackend` for schema population. Thanks @Rjevski! 603 | 604 | ## 0.9.8 (April 2022) 605 | 606 | - Set expandable fields as the default example for expand query parameters in `coreapi.Field`. Thanks @JasperSui! 607 | 608 | ## 0.9.7 (January 2022) 609 | 610 | - Includes m2m in prefetch_related clause even if they're not expanded. Thanks @pablolmedorado and @ADR-007! 611 | 612 | ## 0.9.6 (November 2021) 613 | 614 | - Make it possible to use wildcard values with sparse fields requests. 615 | 616 | ## 0.9.5 (October 2021) 617 | 618 | - Adds OpenAPI support. Thanks @soroush-tabesh! 619 | - Updates tests for Django 3.2 and fixes deprecation warning. Thanks @giovannicimolin! 620 | 621 | ## 0.9.3 (August 2021) 622 | 623 | - Fixes bug where custom parameter names were not passed when constructing nested serializers. Thanks @Kandeel4411! 624 | 625 | ## 0.9.2 (June 2021) 626 | 627 | - Ensures `context` dict is passed down to expanded serializers. Thanks @nikeshyad! 628 | 629 | ## 0.9.1 (June 2021) 630 | 631 | - No longer auto removes `source` argument if it's equal to the field name. 632 | 633 | ## 0.9.0 (April 2021) 634 | 635 | - Allows fully qualified import strings for lazy serializer classes. 636 | 637 | ## 0.8.9 (February 2021) 638 | 639 | - Adds OpenAPI support to experimental filter backend. Thanks @LukasBerka! 640 | 641 | ## 0.8.8 (September 2020) 642 | 643 | - Django 3.1.1 fix. Thansks @NiyazNz! 644 | - Docs typo fix. Thanks @zakjholt! 645 | 646 | ## 0.8.6 (September 2020) 647 | 648 | - Adds `is_included` utility function. 649 | 650 | ## 0.8.5 (May 2020) 651 | 652 | - Adds options to customize parameter names and wildcard values. Closes #10. 653 | 654 | ## 0.8.1 (May 2020) 655 | 656 | - Fixes #44, related to the experimental filter backend. Thanks @jsatt! 657 | 658 | ## 0.8.0 (April 2020) 659 | 660 | - Adds support for `expand`, `omit` and `fields` query parameters for non-GET requests. 661 | - The common use case is creating/updating a model instance and returning a serialized response with expanded fields 662 | - Thanks @kotepillar for raising the issue (#25) and @Crocmagnon for the idea of delaying field modification to `to_representation()`. 663 | 664 | ## 0.7.5 (February 2020) 665 | 666 | - Simplifies declaration of `expandable_fields` 667 | - If using a tuple, the second element - to define the serializer settings - is now optional. 668 | - Instead of a tuple, you can now just use the serializer class or a string to lazily reference that class. 669 | - Updates documentation. 670 | 671 | ## 0.7.0 (February 2020) 672 | 673 | - Adds support for different ways of passing arrays in query strings. Thanks @sentyaev! 674 | - Fixes attribute error when map is supplied to split levels utility function. Thanks @hemache! 675 | 676 | ## 0.6.1 (September 2019) 677 | 678 | - Adds experimental support for automatically SQL query optimization via a `FlexFieldsFilterBackend`. Thanks ADR-007! 679 | - Adds CircleCI config file. Thanks mikeIFTS! 680 | - Moves declaration of `expandable_fields` to `Meta` class on serialzer for consistency with DRF (will continue to support declaration as class property) 681 | - Python 2 is no longer supported. If you need Python 2 support, you can continue to use older versions of this package. 682 | 683 | ## 0.5.0 (April 2019) 684 | 685 | - Added support for `omit` keyword for field exclusion. Code clean up and improved test coverage. 686 | 687 | ## 0.3.4 (May 2018) 688 | 689 | - Handle case where `request` is `None` when accessing request object from serializer. Thanks @jsatt! 690 | 691 | ## 0.3.3 (April 2018) 692 | 693 | - Exposes `FlexFieldsSerializerMixin` in addition to `FlexFieldsModelSerializer`. Thanks @jsatt! 694 | 695 | # Testing 696 | 697 | Tests are found in a simplified DRF project in the `/tests` folder. Install the project requirements and do `./manage.py test` to run them. 698 | 699 | # License 700 | 701 | See [License](LICENSE.md). 702 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | Django REST - FlexFields 2 | ======================== 3 | 4 | Flexible, dynamic fields and nested models for Django REST Framework 5 | serializers. Works with both Python 2 and 3. 6 | 7 | Overview 8 | ======== 9 | 10 | FlexFields (DRF-FF) for `Django REST 11 | Framework `__ is a package designed 12 | to provide a common baseline of functionality for dynamically setting 13 | fields and nested models within DRF serializers. To remove unneeded 14 | fields, you can dynamically set fields, including nested fields, via URL 15 | parameters ``(?fields=name,address.zip)`` or when configuring 16 | serializers. Additionally, you can dynamically expand fields from simple 17 | values to complex nested models, or treat fields as "deferred", and 18 | expand them on an as-needed basis. 19 | 20 | This package is designed for simplicity and provides two classes - a 21 | viewset class and a serializer class (or mixin) - with minimal magic and 22 | entanglement with DRF's foundational classes. Unless DRF makes 23 | significant changes to its serializers, you can count on this package to 24 | work (and if major changes are made, this package will be updated 25 | shortly thereafter). If you are familar with Django REST Framework, it 26 | shouldn't take you long to read over the code and see how it works. 27 | 28 | There are similar packages, such as the powerful `Dynamic 29 | REST `__, which does what 30 | this package does and more, but you may not need all those bells and 31 | whistles. There is also the more basic `Dynamic Fields 32 | Mixin `__, but it lacks 33 | functionality for field expansion and dot-notation field customiziation. 34 | 35 | Table of Contents: 36 | 37 | - `Installation <#installation>`__ 38 | - `Requirements <#requirements>`__ 39 | - `Basics <#basics>`__ 40 | - `Dynamic Field Expansion <#dynamic-field-expansion>`__ 41 | - `Deferred Fields <#deferred-fields>`__ 42 | - `Deep, Nested Expansion <#deep-nested-expansion>`__ 43 | - `Configuration from Serializer 44 | Options <#configuration-from-serializer-options>`__ 45 | - `Field Expansion on "List" Views <#field-expansion-on-list-views>`__ 46 | - `Use "~all" to Expand All Available 47 | Fields <#use-all-to-expand-all-available-fields>`__ 48 | - `Dynamically Setting Fields/Sparse 49 | Fieldsets <#dynamically-setting-fields>`__ 50 | - `From URL Parameters <#from-url-parameters>`__ 51 | - `From Serializer Options <#from-serializer-options>`__ 52 | - `Combining Dynamically-Set Fields and Field 53 | Expansion <#combining-dynamically-set-fields-and-field-expansion>`__ 54 | - `Serializer Introspection <#serializer-introspection>`__ 55 | - `Lazy evaluation of serializer <#lazy-evaluation-of-serializer>`__ 56 | - `Query optimization 57 | (experimental) <#query-optimization-experimental>`__ 58 | - `Change Log <#changelog>`__ 59 | - `Testing <#testing>`__ 60 | - `License <#license>`__ 61 | 62 | Installation 63 | ============ 64 | 65 | :: 66 | 67 | pip install drf-flex-fields 68 | 69 | Requirements 70 | ============ 71 | 72 | - Python >= 2.7 73 | - Django >= 1.8 74 | 75 | Basics 76 | ====== 77 | 78 | To use this package's functionality, your serializers need to subclass 79 | ``FlexFieldsModelSerializer`` or use the provided 80 | ``FlexFieldsSerializerMixin``. If you would like built-in protection for 81 | controlling when clients are allowed to expand resources when listing 82 | resource collections, your viewsets need to subclass 83 | ``FlexFieldsModelViewSet``. 84 | 85 | .. code:: python 86 | 87 | from rest_flex_fields import FlexFieldsModelViewSet, FlexFieldsModelSerializer 88 | 89 | class PersonViewSet(FlexFieldsModelViewSet): 90 | queryset = models.Person.objects.all() 91 | serializer_class = PersonSerializer 92 | # Whitelist fields that can be expanded when listing resources 93 | permit_list_expands = ['country'] 94 | 95 | class CountrySerializer(FlexFieldsModelSerializer): 96 | class Meta: 97 | model = Country 98 | fields = ('id', 'name', 'population') 99 | 100 | class PersonSerializer(FlexFieldsModelSerializer): 101 | class Meta: 102 | model = Person 103 | fields = ('id', 'name', 'country', 'occupation') 104 | 105 | expandable_fields = { 106 | 'country': (CountrySerializer, {'source': 'country'}) 107 | } 108 | 109 | Now you can make requests like 110 | ``GET /person?expand=country&fields=id,name,country`` to dynamically 111 | manipulate which fields are included, as well as expand primitive fields 112 | into nested objects. You can also use dot notation to control both the 113 | ``fields`` and ``expand`` settings at arbitrary levels of depth in your 114 | serialized responses. Read on to learn the details and see more complex 115 | examples. 116 | 117 | :heavy\_check\_mark: The examples below subclass 118 | ``FlexFieldsModelSerializer``, but the same can be accomplished by 119 | mixing in ``FlexFieldsSerializerMixin``, which is also importable from 120 | the same ``rest_flex_fields`` package. 121 | 122 | Dynamic Field Expansion 123 | ======================= 124 | 125 | To define an expandable field, add it to the ``expandable_fields`` 126 | within your serializer: 127 | 128 | .. code:: python 129 | 130 | class CountrySerializer(FlexFieldsModelSerializer): 131 | class Meta: 132 | model = Country 133 | fields = ['name', 'population'] 134 | 135 | 136 | class PersonSerializer(FlexFieldsModelSerializer): 137 | country = serializers.PrimaryKeyRelatedField(read_only=True) 138 | 139 | class Meta: 140 | model = Person 141 | fields = ['id', 'name', 'country', 'occupation'] 142 | 143 | expandable_fields = { 144 | 'country': (CountrySerializer, {'source': 'country', 'fields': ['name']}) 145 | } 146 | 147 | If the default serialized response is the following: 148 | 149 | .. code:: json 150 | 151 | { 152 | "id" : 13322, 153 | "name" : "John Doe", 154 | "country" : 12, 155 | "occupation" : "Programmer", 156 | } 157 | 158 | When you do a ``GET /person/13322?expand=country``, the response will 159 | change to: 160 | 161 | .. code:: json 162 | 163 | { 164 | "id" : 13322, 165 | "name" : "John Doe", 166 | "country" : { 167 | "name" : "United States" 168 | }, 169 | "occupation" : "Programmer", 170 | } 171 | 172 | Notice how ``population`` was ommitted from the nested ``country`` 173 | object. This is because ``fields`` was set to ``['name']`` when passed 174 | to the embedded ``CountrySerializer``. You will learn more about this 175 | later on. 176 | 177 | Deferred Fields 178 | --------------- 179 | 180 | Alternatively, you could treat ``country`` as a "deferred" field by not 181 | defining it among the default fields. To make a field deferred, only 182 | define it within the serializer's ``expandable_fields``. 183 | 184 | Deep, Nested Expansion 185 | ---------------------- 186 | 187 | Let's say you add ``StateSerializer`` as serializer nested inside the 188 | country serializer above: 189 | 190 | .. code:: python 191 | 192 | class StateSerializer(FlexFieldsModelSerializer): 193 | class Meta: 194 | model = State 195 | fields = ['name', 'population'] 196 | 197 | 198 | class CountrySerializer(FlexFieldsModelSerializer): 199 | class Meta: 200 | model = Country 201 | fields = ['name', 'population'] 202 | 203 | expandable_fields = { 204 | 'states': (StateSerializer, {'source': 'states', 'many': True}) 205 | } 206 | 207 | class PersonSerializer(FlexFieldsModelSerializer): 208 | country = serializers.PrimaryKeyRelatedField(read_only=True) 209 | 210 | class Meta: 211 | model = Person 212 | fields = ['id', 'name', 'country', 'occupation'] 213 | 214 | expandable_fields = { 215 | 'country': (CountrySerializer, {'source': 'country', 'fields': ['name']}) 216 | } 217 | 218 | Your default serialized response might be the following for ``person`` 219 | and ``country``, respectively: 220 | 221 | .. code:: json 222 | 223 | { 224 | "id" : 13322, 225 | "name" : "John Doe", 226 | "country" : 12, 227 | "occupation" : "Programmer", 228 | } 229 | 230 | { 231 | "id" : 12, 232 | "name" : "United States", 233 | "states" : "http://www.api.com/countries/12/states" 234 | } 235 | 236 | But if you do a ``GET /person/13322?expand=country.states``, it would 237 | be: 238 | 239 | .. code:: json 240 | 241 | { 242 | "id" : 13322, 243 | "name" : "John Doe", 244 | "occupation" : "Programmer", 245 | "country" : { 246 | "id" : 12, 247 | "name" : "United States", 248 | "states" : [ 249 | { 250 | "name" : "Ohio", 251 | "population": 11000000 252 | } 253 | ] 254 | } 255 | } 256 | 257 | Please be kind to your database, as this could incur many additional 258 | queries. Though, you can mitigate this impact through judicious use of 259 | ``prefetch_related`` and ``select_related`` when defining the queryset 260 | for your viewset. 261 | 262 | Configuration from Serializer Options 263 | ------------------------------------- 264 | 265 | You could accomplish the same result (expanding the ``states`` field 266 | within the embedded country serializer) by explicitly passing the 267 | ``expand`` option within your serializer: 268 | 269 | .. code:: python 270 | 271 | class PersonSerializer(FlexFieldsModelSerializer): 272 | 273 | class Meta: 274 | model = Person 275 | fields = ['id', 'name', 'country', 'occupation'] 276 | 277 | expandable_fields = { 278 | 'country': (CountrySerializer, {'source': 'country', 'expand': ['states']}) 279 | } 280 | 281 | Field Expansion on "List" Views 282 | ------------------------------- 283 | 284 | By default, when subclassing ``FlexFieldsModelViewSet``, you can only 285 | expand fields when you are retrieving single resources, in order to 286 | protect yourself from careless clients. However, if you would like to 287 | make a field expandable even when listing collections, you can add the 288 | field's name to the ``permit_list_expands`` property on the viewset. 289 | Just make sure you are wisely using ``select_related`` and 290 | ``prefetch_related`` in the viewset's queryset. You can take advantage 291 | of a utility function, ``is_expanded()`` to adjust the queryset 292 | accordingly. 293 | 294 | Example: 295 | 296 | .. code:: python 297 | 298 | from drf_flex_fields import is_expanded 299 | 300 | class PersonViewSet(FlexFieldsModelViewSet): 301 | permit_list_expands = ['employer'] 302 | serializer_class = PersonSerializer 303 | 304 | def get_queryset(self): 305 | queryset = models.Person.objects.all() 306 | if is_expanded(self.request, 'employer'): 307 | queryset = queryset.select_related('employer') 308 | return queryset 309 | 310 | Use "~all" to Expand All Available Fields 311 | ----------------------------------------- 312 | 313 | You can set ``expand=~all`` to automatically expand all fields that are 314 | available for expansion. This will take effect only for the top-level 315 | serializer; if you need to also expand fields that are present on deeply 316 | nested models, then you will need to explicitly pass their values using 317 | dot notation. 318 | 319 | Dynamically Setting Fields (Sparse Fields) 320 | ========================================== 321 | 322 | You can use either they ``fields`` or ``omit`` keywords to declare only 323 | the fields you want to include or to specify fields that should be 324 | excluded. 325 | 326 | From URL Parameters 327 | ------------------- 328 | 329 | You can dynamically set fields, with the configuration originating from 330 | the URL parameters or serializer options. 331 | 332 | Consider this as a default serialized response: 333 | 334 | .. code:: json 335 | 336 | { 337 | "id" : 13322, 338 | "name" : "John Doe", 339 | "country" : { 340 | "name" : "United States", 341 | "population": 330000000 342 | }, 343 | "occupation" : "Programmer", 344 | "hobbies" : ["rock climbing", "sipping coffee"] 345 | } 346 | 347 | To whittle down the fields via URL parameters, simply add 348 | ``?fields=id,name,country`` to your requests to get back: 349 | 350 | .. code:: json 351 | 352 | { 353 | "id" : 13322, 354 | "name" : "John Doe", 355 | "country" : { 356 | "name" : "United States", 357 | "population: 330000000 358 | } 359 | } 360 | 361 | Or, for more specificity, you can use dot-notation, 362 | ``?fields=id,name,country.name``: 363 | 364 | .. code:: json 365 | 366 | { 367 | "id" : 13322, 368 | "name" : "John Doe", 369 | "country" : { 370 | "name" : "United States", 371 | } 372 | } 373 | 374 | Or, if you want to leave out the nested country object, do 375 | ``?omit=country``: 376 | 377 | .. code:: json 378 | 379 | { 380 | "id" : 13322, 381 | "name" : "John Doe", 382 | "occupation" : "Programmer", 383 | "hobbies" : ["rock climbing", "sipping coffee"] 384 | } 385 | 386 | From Serializer Options 387 | ----------------------- 388 | 389 | You could accomplish the same outcome as the example above by passing 390 | options to your serializers. With this approach, you lose runtime 391 | dynamism, but gain the ability to re-use serializers, rather than 392 | creating a simplified copy of a serializer for the purposes of embedding 393 | it. The example below uses the ``fields`` keyword, but you can also pass 394 | in keyword argument for ``omit`` to exclude specific fields. 395 | 396 | .. code:: python 397 | 398 | from rest_flex_fields import FlexFieldsModelSerializer 399 | 400 | class CountrySerializer(FlexFieldsModelSerializer): 401 | class Meta: 402 | model = Country 403 | fields = ['id', 'name', 'population'] 404 | 405 | class PersonSerializer(FlexFieldsModelSerializer): 406 | country: CountrySerializer(fields=['name']) 407 | class Meta: 408 | model = Person 409 | fields = ['id', 'name', 'country', 'occupation', 'hobbies'] 410 | 411 | 412 | serializer = PersonSerializer(person, fields=["id", "name", "country.name"]) 413 | print(serializer.data) 414 | 415 | >>>{ 416 | "id": 13322, 417 | "name": "John Doe", 418 | "country": { 419 | "name": "United States", 420 | } 421 | } 422 | 423 | Combining Dynamically Set Fields and Field Expansion 424 | ==================================================== 425 | 426 | You may be wondering how things work if you use both the ``expand`` and 427 | ``fields`` option, and there is overlap. For example, your serialized 428 | person model may look like the following by default: 429 | 430 | .. code:: json 431 | 432 | { 433 | "id": 13322, 434 | "name": "John Doe", 435 | "country": { 436 | "name": "United States", 437 | } 438 | } 439 | 440 | However, you make the following request 441 | ``HTTP GET /person/13322?include=id,name&expand=country``. You will get 442 | the following back: 443 | 444 | .. code:: json 445 | 446 | { 447 | "id": 13322, 448 | "name": "John Doe" 449 | } 450 | 451 | The ``include`` field takes precedence over ``expand``. That is, if a 452 | field is not among the set that is explicitly alllowed, it cannot be 453 | expanded. If such a conflict occurs, you will not pay for the extra 454 | database queries - the expanded field will be silently abandoned. 455 | 456 | Serializer Introspection 457 | ======================== 458 | 459 | When using an instance of ``FlexFieldsModelSerializer``, you can examine 460 | the property ``expanded_fields`` to discover which fields, if any, have 461 | been dynamically expanded. 462 | 463 | Lazy evaluation of serializer 464 | ============================= 465 | 466 | If you want to lazily evaluate the reference to your nested serializer 467 | class from a string inside expandable\_fields, you need to use this 468 | syntax: 469 | 470 | .. code:: python 471 | 472 | expandable_fields = { 473 | 'record_set': ('.RelatedSerializer', {'source': 'related_set', 'many': True}) 474 | } 475 | 476 | Substitute the name of your Django app where the serializer is found for 477 | ````. 478 | 479 | This allows to reference a serializer that has not yet been defined. 480 | 481 | Query optimization (experimental) 482 | ================================= 483 | 484 | An experimental filter backend is available to help you automatically 485 | reduce the number of SQL queries and their transfer size. *This feature 486 | has not been tested thorougly and any help testing and reporting bugs is 487 | greatly appreciated.* You can add FlexFieldFilterBackend to 488 | ``DEFAULT_FILTER_BACKENDS`` in the settings: 489 | 490 | .. code:: python 491 | 492 | # settings.py 493 | 494 | REST_FRAMEWORK = { 495 | 'DEFAULT_FILTER_BACKENDS': ( 496 | 'rest_flex_fields.filter_backends.FlexFieldsFilterBackend', 497 | # ... 498 | ), 499 | # ... 500 | } 501 | 502 | It will automatically call ``select_related`` and ``prefetch_related`` 503 | on the current QuerySet by determining which fields are needed from 504 | many-to-many and foreign key-related models. For sparse fields requests 505 | (``?omit=fieldX,fieldY`` or ``?fields=fieldX,fieldY``), the backend will 506 | automatically call ``only(*field_names)`` using only the fields needed 507 | for serialization. 508 | 509 | **WARNING:** The optimization currently works only for one nesting 510 | level. 511 | 512 | Changelog 513 | ========== 514 | 515 | 0.6.0 (May 2019) 516 | ---------------- 517 | 518 | - Adds experimental support for automatically SQL query optimization 519 | via a ``FlexFieldsFilterBackend``. Thanks ADR-007! 520 | - Adds CircleCI config file. Thanks mikeIFTS! 521 | - Moves declaration of ``expandable_fields`` to ``Meta`` class on 522 | serialzer for consistency with DRF (will continue to support 523 | declaration as class property) 524 | 525 | 0.5.0 (April 2019) 526 | ------------------ 527 | 528 | - Added support for ``omit`` keyword for field exclusion. Code clean up 529 | and improved test coverage. 530 | 531 | 0.3.4 (May 2018) 532 | ---------------- 533 | 534 | - Handle case where ``request`` is ``None`` when accessing request 535 | object from serializer. Thanks @jsatt! 536 | 537 | 0.3.3 (April 2018) 538 | ------------------ 539 | 540 | - Exposes ``FlexFieldsSerializerMixin`` in addition to 541 | ``FlexFieldsModelSerializer``. Thanks @jsatt! 542 | 543 | Testing 544 | ======= 545 | 546 | Tests are found in a simplified DRF project in the ``/tests`` folder. 547 | Install the project requirements and do ``./manage.py test`` to run 548 | them. 549 | 550 | License 551 | ======= 552 | 553 | See `License `__. 554 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) -------------------------------------------------------------------------------- /pypi_submit.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.system("python setup.py sdist --verbose") 4 | os.system("twine upload dist/*") 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | asgiref==3.4.1 3 | attrs==19.1.0 4 | black==19.3b0 5 | Click==7.0 6 | Django==3.2.18 7 | djangorestframework==3.12.1 8 | entrypoints==0.3 9 | flake8==3.7.7 10 | mccabe==0.6.1 11 | mypy==0.910 12 | mypy-extensions==0.4.3 13 | pycodestyle==2.5.0 14 | pyflakes==2.1.1 15 | pytz==2019.1 16 | sqlparse==0.3.0 17 | toml==0.10.0 18 | typed-ast==1.4.3 19 | typing-extensions==3.10.0.0 20 | -------------------------------------------------------------------------------- /rest_flex_fields/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | FLEX_FIELDS_OPTIONS = getattr(settings, "REST_FLEX_FIELDS", {}) 5 | EXPAND_PARAM = FLEX_FIELDS_OPTIONS.get("EXPAND_PARAM", "expand") 6 | FIELDS_PARAM = FLEX_FIELDS_OPTIONS.get("FIELDS_PARAM", "fields") 7 | OMIT_PARAM = FLEX_FIELDS_OPTIONS.get("OMIT_PARAM", "omit") 8 | MAXIMUM_EXPANSION_DEPTH = FLEX_FIELDS_OPTIONS.get("MAXIMUM_EXPANSION_DEPTH", None) 9 | RECURSIVE_EXPANSION_PERMITTED = FLEX_FIELDS_OPTIONS.get( 10 | "RECURSIVE_EXPANSION_PERMITTED", True 11 | ) 12 | 13 | WILDCARD_ALL = "~all" 14 | WILDCARD_ASTERISK = "*" 15 | 16 | if "WILDCARD_EXPAND_VALUES" in FLEX_FIELDS_OPTIONS: 17 | WILDCARD_VALUES = FLEX_FIELDS_OPTIONS["WILDCARD_EXPAND_VALUES"] 18 | elif "WILDCARD_VALUES" in FLEX_FIELDS_OPTIONS: 19 | WILDCARD_VALUES = FLEX_FIELDS_OPTIONS["WILDCARD_VALUES"] 20 | else: 21 | WILDCARD_VALUES = [WILDCARD_ALL, WILDCARD_ASTERISK] 22 | 23 | assert isinstance(EXPAND_PARAM, str), "'EXPAND_PARAM' should be a string" 24 | assert isinstance(FIELDS_PARAM, str), "'FIELDS_PARAM' should be a string" 25 | assert isinstance(OMIT_PARAM, str), "'OMIT_PARAM' should be a string" 26 | 27 | if type(WILDCARD_VALUES) not in (list, type(None)): 28 | raise ValueError("'WILDCARD_EXPAND_VALUES' should be a list of strings or None") 29 | if type(MAXIMUM_EXPANSION_DEPTH) not in (int, type(None)): 30 | raise ValueError("'MAXIMUM_EXPANSION_DEPTH' should be a int or None") 31 | if type(RECURSIVE_EXPANSION_PERMITTED) is not bool: 32 | raise ValueError("'RECURSIVE_EXPANSION_PERMITTED' should be a bool") 33 | 34 | from .utils import * 35 | from .serializers import FlexFieldsModelSerializer 36 | from .views import FlexFieldsModelViewSet 37 | -------------------------------------------------------------------------------- /rest_flex_fields/filter_backends.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Optional 3 | 4 | from django.core.exceptions import FieldDoesNotExist 5 | from django.db import models 6 | from django.db.models import QuerySet 7 | from rest_framework.compat import coreapi, coreschema 8 | from rest_framework.filters import BaseFilterBackend 9 | from rest_framework.request import Request 10 | from rest_framework.viewsets import GenericViewSet 11 | 12 | from rest_flex_fields import ( 13 | FIELDS_PARAM, 14 | EXPAND_PARAM, 15 | OMIT_PARAM, 16 | WILDCARD_VALUES 17 | ) 18 | 19 | WILDCARD_VALUES_JOINED = ",".join(WILDCARD_VALUES) 20 | 21 | from rest_flex_fields.serializers import ( 22 | FlexFieldsModelSerializer, 23 | FlexFieldsSerializerMixin, 24 | ) 25 | 26 | 27 | class FlexFieldsDocsFilterBackend(BaseFilterBackend): 28 | """ 29 | A dummy filter backend only for schema/documentation purposes. 30 | """ 31 | 32 | def filter_queryset(self, request, queryset, view): 33 | return queryset 34 | 35 | @staticmethod 36 | @lru_cache() 37 | def _get_field(field_name: str, model: models.Model) -> Optional[models.Field]: 38 | try: 39 | # noinspection PyProtectedMember 40 | return model._meta.get_field(field_name) 41 | except FieldDoesNotExist: 42 | return None 43 | 44 | @staticmethod 45 | def _get_expandable_fields(serializer_class: FlexFieldsModelSerializer) -> list: 46 | expandable_fields = list(getattr(serializer_class.Meta, 'expandable_fields').items()) 47 | expand_list = [] 48 | while expandable_fields: 49 | key, cls = expandable_fields.pop() 50 | cls = cls[0] if hasattr(cls, '__iter__') else cls 51 | 52 | expand_list.append(key) 53 | 54 | if hasattr(cls, "Meta") and issubclass(cls, FlexFieldsSerializerMixin) and hasattr(cls.Meta, "expandable_fields"): 55 | next_layer = getattr(cls.Meta, 'expandable_fields') 56 | expandable_fields.extend([(f"{key}.{k}", cls) for k, cls in list(next_layer.items())]) 57 | 58 | return expand_list 59 | 60 | @staticmethod 61 | def _get_fields(serializer_class): 62 | fields = getattr(serializer_class.Meta, "fields", []) 63 | return ",".join(fields) 64 | 65 | def get_schema_fields(self, view): 66 | assert ( 67 | coreapi is not None 68 | ), "coreapi must be installed to use `get_schema_fields()`" 69 | assert ( 70 | coreschema is not None 71 | ), "coreschema must be installed to use `get_schema_fields()`" 72 | 73 | serializer_class = view.get_serializer_class() 74 | if not issubclass(serializer_class, FlexFieldsSerializerMixin): 75 | return [] 76 | 77 | fields = self._get_fields(serializer_class) 78 | expandable_fields_joined = ",".join(self._get_expandable_fields(serializer_class)) 79 | 80 | return [ 81 | coreapi.Field( 82 | name=FIELDS_PARAM, 83 | required=False, 84 | location="query", 85 | schema=coreschema.String( 86 | title="Selected fields", 87 | description="Specify required fields by comma", 88 | ), 89 | example=(fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED, 90 | ), 91 | coreapi.Field( 92 | name=OMIT_PARAM, 93 | required=False, 94 | location="query", 95 | schema=coreschema.String( 96 | title="Omitted fields", 97 | description="Specify omitted fields by comma", 98 | ), 99 | example=(fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED, 100 | ), 101 | coreapi.Field( 102 | name=EXPAND_PARAM, 103 | required=False, 104 | location="query", 105 | schema=coreschema.String( 106 | title="Expanded fields", 107 | description="Specify expanded fields by comma", 108 | ), 109 | example=(expandable_fields_joined or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED, 110 | ), 111 | ] 112 | 113 | def get_schema_operation_parameters(self, view): 114 | serializer_class = view.get_serializer_class() 115 | if not issubclass(serializer_class, FlexFieldsSerializerMixin): 116 | return [] 117 | 118 | fields = self._get_fields(serializer_class) 119 | expandable_fields = self._get_expandable_fields(serializer_class) 120 | expandable_fields.extend(WILDCARD_VALUES) 121 | 122 | parameters = [ 123 | { 124 | "name": FIELDS_PARAM, 125 | "required": False, 126 | "in": "query", 127 | "description": "Specify required fields by comma", 128 | "schema": { 129 | "title": "Selected fields", 130 | "type": "string", 131 | }, 132 | "example": (fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED, 133 | }, 134 | { 135 | "name": OMIT_PARAM, 136 | "required": False, 137 | "in": "query", 138 | "description": "Specify omitted fields by comma", 139 | "schema": { 140 | "title": "Omitted fields", 141 | "type": "string", 142 | }, 143 | "example": (fields or "field1,field2,nested.field") + "," + WILDCARD_VALUES_JOINED, 144 | }, 145 | { 146 | "name": EXPAND_PARAM, 147 | "required": False, 148 | "in": "query", 149 | "description": "Select fields to expand", 150 | "style": "form", 151 | "explode": False, 152 | "schema": { 153 | "title": "Expanded fields", 154 | "type": "array", 155 | "items": { 156 | "type": "string", 157 | "enum": expandable_fields 158 | } 159 | }, 160 | }, 161 | ] 162 | 163 | return parameters 164 | 165 | 166 | class FlexFieldsFilterBackend(FlexFieldsDocsFilterBackend): 167 | def filter_queryset( 168 | self, request: Request, queryset: QuerySet, view: GenericViewSet 169 | ): 170 | if ( 171 | not issubclass(view.get_serializer_class(), FlexFieldsSerializerMixin) 172 | or request.method != "GET" 173 | ): 174 | return queryset 175 | 176 | auto_remove_fields_from_query = getattr( 177 | view, "auto_remove_fields_from_query", True 178 | ) 179 | auto_select_related_on_query = getattr( 180 | view, "auto_select_related_on_query", True 181 | ) 182 | required_query_fields = list(getattr(view, "required_query_fields", [])) 183 | 184 | serializer = view.get_serializer( # type: FlexFieldsSerializerMixin 185 | context=view.get_serializer_context() 186 | ) 187 | 188 | serializer.apply_flex_fields( 189 | serializer.fields, serializer._flex_options_rep_only 190 | ) 191 | serializer._flex_fields_rep_applied = True 192 | 193 | model_fields = [] 194 | nested_model_fields = [] 195 | for field in serializer.fields.values(): 196 | model_field = self._get_field(field.source, queryset.model) 197 | if model_field: 198 | model_fields.append(model_field) 199 | if field.field_name in serializer.expanded_fields or \ 200 | (model_field.is_relation and not model_field.many_to_one) or \ 201 | (model_field.is_relation and model_field.many_to_one and not model_field.concrete): # Include GenericForeignKey 202 | nested_model_fields.append(model_field) 203 | 204 | if auto_remove_fields_from_query: 205 | queryset = queryset.only( 206 | *( 207 | required_query_fields 208 | + [ 209 | model_field.name 210 | for model_field in model_fields if ( 211 | not model_field.is_relation or 212 | model_field.many_to_one and model_field.concrete) 213 | ] 214 | ) 215 | ) 216 | 217 | if auto_select_related_on_query and nested_model_fields: 218 | queryset = queryset.select_related( 219 | *( 220 | model_field.name 221 | for model_field in nested_model_fields if ( 222 | model_field.is_relation and 223 | model_field.many_to_one and 224 | model_field.concrete) # Exclude GenericForeignKey 225 | ) 226 | ) 227 | 228 | queryset = queryset.prefetch_related(*( 229 | model_field.name for model_field in nested_model_fields if 230 | (model_field.is_relation and not model_field.many_to_one) or 231 | (model_field.is_relation and model_field.many_to_one and not model_field.concrete) # Include GenericForeignKey) 232 | ) 233 | ) 234 | 235 | return queryset 236 | 237 | @staticmethod 238 | @lru_cache() 239 | def _get_field(field_name: str, model: models.Model) -> Optional[models.Field]: 240 | try: 241 | # noinspection PyProtectedMember 242 | return model._meta.get_field(field_name) 243 | except FieldDoesNotExist: 244 | return None 245 | -------------------------------------------------------------------------------- /rest_flex_fields/serializers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import importlib 3 | from typing import List, Optional, Tuple 4 | 5 | from rest_framework import serializers 6 | 7 | from rest_flex_fields import ( 8 | EXPAND_PARAM, 9 | FIELDS_PARAM, 10 | OMIT_PARAM, 11 | WILDCARD_VALUES, 12 | MAXIMUM_EXPANSION_DEPTH, 13 | RECURSIVE_EXPANSION_PERMITTED, 14 | split_levels, 15 | ) 16 | 17 | 18 | class FlexFieldsSerializerMixin(object): 19 | """ 20 | A ModelSerializer that takes additional arguments for 21 | "fields", "omit" and "expand" in order to 22 | control which fields are displayed, and whether to replace simple 23 | values with complex, nested serializations 24 | """ 25 | 26 | expandable_fields = {} 27 | maximum_expansion_depth: Optional[int] = None 28 | recursive_expansion_permitted: Optional[bool] = None 29 | 30 | def __init__(self, *args, **kwargs): 31 | expand = list(kwargs.pop(EXPAND_PARAM, [])) 32 | fields = list(kwargs.pop(FIELDS_PARAM, [])) 33 | omit = list(kwargs.pop(OMIT_PARAM, [])) 34 | parent = kwargs.pop("parent", None) 35 | 36 | super(FlexFieldsSerializerMixin, self).__init__(*args, **kwargs) 37 | 38 | self.parent = parent 39 | self.expanded_fields = [] 40 | self._flex_fields_rep_applied = False 41 | 42 | self._flex_options_base = { 43 | "expand": expand, 44 | "fields": fields, 45 | "omit": omit, 46 | } 47 | self._flex_options_rep_only = { 48 | "expand": ( 49 | self._get_permitted_expands_from_query_param(EXPAND_PARAM) 50 | if not expand 51 | else [] 52 | ), 53 | "fields": (self._get_query_param_value(FIELDS_PARAM) if not fields else []), 54 | "omit": (self._get_query_param_value(OMIT_PARAM) if not omit else []), 55 | } 56 | self._flex_options_all = { 57 | "expand": self._flex_options_base["expand"] 58 | + self._flex_options_rep_only["expand"], 59 | "fields": self._flex_options_base["fields"] 60 | + self._flex_options_rep_only["fields"], 61 | "omit": self._flex_options_base["omit"] 62 | + self._flex_options_rep_only["omit"], 63 | } 64 | 65 | def get_maximum_expansion_depth(self) -> Optional[int]: 66 | """ 67 | Defined at serializer level or based on MAXIMUM_EXPANSION_DEPTH setting 68 | """ 69 | return self.maximum_expansion_depth or MAXIMUM_EXPANSION_DEPTH 70 | 71 | def get_recursive_expansion_permitted(self) -> bool: 72 | """ 73 | Defined at serializer level or based on RECURSIVE_EXPANSION_PERMITTED setting 74 | """ 75 | if self.recursive_expansion_permitted is not None: 76 | return self.recursive_expansion_permitted 77 | else: 78 | return RECURSIVE_EXPANSION_PERMITTED 79 | 80 | def to_representation(self, instance): 81 | if not self._flex_fields_rep_applied: 82 | self.apply_flex_fields(self.fields, self._flex_options_rep_only) 83 | self._flex_fields_rep_applied = True 84 | return super().to_representation(instance) 85 | 86 | def get_fields(self): 87 | fields = super().get_fields() 88 | self.apply_flex_fields(fields, self._flex_options_base) 89 | return fields 90 | 91 | def apply_flex_fields(self, fields, flex_options): 92 | expand_fields, next_expand_fields = split_levels(flex_options["expand"]) 93 | sparse_fields, next_sparse_fields = split_levels(flex_options["fields"]) 94 | omit_fields, next_omit_fields = split_levels(flex_options["omit"]) 95 | 96 | for field_name in self._get_fields_names_to_remove( 97 | fields, omit_fields, sparse_fields, next_omit_fields 98 | ): 99 | fields.pop(field_name) 100 | 101 | for name in self._get_expanded_field_names( 102 | expand_fields, omit_fields, sparse_fields, next_omit_fields 103 | ): 104 | self.expanded_fields.append(name) 105 | 106 | fields[name] = self._make_expanded_field_serializer( 107 | name, next_expand_fields, next_sparse_fields, next_omit_fields 108 | ) 109 | 110 | return fields 111 | 112 | def _make_expanded_field_serializer( 113 | self, name, nested_expand, nested_fields, nested_omit 114 | ): 115 | """ 116 | Returns an instance of the dynamically created nested serializer. 117 | """ 118 | field_options = self._expandable_fields[name] 119 | 120 | if isinstance(field_options, tuple): 121 | serializer_class = field_options[0] 122 | settings = copy.deepcopy(field_options[1]) if len(field_options) > 1 else {} 123 | else: 124 | serializer_class = field_options 125 | settings = {} 126 | 127 | if type(serializer_class) == str: 128 | serializer_class = self._get_serializer_class_from_lazy_string( 129 | serializer_class 130 | ) 131 | 132 | if issubclass(serializer_class, serializers.Serializer): 133 | settings["context"] = self.context 134 | 135 | if issubclass(serializer_class, FlexFieldsSerializerMixin): 136 | settings["parent"] = self 137 | 138 | if name in nested_expand: 139 | settings[EXPAND_PARAM] = nested_expand[name] 140 | 141 | if name in nested_fields: 142 | settings[FIELDS_PARAM] = nested_fields[name] 143 | 144 | if name in nested_omit: 145 | settings[OMIT_PARAM] = nested_omit[name] 146 | 147 | return serializer_class(**settings) 148 | 149 | def _get_serializer_class_from_lazy_string(self, full_lazy_path: str): 150 | path_parts = full_lazy_path.split(".") 151 | class_name = path_parts.pop() 152 | path = ".".join(path_parts) 153 | serializer_class, error = self._import_serializer_class(path, class_name) 154 | 155 | if error and not path.endswith(".serializers"): 156 | serializer_class, error = self._import_serializer_class( 157 | path + ".serializers", class_name 158 | ) 159 | 160 | if serializer_class: 161 | return serializer_class 162 | 163 | raise Exception(error) 164 | 165 | def _import_serializer_class( 166 | self, path: str, class_name: str 167 | ) -> Tuple[Optional[str], Optional[str]]: 168 | try: 169 | module = importlib.import_module(path) 170 | except ImportError: 171 | return ( 172 | None, 173 | "No module found at path: %s when trying to import %s" 174 | % (path, class_name), 175 | ) 176 | 177 | try: 178 | return getattr(module, class_name), None 179 | except AttributeError: 180 | return None, "No class %s class found in module %s" % (path, class_name) 181 | 182 | def _get_fields_names_to_remove( 183 | self, 184 | current_fields: List[str], 185 | omit_fields: List[str], 186 | sparse_fields: List[str], 187 | next_level_omits: List[str], 188 | ) -> List[str]: 189 | """ 190 | Remove fields that are found in omit list, and if sparse names 191 | are passed, remove any fields not found in that list. 192 | """ 193 | sparse = len(sparse_fields) > 0 194 | to_remove = [] 195 | 196 | if not sparse and len(omit_fields) == 0: 197 | return to_remove 198 | 199 | for field_name in current_fields: 200 | should_exist = self._should_field_exist( 201 | field_name, omit_fields, sparse_fields, next_level_omits 202 | ) 203 | 204 | if not should_exist: 205 | to_remove.append(field_name) 206 | 207 | return to_remove 208 | 209 | def _should_field_exist( 210 | self, 211 | field_name: str, 212 | omit_fields: List[str], 213 | sparse_fields: List[str], 214 | next_level_omits: List[str], 215 | ) -> bool: 216 | """ 217 | Next level omits take form of: 218 | { 219 | 'this_level_field': [field_to_omit_at_next_level] 220 | } 221 | We don't want to prematurely omit a field, eg "omit=house.rooms.kitchen" 222 | should not omit the entire house or all the rooms, just the kitchen. 223 | """ 224 | if field_name in omit_fields and field_name not in next_level_omits: 225 | return False 226 | elif self._contains_wildcard_value(sparse_fields): 227 | return True 228 | elif len(sparse_fields) > 0 and field_name not in sparse_fields: 229 | return False 230 | else: 231 | return True 232 | 233 | def _get_expanded_field_names( 234 | self, 235 | expand_fields: List[str], 236 | omit_fields: List[str], 237 | sparse_fields: List[str], 238 | next_level_omits: List[str], 239 | ) -> List[str]: 240 | if len(expand_fields) == 0: 241 | return [] 242 | 243 | if self._contains_wildcard_value(expand_fields): 244 | expand_fields = self._expandable_fields.keys() 245 | 246 | accum = [] 247 | 248 | for name in expand_fields: 249 | if name not in self._expandable_fields: 250 | continue 251 | 252 | if not self._should_field_exist( 253 | name, omit_fields, sparse_fields, next_level_omits 254 | ): 255 | continue 256 | 257 | accum.append(name) 258 | 259 | return accum 260 | 261 | @property 262 | def _expandable_fields(self) -> dict: 263 | """It's more consistent with DRF to declare the expandable fields 264 | on the Meta class, however we need to support both places 265 | for legacy reasons.""" 266 | if hasattr(self, "Meta") and hasattr(self.Meta, "expandable_fields"): 267 | return self.Meta.expandable_fields 268 | 269 | return self.expandable_fields 270 | 271 | def _get_query_param_value(self, field: str) -> List[str]: 272 | """ 273 | Only allowed to examine query params if it's the root serializer. 274 | """ 275 | if self.parent: 276 | return [] 277 | 278 | if not hasattr(self, "context") or not self.context.get("request"): 279 | return [] 280 | 281 | values = self.context["request"].query_params.getlist(field) 282 | 283 | if not values: 284 | values = self.context["request"].query_params.getlist(f"{field}[]") 285 | 286 | if values and len(values) == 1: 287 | values = values[0].split(",") 288 | 289 | for expand_path in values: 290 | self._validate_recursive_expansion(expand_path) 291 | self._validate_expansion_depth(expand_path) 292 | 293 | return values or [] 294 | 295 | def _split_expand_field(self, expand_path: str) -> List[str]: 296 | return expand_path.split(".") 297 | 298 | def recursive_expansion_not_permitted(self): 299 | """ 300 | A customized exception can be raised when recursive expansion is found, default ValidationError 301 | """ 302 | raise serializers.ValidationError(detail="Recursive expansion found") 303 | 304 | def _validate_recursive_expansion(self, expand_path: str) -> None: 305 | """ 306 | Given an expand_path, a dotted-separated string, 307 | an Exception is raised when a recursive 308 | expansion is detected. 309 | Only applies when REST_FLEX_FIELDS["RECURSIVE_EXPANSION"] setting is False. 310 | """ 311 | recursive_expansion_permitted = self.get_recursive_expansion_permitted() 312 | if recursive_expansion_permitted is True: 313 | return 314 | 315 | expansion_path = self._split_expand_field(expand_path) 316 | expansion_length = len(expansion_path) 317 | expansion_length_unique = len(set(expansion_path)) 318 | if expansion_length != expansion_length_unique: 319 | self.recursive_expansion_not_permitted() 320 | 321 | def expansion_depth_exceeded(self): 322 | """ 323 | A customized exception can be raised when expansion depth is found, default ValidationError 324 | """ 325 | raise serializers.ValidationError(detail="Expansion depth exceeded") 326 | 327 | def _validate_expansion_depth(self, expand_path: str) -> None: 328 | """ 329 | Given an expand_path, a dotted-separated string, 330 | an Exception is raised when expansion level is 331 | greater than the `expansion_depth` property configuration. 332 | Only applies when REST_FLEX_FIELDS["EXPANSION_DEPTH"] setting is set 333 | or serializer has its own expansion configuration through default_expansion_depth attribute. 334 | """ 335 | maximum_expansion_depth = self.get_maximum_expansion_depth() 336 | if maximum_expansion_depth is None: 337 | return 338 | 339 | expansion_path = self._split_expand_field(expand_path) 340 | if len(expansion_path) > maximum_expansion_depth: 341 | self.expansion_depth_exceeded() 342 | 343 | def _get_permitted_expands_from_query_param(self, expand_param: str) -> List[str]: 344 | """ 345 | If a list of permitted_expands has been passed to context, 346 | make sure that the "expand" fields from the query params 347 | comply. 348 | """ 349 | expand = self._get_query_param_value(expand_param) 350 | 351 | if "permitted_expands" in self.context: 352 | permitted_expands = self.context["permitted_expands"] 353 | 354 | if self._contains_wildcard_value(expand): 355 | return permitted_expands 356 | else: 357 | return list(set(expand) & set(permitted_expands)) 358 | 359 | return expand 360 | 361 | def _contains_wildcard_value(self, expand_values: List[str]) -> bool: 362 | if WILDCARD_VALUES is None: 363 | return False 364 | intersecting_values = list(set(expand_values) & set(WILDCARD_VALUES)) 365 | return len(intersecting_values) > 0 366 | 367 | 368 | class FlexFieldsModelSerializer(FlexFieldsSerializerMixin, serializers.ModelSerializer): 369 | pass 370 | -------------------------------------------------------------------------------- /rest_flex_fields/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | from rest_flex_fields import EXPAND_PARAM, FIELDS_PARAM, OMIT_PARAM, WILDCARD_VALUES 4 | 5 | 6 | def is_expanded(request, field: str) -> bool: 7 | """ Examines request object to return boolean of whether 8 | passed field is expanded. 9 | """ 10 | expand_value = request.query_params.get(EXPAND_PARAM) 11 | expand_fields = [] 12 | 13 | if expand_value: 14 | for f in expand_value.split(","): 15 | expand_fields.extend([_ for _ in f.split(".")]) 16 | 17 | return any(field for field in expand_fields if field in WILDCARD_VALUES) or field in expand_fields 18 | 19 | 20 | def is_included(request, field: str) -> bool: 21 | """ Examines request object to return boolean of whether 22 | passed field has been excluded, either because `fields` is 23 | set, and it is not among them, or because `omit` is set and 24 | it is among them. 25 | """ 26 | sparse_value = request.query_params.get(FIELDS_PARAM) 27 | omit_value = request.query_params.get(OMIT_PARAM) 28 | sparse_fields, omit_fields = [], [] 29 | 30 | if sparse_value: 31 | for f in sparse_value.split(","): 32 | sparse_fields.extend([_ for _ in f.split(".")]) 33 | 34 | if omit_value: 35 | for f in omit_value.split(","): 36 | omit_fields.extend([_ for _ in f.split(".")]) 37 | 38 | if len(sparse_fields) > 0 and field not in sparse_fields: 39 | return False 40 | 41 | if len(omit_fields) > 0 and field in omit_fields: 42 | return False 43 | 44 | return True 45 | 46 | 47 | def split_levels(fields): 48 | """ 49 | Convert dot-notation such as ['a', 'a.b', 'a.d', 'c'] into 50 | current-level fields ['a', 'c'] and next-level fields 51 | {'a': ['b', 'd']}. 52 | """ 53 | first_level_fields = [] 54 | next_level_fields = {} 55 | 56 | if not fields: 57 | return first_level_fields, next_level_fields 58 | 59 | assert isinstance( 60 | fields, Iterable 61 | ), "`fields` must be iterable (e.g. list, tuple, or generator)" 62 | 63 | if isinstance(fields, str): 64 | fields = [a.strip() for a in fields.split(",") if a.strip()] 65 | for e in fields: 66 | if "." in e: 67 | first_level, next_level = e.split(".", 1) 68 | first_level_fields.append(first_level) 69 | next_level_fields.setdefault(first_level, []).append(next_level) 70 | else: 71 | first_level_fields.append(e) 72 | 73 | first_level_fields = list(set(first_level_fields)) 74 | return first_level_fields, next_level_fields 75 | -------------------------------------------------------------------------------- /rest_flex_fields/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | This class helps provide control over which fields can be expanded when a 3 | collection is request via the list method. 4 | """ 5 | 6 | from rest_framework import viewsets 7 | 8 | 9 | class FlexFieldsMixin(object): 10 | permit_list_expands = [] 11 | 12 | def get_serializer_context(self): 13 | default_context = super(FlexFieldsMixin, self).get_serializer_context() 14 | 15 | if hasattr(self, "action") and self.action == "list": 16 | default_context["permitted_expands"] = self.permit_list_expands 17 | 18 | return default_context 19 | 20 | 21 | class FlexFieldsModelViewSet(FlexFieldsMixin, viewsets.ModelViewSet): 22 | pass 23 | 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | from codecs import open 4 | 5 | 6 | def readme(): 7 | with open("README.md", "r") as infile: 8 | return infile.read() 9 | 10 | 11 | classifiers = [ 12 | # Pick your license as you wish (should match "license" above) 13 | "License :: OSI Approved :: MIT License", 14 | # Specify the Python versions you support here. In particular, ensure 15 | # that you indicate whether you support Python 2, Python 3 or both. 16 | "Programming Language :: Python :: 2.7", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.2", 19 | "Programming Language :: Python :: 3.3", 20 | "Programming Language :: Python :: 3.4", 21 | "Programming Language :: Python :: 3.5", 22 | "Programming Language :: Python :: 3.6", 23 | "Programming Language :: Python :: 3.7", 24 | ] 25 | setup( 26 | name="drf-flex-fields", 27 | version="1.0.2", 28 | description="Flexible, dynamic fields and nested resources for Django REST Framework serializers.", 29 | author="Robert Singer", 30 | author_email="robertgsinger@gmail.com", 31 | packages=["rest_flex_fields"], 32 | url="https://github.com/rsinger86/drf-flex-fields", 33 | license="MIT", 34 | keywords="django rest api dynamic fields", 35 | long_description=readme(), 36 | classifiers=classifiers, 37 | long_description_content_type="text/markdown", 38 | ) 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsinger86/drf-flex-fields/9dd6a9140fd6d2ffe1baf9ab1ffc728540dea84d/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "pn^^1@z0@4+kc*z-l93q4b#dav+_caec#!job^0#0v$f&8s8+e" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.auth", 35 | "django.contrib.contenttypes", 36 | "django.contrib.sessions", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | "tests.testapp", 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | "django.middleware.security.SecurityMiddleware", 44 | "django.contrib.sessions.middleware.SessionMiddleware", 45 | "django.middleware.common.CommonMiddleware", 46 | "django.middleware.csrf.CsrfViewMiddleware", 47 | "django.contrib.auth.middleware.AuthenticationMiddleware", 48 | "django.contrib.messages.middleware.MessageMiddleware", 49 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 50 | ] 51 | 52 | ROOT_URLCONF = "tests.urls" 53 | 54 | TEMPLATES = [ 55 | { 56 | "BACKEND": "django.template.backends.django.DjangoTemplates", 57 | "DIRS": [], 58 | "APP_DIRS": True, 59 | "OPTIONS": { 60 | "context_processors": [ 61 | "django.template.context_processors.debug", 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = "project.wsgi.application" 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 99 | }, 100 | ] 101 | 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 105 | 106 | LANGUAGE_CODE = "en-us" 107 | 108 | TIME_ZONE = "UTC" 109 | 110 | USE_I18N = True 111 | 112 | USE_L10N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 119 | 120 | STATIC_URL = "/static/" 121 | 122 | 123 | REST_FLEX_FIELDS = {"EXPAND_PARAM": "expand"} 124 | 125 | # In Django 3.2 and onwards, the primary keys are generated using `BigAutoField` instead 126 | # of `AutoField`. To avoid introducing migrations and silence the configuration warnings, 127 | # we're setting this to `AutoField`, which is ok for this use case (tests). 128 | # Reference: https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys 129 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 130 | -------------------------------------------------------------------------------- /tests/test_flex_fields_model_serializer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch, PropertyMock 3 | 4 | from django.test import override_settings 5 | from django.utils.datastructures import MultiValueDict 6 | from rest_framework import serializers 7 | 8 | from rest_flex_fields import FlexFieldsModelSerializer 9 | 10 | 11 | class MockRequest(object): 12 | def __init__(self, query_params=None, method="GET"): 13 | if query_params is None: 14 | query_params = MultiValueDict() 15 | self.query_params = query_params 16 | self.method = method 17 | 18 | 19 | class TestFlexFieldModelSerializer(TestCase): 20 | def test_field_should_not_exist_if_omitted(self): 21 | serializer = FlexFieldsModelSerializer() 22 | result = serializer._should_field_exist("name", ["name"], [], {}) 23 | self.assertFalse(result) 24 | 25 | def test_field_should_not_exist_if_not_in_sparse(self): 26 | serializer = FlexFieldsModelSerializer() 27 | result = serializer._should_field_exist("name", [], ["age"], {}) 28 | self.assertFalse(result) 29 | 30 | def test_field_should_exist_if_ommitted_but_is_parent_of_omit(self): 31 | serializer = FlexFieldsModelSerializer() 32 | 33 | result = serializer._should_field_exist( 34 | "employer", ["employer"], [], {"employer": ["address"]} 35 | ) 36 | 37 | self.assertTrue(result) 38 | 39 | def test_clean_fields(self): 40 | serializer = FlexFieldsModelSerializer() 41 | fields = {"cat": 1, "dog": 2, "zebra": 3} 42 | result = serializer._get_fields_names_to_remove(fields, ["cat"], [], {}) 43 | self.assertEqual(result, ["cat"]) 44 | 45 | def test_get_expanded_field_names_if_all(self): 46 | serializer = FlexFieldsModelSerializer() 47 | serializer.expandable_fields = {"cat": "field", "dog": "field"} 48 | result = serializer._get_expanded_field_names("*", [], [], {}) 49 | self.assertEqual(result, ["cat", "dog"]) 50 | 51 | def test_get_expanded_names_but_not_omitted(self): 52 | serializer = FlexFieldsModelSerializer() 53 | serializer.expandable_fields = {"cat": "field", "dog": "field"} 54 | result = serializer._get_expanded_field_names(["cat", "dog"], ["cat"], [], {}) 55 | self.assertEqual(result, ["dog"]) 56 | 57 | def test_get_expanded_names_but_only_sparse(self): 58 | serializer = FlexFieldsModelSerializer() 59 | serializer.expandable_fields = {"cat": "field", "dog": "field"} 60 | result = serializer._get_expanded_field_names(["cat"], [], ["cat"], {}) 61 | self.assertEqual(result, ["cat"]) 62 | 63 | def test_get_expanded_names_including_omitted_when_defer_to_next_level(self): 64 | serializer = FlexFieldsModelSerializer() 65 | serializer.expandable_fields = {"cat": "field", "dog": "field"} 66 | result = serializer._get_expanded_field_names( 67 | ["cat"], ["cat"], [], {"cat": ["age"]} 68 | ) 69 | self.assertEqual(result, ["cat"]) 70 | 71 | def test_get_query_param_value_should_return_empty_if_not_root_serializer(self): 72 | serializer = FlexFieldsModelSerializer( 73 | context={ 74 | "request": MockRequest( 75 | method="GET", query_params=MultiValueDict({"expand": ["cat"]}) 76 | ) 77 | }, 78 | ) 79 | serializer.parent = "Another serializer here" 80 | self.assertFalse(serializer._get_query_param_value("expand"), []) 81 | 82 | def test_get_omit_input_from_explicit_settings(self): 83 | serializer = FlexFieldsModelSerializer( 84 | omit=["fish"], 85 | context={ 86 | "request": MockRequest( 87 | method="GET", query_params=MultiValueDict({"omit": "cat,dog"}) 88 | ) 89 | }, 90 | ) 91 | 92 | self.assertEqual(serializer._flex_options_all["omit"], ["fish"]) 93 | 94 | def test_set_omit_input_from_query_param(self): 95 | serializer = FlexFieldsModelSerializer( 96 | context={ 97 | "request": MockRequest( 98 | method="GET", query_params=MultiValueDict({"omit": ["cat,dog"]}) 99 | ) 100 | } 101 | ) 102 | self.assertEqual(serializer._flex_options_all["omit"], ["cat", "dog"]) 103 | 104 | def test_set_fields_input_from_explicit_settings(self): 105 | serializer = FlexFieldsModelSerializer( 106 | fields=["fish"], 107 | context={ 108 | "request": MockRequest( 109 | method="GET", query_params=MultiValueDict({"fields": "cat,dog"}) 110 | ) 111 | }, 112 | ) 113 | 114 | self.assertEqual(serializer._flex_options_all["fields"], ["fish"]) 115 | 116 | def test_set_fields_input_from_query_param(self): 117 | serializer = FlexFieldsModelSerializer( 118 | context={ 119 | "request": MockRequest( 120 | method="GET", query_params=MultiValueDict({"fields": ["cat,dog"]}) 121 | ) 122 | } 123 | ) 124 | 125 | self.assertEqual(serializer._flex_options_all["fields"], ["cat", "dog"]) 126 | 127 | def test_set_expand_input_from_explicit_setting(self): 128 | serializer = FlexFieldsModelSerializer( 129 | fields=["cat"], 130 | context={ 131 | "request": MockRequest( 132 | method="GET", query_params=MultiValueDict({"fields": "cat,dog"}) 133 | ) 134 | }, 135 | ) 136 | 137 | self.assertEqual(serializer._flex_options_all["fields"], ["cat"]) 138 | 139 | def test_set_expand_input_from_query_param(self): 140 | serializer = FlexFieldsModelSerializer( 141 | context={ 142 | "request": MockRequest( 143 | method="GET", query_params=MultiValueDict({"expand": ["cat,dog"]}) 144 | ) 145 | } 146 | ) 147 | 148 | self.assertEqual(serializer._flex_options_all["expand"], ["cat", "dog"]) 149 | 150 | def test_get_expand_input_from_query_param_limit_to_list_permitted(self): 151 | serializer = FlexFieldsModelSerializer( 152 | context={ 153 | "request": MockRequest( 154 | method="GET", query_params=MultiValueDict({"expand": ["cat,dog"]}) 155 | ), 156 | "permitted_expands": ["cat"], 157 | } 158 | ) 159 | 160 | self.assertEqual(serializer._flex_options_all["expand"], ["cat"]) 161 | 162 | def test_parse_request_list_value(self): 163 | test_params = [ 164 | {"abc": ["cat,dog,mouse"]}, 165 | {"abc": ["cat", "dog", "mouse"]}, 166 | {"abc[]": ["cat", "dog", "mouse"]}, 167 | ] 168 | for query_params in test_params: 169 | serializer = FlexFieldsModelSerializer(context={}) 170 | serializer.context["request"] = MockRequest( 171 | method="GET", query_params=MultiValueDict(query_params) 172 | ) 173 | 174 | result = serializer._get_query_param_value("abc") 175 | self.assertEqual(result, ["cat", "dog", "mouse"]) 176 | 177 | def test_parse_request_list_value_empty_if_cannot_access_request(self): 178 | serializer = FlexFieldsModelSerializer(context={}) 179 | result = serializer._get_query_param_value("abc") 180 | self.assertEqual(result, []) 181 | 182 | def test_import_serializer_class(self): 183 | pass 184 | 185 | def test_make_expanded_field_serializer(self): 186 | pass 187 | 188 | @patch("rest_flex_fields.serializers.RECURSIVE_EXPANSION_PERMITTED", False) 189 | def test_recursive_expansion(self): 190 | with self.assertRaises(serializers.ValidationError): 191 | FlexFieldsModelSerializer( 192 | context={ 193 | "request": MockRequest( 194 | method="GET", 195 | query_params=MultiValueDict({"expand": ["dog.leg.dog"]}), 196 | ) 197 | } 198 | ) 199 | 200 | @patch( 201 | "rest_flex_fields.FlexFieldsModelSerializer.recursive_expansion_permitted", 202 | new_callable=PropertyMock, 203 | ) 204 | def test_recursive_expansion_serializer_level( 205 | self, mock_recursive_expansion_permitted 206 | ): 207 | mock_recursive_expansion_permitted.return_value = False 208 | 209 | with self.assertRaises(serializers.ValidationError): 210 | FlexFieldsModelSerializer( 211 | context={ 212 | "request": MockRequest( 213 | method="GET", 214 | query_params=MultiValueDict({"expand": ["dog.leg.dog"]}), 215 | ) 216 | } 217 | ) 218 | 219 | @override_settings(REST_FLEX_FIELDS={"MAXIMUM_EXPANSION_DEPTH": 3}) 220 | def test_expansion_depth(self): 221 | serializer = FlexFieldsModelSerializer( 222 | context={ 223 | "request": MockRequest( 224 | method="GET", 225 | query_params=MultiValueDict({"expand": ["dog.leg.paws"]}), 226 | ) 227 | } 228 | ) 229 | self.assertEqual(serializer._flex_options_all["expand"], ["dog.leg.paws"]) 230 | 231 | @patch("rest_flex_fields.serializers.MAXIMUM_EXPANSION_DEPTH", 2) 232 | def test_expansion_depth_exception(self): 233 | with self.assertRaises(serializers.ValidationError): 234 | FlexFieldsModelSerializer( 235 | context={ 236 | "request": MockRequest( 237 | method="GET", 238 | query_params=MultiValueDict({"expand": ["dog.leg.paws"]}), 239 | ) 240 | } 241 | ) 242 | 243 | @patch( 244 | "rest_flex_fields.FlexFieldsModelSerializer.maximum_expansion_depth", 245 | new_callable=PropertyMock, 246 | ) 247 | def test_expansion_depth_serializer_level(self, mock_maximum_expansion_depth): 248 | mock_maximum_expansion_depth.return_value = 3 249 | serializer = FlexFieldsModelSerializer( 250 | context={ 251 | "request": MockRequest( 252 | method="GET", 253 | query_params=MultiValueDict({"expand": ["dog.leg.paws"]}), 254 | ) 255 | } 256 | ) 257 | self.assertEqual(serializer._flex_options_all["expand"], ["dog.leg.paws"]) 258 | 259 | @patch( 260 | "rest_flex_fields.FlexFieldsModelSerializer.maximum_expansion_depth", 261 | new_callable=PropertyMock, 262 | ) 263 | def test_expansion_depth_serializer_level_exception( 264 | self, mock_maximum_expansion_depth 265 | ): 266 | mock_maximum_expansion_depth.return_value = 2 267 | with self.assertRaises(serializers.ValidationError): 268 | FlexFieldsModelSerializer( 269 | context={ 270 | "request": MockRequest( 271 | method="GET", 272 | query_params=MultiValueDict({"expand": ["dog.leg.paws"]}), 273 | ) 274 | } 275 | ) 276 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | from django.utils.datastructures import MultiValueDict 5 | from rest_framework import serializers 6 | 7 | from rest_flex_fields.serializers import FlexFieldsModelSerializer 8 | from tests.testapp.models import Company, Person, Pet 9 | from tests.testapp.serializers import PetSerializer 10 | 11 | 12 | class MockRequest(object): 13 | def __init__(self, query_params=None, method="GET"): 14 | if query_params is None: 15 | query_params = {} 16 | self.query_params = query_params 17 | self.method = method 18 | 19 | 20 | class TestSerialize(TestCase): 21 | def test_basic_field_omit(self): 22 | pet = Pet( 23 | name="Garfield", 24 | toys="paper ball, string", 25 | species="cat", 26 | owner=Person(name="Fred"), 27 | ) 28 | 29 | expected_serializer_data = { 30 | "name": "Garfield", 31 | "toys": "paper ball, string", 32 | "diet": "", 33 | "sold_from": None, 34 | } 35 | 36 | serializer = PetSerializer(pet, omit=["species", "owner"]) 37 | self.assertEqual(serializer.data, expected_serializer_data) 38 | 39 | serializer = PetSerializer(pet, omit=(field for field in ("species", "owner"))) 40 | self.assertEqual(serializer.data, expected_serializer_data) 41 | 42 | def test_nested_field_omit(self): 43 | pet = Pet( 44 | name="Garfield", 45 | toys="paper ball, string", 46 | species="cat", 47 | owner=Person(name="Fred", employer=Company(name="McDonalds")), 48 | ) 49 | 50 | expected_serializer_data = { 51 | "diet": "", 52 | "name": "Garfield", 53 | "toys": "paper ball, string", 54 | "species": "cat", 55 | "owner": {"hobbies": "", "employer": {"name": "McDonalds"}}, 56 | "sold_from": None, 57 | } 58 | 59 | serializer = PetSerializer( 60 | pet, expand=["owner.employer"], omit=["owner.name", "owner.employer.public"] 61 | ) 62 | 63 | self.assertEqual(serializer.data, expected_serializer_data) 64 | 65 | serializer = PetSerializer( 66 | pet, 67 | expand=(field for field in ("owner.employer",)), 68 | omit=(field for field in ("owner.name", "owner.employer.public")), 69 | ) 70 | self.assertEqual(serializer.data, expected_serializer_data) 71 | 72 | def test_basic_field_include(self): 73 | pet = Pet( 74 | name="Garfield", 75 | toys="paper ball, string", 76 | species="cat", 77 | owner=Person(name="Fred"), 78 | ) 79 | 80 | expected_serializer_data = {"name": "Garfield", "toys": "paper ball, string"} 81 | 82 | serializer = PetSerializer(pet, fields=["name", "toys"]) 83 | self.assertEqual(serializer.data, expected_serializer_data) 84 | 85 | serializer = PetSerializer(pet, fields=(field for field in ("name", "toys"))) 86 | self.assertEqual(serializer.data, expected_serializer_data) 87 | 88 | def test_nested_field_include(self): 89 | pet = Pet( 90 | name="Garfield", 91 | toys="paper ball, string", 92 | species="cat", 93 | owner=Person(name="Fred", employer=Company(name="McDonalds")), 94 | ) 95 | 96 | expected_serializer_data = {"owner": {"employer": {"name": "McDonalds"}}} 97 | 98 | serializer = PetSerializer( 99 | pet, expand=["owner.employer"], fields=["owner.employer.name"] 100 | ) 101 | self.assertEqual(serializer.data, expected_serializer_data) 102 | 103 | serializer = PetSerializer( 104 | pet, 105 | expand=(field for field in ("owner.employer",)), 106 | fields=(field for field in ("owner.employer.name",)), 107 | ) 108 | self.assertEqual(serializer.data, expected_serializer_data) 109 | 110 | def test_basic_expand(self): 111 | pet = Pet( 112 | name="Garfield", 113 | toys="paper ball, string", 114 | species="cat", 115 | owner=Person(name="Fred", hobbies="sailing"), 116 | ) 117 | 118 | expected_serializer_data = { 119 | "name": "Garfield", 120 | "toys": "paper ball, string", 121 | "species": "cat", 122 | "owner": {"name": "Fred", "hobbies": "sailing"}, 123 | "sold_from": None, 124 | "diet": "", 125 | } 126 | 127 | request = MockRequest(query_params=MultiValueDict({"expand": ["owner"]})) 128 | serializer = PetSerializer(pet, context={"request": request}) 129 | self.assertEqual(serializer.data, expected_serializer_data) 130 | self.assertEqual(serializer.fields["owner"].context.get("request"), request) 131 | 132 | serializer = PetSerializer(pet, expand=(field for field in ("owner",))) 133 | self.assertEqual(serializer.data, expected_serializer_data) 134 | 135 | def test_nested_expand(self): 136 | pet = Pet( 137 | name="Garfield", 138 | toys="paper ball, string", 139 | species="cat", 140 | owner=Person( 141 | name="Fred", hobbies="sailing", employer=Company(name="McDonalds") 142 | ), 143 | ) 144 | 145 | expected_serializer_data = { 146 | "diet": "", 147 | "name": "Garfield", 148 | "toys": "paper ball, string", 149 | "species": "cat", 150 | "owner": { 151 | "name": "Fred", 152 | "hobbies": "sailing", 153 | "employer": {"public": False, "name": "McDonalds"}, 154 | }, 155 | "sold_from": None, 156 | } 157 | 158 | request = MockRequest( 159 | query_params=MultiValueDict({"expand": ["owner.employer"]}) 160 | ) 161 | serializer = PetSerializer(pet, context={"request": request}) 162 | self.assertEqual(serializer.data, expected_serializer_data) 163 | self.assertEqual( 164 | serializer.fields["owner"].fields["employer"].context.get("request"), 165 | request, 166 | ) 167 | 168 | serializer = PetSerializer(pet, expand=(field for field in ("owner.employer",))) 169 | self.assertEqual(serializer.data, expected_serializer_data) 170 | 171 | def test_expand_from_request(self): 172 | pet = Pet( 173 | name="Garfield", 174 | toys="paper ball, string", 175 | species="cat", 176 | owner=Person( 177 | name="Fred", hobbies="sailing", employer=Company(name="McDonalds") 178 | ), 179 | ) 180 | 181 | request = MockRequest( 182 | query_params=MultiValueDict({"expand": ["owner.employer"]}) 183 | ) 184 | serializer = PetSerializer(pet, context={"request": request}) 185 | 186 | self.assertEqual( 187 | serializer.data, 188 | { 189 | "diet": "", 190 | "name": "Garfield", 191 | "toys": "paper ball, string", 192 | "species": "cat", 193 | "sold_from": None, 194 | "owner": { 195 | "name": "Fred", 196 | "hobbies": "sailing", 197 | "employer": {"public": False, "name": "McDonalds"}, 198 | }, 199 | }, 200 | ) 201 | 202 | @patch("rest_flex_fields.serializers.EXPAND_PARAM", "include") 203 | def test_expand_with_custom_param_name(self): 204 | pet = Pet( 205 | name="Garfield", 206 | toys="paper ball, string", 207 | species="cat", 208 | owner=Person(name="Fred", hobbies="sailing"), 209 | ) 210 | 211 | expected_serializer_data = { 212 | "diet": "", 213 | "name": "Garfield", 214 | "toys": "paper ball, string", 215 | "species": "cat", 216 | "owner": {"name": "Fred", "hobbies": "sailing"}, 217 | "sold_from": None, 218 | } 219 | 220 | serializer = PetSerializer(pet, include=["owner"]) 221 | self.assertEqual(serializer.data, expected_serializer_data) 222 | 223 | @patch("rest_flex_fields.serializers.OMIT_PARAM", "exclude") 224 | def test_omit_with_custom_param_name(self): 225 | pet = Pet( 226 | name="Garfield", 227 | toys="paper ball, string", 228 | species="cat", 229 | owner=Person(name="Fred"), 230 | ) 231 | 232 | expected_serializer_data = { 233 | "name": "Garfield", 234 | "toys": "paper ball, string", 235 | "diet": "", 236 | "sold_from": None, 237 | } 238 | 239 | serializer = PetSerializer(pet, exclude=["species", "owner"]) 240 | self.assertEqual(serializer.data, expected_serializer_data) 241 | 242 | @patch("rest_flex_fields.serializers.FIELDS_PARAM", "only") 243 | def test_fields_include_with_custom_param_name(self): 244 | pet = Pet( 245 | name="Garfield", 246 | toys="paper ball, string", 247 | species="cat", 248 | owner=Person(name="Fred"), 249 | ) 250 | 251 | expected_serializer_data = {"name": "Garfield", "toys": "paper ball, string"} 252 | 253 | serializer = PetSerializer(pet, only=["name", "toys"]) 254 | self.assertEqual(serializer.data, expected_serializer_data) 255 | 256 | def test_all_special_value_in_serialize(self): 257 | pet = Pet( 258 | name="Garfield", 259 | toys="paper ball, string", 260 | species="cat", 261 | owner=Person(name="Fred", employer=Company(name="McDonalds")), 262 | ) 263 | 264 | class PetSerializer(FlexFieldsModelSerializer): 265 | owner = serializers.PrimaryKeyRelatedField( 266 | queryset=Person.objects.all(), allow_null=True 267 | ) 268 | 269 | class Meta: 270 | model = Pet 271 | fields = "__all__" 272 | 273 | serializer = PetSerializer( 274 | fields=("name", "toys"), 275 | data={ 276 | "name": "Garfield", 277 | "toys": "paper ball", 278 | "species": "cat", 279 | "owner": None, 280 | "diet": "lasanga", 281 | }, 282 | ) 283 | 284 | serializer.is_valid(raise_exception=True) 285 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from rest_flex_fields import is_included, is_expanded, WILDCARD_ALL, WILDCARD_ASTERISK 4 | 5 | 6 | class MockRequest(object): 7 | def __init__(self, query_params=None, method="GET"): 8 | if query_params is None: 9 | query_params = {} 10 | self.query_params = query_params 11 | self.method = method 12 | 13 | 14 | class TestUtils(TestCase): 15 | def test_should_be_included(self): 16 | request = MockRequest(query_params={}) 17 | self.assertTrue(is_included(request, "name")) 18 | 19 | def test_should_not_be_included(self): 20 | request = MockRequest(query_params={"omit": "name,address"}) 21 | self.assertFalse(is_included(request, "name")) 22 | 23 | def test_should_not_be_included_and_due_to_omit_and_has_dot_notation(self): 24 | request = MockRequest(query_params={"omit": "friend.name,address"}) 25 | self.assertFalse(is_included(request, "name")) 26 | 27 | def test_should_not_be_included_and_due_to_fields_and_has_dot_notation(self): 28 | request = MockRequest(query_params={"fields": "hobby,address"}) 29 | self.assertFalse(is_included(request, "name")) 30 | 31 | def test_should_be_expanded(self): 32 | request = MockRequest(query_params={"expand": "name,address"}) 33 | self.assertTrue(is_expanded(request, "name")) 34 | 35 | def test_should_not_be_expanded(self): 36 | request = MockRequest(query_params={"expand": "name,address"}) 37 | self.assertFalse(is_expanded(request, "hobby")) 38 | 39 | def test_should_be_expanded_and_has_dot_notation(self): 40 | request = MockRequest(query_params={"expand": "person.name,address"}) 41 | self.assertTrue(is_expanded(request, "name")) 42 | 43 | def test_all_should_be_expanded(self): 44 | request = MockRequest(query_params={"expand": WILDCARD_ALL}) 45 | self.assertTrue(is_expanded(request, "name")) 46 | 47 | def test_asterisk_should_be_expanded(self): 48 | request = MockRequest(query_params={"expand": WILDCARD_ASTERISK}) 49 | self.assertTrue(is_expanded(request, "name")) 50 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from pprint import pprint 3 | from unittest.mock import patch 4 | 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import connection 7 | from django.test import override_settings 8 | from django.urls import reverse 9 | from rest_framework.test import APITestCase 10 | 11 | from rest_flex_fields.filter_backends import FlexFieldsFilterBackend 12 | from tests.testapp.models import Company, Person, Pet, PetStore, TaggedItem 13 | 14 | 15 | class PetViewTests(APITestCase): 16 | def setUp(self): 17 | self.company = Company.objects.create(name="McDonalds") 18 | 19 | self.person = Person.objects.create( 20 | name="Fred", hobbies="sailing", employer=self.company 21 | ) 22 | 23 | self.pet = Pet.objects.create( 24 | name="Garfield", toys="paper ball, string", species="cat", owner=self.person 25 | ) 26 | 27 | def tearDown(self): 28 | Company.objects.all().delete() 29 | Person.objects.all().delete() 30 | Pet.objects.all().delete() 31 | 32 | def test_retrieve_expanded(self): 33 | url = reverse("pet-detail", args=[self.pet.id]) 34 | response = self.client.get(url + "?expand=owner", format="json") 35 | 36 | self.assertEqual( 37 | response.data, 38 | { 39 | "diet": "", 40 | "name": "Garfield", 41 | "toys": "paper ball, string", 42 | "species": "cat", 43 | "sold_from": None, 44 | "owner": {"name": "Fred", "hobbies": "sailing"}, 45 | }, 46 | ) 47 | 48 | def test_retrieve_sparse(self): 49 | url = reverse("pet-detail", args=[self.pet.id]) 50 | response = self.client.get(url + "?fields=name,species", format="json") 51 | 52 | self.assertEqual(response.data, {"name": "Garfield", "species": "cat"}) 53 | 54 | def test_retrieve_sparse_and_deep_expanded(self): 55 | url = reverse("pet-detail", args=[self.pet.id]) 56 | url = url + "?fields=owner&expand=owner.employer" 57 | response = self.client.get(url, format="json") 58 | 59 | self.assertEqual( 60 | response.data, 61 | { 62 | "owner": { 63 | "name": "Fred", 64 | "hobbies": "sailing", 65 | "employer": {"public": False, "name": "McDonalds"}, 66 | } 67 | }, 68 | ) 69 | 70 | def test_retrieve_all_fields_at_root_and_sparse_fields_at_next_level(self): 71 | url = reverse("pet-detail", args=[self.pet.id]) 72 | url = url + "?fields=*,owner.name&expand=owner" 73 | response = self.client.get(url, format="json") 74 | 75 | self.assertEqual( 76 | response.data, 77 | { 78 | "name": "Garfield", 79 | "toys": "paper ball, string", 80 | "species": "cat", 81 | "diet": "", 82 | "sold_from": None, 83 | "owner": { 84 | "name": "Fred", 85 | }, 86 | }, 87 | ) 88 | 89 | def test_list_expanded(self): 90 | url = reverse("pet-list") 91 | url = url + "?expand=owner" 92 | response = self.client.get(url, format="json") 93 | 94 | self.assertEqual( 95 | response.data[0], 96 | { 97 | "diet": "", 98 | "name": "Garfield", 99 | "toys": "paper ball, string", 100 | "species": "cat", 101 | "sold_from": None, 102 | "owner": {"name": "Fred", "hobbies": "sailing"}, 103 | }, 104 | ) 105 | 106 | def test_create_and_return_expanded_field(self): 107 | url = reverse("pet-list") 108 | url = url + "?expand=owner" 109 | 110 | response = self.client.post( 111 | url, 112 | { 113 | "diet": "rats", 114 | "owner": self.person.id, 115 | "species": "snake", 116 | "toys": "playstation", 117 | "name": "Freddy", 118 | "sold_from": None, 119 | }, 120 | format="json", 121 | ) 122 | 123 | self.assertEqual( 124 | response.data, 125 | { 126 | "name": "Freddy", 127 | "diet": "rats", 128 | "toys": "playstation", 129 | "sold_from": None, 130 | "species": "snake", 131 | "owner": {"name": "Fred", "hobbies": "sailing"}, 132 | }, 133 | ) 134 | 135 | def test_expand_drf_serializer_field(self): 136 | url = reverse("pet-detail", args=[self.pet.id]) 137 | response = self.client.get(url + "?expand=diet", format="json") 138 | 139 | self.assertEqual( 140 | response.data, 141 | { 142 | "diet": "homemade lasanga", 143 | "name": "Garfield", 144 | "toys": "paper ball, string", 145 | "sold_from": None, 146 | "species": "cat", 147 | "owner": self.pet.owner_id, 148 | }, 149 | ) 150 | 151 | def test_expand_drf_model_serializer(self): 152 | petco = PetStore.objects.create(name="PetCo") 153 | self.pet.sold_from = petco 154 | self.pet.save() 155 | 156 | url = reverse("pet-detail", args=[self.pet.id]) 157 | response = self.client.get(url + "?expand=sold_from", format="json") 158 | 159 | self.assertEqual( 160 | response.data, 161 | { 162 | "diet": "", 163 | "name": "Garfield", 164 | "toys": "paper ball, string", 165 | "sold_from": {"id": petco.id, "name": "PetCo"}, 166 | "species": "cat", 167 | "owner": self.pet.owner_id, 168 | }, 169 | ) 170 | 171 | 172 | @override_settings(DEBUG=True) 173 | @patch("tests.testapp.views.PetViewSet.filter_backends", [FlexFieldsFilterBackend]) 174 | class PetViewWithSelectFieldsFilterBackendTests(PetViewTests): 175 | def test_query_optimization(self): 176 | url = reverse("pet-list") 177 | url = url + "?expand=owner&fields=name,owner" 178 | 179 | response = self.client.get(url, format="json") 180 | self.assertEqual(response.status_code, HTTPStatus.OK) 181 | 182 | self.assertEqual(len(connection.queries), 1) 183 | self.assertEqual( 184 | connection.queries[0]["sql"], 185 | ( 186 | "SELECT " 187 | '"testapp_pet"."id", ' 188 | '"testapp_pet"."name", ' 189 | '"testapp_pet"."owner_id", ' 190 | '"testapp_person"."id", ' 191 | '"testapp_person"."name", ' 192 | '"testapp_person"."hobbies", ' 193 | '"testapp_person"."employer_id" ' 194 | 'FROM "testapp_pet" ' 195 | 'INNER JOIN "testapp_person" ON ("testapp_pet"."owner_id" = "testapp_person"."id")' 196 | ), 197 | ) 198 | 199 | # todo: test many to one 200 | # todo: test many to many 201 | # todo: test view options for SelectFieldsFilterBackend 202 | 203 | 204 | @override_settings(DEBUG=True) 205 | @patch("tests.testapp.views.TaggedItemViewSet.filter_backends", [FlexFieldsFilterBackend]) 206 | class TaggedItemViewWithSelectFieldsFilterBackendTests(APITestCase): 207 | def test_query_optimization_includes_generic_foreign_keys_in_prefetch_related(self): 208 | self.company = Company.objects.create(name="McDonalds") 209 | 210 | self.person = Person.objects.create( 211 | name="Fred", hobbies="sailing", employer=self.company 212 | ) 213 | 214 | self.pet1 = Pet.objects.create( 215 | name="Garfield", toys="paper ball, string", species="cat", 216 | owner=self.person 217 | ) 218 | self.pet2 = Pet.objects.create( 219 | name="Garfield", toys="paper ball, string", species="cat", 220 | owner=self.person 221 | ) 222 | 223 | self.tagged_item1 = TaggedItem.objects.create( 224 | content_type=ContentType.objects.get_for_model(Pet), 225 | object_id=self.pet1.id 226 | ) 227 | self.tagged_item2 = TaggedItem.objects.create( 228 | content_type=ContentType.objects.get_for_model(Pet), 229 | object_id=self.pet2.id 230 | ) 231 | self.tagged_item3 = TaggedItem.objects.create( 232 | content_type=ContentType.objects.get_for_model(Person), 233 | object_id=self.person.id 234 | ) 235 | self.tagged_item4 = TaggedItem.objects.create( 236 | content_type=ContentType.objects.get_for_model(Company), 237 | object_id=self.company.id 238 | ) 239 | 240 | url = reverse("tagged-item-list") 241 | 242 | response = self.client.get(url, format="json") 243 | self.assertEqual(response.status_code, HTTPStatus.OK) 244 | self.assertEqual(len(connection.queries), 4) 245 | 246 | self.assertEqual( 247 | connection.queries[0]["sql"], 248 | ( 249 | 'SELECT ' 250 | '"testapp_taggeditem"."id", ' 251 | '"testapp_taggeditem"."content_type_id", ' 252 | '"testapp_taggeditem"."object_id", ' 253 | '"django_content_type"."id", ' 254 | '"django_content_type"."app_label", ' 255 | '"django_content_type"."model" ' 256 | 'FROM "testapp_taggeditem" ' 257 | 'INNER JOIN "django_content_type" ON ("testapp_taggeditem"."content_type_id" = "django_content_type"."id")' 258 | )) 259 | self.assertEqual( 260 | connection.queries[1]["sql"], 261 | ( 262 | 'SELECT ' 263 | '"testapp_pet"."id", ' 264 | '"testapp_pet"."name", ' 265 | '"testapp_pet"."toys", ' 266 | '"testapp_pet"."species", ' 267 | '"testapp_pet"."owner_id", ' 268 | '"testapp_pet"."sold_from_id", ' 269 | '"testapp_pet"."diet" ' 270 | 'FROM "testapp_pet" WHERE "testapp_pet"."id" IN ({0}, {1})'.format(self.pet1.id, self.pet2.id) 271 | ) 272 | ) 273 | self.assertEqual( 274 | connection.queries[2]["sql"], 275 | ( 276 | 'SELECT ' 277 | '"testapp_person"."id", ' 278 | '"testapp_person"."name", ' 279 | '"testapp_person"."hobbies", ' 280 | '"testapp_person"."employer_id" ' 281 | 'FROM "testapp_person" WHERE "testapp_person"."id" IN ({0})'.format(self.person.id) 282 | ) 283 | ) 284 | self.assertEqual( 285 | connection.queries[3]["sql"], 286 | ( 287 | 'SELECT ' 288 | '"testapp_company"."id", ' 289 | '"testapp_company"."name", ' 290 | '"testapp_company"."public" ' 291 | 'FROM "testapp_company" WHERE "testapp_company"."id" IN ({0})'.format(self.company.id) 292 | ) 293 | ) 294 | 295 | self.assertEqual(len(response.json()), 4) -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsinger86/drf-flex-fields/9dd6a9140fd6d2ffe1baf9ab1ffc728540dea84d/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | name = 'tests.testapp' 6 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | 7 | 8 | class Company(models.Model): 9 | name = models.CharField(max_length=30) 10 | public = models.BooleanField(default=False) 11 | 12 | 13 | class PetStore(models.Model): 14 | name = models.CharField(max_length=30) 15 | 16 | 17 | class Person(models.Model): 18 | name = models.CharField(max_length=30) 19 | hobbies = models.CharField(max_length=30) 20 | employer = models.ForeignKey(Company, on_delete=models.CASCADE) 21 | 22 | 23 | class Pet(models.Model): 24 | name = models.CharField(max_length=30) 25 | toys = models.CharField(max_length=30) 26 | species = models.CharField(max_length=30) 27 | owner = models.ForeignKey(Person, on_delete=models.CASCADE) 28 | sold_from = models.ForeignKey(PetStore, null=True, on_delete=models.CASCADE) 29 | diet = models.CharField(max_length=200) 30 | 31 | 32 | class TaggedItem(models.Model): 33 | tag = models.SlugField() 34 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 35 | object_id = models.PositiveIntegerField() 36 | content_object = GenericForeignKey('content_type', 'object_id') -------------------------------------------------------------------------------- /tests/testapp/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.relations import PrimaryKeyRelatedField 3 | 4 | from rest_flex_fields import FlexFieldsModelSerializer 5 | from tests.testapp.models import Pet, PetStore, Person, Company, TaggedItem 6 | 7 | 8 | class CompanySerializer(FlexFieldsModelSerializer): 9 | class Meta: 10 | model = Company 11 | fields = ["name", "public"] 12 | 13 | 14 | class PersonSerializer(FlexFieldsModelSerializer): 15 | class Meta: 16 | model = Person 17 | fields = ["name", "hobbies"] 18 | expandable_fields = {"employer": "tests.testapp.serializers.CompanySerializer"} 19 | 20 | 21 | class PetStoreSerializer(serializers.ModelSerializer): 22 | class Meta: 23 | model = PetStore 24 | fields = ["id", "name"] 25 | 26 | 27 | class PetSerializer(FlexFieldsModelSerializer): 28 | owner = serializers.PrimaryKeyRelatedField(queryset=Person.objects.all()) 29 | sold_from = serializers.PrimaryKeyRelatedField( 30 | queryset=PetStore.objects.all(), allow_null=True 31 | ) 32 | diet = serializers.CharField() 33 | 34 | class Meta: 35 | model = Pet 36 | fields = ["owner", "name", "toys", "species", "diet", "sold_from"] 37 | 38 | expandable_fields = { 39 | "owner": "tests.testapp.PersonSerializer", 40 | "sold_from": "tests.testapp.PetStoreSerializer", 41 | "diet": serializers.SerializerMethodField, 42 | } 43 | 44 | def get_diet(self, obj): 45 | if obj.name == "Garfield": 46 | return "homemade lasanga" 47 | return "pet food" 48 | 49 | 50 | class TaggedItemSerializer(FlexFieldsModelSerializer): 51 | content_object = PrimaryKeyRelatedField(read_only=True) 52 | 53 | class Meta: 54 | model = TaggedItem 55 | fields = ( 56 | "id", 57 | "content_type", 58 | "object_id", 59 | "content_object" 60 | ) 61 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.viewsets import ModelViewSet 2 | 3 | from rest_flex_fields import FlexFieldsModelViewSet 4 | from tests.testapp.models import Pet, TaggedItem 5 | from tests.testapp.serializers import PetSerializer, TaggedItemSerializer 6 | 7 | 8 | class PetViewSet(FlexFieldsModelViewSet): 9 | """ 10 | API endpoint for testing purposes. 11 | """ 12 | 13 | serializer_class = PetSerializer 14 | queryset = Pet.objects.all() 15 | permit_list_expands = ["owner"] 16 | 17 | 18 | class TaggedItemViewSet(ModelViewSet): 19 | serializer_class = TaggedItemSerializer 20 | queryset = TaggedItem.objects.all() 21 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from rest_framework import routers 3 | from tests.testapp.views import PetViewSet, TaggedItemViewSet 4 | 5 | # Standard viewsets 6 | router = routers.DefaultRouter() 7 | router.register(r"pets", PetViewSet, basename="pet") 8 | router.register(r"tagged-items", TaggedItemViewSet, basename="tagged-item") 9 | 10 | urlpatterns = [url(r"^", include(router.urls))] 11 | --------------------------------------------------------------------------------