├── .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 | [](https://pypi.python.org/pypi/drf-flex-fields)
4 | [](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 |
--------------------------------------------------------------------------------