├── .codecov.yml
├── .editorconfig
├── .flake8
├── .github
├── ISSUE_TEMPLATE
│ └── feature_request.md
└── workflows
│ ├── codesee-arch-diagram.yml
│ ├── docs.yml
│ ├── publish.yml
│ └── tests.yml
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── django_api_forms
├── __init__.py
├── apps.py
├── exceptions.py
├── fields.py
├── forms.py
├── population_strategies.py
├── settings.py
├── utils.py
└── version.py
├── docs
├── api_reference.md
├── contributing.md
├── custom
│ └── main.html
├── example.md
├── fields.md
├── index.md
├── install.md
├── navicat.png
└── tutorial.md
├── mkdocs.yml
├── poetry.lock
├── pyproject.toml
├── runtests.py
└── tests
├── __init__.py
├── data
├── invalid.json
├── invalid_concert.json
├── invalid_image.txt
├── kitten.txt
├── kitten_mismatch.txt
├── kitten_missing.txt
├── valid.json
├── valid_artist.json
├── valid_concert.json
└── valid_pdf.txt
├── settings.py
├── test_fields.py
├── test_forms.py
├── test_modelchoicefield.py
├── test_modelforms.py
├── test_nested_forms.py
├── test_population.py
├── test_settings.py
├── test_validation.py
└── testapp
├── __init__.py
├── apps.py
├── forms.py
├── models.py
└── population_strategies.py
/.codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | precision: 2
3 | round: down
4 | range: "70...100"
5 |
6 | status:
7 | project: false
8 | patch: true
9 | changes: false
10 |
11 | comment:
12 | layout: "header, diff, changes, tree"
13 | behavior: default
14 |
15 | ignore:
16 | - "docs/**"
17 | - ".github/**"
18 | - "tests/**"
19 | - "runtests.py"
20 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | # Unix-style newlines with a newline ending every file
4 | [*]
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 | end_of_line = lf
8 | charset = utf-8
9 |
10 | # https://www.python.org/dev/peps/pep-0008/
11 | [*.py]
12 | indent_style = space
13 | indent_size = 4
14 | max_line_length = 119
15 |
16 | [*.html]
17 | indent_style = tab
18 | indent_size = 2
19 |
20 | [*.md]
21 | max_line_length = 119
22 | indent_size = 4
23 | indent_style = space
24 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | exclude = .git,.mypy_cache,.pytest_cache,.tox,.venv,__pycache__,build,dist,docs,venv
3 | max-line-length = 119
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when...
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/codesee-arch-diagram.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 | pull_request_target:
6 | types: [opened, synchronize, reopened]
7 |
8 | name: CodeSee
9 |
10 | permissions: read-all
11 |
12 | jobs:
13 | codesee:
14 | runs-on: ubuntu-latest
15 | continue-on-error: true
16 | name: Analyze the repo with CodeSee
17 | steps:
18 | - uses: Codesee-io/codesee-action@v2
19 | with:
20 | codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }}
21 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Publish docs via GitHub Pages
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | jobs:
8 | build:
9 | name: Deploy docs
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout master
13 | uses: actions/checkout@v3
14 |
15 | - name: Deploy docs
16 | uses: mhausenblas/mkdocs-deploy-gh-pages@master
17 | env:
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Upload Python Package
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 |
13 | - name: Install poetry
14 | run: |
15 | sudo apt install -y pipx
16 | pipx ensurepath
17 | pipx install poetry
18 | pipx inject poetry poetry-plugin-export
19 | - name: Build and publish to Python package repository
20 | run: |
21 | poetry build
22 | poetry config pypi-token.pypi "${{ secrets.PYPI_TOKEN }}"
23 | poetry publish
24 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | max-parallel: 4
10 | matrix:
11 | python-version: [3.9, '3.10', '3.11', '3.12']
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Set up Python ${{ matrix.python-version }}
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version: ${{ matrix.python-version }}
19 | - name: Install dependencies
20 | run: |
21 | sudo apt-add-repository ppa:ubuntugis/ubuntugis-unstable
22 | sudo apt-get update
23 | sudo apt-get install gdal-bin libgdal-dev -y
24 | python -m pip install --upgrade pip
25 | python -m pip install poetry
26 | python -m pip install poetry setuptools
27 | poetry install --all-extras
28 | - name: Lint with flake8
29 | run: |
30 | poetry run flake8 .
31 | - name: Test with Django test
32 | run: |
33 | poetry run python runtests.py
34 | - name: Test release process
35 | run: |
36 | poetry publish --build --dry-run
37 | - name: Coverage with pytest
38 | run: |
39 | poetry run coverage run runtests.py
40 | poetry run coverage xml
41 | - name: Run codacy-coverage-reporter
42 | if: github.event_name != 'pull_request'
43 | uses: codacy/codacy-coverage-reporter-action@v1
44 | with:
45 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
46 | coverage-reports: coverage.xml
47 | - uses: codecov/codecov-action@v4
48 | with:
49 | token: ${{ secrets.CODECOV_TOKEN }}
50 | files: ./coverage.xml
51 | flags: unittests
52 | verbose: true
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | *.DS_Store
3 | *.pyc
4 | *.pyo
5 | *.log
6 | __pycache__
7 |
8 | # Distribution / packaging
9 | .Python
10 | build/
11 | develop-eggs/
12 | dist/
13 | downloads/
14 | eggs/
15 | .eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | wheels/
22 | pip-wheel-metadata/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | venv/
28 | .coverage
29 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.0.0-rc.11 : 16.08.2024
4 |
5 | - **Fixed**: Proper manipulation with `BaseStrategy` instances during population
6 |
7 | ## 1.0.0-rc.10 : 02.08.2024 (unreleased)
8 |
9 | - **Added**: `AliasStrategy` for overriding property name on target object during `setattr()`
10 | - **Changed**:`field_strategy` now can be also an instance of `BaseStrategy`
11 | - **Fixed**: Fixed calling population methods when declared in form
12 |
13 | ## 1.0.0-rc.9 : 24.03.2023
14 |
15 | - **Added**: Introduced extra optional arguments when creating `From` instance
16 |
17 | ## 1.0.0-rc.8 : 14.03.2023
18 |
19 | - **Added**: `GeoJSON` field introduced
20 | - **Changed**: Default clean method `Form.clean` is only called when there are no errors.
21 |
22 | ## 1.0.0-rc.7 : 24.11.2022
23 |
24 | CI/CD fixies and project settings. There was no application change.
25 |
26 | ## 1.0.0-rc.6 : 22.11.2022
27 |
28 | Release by [@paimvictor](https://github.com/paimvictor)
29 |
30 | - **Added**: Introduced `RRuleField` to represent [Recurrence Rule objects](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)
31 |
32 | ## 1.0.0-rc.5 : 20.10.2022
33 |
34 | - **Added**: Introduced defining a form field for the dictionary key in `DictionaryField`
35 | - **Changed**: `value_field` in `DictionaryField` is forced keyword arguments
36 | - **Changed**: Replaced `RuntimeError` with `ApiFormException`
37 |
38 | ## 1.0.0-rc.4 : 09.09.2022
39 |
40 | - **Fixed**: Fixed missing validation errors
41 | - **Changed**: `Form.add_error()` now takes only `Tuple` as a `field` argument
42 |
43 | ## 1.0.0-rc.3 : 04.09.2022
44 |
45 | - **Fixed**: Removed validation of non-required fields if they are not present in the request
46 |
47 | ## 1.0.0-rc.2 : 31.05.2022
48 |
49 | - **Fixed**: Fixed "weird" behaviour with missing `clean_data` values if using `ListField`
50 |
51 | ## 1.0.0-rc.1 : 28.04.2022
52 |
53 | This release has been inspired by [Problem Details for HTTP APIs - RFC7807](https://tools.ietf.org/html/rfc7807) and
54 | blog post [Structuring validation errors in REST APIs](https://medium.com/engineering-brainly/structuring-validation-errors-in-rest-apis-40c15fbb7bc3)
55 | written by [@k3nn7](https://github.com/k3nn7).
56 |
57 | The main idea has been to simplify validation process on the client side by flattening errors output. To achieve such
58 | a goal, the whole validation process has been rewritten (and luckily for us, much simplified).
59 |
60 | - **Changed**: Positional validation errors for lists
61 | - **Changed**: `ImageField` and `FileField` requires [Data URI](https://datatracker.ietf.org/doc/html/rfc2397)
62 | (issue [Raise ValidationError in invalid Data URI by default](https://github.com/Sibyx/django_api_forms/issues/22))
63 | - **Removed**: `Form.fill()` method replaced by `Form.populate()`
64 | - **Removed**: `fill_` methods replaced by population strategies
65 |
66 | ## 0.21.1 : 14.02.2022
67 |
68 | - **Changed**: Raw base64 payload in `FileField` and `ImageField` fires `DeprecationWarning`. Use Data URI instead.
69 |
70 | ## 0.21.0 : 03.02.2022
71 |
72 | - **Added**: Introduced `mapping`
73 | - **Added**: Override strategies using `field_type_strategy` and `field_strategy`
74 |
75 | ## 0.20.1 : 13.1.2022
76 |
77 | - **Fixed**: `DictionaryField` was unable to raise validation errors for keys
78 |
79 | ## 0.20.0 : 14.10.2021
80 |
81 | Anniversary release 🥳
82 |
83 | - **Added**: Population strategies introduced
84 | - **Added**: `fill` method is deprecated and replaced by `populate`
85 | - **Added**: `Settings` object introduced (`form.settings`)
86 | - **Added**: Pluggable content-type parsers using `DJANGO_API_FORMS_PARSERS` setting
87 |
88 | ## 0.19.1 : 17.09.2021
89 |
90 | - **Changed**: `mime` argument in `FileField` is supposed to be a `tuple`
91 |
92 | ## 0.19.0 : 12.07.2021
93 |
94 | - **Added**: `FieldList` and `FormFieldList` now supports optional min/max constrains using `min_length`/`max_length`
95 |
96 | ## 0.18.0 : 16.04.2021
97 |
98 | - **Added**: `ModelForm` class introduced (experimental, initial support - not recommended for production)
99 |
100 | ## 0.17.0 : 24.02.2021
101 |
102 | - **Added**: `fill_method` introduced
103 |
104 | ## 0.16.4 : 20.12.2020
105 |
106 | - **Fixed**: Pillow image object have to be reopened after `Image.verify()` call in `ImageField::to_python`
107 |
108 | ## 0.16.3 : 13.11.2020
109 |
110 | - **Fixed**: `ApiFormException('No clean data provided! Try to call is_valid() first.')` was incorrectly raised if
111 | request payload was empty during `Form::fill` method call
112 | - **Changed**: `clean_data` property is by default `None` instead of empty dictionary
113 |
114 | ## 0.16.2 : 06.11.2020
115 |
116 | - **Fixed**: Fixed issue with `clean_` methods returning values resolved as False (`False`, `None`, `''`)
117 |
118 | ## 0.16.1 : 29.10.2020
119 |
120 | - **Fixed**: Ignore `ModelMultipleChoiceField` in `Form::fill()`
121 |
122 | ## 0.16.0 : 14.09.2020
123 |
124 | One more step to get rid of `pytest` in project (we don't need it)
125 |
126 | - **Changed**: Correctly resolve key postfix if `ModelChoiceField` is used in `Form::fill()`
127 | - **Changed**: `DjangoApiFormsConfig` is created
128 |
129 | ## 0.15.1 : 29.08.2020
130 |
131 | - **Added**: `FileField.content_type` introduced (contains mime)
132 |
133 | ## 0.15.0 : 23.08.2020
134 |
135 | - **Added**: `FileField` and `ImageField` introduced
136 | - **Added**: Defined extras in `setup.py` for optional `Pillow` and `msgpack` dependencies
137 | - **Added**: Working `Form::fill()` method for primitive data types. Introduced `IgnoreFillMixin`
138 |
139 | ## 0.14.0 : 07.08.2020
140 |
141 | - **Added**: `BaseForm._request` property introduced (now it's possible to use request in `clean_` methods)
142 |
143 | ## 0.13.0 : 09.07.2020
144 |
145 | - **Fixed**: Fixed `Content-Type` handling if `charset` or `boundary` is present
146 |
147 | ## 0.12.0 : 11.06.2020
148 |
149 | - **Fixed**: Do not call resolvers methods, if property is not required and not present in request
150 |
151 | ## 0.11.0 : 10.06.2020
152 |
153 | - **Changed**: Non specified non-required fields will no longer be available in the cleaned_data form attribute.
154 |
155 | ## 0.10.0 : 01.06.2020
156 |
157 | - **Changed**: All package exceptions inherits from `ApiFormException`.
158 | - **Fixed**: Specifying encoding while opening files in `setup.py` (failing on Windows OS).
159 |
160 | ## 0.9.0 : 11.05.2020
161 |
162 | - **Changed**: Moved field error messages to default_error_messages for easier overriding and testing.
163 | - **Fixed**: Fix KeyError when invalid values are sent to FieldList.
164 | - **Fixed**: Removed unnecessary error checking in FieldList.
165 |
166 | ## 0.8.0 : 05.05.2020
167 |
168 | - **Added**: Tests for fields
169 | - **Changed**: Remove DeclarativeFieldsMetaclass and import from Django instead.
170 | - **Changed**: Msgpack dependency is no longer required.
171 | - **Changed**: Empty values passed into a FormField now return {} rather than None.
172 | - **Fixed**: Throw a more user friendly error when passing non-Enums or invalid values to EnumField.
173 |
174 | ## 0.7.1 : 13.04.2020
175 |
176 | - **Changed** Use [poetry](https://python-poetry.org/) instead of [pipenv](https://github.com/pypa/pipenv)
177 | - **Changed**: Library renamed from `django_api_forms` to `django-api-forms` (cosmetic change without effect)
178 |
179 | ## 0.7.0 : 03.03.2020
180 |
181 | - **Changed**: Library renamed from `django_request_formatter` to `django_api_forms`
182 | - **Changed**: Imports in main module `django_api_forms`
183 |
184 | ## 0.6.0 : 18.02.2020
185 |
186 | - **Added**: `BooleanField` introduced
187 |
188 | ## 0.5.8 : 07.01.2020
189 |
190 | - **Fixed**: Pass `Invalid value` as `ValidationError` not as a `string`
191 |
192 | ## 0.5.7 : 07.01.2020
193 |
194 | - **Fixed**: Introduced generic `Invalid value` error message, if there is `AttributeError`, `TypeError`, `ValueError`
195 |
196 | ## 0.5.6 : 01.01.2020
197 |
198 | - **Fixed**: Fixing issue from version `0.5.5` but this time for real
199 | - **Changed**: Renamed version file from `__version__.py` to `version.py`
200 |
201 | ## 0.5.5 : 01.01.2020
202 |
203 | - **Fixed**: Check instance only if there is a value in `FieldList` and `FormFieldList`
204 |
205 | ## 0.5.4 : 24.12.2019
206 |
207 | - **Fixed**: Added missing `msgpack`` dependency to `setup.py`
208 |
209 | ## 0.5.3 : 20.12.2019
210 |
211 | - **Added**: Introduced generic `AnyField`
212 |
213 | ## 0.5.2 : 19.12.2019
214 |
215 | - **Fixed**: Skip processing of the `FormField` if value is not required and empty
216 |
217 | ## 0.5.1 : 19.12.2019
218 |
219 | - **Fixed**: Process `EnumField` even if it's not marked as required
220 |
221 | ## 0.5.0 : 16.12.2019
222 |
223 | - **Changed**: Use native `django.form.fields` if possible
224 | - **Changed**: Removed `kwargs` propagation from release `0.3.0`
225 | - **Changed**: Changed syntax back to `django.forms` compatible (e.g. `form.validate_{key}()` -> `form.clean_{key}()`)
226 | - **Changed**: `FieldList` raises `ValidationError` instead of `RuntimeException` if there is a type in validation
227 | - **Changed**: Use private properties for internal data in field objects
228 | - **Fixed**: `FieldList` returns values instead of `None`
229 | - **Fixed**: Fixed validation in `DictionaryField`
230 | - **Added**: Basic unit tests
231 |
232 | ## 0.4.3 : 29.11.2019
233 |
234 | - **Fixed**: Fixed `Form` has no attribute `self._data`
235 |
236 | ## 0.4.2 : 29.11.2019
237 |
238 | - **Fixed**: If payload is empty, create empty dictionary to avoid `NoneType` error
239 |
240 | ## 0.4.1 : 14.11.2019
241 |
242 | - **Added**: Introduced `UUIDField`
243 |
244 | ## 0.4.0 : 13.11.2019
245 |
246 | - **Added**: Introduced `DictionaryField`
247 |
248 | ## 0.3.0 : 11.11.2019
249 |
250 | - **Added**: Propagate `kwargs` from `Form.is_valid()` to `Form.validate()` and `Form.validate_{key}()` methods
251 |
252 | ## 0.2.1 : 4.11.2019
253 |
254 | - **Fixed**: Fixed `to_python()` in FormFieldList
255 |
256 | ## 0.2.0 : 31.10.2019
257 |
258 | - **Changed**: `Form.validate()` replaced by `Form.is_valid()`
259 | - **Added**: `Form.validate()` is now used as a last step of form validation and it's aimed to be overwritten if
260 | needed
261 | - **Added**: Unit tests initialization
262 |
263 | ## 0.1.6 : 24.10.2019
264 |
265 | - **Fixed**: Non-required EnumField is now working
266 | - **Added**: WIP: Initial method for filling objects `Form::fill()`
267 |
268 | ## 0.1.5 : 23.10.2019
269 |
270 | - **Fixed**: Assign errors to form before raising `ValidationError`
271 |
272 | ## 0.1.4 : 23.10.2019
273 |
274 | - **Fixed**: Do not return empty error records in `Form:errors`
275 |
276 | ## 0.1.3 : 23.10.2019
277 |
278 | - **Fixed**: Use custom `DeclarativeFieldsMetaclass` because of custom `Field` class
279 | - **Fixed**: Do not return untouched fields in `Form::payload`
280 | - **Fixed**: Fix for None `default_validators` in `Field`
281 |
282 | ## 0.1.2 : 22:10.2019
283 |
284 | - **Added**: Support for `validation_{field}` methods in `Form` (initial support)
285 |
286 | ## 0.1.1 : 22.10.2019
287 |
288 | - **Added**: `EnumField`
289 |
290 | ## 0.1.0 : 22.10.2019
291 |
292 | - **Added**: First version of `Form` class
293 | - **Added**: `CharField`
294 | - **Added**: `IntegerField`
295 | - **Added**: `FloatField`
296 | - **Added**: `DecimalField`
297 | - **Added**: `DateField`
298 | - **Added**: `TimeField`
299 | - **Added**: `DateTimeField`
300 | - **Added**: `DurationField`
301 | - **Added**: `RegexField`
302 | - **Added**: `EmailField`
303 | - **Added**: `BooleanField`
304 | - **Added**: `RegexField`
305 | - **Added**: `FieldList`
306 | - **Added**: `FormField`
307 | - **Added**: `FormFieldList`
308 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at jakub.dubec@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | For answers to common questions about this code of conduct, see
74 | https://www.contributor-covenant.org/faq
75 |
76 | [homepage]: https://www.contributor-covenant.org
77 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to django-api-forms
2 |
3 | If you like nice diagrams you can also check repository
4 | [code map](https://app.codesee.io/maps/public/c7286640-20f6-11ec-a894-61b8cbaa0d26).
5 |
6 | ## Pull requests
7 |
8 | Feel free to open pull requests but please keep in mind this checklist:
9 |
10 | - write tests
11 | - write changes to to `CHANGELOG.md`
12 | - update `README.md` (if needed)
13 | - update documentation (if needed)
14 |
15 | ## Development
16 |
17 | We use [poetry](https://python-poetry.org/) for dependency management. Please write your source code according to the
18 | [PEP8](https://www.python.org/dev/peps/pep-0008/) code-style. [flake8](https://github.com/pycqa/flake8) is used for
19 | code-style and code-quality checks. Please, be sure that your IDE is following settings according to `.editorconfig`
20 | file. Use `poetry install --all-extras` to install all dependencies for development.
21 |
22 | We use[Django-style tests](https://docs.djangoproject.com/en/3.1/topics/testing/overview/).
23 |
24 | ```shell script
25 | # Run tests
26 | poetry run python runtests.py
27 |
28 | # Run flake8
29 | poetry run flake8 .
30 | ```
31 |
32 | ## Documentation
33 |
34 | Documentation is placed in `docs` directory and it's generated using
35 | [mkdocs-material](https://squidfunk.github.io/mkdocs-material/). You can build docs calling `poetry run mkdocs build`.
36 | Docs will be in `sites` directory after build. Documentation is updated after every push to `origin/master` branch
37 | using GitHub Actions.
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Jakub Dubec
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django API Forms
2 |
3 | [](https://badge.fury.io/py/django-api-forms)
4 | [](https://codecov.io/gh/Sibyx/django_api_forms)
5 |
6 | **Django API Forms** is a Python library that brings the [Django Forms](https://docs.djangoproject.com/en/4.1/topics/forms/) approach to processing RESTful HTTP request payloads (such as [JSON](https://www.json.org/) or [MessagePack](https://msgpack.org/)) without the HTML front-end overhead.
7 |
8 | ## Overview
9 |
10 | Django API Forms provides a declarative way to:
11 |
12 | - Define request validation schemas using a familiar Django-like syntax
13 | - Parse and validate incoming API requests
14 | - Handle nested data structures and complex validation rules
15 | - Convert validated data into Python objects
16 | - Populate Django models or other objects with validated data
17 |
18 | [**📚 Read the full documentation**](https://sibyx.github.io/django_api_forms/)
19 |
20 | ## Key Features
21 |
22 | - **Declarative Form Definition**: Define your API request schemas using a familiar Django Forms-like syntax
23 | - **Request Validation**: Validate incoming requests against your defined schemas
24 | - **Nested Data Structures**: Handle complex nested JSON objects and arrays
25 | - **Custom Field Types**: Specialized fields for common API use cases (BooleanField, EnumField, etc.)
26 | - **File Uploads**: Support for BASE64-encoded file and image uploads
27 | - **Object Population**: Easily populate Django models or other objects with validated data
28 | - **Customizable Validation**: Define custom validation rules at the field or form level
29 | - **Multiple Content Types**: Support for JSON, MessagePack, and extensible to other formats
30 |
31 | ## Motivation
32 |
33 | The main idea was to create a simple and declarative way to specify the format of expected requests with the ability
34 | to validate them. Firstly, I tried to use [Django Forms](https://docs.djangoproject.com/en/4.1/topics/forms/) to
35 | validate my API requests (I use pure Django in my APIs). I encountered a problem with nesting my requests without
36 | a huge boilerplate. Also, the whole HTML thing was pretty useless in my RESTful APIs.
37 |
38 | I wanted to:
39 |
40 | - Define my requests as objects (`Form`)
41 | - Pass the request to my defined object (`form = Form.create_from_request(request, param=param))`)
42 | - With the ability to pass any extra optional arguments
43 | - Validate my request (`form.is_valid()`)
44 | - Extract data (`form.cleaned_data` property)
45 |
46 | I wanted to keep:
47 |
48 | - Friendly declarative Django syntax
49 | ([DeclarativeFieldsMetaclass](https://github.com/django/django/blob/master/django/forms/forms.py#L22) is beautiful)
50 | - [Validators](https://docs.djangoproject.com/en/4.1/ref/validators/)
51 | - [ValidationError](https://docs.djangoproject.com/en/4.1/ref/exceptions/#validationerror)
52 | - [Form fields](https://docs.djangoproject.com/en/4.1/ref/forms/fields/) (In the end, I had to "replace" some of them)
53 |
54 | So I created this Python package to cover all these expectations.
55 |
56 | ## Installation
57 |
58 | ```shell
59 | # Using pip
60 | pip install django-api-forms
61 |
62 | # Using poetry
63 | poetry add django-api-forms
64 |
65 | # Local installation from source
66 | python -m pip install .
67 | ```
68 |
69 | ### Requirements
70 |
71 | - Python 3.9+
72 | - Django 2.0+
73 |
74 | ### Optional Dependencies
75 |
76 | Django API Forms supports additional functionality through optional dependencies:
77 |
78 | ```shell
79 | # MessagePack support (for Content-Type: application/x-msgpack)
80 | pip install django-api-forms[msgpack]
81 |
82 | # File and Image Fields support
83 | pip install django-api-forms[Pillow]
84 |
85 | # RRule Field support
86 | pip install django-api-forms[rrule]
87 |
88 | # GeoJSON Field support
89 | pip install django-api-forms[gdal]
90 |
91 | # Install multiple extras at once
92 | pip install django-api-forms[Pillow,msgpack]
93 | ```
94 |
95 | For more detailed installation instructions, see the [Installation Guide](https://sibyx.github.io/django_api_forms/install/).
96 |
97 | Install application in your Django project by adding `django_api_forms` to yours `INSTALLED_APPS`:
98 |
99 | ```python
100 | INSTALLED_APPS = (
101 | 'django.contrib.auth',
102 | 'django.contrib.contenttypes',
103 | 'django.contrib.sessions',
104 | 'django.contrib.messages',
105 | 'django_api_forms'
106 | )
107 | ```
108 |
109 | You can change the default behavior of population strategies or parsers using these settings (listed with default
110 | values). Keep in mind, that dictionaries are not replaced by your settings they are merged with defaults.
111 |
112 | For more information about the parsers and the population strategies check the documentation.
113 |
114 | ```python
115 | DJANGO_API_FORMS_POPULATION_STRATEGIES = {
116 | 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
117 | 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
118 | 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
119 | 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
120 | 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
121 | 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
122 | }
123 |
124 | DJANGO_API_FORMS_DEFAULT_POPULATION_STRATEGY = 'django_api_forms.population_strategies.BaseStrategy'
125 |
126 | DJANGO_API_FORMS_PARSERS = {
127 | 'application/json': 'json.loads',
128 | 'application/x-msgpack': 'msgpack.loads'
129 | }
130 | ```
131 |
132 | ## Quick Example
133 |
134 | Here's a simple example demonstrating how to use Django API Forms:
135 |
136 | ```python
137 | from django.forms import fields
138 | from django.http import JsonResponse
139 | from django_api_forms import Form, FormField, FieldList
140 |
141 | # Define a nested form
142 | class ArtistForm(Form):
143 | name = fields.CharField(required=True, max_length=100)
144 | genres = FieldList(field=fields.CharField(max_length=30))
145 | members = fields.IntegerField()
146 |
147 | # Define the main form
148 | class AlbumForm(Form):
149 | title = fields.CharField(max_length=100)
150 | year = fields.IntegerField()
151 | artist = FormField(form=ArtistForm)
152 |
153 | # In your view
154 | def create_album(request):
155 | form = AlbumForm.create_from_request(request)
156 | if not form.is_valid():
157 | # Handle validation errors
158 | return JsonResponse({"errors": form.errors}, status=400)
159 |
160 | # Access validated data
161 | album_data = form.cleaned_data
162 | # Do something with the data...
163 |
164 | return JsonResponse({"status": "success"})
165 | ```
166 |
167 | This form can validate a JSON request like:
168 |
169 | ```json
170 | {
171 | "title": "Unknown Pleasures",
172 | "year": 1979,
173 | "artist": {
174 | "name": "Joy Division",
175 | "genres": ["rock", "punk"],
176 | "members": 4
177 | }
178 | }
179 | ```
180 |
181 | ### More Examples
182 |
183 | For more comprehensive examples, check out the documentation:
184 |
185 | - [Basic Example with Nested Data](https://sibyx.github.io/django_api_forms/example/#basic-example-music-album-api)
186 | - [User Registration with File Upload](https://sibyx.github.io/django_api_forms/example/#example-user-registration-with-file-upload)
187 | - [API with Django Models](https://sibyx.github.io/django_api_forms/example/#example-api-with-django-models)
188 | - [ModelChoiceField Example](https://github.com/pawl/django_api_forms_modelchoicefield_example) - External repository by [pawl](https://github.com/pawl)
189 |
190 | ## Documentation
191 |
192 | Comprehensive documentation is available at [https://sibyx.github.io/django_api_forms/](https://sibyx.github.io/django_api_forms/)
193 |
194 | The documentation includes:
195 |
196 | - [Installation Guide](https://sibyx.github.io/django_api_forms/install/)
197 | - [Tutorial](https://sibyx.github.io/django_api_forms/tutorial/)
198 | - [Field Reference](https://sibyx.github.io/django_api_forms/fields/)
199 | - [Examples](https://sibyx.github.io/django_api_forms/example/)
200 | - [API Reference](https://sibyx.github.io/django_api_forms/api_reference/)
201 | - [Contributing Guide](https://sibyx.github.io/django_api_forms/contributing/)
202 |
203 | ## Running Tests
204 |
205 | ```shell
206 | # Install all dependencies
207 | poetry install
208 |
209 | # Run code-style check
210 | poetry run flake8 .
211 |
212 | # Run the tests
213 | poetry run python runtests.py
214 |
215 | # Run tests with coverage
216 | poetry run coverage run runtests.py
217 | poetry run coverage report
218 | ```
219 |
220 | ## License
221 |
222 | Django API Forms is released under the MIT License.
223 |
224 | ---
225 | Made with ❤️ and ☕️ by Jakub Dubec, [BACKBONE s.r.o.](https://www.backbone.sk/en/) &
226 | [contributors](https://github.com/Sibyx/django_api_forms/graphs/contributors).
227 |
--------------------------------------------------------------------------------
/django_api_forms/__init__.py:
--------------------------------------------------------------------------------
1 | from .exceptions import DetailValidationError
2 | from .fields import BooleanField
3 | from .fields import FieldList
4 | from .fields import FormField
5 | from .fields import FormFieldList
6 | from .fields import EnumField
7 | from .fields import DictionaryField
8 | from .fields import AnyField
9 | from .fields import FileField
10 | from .fields import ImageField
11 | from .fields import RRuleField
12 | from .fields import GeoJSONField
13 | from .forms import Form
14 | from .forms import ModelForm
15 | from .version import __version__
16 |
17 | __all__ = [
18 | 'DetailValidationError',
19 | 'BooleanField',
20 | 'FieldList',
21 | 'FormField',
22 | 'FormFieldList',
23 | 'EnumField',
24 | 'DictionaryField',
25 | 'AnyField',
26 | 'FileField',
27 | 'ImageField',
28 | 'RRuleField',
29 | 'GeoJSONField',
30 | 'Form',
31 | 'ModelForm',
32 | '__version__'
33 | ]
34 |
--------------------------------------------------------------------------------
/django_api_forms/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class DjangoApiFormsConfig(AppConfig):
5 | name = 'django_api_forms'
6 |
--------------------------------------------------------------------------------
/django_api_forms/exceptions.py:
--------------------------------------------------------------------------------
1 | from typing import Tuple
2 |
3 | from django.core.exceptions import ValidationError
4 |
5 |
6 | class ApiFormException(Exception):
7 | """Generic Django API Form exception"""
8 |
9 |
10 | class UnsupportedMediaType(ApiFormException):
11 | """Unable to parse the request (based on the Content-Type)"""
12 |
13 |
14 | class DetailValidationError(ValidationError):
15 | def __init__(self, error: ValidationError, path: Tuple):
16 | if not hasattr(error, 'message') and isinstance(error.error_list, list):
17 | for item in error.error_list:
18 | item.path = path
19 |
20 | super().__init__(error)
21 | self._path = path
22 |
23 | @property
24 | def path(self) -> Tuple:
25 | return self._path
26 |
27 | def prepend(self, key: Tuple):
28 | self._path = key + self._path
29 |
30 | def to_list(self) -> list:
31 | return list(self.path)
32 |
33 | def to_dict(self) -> dict:
34 | return {
35 | 'code': self.code,
36 | 'message': self.message,
37 | 'path': self.to_list()
38 | }
39 |
--------------------------------------------------------------------------------
/django_api_forms/fields.py:
--------------------------------------------------------------------------------
1 | import typing
2 | import warnings
3 | from base64 import b64decode
4 | from enum import Enum
5 | from io import BytesIO
6 | from mimetypes import guess_type
7 | import re
8 |
9 | from django.core.exceptions import ValidationError
10 | from django.core.files import File
11 | from django.forms import Field
12 | from django.utils.translation import gettext_lazy as _
13 |
14 | from .exceptions import DetailValidationError, ApiFormException
15 | from .version import __version__ as version
16 |
17 | DATA_URI_PATTERN = r"data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w=]*[^;])*),(.+)"
18 |
19 |
20 | class BooleanField(Field):
21 | def to_python(self, value):
22 | if value in (True, 'True', 'true', '1', 1):
23 | return True
24 | elif value in (False, 'False', 'false', '0', 0):
25 | return False
26 | else:
27 | return None
28 |
29 | def validate(self, value):
30 | if value is None and self.required:
31 | raise ValidationError(self.error_messages['required'], code='required')
32 |
33 | def has_changed(self, initial, data):
34 | if self.disabled:
35 | return False
36 |
37 | return self.to_python(initial) != self.to_python(data)
38 |
39 |
40 | class FieldList(Field):
41 | default_error_messages = {
42 | 'max_length': _('Ensure this list has at most %(max)d values (it has %(length)d).'),
43 | 'min_length': _('Ensure this list has at least %(min)d values (it has %(length)d).'),
44 | 'not_field': _('Invalid Field type passed into FieldList!'),
45 | 'not_list': _('This field needs to be a list of objects!'),
46 | }
47 |
48 | def __init__(self, field, min_length=None, max_length=None, **kwargs):
49 | super().__init__(**kwargs)
50 |
51 | if not isinstance(field, Field):
52 | raise ApiFormException(self.error_messages['not_field'])
53 |
54 | self._min_length = min_length
55 | self._max_length = max_length
56 | self._field = field
57 |
58 | def to_python(self, value) -> typing.List:
59 | if not value:
60 | return []
61 |
62 | if not isinstance(value, list):
63 | raise ValidationError(self.error_messages['not_list'], code='not_list')
64 |
65 | if self._min_length is not None and len(value) < self._min_length:
66 | params = {'min': self._min_length, 'length': len(value)}
67 | raise ValidationError(self.error_messages['min_length'], code='min_length', params=params)
68 |
69 | if self._max_length is not None and len(value) > self._max_length:
70 | params = {'max': self._max_length, 'length': len(value)}
71 | raise ValidationError(self.error_messages['max_length'], code='max_length', params=params)
72 |
73 | result = []
74 | errors = []
75 |
76 | for position, item in enumerate(value):
77 | try:
78 | result.append(self._field.clean(item))
79 | except ValidationError as e:
80 | errors.append(DetailValidationError(e, (position,)))
81 |
82 | if errors:
83 | raise ValidationError(errors)
84 |
85 | return result
86 |
87 |
88 | class FormField(Field):
89 | def __init__(self, form: typing.Type, **kwargs):
90 | self._form = form
91 |
92 | super().__init__(**kwargs)
93 |
94 | @property
95 | def form(self):
96 | return self._form
97 |
98 | def to_python(self, value) -> typing.Union[typing.Dict, None]:
99 | if not value:
100 | return {}
101 |
102 | form = self._form(value)
103 | if form.is_valid():
104 | return form.cleaned_data
105 | else:
106 | raise ValidationError(form.errors)
107 |
108 |
109 | class FormFieldList(FormField):
110 | def __init__(self, form: typing.Type, min_length=None, max_length=None, **kwargs):
111 | self._min_length = min_length
112 | self._max_length = max_length
113 | super().__init__(form, **kwargs)
114 |
115 | default_error_messages = {
116 | 'max_length': _('Ensure this list has at most %(max)d values (it has %(length)d).'),
117 | 'min_length': _('Ensure this list has at least %(min)d values (it has %(length)d).'),
118 | 'not_list': _('This field needs to be a list of objects!')
119 | }
120 |
121 | def to_python(self, value):
122 | if not value:
123 | return []
124 |
125 | if not isinstance(value, list):
126 | raise ValidationError(self.error_messages['not_list'], code='not_list')
127 |
128 | if self._min_length is not None and len(value) < self._min_length:
129 | params = {'min': self._min_length, 'length': len(value)}
130 | raise ValidationError(self.error_messages['min_length'], code='min_length', params=params)
131 |
132 | if self._max_length is not None and len(value) > self._max_length:
133 | params = {'max': self._max_length, 'length': len(value)}
134 | raise ValidationError(self.error_messages['max_length'], code='max_length', params=params)
135 |
136 | result = []
137 | errors = []
138 |
139 | for position, item in enumerate(value):
140 | form = self._form(item)
141 | if form.is_valid():
142 | result.append(form.cleaned_data)
143 | else:
144 | for error in form.errors:
145 | error.prepend((position, ))
146 | errors.append(error)
147 |
148 | if errors:
149 | raise ValidationError(errors)
150 |
151 | return result
152 |
153 |
154 | class EnumField(Field):
155 | default_error_messages = {
156 | 'not_enum': _('Invalid Enum type passed into EnumField!'),
157 | 'invalid': _('Invalid enum value "{}" passed to {}'),
158 | }
159 |
160 | def __init__(self, enum: typing.Type, **kwargs):
161 | super().__init__(**kwargs)
162 |
163 | # isinstance(enum, type) prevents "TypeError: issubclass() arg 1 must be a class"
164 | # based on: https://github.com/samuelcolvin/pydantic/blob/v0.32.x/pydantic/utils.py#L260-L261
165 | if not (isinstance(enum, type) and issubclass(enum, Enum)):
166 | raise ApiFormException(self.error_messages['not_enum'])
167 |
168 | self.enum = enum
169 |
170 | def to_python(self, value) -> typing.Union[typing.Type[Enum], None]:
171 | if value is not None:
172 | try:
173 | return self.enum(value)
174 | except ValueError:
175 | raise ValidationError(self.error_messages['invalid'].format(value, self.enum), code='invalid')
176 | return None
177 |
178 |
179 | class DictionaryField(Field):
180 | default_error_messages = {
181 | 'not_field': _('Invalid Field type passed into DictionaryField!'),
182 | 'not_dict': _('Invalid value passed to DictionaryField (got {}, expected dict)'),
183 | }
184 |
185 | def __init__(self, *, value_field, key_field=None, **kwargs):
186 | super().__init__(**kwargs)
187 |
188 | if not isinstance(value_field, Field):
189 | raise ApiFormException(self.error_messages['not_field'])
190 |
191 | if key_field and not isinstance(key_field, Field):
192 | raise ApiFormException(self.error_messages['not_field'])
193 |
194 | self._value_field = value_field
195 | self._key_field = key_field
196 |
197 | def to_python(self, value) -> dict:
198 | if not isinstance(value, dict):
199 | msg = self.error_messages['not_dict'].format(type(value))
200 | raise ValidationError(msg)
201 |
202 | result = {}
203 | errors = {}
204 |
205 | for key, item in value.items():
206 | try:
207 | if self._key_field:
208 | key = self._key_field.clean(key)
209 | result[key] = self._value_field.clean(item)
210 | except ValidationError as e:
211 | errors[key] = DetailValidationError(e, (key, ))
212 |
213 | if errors:
214 | raise ValidationError(errors)
215 |
216 | return result
217 |
218 |
219 | class AnyField(Field):
220 | def to_python(self, value) -> typing.Union[typing.Dict, typing.List]:
221 | return value
222 |
223 |
224 | class FileField(Field):
225 | default_error_messages = {
226 | 'max_length': _('Ensure this file has at most %(max)d bytes (it has %(length)d).'),
227 | 'invalid_uri': _("The given URI is not a valid Data URI."),
228 | 'invalid_mime': _("The submitted file is empty."),
229 | }
230 |
231 | def __init__(self, max_length=None, mime: typing.Tuple = None, **kwargs):
232 | self._max_length = max_length
233 | self._mime = mime
234 | super().__init__(**kwargs)
235 |
236 | def to_python(self, value: str) -> typing.Optional[File]:
237 | if not value:
238 | return None
239 |
240 | if re.fullmatch(DATA_URI_PATTERN, value) is None:
241 | warnings.warn(
242 | "Raw base64 inside of FileField/ImageField is deprecated and will throw a validation error from "
243 | "version >=1.0.0 Provide value as a Data URI.",
244 | DeprecationWarning
245 | )
246 | if version >= "1.0.0":
247 | raise ValidationError(self.error_messages["invalid_uri"], code="invalid_uri")
248 |
249 | mime = None
250 |
251 | if ',' in value:
252 | mime, strict = guess_type(value)
253 | value = value.split(',')[-1]
254 |
255 | if self._mime and mime not in self._mime:
256 | params = {'allowed': ', '.join(self._mime), 'received': mime}
257 | raise ValidationError(self.error_messages['invalid_mime'], code='invalid_mime', params=params)
258 |
259 | file = File(BytesIO(b64decode(value)))
260 |
261 | if self._max_length is not None and file.size > self._max_length:
262 | params = {'max': self._max_length, 'length': file.size}
263 | raise ValidationError(self.error_messages['max_length'], code='max_length', params=params)
264 |
265 | file.content_type = mime
266 |
267 | return file
268 |
269 |
270 | class ImageField(FileField):
271 | default_error_messages = {
272 | 'invalid_image': _("Upload a valid image. The file you uploaded was either not an image or a corrupted image.")
273 | }
274 |
275 | def to_python(self, value) -> typing.Optional[File]:
276 | f = super().to_python(value)
277 |
278 | if f is None:
279 | return None
280 |
281 | # Pillow is required for ImageField
282 | from PIL import Image
283 |
284 | file = BytesIO(f.read()) # Create fp for Pillow
285 |
286 | try:
287 | image = Image.open(file)
288 | image.verify()
289 | f.image = Image.open(file) # Image have to be reopened after Image.verify() call
290 | f.content_type = Image.MIME.get(image.format)
291 | except Exception:
292 | raise ValidationError(
293 | self.error_messages['invalid_image'],
294 | code='invalid_image'
295 | )
296 |
297 | if self._mime and f.content_type not in self._mime:
298 | params = {'allowed': ', '.join(self._mime), 'received': f.content_type}
299 | raise ValidationError(self.error_messages['invalid_mime'], code='invalid_mime', params=params)
300 |
301 | f.seek(0) # Return to start of the file
302 |
303 | return f
304 |
305 |
306 | class RRuleField(Field):
307 | default_error_messages = {
308 | 'invalid_rrule': _('This given RRule String is not in a valid RRule syntax.'),
309 | }
310 |
311 | def __init__(self, **kwargs) -> None:
312 | super().__init__(**kwargs)
313 |
314 | def to_python(self, value: str):
315 | # Dateutil is required for RRuleField
316 | from dateutil.rrule import rrulestr
317 |
318 | try:
319 | result = rrulestr(value)
320 | except Exception:
321 | raise ValidationError(
322 | self.error_messages['invalid_rrule'], code='invalid_rrule'
323 | )
324 |
325 | return result
326 |
327 |
328 | class GeoJSONField(Field):
329 | default_error_messages = {
330 | 'not_dict': _('Invalid value passed to GeoJSONField (got {}, expected dict)'),
331 | 'not_geojson': _('Invalid value passed to GeoJSONField'),
332 | 'not_int': _('Value must be integer'),
333 | 'transform_error': _('Error at transform')
334 | }
335 |
336 | def __init__(self, srid=4326, transform=None, **kwargs):
337 | super().__init__(**kwargs)
338 |
339 | self._srid = srid
340 | self._transform = transform
341 |
342 | def to_python(self, value):
343 | if not self._srid or not isinstance(self._srid, int):
344 | params = {'srid': self._srid}
345 | raise ValidationError(self.error_messages['not_int'], code='not_int', params=params)
346 |
347 | if self._transform and not isinstance(self._transform, int):
348 | params = {'transform': self._transform}
349 | raise ValidationError(self.error_messages['not_int'], code='not_int', params=params)
350 |
351 | if not isinstance(value, dict):
352 | msg = self.error_messages['not_dict'].format(type(value))
353 | raise ValidationError(msg)
354 |
355 | if value == {}:
356 | raise ValidationError(self.error_messages['not_geojson'], code='not_geojson')
357 |
358 | from django.contrib.gis.gdal import GDALException
359 | from django.contrib.gis.geos import GEOSGeometry
360 | try:
361 | if 'crs' not in value.keys():
362 | value['crs'] = {
363 | "type": "name",
364 | "properties": {
365 | "name": f"ESRI::{self._srid}"
366 | }
367 | }
368 | result = GEOSGeometry(f'{value}', srid=self._srid)
369 | except GDALException:
370 | raise ValidationError(self.error_messages['not_geojson'], code='not_geojson')
371 |
372 | if self._transform:
373 | try:
374 | result.transform(self._transform)
375 | except GDALException:
376 | raise ValidationError(self.error_messages['transform_error'], code='transform_error')
377 |
378 | return result
379 |
--------------------------------------------------------------------------------
/django_api_forms/forms.py:
--------------------------------------------------------------------------------
1 | import copy
2 | from typing import List, Tuple
3 |
4 | from django.core.exceptions import ValidationError
5 | from django.forms import fields_for_model
6 | from django.forms.forms import DeclarativeFieldsMetaclass as DjangoDeclarativeFieldsMetaclass
7 | from django.forms.models import ModelFormOptions
8 | from django.utils.translation import gettext as _
9 |
10 | from .exceptions import UnsupportedMediaType, ApiFormException, DetailValidationError
11 | from .population_strategies import BaseStrategy
12 | from .settings import Settings
13 | from .utils import resolve_from_path
14 |
15 |
16 | class BaseForm:
17 | def __init__(self, data=None, request=None, settings: Settings = None, **kwargs):
18 | if data is None:
19 | self._data = {}
20 | else:
21 | self._data = data
22 | self.fields = copy.deepcopy(getattr(self, 'base_fields'))
23 | self._errors = None
24 | self._dirty = []
25 | self.cleaned_data = None
26 | self._request = request
27 | self.settings = settings or Settings()
28 | self.extras = kwargs
29 |
30 | if isinstance(self.Meta, type):
31 | if hasattr(self.Meta, 'field_type_strategy'):
32 | for key in self.Meta.field_type_strategy.keys():
33 | self.settings.POPULATION_STRATEGIES[key] = self.Meta.field_type_strategy[key]
34 | if hasattr(self.Meta, 'mapping'):
35 | for key in data.copy():
36 | if key in self.Meta.mapping.keys():
37 | data[self.Meta.mapping[key]] = data.pop(key)
38 |
39 | if isinstance(data, dict):
40 | for key in data.keys():
41 | if key in self.fields.keys():
42 | self._dirty.append(key)
43 |
44 | def __getitem__(self, name):
45 | try:
46 | field = self.fields[name]
47 | except KeyError:
48 | raise KeyError(
49 | "Key '{}' not found in '{}'. Choices are: {}.".format(
50 | name,
51 | self.__class__.__name__,
52 | ', '.join(sorted(self.fields)),
53 | )
54 | )
55 | return field
56 |
57 | def __iter__(self):
58 | for name in self.fields:
59 | yield self[name]
60 |
61 | @classmethod
62 | def create_from_request(cls, request, **kwargs):
63 | """
64 | :rtype: BaseForm
65 | """
66 | if not request.body:
67 | return cls()
68 |
69 | settings = Settings()
70 |
71 | all_attributes = request.META.get('CONTENT_TYPE', '').replace(' ', '').split(';')
72 | content_type = all_attributes.pop(0)
73 |
74 | optional_attributes = {}
75 | for attribute in all_attributes:
76 | key, value = attribute.split('=')
77 | optional_attributes[key] = value
78 |
79 | if content_type not in settings.PARSERS:
80 | raise UnsupportedMediaType()
81 |
82 | parser = resolve_from_path(settings.PARSERS[content_type])
83 | data = parser(request.body)
84 |
85 | return cls(data, request, settings, **kwargs)
86 |
87 | @property
88 | def dirty(self) -> List:
89 | return self._dirty
90 |
91 | @property
92 | def errors(self) -> dict:
93 | if not self._errors:
94 | self.full_clean()
95 | return self._errors
96 |
97 | def is_valid(self) -> bool:
98 | return not self.errors
99 |
100 | def add_error(self, field: Tuple, errors: ValidationError):
101 | if hasattr(errors, 'error_dict'):
102 | for key, items in errors.error_dict.items():
103 | for error in items:
104 | if isinstance(error, DetailValidationError):
105 | error.prepend(field)
106 | self.add_error(error.path, error)
107 | elif isinstance(error, ValidationError):
108 | self.add_error(field + (key, ), error)
109 | elif not hasattr(errors, 'message') and isinstance(errors.error_list, list):
110 | for item in errors.error_list:
111 | if isinstance(item, DetailValidationError):
112 | item.prepend(field)
113 | self.add_error(item.path, item)
114 | elif isinstance(item, ValidationError):
115 | path = field
116 | if hasattr(item, 'path'):
117 | path = field + item.path
118 | self.add_error(path, item)
119 | else:
120 | self._errors.append(
121 | DetailValidationError(errors, (field,) if isinstance(field, str) else field)
122 | )
123 |
124 | if field in self.cleaned_data:
125 | del self.cleaned_data[field]
126 |
127 | def full_clean(self):
128 | """
129 | Clean all of self.data and populate self._errors and self.cleaned_data.
130 | """
131 | self._errors = []
132 | self.cleaned_data = {}
133 |
134 | for key, field in self.fields.items():
135 | try:
136 | if key in self.dirty or field.required:
137 | validated_form_item = field.clean(self._data.get(key, None))
138 |
139 | self.cleaned_data[key] = validated_form_item
140 |
141 | if hasattr(self, f"clean_{key}"):
142 | self.cleaned_data[key] = getattr(self, f"clean_{key}")()
143 | except ValidationError as e:
144 | self.add_error((key, ), e)
145 | except (AttributeError, TypeError, ValueError):
146 | self.add_error((key, ), ValidationError(_("Invalid value")))
147 |
148 | if not self._errors:
149 | try:
150 | self.cleaned_data = self.clean()
151 | except ValidationError as e:
152 | self.add_error(('$body',), e)
153 |
154 | def clean(self):
155 | """
156 | Hook for doing any extra form-wide cleaning after Field.clean() has been
157 | called on every field. Any ValidationError raised by this method will
158 | not be associated with a particular field; it will have a special-case
159 | association with the field named '$body'.
160 | """
161 | return self.cleaned_data
162 |
163 | def populate(self, obj, exclude: List[str] = None):
164 | """
165 | :param exclude:
166 | :param obj:
167 | :return:
168 | """
169 | if exclude is None:
170 | exclude = []
171 |
172 | if self.cleaned_data is None:
173 | raise ApiFormException("No clean data provided! Try to call is_valid() first.")
174 |
175 | for key, field in self.fields.items():
176 | # Skip if field is in exclude
177 | if key in exclude:
178 | continue
179 |
180 | # Skip if field is not in validated data
181 | if key not in self.cleaned_data.keys():
182 | continue
183 |
184 | field_class = f"{field.__class__.__module__}.{field.__class__.__name__}"
185 | strategy = resolve_from_path(
186 | self.settings.POPULATION_STRATEGIES.get(
187 | field_class, "django_api_forms.population_strategies.BaseStrategy"
188 | )
189 | )
190 |
191 | if isinstance(self.Meta, type):
192 | if hasattr(self.Meta, 'field_strategy'):
193 | if key in self.Meta.field_strategy.keys():
194 | if isinstance(self.Meta.field_strategy[key], str):
195 | strategy = resolve_from_path(
196 | self.Meta.field_strategy[key]
197 | )
198 | else:
199 | strategy = self.Meta.field_strategy[key]
200 |
201 | if hasattr(self, f'populate_{key}'):
202 | self.cleaned_data[key] = getattr(self, f'populate_{key}')(obj, self.cleaned_data[key])
203 |
204 | if isinstance(strategy, BaseStrategy):
205 | strategy(field, obj, key, self.cleaned_data[key])
206 | else:
207 | strategy()(field, obj, key, self.cleaned_data[key])
208 |
209 | return obj
210 |
211 |
212 | class DeclarativeFieldsMetaclass(DjangoDeclarativeFieldsMetaclass):
213 | """Collect Fields declared on the base classes."""
214 | def __new__(mcs, name, bases, attrs):
215 | new_class = super().__new__(mcs, name, bases, attrs)
216 |
217 | new_class.Meta = attrs.pop('Meta', None)
218 |
219 | return new_class
220 |
221 |
222 | class ModelForm(BaseForm, metaclass=DeclarativeFieldsMetaclass):
223 | """
224 | SUPER EXPERIMENTAL
225 | """
226 | def __new__(cls, *args, **kwargs):
227 | new_class = super().__new__(cls, **kwargs)
228 | config = getattr(cls, 'Meta', None)
229 |
230 | model_opts = ModelFormOptions(getattr(config.model, '_meta', None))
231 | model_opts.exclude = getattr(config, 'exclude', tuple())
232 |
233 | fields = fields_for_model(
234 | model=model_opts.model,
235 | fields=None,
236 | exclude=model_opts.exclude,
237 | widgets=None,
238 | formfield_callback=None,
239 | localized_fields=model_opts.localized_fields,
240 | labels=model_opts.labels,
241 | help_texts=model_opts.help_texts,
242 | error_messages=model_opts.error_messages,
243 | field_classes=model_opts.field_classes,
244 | apply_limit_choices_to=False,
245 | )
246 |
247 | # AutoField has to be added manually
248 | # Do not add AutoFields right now
249 | # if config.model._meta.auto_field and config.model._meta.auto_field.attname not in model_opts.exclude:
250 | # fields[config.model._meta.auto_field.attname] = IntegerField()
251 |
252 | fields.update(new_class.declared_fields)
253 | # Remove None value keys
254 | fields = {k: v for k, v in fields.items() if v is not None}
255 |
256 | new_class.base_fields = fields
257 | new_class.declared_fields = fields
258 |
259 | return new_class
260 |
261 |
262 | class Form(BaseForm, metaclass=DeclarativeFieldsMetaclass):
263 | """A collection of Fields, plus their associated data."""
264 |
--------------------------------------------------------------------------------
/django_api_forms/population_strategies.py:
--------------------------------------------------------------------------------
1 | class BaseStrategy:
2 | def __call__(self, field, obj, key: str, value):
3 | setattr(obj, key, value)
4 |
5 |
6 | class AliasStrategy(BaseStrategy):
7 | def __init__(self, property_name: str):
8 | self._property_name = property_name
9 |
10 | def __call__(self, field, obj, key: str, value):
11 | setattr(obj, self._property_name, value)
12 |
13 |
14 | class IgnoreStrategy(BaseStrategy):
15 | def __call__(self, field, obj, key: str, value):
16 | pass
17 |
18 |
19 | class ModelChoiceFieldStrategy(BaseStrategy):
20 |
21 | """
22 | We need to change key postfix if there is a ModelChoiceField (because of _id etc.)
23 | We always try to assign whole object instance, for example:
24 | artis_id is normalized as Artist model, but it have to be assigned to artist model property
25 | because artist_id in model has different type (for example int if your are using int primary keys)
26 | If you are still confused (sorry), try to check docs
27 | """
28 | def __call__(self, field, obj, key: str, value):
29 | model_key = key
30 | if field.to_field_name:
31 | postfix_to_remove = f"_{field.to_field_name}"
32 | else:
33 | postfix_to_remove = "_id"
34 | if key.endswith(postfix_to_remove):
35 | model_key = key[:-len(postfix_to_remove)]
36 | setattr(obj, model_key, value)
37 |
--------------------------------------------------------------------------------
/django_api_forms/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | DEFAULTS = {
4 | 'POPULATION_STRATEGIES': {
5 | 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
6 | 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
7 | 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
8 | 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
9 | 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
10 | 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
11 | },
12 | 'DEFAULT_POPULATION_STRATEGY': 'django_api_forms.population_strategies.BaseStrategy',
13 | 'PARSERS': {
14 | 'application/json': 'json.loads',
15 | 'application/x-msgpack': 'msgpack.loads'
16 | }
17 | }
18 |
19 |
20 | class Settings:
21 | def __getattr__(self, item):
22 | if item not in DEFAULTS:
23 | raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'")
24 |
25 | django_setting = f"DJANGO_API_FORMS_{item}"
26 | default = DEFAULTS[item]
27 |
28 | if hasattr(settings, django_setting):
29 | customized_value = getattr(settings, django_setting)
30 | if isinstance(default, dict):
31 | value = {**default, **customized_value}
32 | else:
33 | value = customized_value
34 | else:
35 | value = default
36 |
37 | setattr(self, item, value)
38 | return value
39 |
--------------------------------------------------------------------------------
/django_api_forms/utils.py:
--------------------------------------------------------------------------------
1 | from importlib import import_module
2 |
3 |
4 | def resolve_from_path(path: str):
5 | module_path, class_name = path.rsplit('.', 1)
6 | module = import_module(module_path)
7 | return getattr(module, class_name)
8 |
--------------------------------------------------------------------------------
/django_api_forms/version.py:
--------------------------------------------------------------------------------
1 | __version__ = '1.0.0-rc.10'
2 |
--------------------------------------------------------------------------------
/docs/api_reference.md:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | This page provides detailed documentation for the core classes and methods in Django API Forms.
4 |
5 | ## Core Classes
6 |
7 | ### Form
8 |
9 | The `Form` class is the main class for defining API request validation schemas.
10 |
11 | ```python
12 | from django_api_forms import Form
13 |
14 | class MyForm(Form):
15 | # Field definitions go here
16 | pass
17 | ```
18 |
19 | #### Class Methods
20 |
21 | ##### `create_from_request`
22 |
23 | ```python
24 | @classmethod
25 | def create_from_request(cls, request, **kwargs)
26 | ```
27 |
28 | Creates a form instance from a Django request, parsing the request body according to the Content-Type header.
29 |
30 | **Parameters:**
31 | - `request`: A Django HttpRequest object
32 | - `**kwargs`: Additional keyword arguments that will be available in `form.extras`
33 |
34 | **Returns:**
35 | - A form instance
36 |
37 | **Example:**
38 | ```python
39 | form = MyForm.create_from_request(request, user=request.user)
40 | ```
41 |
42 | #### Instance Methods
43 |
44 | ##### `is_valid`
45 |
46 | ```python
47 | def is_valid() -> bool
48 | ```
49 |
50 | Validates the form data and returns True if the data is valid, False otherwise.
51 |
52 | **Returns:**
53 | - `bool`: True if the form data is valid, False otherwise
54 |
55 | **Example:**
56 | ```python
57 | if form.is_valid():
58 | # Process valid data
59 | pass
60 | ```
61 |
62 | ##### `add_error`
63 |
64 | ```python
65 | def add_error(field: Tuple, errors: ValidationError)
66 | ```
67 |
68 | Adds a validation error to the form.
69 |
70 | **Parameters:**
71 | - `field`: A tuple representing the path to the field
72 | - `errors`: A ValidationError instance
73 |
74 | **Example:**
75 | ```python
76 | from django.core.exceptions import ValidationError
77 | form.add_error(('name',), ValidationError("Invalid name"))
78 | ```
79 |
80 | ##### `clean`
81 |
82 | ```python
83 | def clean()
84 | ```
85 |
86 | Hook for performing form-wide validation. Override this method to add custom validation logic.
87 |
88 | **Returns:**
89 | - The cleaned data
90 |
91 | **Example:**
92 | ```python
93 | def clean(self):
94 | if self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
95 | raise ValidationError("Passwords do not match")
96 | return self.cleaned_data
97 | ```
98 |
99 | ##### `populate`
100 |
101 | ```python
102 | def populate(obj, exclude: List[str] = None)
103 | ```
104 |
105 | Populates an object with the form's cleaned data.
106 |
107 | **Parameters:**
108 | - `obj`: The object to populate
109 | - `exclude`: A list of field names to exclude from population
110 |
111 | **Returns:**
112 | - The populated object
113 |
114 | **Example:**
115 | ```python
116 | user = User()
117 | form.populate(user)
118 | user.save()
119 | ```
120 |
121 | #### Properties
122 |
123 | ##### `cleaned_data`
124 |
125 | A dictionary containing the validated form data.
126 |
127 | ##### `errors`
128 |
129 | A list of validation errors.
130 |
131 | ##### `dirty`
132 |
133 | A list of field names that were present in the request data.
134 |
135 | ##### `extras`
136 |
137 | A dictionary containing the additional keyword arguments passed to `create_from_request`.
138 |
139 | ### ModelForm
140 |
141 | The `ModelForm` class is an experimental class for creating forms from Django models.
142 |
143 | ```python
144 | from django_api_forms import ModelForm
145 | from myapp.models import MyModel
146 |
147 | class MyModelForm(ModelForm):
148 | class Meta:
149 | model = MyModel
150 | exclude = ('created_at',)
151 | ```
152 |
153 | ## Field Classes
154 |
155 | Django API Forms provides several custom field classes in addition to the standard Django form fields.
156 |
157 | ### BooleanField
158 |
159 | A field that normalizes to a Python boolean value.
160 |
161 | ```python
162 | from django_api_forms import BooleanField
163 |
164 | class MyForm(Form):
165 | is_active = BooleanField()
166 | ```
167 |
168 | ### FieldList
169 |
170 | A field for lists of primitive values.
171 |
172 | ```python
173 | from django_api_forms import FieldList
174 | from django.forms import fields
175 |
176 | class MyForm(Form):
177 | tags = FieldList(field=fields.CharField(max_length=50))
178 | ```
179 |
180 | ### FormField
181 |
182 | A field for nested objects.
183 |
184 | ```python
185 | from django_api_forms import FormField
186 |
187 | class AddressForm(Form):
188 | street = fields.CharField(max_length=100)
189 | city = fields.CharField(max_length=50)
190 |
191 | class UserForm(Form):
192 | name = fields.CharField(max_length=100)
193 | address = FormField(form=AddressForm)
194 | ```
195 |
196 | ### FormFieldList
197 |
198 | A field for lists of nested objects.
199 |
200 | ```python
201 | from django_api_forms import FormFieldList
202 |
203 | class PhoneForm(Form):
204 | number = fields.CharField(max_length=20)
205 | type = fields.CharField(max_length=10)
206 |
207 | class UserForm(Form):
208 | name = fields.CharField(max_length=100)
209 | phones = FormFieldList(form=PhoneForm)
210 | ```
211 |
212 | ### EnumField
213 |
214 | A field for enumeration values.
215 |
216 | ```python
217 | from django_api_forms import EnumField
218 | from enum import Enum
219 |
220 | class UserType(Enum):
221 | ADMIN = 'admin'
222 | USER = 'user'
223 |
224 | class UserForm(Form):
225 | name = fields.CharField(max_length=100)
226 | type = EnumField(enum=UserType)
227 | ```
228 |
229 | ### DictionaryField
230 |
231 | A field for key-value pairs.
232 |
233 | ```python
234 | from django_api_forms import DictionaryField
235 |
236 | class MetadataForm(Form):
237 | metadata = DictionaryField(value_field=fields.CharField())
238 | ```
239 |
240 | ### FileField
241 |
242 | A field for BASE64-encoded files.
243 |
244 | ```python
245 | from django_api_forms import FileField
246 |
247 | class DocumentForm(Form):
248 | document = FileField(max_length=10485760, mime=('application/pdf',))
249 | ```
250 |
251 | ### ImageField
252 |
253 | A field for BASE64-encoded images.
254 |
255 | ```python
256 | from django_api_forms import ImageField
257 |
258 | class ProfileForm(Form):
259 | avatar = ImageField(max_length=10485760, mime=('image/jpeg', 'image/png'))
260 | ```
261 |
262 | ### RRuleField
263 |
264 | A field for recurring date rules.
265 |
266 | ```python
267 | from django_api_forms import RRuleField
268 |
269 | class EventForm(Form):
270 | recurrence = RRuleField()
271 | ```
272 |
273 | ### GeoJSONField
274 |
275 | A field for geographic data in GeoJSON format.
276 |
277 | ```python
278 | from django_api_forms import GeoJSONField
279 |
280 | class LocationForm(Form):
281 | geometry = GeoJSONField(srid=4326)
282 | ```
283 |
284 | ## Population Strategies
285 |
286 | Django API Forms provides several population strategies for populating objects with form data.
287 |
288 | ### BaseStrategy
289 |
290 | The default strategy that sets object attributes using `setattr`.
291 |
292 | ### IgnoreStrategy
293 |
294 | A strategy that ignores the field during population.
295 |
296 | ### ModelChoiceFieldStrategy
297 |
298 | A strategy for populating model choice fields.
299 |
300 | ### AliasStrategy
301 |
302 | A strategy for populating fields with different names.
303 |
304 | ```python
305 | from django_api_forms import Form
306 | from django_api_forms.population_strategies import AliasStrategy
307 |
308 | class MyForm(Form):
309 | class Meta:
310 | field_strategy = {
311 | 'username': AliasStrategy(property_name='name')
312 | }
313 |
314 | username = fields.CharField(max_length=100)
315 | ```
316 |
317 | ## Settings
318 |
319 | Django API Forms can be configured through Django settings.
320 |
321 | ### `DJANGO_API_FORMS_PARSERS`
322 |
323 | A dictionary mapping content types to parser functions.
324 |
325 | ```python
326 | DJANGO_API_FORMS_PARSERS = {
327 | 'application/json': 'json.loads',
328 | 'application/x-msgpack': 'msgpack.loads'
329 | }
330 | ```
331 |
332 | ### `DJANGO_API_FORMS_POPULATION_STRATEGIES`
333 |
334 | A dictionary mapping field types to population strategies.
335 |
336 | ```python
337 | DJANGO_API_FORMS_POPULATION_STRATEGIES = {
338 | 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
339 | 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
340 | 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
341 | 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
342 | 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
343 | 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
344 | }
345 | ```
346 |
347 | ### `DJANGO_API_FORMS_DEFAULT_POPULATION_STRATEGY`
348 |
349 | The default population strategy to use when no specific strategy is defined.
350 |
351 | ```python
352 | DJANGO_API_FORMS_DEFAULT_POPULATION_STRATEGY = 'django_api_forms.population_strategies.BaseStrategy'
353 | ```
354 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing to Django API Forms
2 |
3 | Thank you for your interest in contributing to Django API Forms! This document provides guidelines and instructions for contributing to the project.
4 |
5 | ## Code of Conduct
6 |
7 | By participating in this project, you are expected to uphold our Code of Conduct, which is to be respectful and considerate of others.
8 |
9 | ## Getting Started
10 |
11 | ### Prerequisites
12 |
13 | - Python 3.9+
14 | - Django 2.0+
15 | - Poetry (for dependency management)
16 |
17 | ### Setting Up the Development Environment
18 |
19 | 1. Fork the repository on GitHub
20 | 2. Clone your fork locally:
21 | ```shell
22 | git clone https://github.com/YOUR-USERNAME/django_api_forms.git
23 | cd django_api_forms
24 | ```
25 | 3. Install dependencies using Poetry:
26 | ```shell
27 | poetry install
28 | ```
29 |
30 | ## Development Workflow
31 |
32 | ### Running Tests
33 |
34 | We use Django's test framework for testing. To run the tests:
35 |
36 | ```shell
37 | poetry run python runtests.py
38 | ```
39 |
40 | To run tests with coverage:
41 |
42 | ```shell
43 | poetry run coverage run runtests.py
44 | poetry run coverage report
45 | ```
46 |
47 | ### Code Style
48 |
49 | We follow PEP 8 style guidelines. We use flake8 for code style checking:
50 |
51 | ```shell
52 | poetry run flake8 .
53 | ```
54 |
55 | ### Documentation
56 |
57 | We use mkdocs-material for documentation. To build and serve the documentation locally:
58 |
59 | ```shell
60 | poetry run mkdocs serve
61 | ```
62 |
63 | Then open http://127.0.0.1:8000/ in your browser.
64 |
65 | ## Pull Request Process
66 |
67 | 1. Create a new branch for your feature or bugfix:
68 | ```shell
69 | git checkout -b feature/your-feature-name
70 | ```
71 | or
72 | ```shell
73 | git checkout -b fix/your-bugfix-name
74 | ```
75 |
76 | 2. Make your changes and commit them with a descriptive commit message:
77 | ```shell
78 | git commit -m "Add feature: your feature description"
79 | ```
80 |
81 | 3. Push your branch to your fork:
82 | ```shell
83 | git push origin feature/your-feature-name
84 | ```
85 |
86 | 4. Open a pull request against the `master` branch of the original repository.
87 |
88 | 5. Ensure that all tests pass and the documentation is updated if necessary.
89 |
90 | 6. Wait for a maintainer to review your pull request. They may request changes or improvements.
91 |
92 | 7. Once your pull request is approved, it will be merged into the main codebase.
93 |
94 | ## Reporting Issues
95 |
96 | If you find a bug or have a feature request, please open an issue on the [GitHub issue tracker](https://github.com/Sibyx/django_api_forms/issues).
97 |
98 | When reporting a bug, please include:
99 |
100 | - A clear and descriptive title
101 | - Steps to reproduce the issue
102 | - Expected behavior
103 | - Actual behavior
104 | - Django and Python versions
105 | - Any relevant code snippets or error messages
106 |
107 | ## Feature Requests
108 |
109 | Feature requests are welcome. Please provide a clear description of the feature and why it would be beneficial to the project.
110 |
111 | ## Versioning
112 |
113 | We use [Semantic Versioning](https://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/Sibyx/django_api_forms/tags).
114 |
115 | ## License
116 |
117 | By contributing to Django API Forms, you agree that your contributions will be licensed under the project's MIT License.
118 |
119 | ## Questions?
120 |
121 | If you have any questions about contributing, feel free to open an issue or contact the maintainers.
122 |
123 | Thank you for contributing to Django API Forms!
124 |
--------------------------------------------------------------------------------
/docs/custom/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block analytics %}
4 |
5 |
18 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/docs/example.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | This page provides examples of how to use Django API Forms in different scenarios.
4 |
5 | ## Configuration Settings
6 |
7 | Django API Forms can be configured through Django settings. Here are the default settings:
8 |
9 | ```python
10 | # Settings for population strategies
11 | DJANGO_API_FORMS_POPULATION_STRATEGIES = {
12 | 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
13 | 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
14 | 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
15 | 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
16 | 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
17 | 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
18 | }
19 |
20 | # Default population strategy
21 | DJANGO_API_FORMS_DEFAULT_POPULATION_STRATEGY = 'django_api_forms.population_strategies.BaseStrategy'
22 |
23 | # Content type parsers
24 | DJANGO_API_FORMS_PARSERS = {
25 | 'application/json': 'json.loads',
26 | 'application/x-msgpack': 'msgpack.loads'
27 | }
28 | ```
29 |
30 | ## Basic Example: Music Album API
31 |
32 | This example demonstrates a music album API with nested data structures, field mapping, and custom validation.
33 |
34 | ### JSON Request
35 |
36 | ```json
37 | {
38 | "title": "Unknown Pleasures",
39 | "type": "vinyl",
40 | "artist": {
41 | "_name": "Joy Division",
42 | "genres": [
43 | "rock",
44 | "punk"
45 | ],
46 | "members": 4
47 | },
48 | "year": 1979,
49 | "songs": [
50 | {
51 | "title": "Disorder",
52 | "duration": "3:29"
53 | },
54 | {
55 | "title": "Day of the Lords",
56 | "duration": "4:48",
57 | "metadata": {
58 | "_section": {
59 | "type": "ID3v2",
60 | "offset": 0,
61 | "byteLength": 2048
62 | },
63 | "header": {
64 | "majorVersion": 3,
65 | "minorRevision": 0,
66 | "flagsOctet": 0,
67 | "unsynchronisationFlag": false,
68 | "extendedHeaderFlag": false,
69 | "experimentalIndicatorFlag": false,
70 | "size": 2038
71 | }
72 | }
73 | }
74 | ],
75 | "metadata": {
76 | "created_at": "2019-10-21T18:57:03+0100",
77 | "updated_at": "2019-10-21T18:57:03+0100"
78 | }
79 | }
80 | ```
81 |
82 | ### Python Implementation
83 |
84 | ```python
85 | from enum import Enum
86 |
87 | from django.core.exceptions import ValidationError
88 | from django.forms import fields
89 | from django.http import JsonResponse
90 |
91 | from django_api_forms import FieldList, FormField, FormFieldList, DictionaryField, EnumField, AnyField, Form
92 |
93 |
94 | class AlbumType(Enum):
95 | CD = 'cd'
96 | VINYL = 'vinyl'
97 |
98 |
99 | class ArtistForm(Form):
100 | class Meta:
101 | mapping = {
102 | '_name': 'name' # Map '_name' in JSON to 'name' in form
103 | }
104 |
105 | name = fields.CharField(required=True, max_length=100)
106 | genres = FieldList(field=fields.CharField(max_length=30))
107 | members = fields.IntegerField()
108 |
109 |
110 | class SongForm(Form):
111 | title = fields.CharField(required=True, max_length=100)
112 | duration = fields.DurationField(required=False)
113 | metadata = AnyField(required=False)
114 |
115 |
116 | class AlbumForm(Form):
117 | title = fields.CharField(max_length=100)
118 | year = fields.IntegerField()
119 | artist = FormField(form=ArtistForm) # Nested form
120 | songs = FormFieldList(form=SongForm) # List of nested forms
121 | type = EnumField(enum=AlbumType, required=True)
122 | metadata = DictionaryField(value_field=fields.DateTimeField())
123 |
124 | def clean_year(self):
125 | # Field-level validation
126 | if 'param' not in self.extras:
127 | raise ValidationError("You can use request GET params in form validation!")
128 |
129 | if self.cleaned_data['year'] == 1992:
130 | raise ValidationError("Year 1992 is forbidden!", 'forbidden-value')
131 | return self.cleaned_data['year']
132 |
133 | def clean(self):
134 | # Form-level validation
135 | if (self.cleaned_data['year'] == 1998) and (self.cleaned_data['artist']['name'] == "Nirvana"):
136 | raise ValidationError("Sounds like a bullshit", code='time-traveling')
137 | if 'param' not in self.extras:
138 | self.add_error(
139 | ('param', ),
140 | ValidationError("You can use extra optional arguments in form validation!", code='param-where')
141 | )
142 | return self.cleaned_data
143 |
144 |
145 | # Django view example
146 | def create_album(request):
147 | # Create form from request and pass extra parameters
148 | form = AlbumForm.create_from_request(request, param=request.GET.get('param'))
149 |
150 | if not form.is_valid():
151 | # Return validation errors
152 | return JsonResponse({"errors": form.errors}, status=400)
153 |
154 | # Access validated data
155 | album_data = form.cleaned_data
156 |
157 | # Do something with the data (e.g., save to database)
158 | # ...
159 |
160 | return JsonResponse({"status": "success", "id": 123})
161 | ```
162 |
163 | ## Example: User Registration with File Upload
164 |
165 | This example demonstrates a user registration API with profile image upload.
166 |
167 | ### JSON Request
168 |
169 | ```json
170 | {
171 | "username": "johndoe",
172 | "email": "john@example.com",
173 | "password": "securepassword123",
174 | "confirm_password": "securepassword123",
175 | "profile": {
176 | "first_name": "John",
177 | "last_name": "Doe",
178 | "bio": "Software developer and music enthusiast",
179 | "avatar": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFdwI2Q4g02QAAAABJRU5ErkJggg=="
180 | }
181 | }
182 | ```
183 |
184 | ### Python Implementation
185 |
186 | ```python
187 | from django.core.exceptions import ValidationError
188 | from django.forms import fields
189 | from django.http import JsonResponse
190 | from django.contrib.auth.models import User
191 | from django.contrib.auth.password_validation import validate_password
192 |
193 | from django_api_forms import Form, FormField, ImageField
194 |
195 |
196 | class ProfileForm(Form):
197 | first_name = fields.CharField(max_length=30)
198 | last_name = fields.CharField(max_length=30)
199 | bio = fields.CharField(max_length=500, required=False)
200 | avatar = ImageField(required=False, mime=('image/jpeg', 'image/png'))
201 |
202 |
203 | class UserRegistrationForm(Form):
204 | username = fields.CharField(max_length=150)
205 | email = fields.EmailField()
206 | password = fields.CharField(min_length=8)
207 | confirm_password = fields.CharField()
208 | profile = FormField(form=ProfileForm)
209 |
210 | def clean_username(self):
211 | username = self.cleaned_data['username']
212 | if User.objects.filter(username=username).exists():
213 | raise ValidationError("Username already exists")
214 | return username
215 |
216 | def clean_email(self):
217 | email = self.cleaned_data['email']
218 | if User.objects.filter(email=email).exists():
219 | raise ValidationError("Email already exists")
220 | return email
221 |
222 | def clean_password(self):
223 | password = self.cleaned_data['password']
224 | # Use Django's password validation
225 | validate_password(password)
226 | return password
227 |
228 | def clean(self):
229 | cleaned_data = self.cleaned_data
230 | if cleaned_data.get('password') != cleaned_data.get('confirm_password'):
231 | raise ValidationError("Passwords do not match")
232 | return cleaned_data
233 |
234 | def populate_avatar(self, user, value):
235 | # Custom population for avatar field
236 | if value and hasattr(user, 'profile'):
237 | filename = f"{user.username}_avatar.png"
238 | user.profile.avatar.save(filename, value, save=False)
239 | return value
240 |
241 |
242 | def register_user(request):
243 | form = UserRegistrationForm.create_from_request(request)
244 |
245 | if not form.is_valid():
246 | return JsonResponse({"errors": form.errors}, status=400)
247 |
248 | # Create user
249 | user = User(
250 | username=form.cleaned_data['username'],
251 | email=form.cleaned_data['email']
252 | )
253 | user.set_password(form.cleaned_data['password'])
254 | user.save()
255 |
256 | # Create profile
257 | profile_data = form.cleaned_data['profile']
258 | profile = user.profile # Assuming a profile is created via signal
259 | profile.first_name = profile_data['first_name']
260 | profile.last_name = profile_data['last_name']
261 | profile.bio = profile_data.get('bio', '')
262 |
263 | # Handle avatar upload
264 | if 'avatar' in profile_data:
265 | form.populate_avatar(user, profile_data['avatar'])
266 |
267 | profile.save()
268 |
269 | return JsonResponse({"status": "success", "id": user.id})
270 | ```
271 |
272 | ## Example: API with Django Models
273 |
274 | This example demonstrates how to use Django API Forms with Django models.
275 |
276 | ### Models
277 |
278 | ```python
279 | from django.db import models
280 |
281 | class Category(models.Model):
282 | name = models.CharField(max_length=100)
283 |
284 | def __str__(self):
285 | return self.name
286 |
287 | class Tag(models.Model):
288 | name = models.CharField(max_length=50)
289 |
290 | def __str__(self):
291 | return self.name
292 |
293 | class Article(models.Model):
294 | title = models.CharField(max_length=200)
295 | content = models.TextField()
296 | category = models.ForeignKey(Category, on_delete=models.CASCADE)
297 | tags = models.ManyToManyField(Tag)
298 | published = models.BooleanField(default=False)
299 | created_at = models.DateTimeField(auto_now_add=True)
300 | updated_at = models.DateTimeField(auto_now=True)
301 |
302 | def __str__(self):
303 | return self.title
304 | ```
305 |
306 | ### JSON Request
307 |
308 | ```json
309 | {
310 | "title": "Introduction to Django API Forms",
311 | "content": "Django API Forms is a powerful library for validating API requests...",
312 | "category_id": 1,
313 | "tags": [1, 2, 3],
314 | "published": true
315 | }
316 | ```
317 |
318 | ### Python Implementation
319 |
320 | ```python
321 | from django.forms import fields, ModelChoiceField, ModelMultipleChoiceField
322 | from django.http import JsonResponse
323 |
324 | from django_api_forms import Form, BooleanField
325 | from myapp.models import Article, Category, Tag
326 |
327 |
328 | class ArticleForm(Form):
329 | title = fields.CharField(max_length=200)
330 | content = fields.CharField()
331 | category_id = ModelChoiceField(queryset=Category.objects.all())
332 | tags = ModelMultipleChoiceField(queryset=Tag.objects.all())
333 | published = BooleanField(required=False, default=False)
334 |
335 | def clean_title(self):
336 | title = self.cleaned_data['title']
337 | if Article.objects.filter(title=title).exists():
338 | raise ValidationError("An article with this title already exists")
339 | return title
340 |
341 |
342 | def create_article(request):
343 | form = ArticleForm.create_from_request(request)
344 |
345 | if not form.is_valid():
346 | return JsonResponse({"errors": form.errors}, status=400)
347 |
348 | # Create article
349 | article = Article()
350 | form.populate(article)
351 | article.save()
352 |
353 | # Many-to-many relationships need to be set after save
354 | article.tags.set(form.cleaned_data['tags'])
355 |
356 | return JsonResponse({
357 | "status": "success",
358 | "id": article.id,
359 | "title": article.title
360 | })
361 | ```
362 |
363 | These examples demonstrate different ways to use Django API Forms in real-world scenarios. You can adapt them to your specific needs and requirements.
364 |
--------------------------------------------------------------------------------
/docs/fields.md:
--------------------------------------------------------------------------------
1 | # Fields
2 |
3 | Even if we tried to keep most of the native Django fields, we had to override some of them to be more fit for RESTful
4 | applications. Also, we introduced new ones, to cover extra functionality like nested requests. In this section, we will
5 | explain our intentions and describe their usage.
6 |
7 | To sum up:
8 |
9 | - You can use [Django Form Fields](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#module-django.forms.fields):
10 | - [CharField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#charfield)
11 | - [ChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#choicefield)
12 | - [TypedChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#typedchoicefield)
13 | - [DateField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#datefield)
14 | - [DateTimeField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#datetimefield)
15 | - [DecimalField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#decimalfield)
16 | - [DurationField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#durationfield)
17 | - [EmailField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#emailfield)
18 | - [FilePathField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#filepathfield)
19 | - [FloatField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#floatfield)
20 | - [IntegerField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#integerfield)
21 | - [GenericIPAddressField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#genericipaddressfield)
22 | - [MultipleChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#multiplechoicefield)
23 | - [TypedMultipleChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#typedmultiplechoicefield)
24 | - [RegexField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#regexfield)
25 | - [SlugField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#slugfield)
26 | - [TimeField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#timefield)
27 | - [URLField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#urlfield)
28 | - [UUIDField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#uuidfield)
29 | - [ModelChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#modelchoicefield)
30 | - [ModelMultipleChoiceField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#modelmultiplechoicefield)
31 | - You can use [Django Validators](https://docs.djangoproject.com/en/4.1/ref/validators/).
32 |
33 | Fields that are not in the list above were not been tested or been replaced with our customized implementation
34 | (or it just doesn't make sense to use them in RESTful APIs).
35 |
36 | ## BooleanField
37 |
38 | - Normalizes to: A Python **True** or **False** value (or **None** if it's not required)
39 |
40 | [Django BooleanField](https://docs.djangoproject.com/en/4.1/ref/forms/fields/#booleanfield)
41 | [checks only for False](https://github.com/django/django/blob/master/django/forms/fields.py#L712) (`false`, `0`)
42 | values and everything else is suppose to be **True**.
43 |
44 | In my point of view this kind of behaviour it's little bit weird, so we decided to check explicitly for **True** and
45 | **False** values. If field is required
46 | [ValidationError](https://docs.djangoproject.com/en/4.1/ref/exceptions/#django.core.exceptions.ValidationError) is
47 | raised or value is normalized as **None**.
48 |
49 | Checked values:
50 |
51 | - **True**: `True` `'True'` `'true'` `1` `'1'`
52 | - **False**: `False` `'False'` `'false'` `0` `'0'`
53 |
54 | **Note: We would like to change this behaviour to support only boolean values and rely on deserializers.**
55 |
56 | ## FieldList
57 |
58 | This field is used to parse list of primitive values (like strings or numbers). If you want to parse list of object,
59 | check `FormFieldList`.
60 |
61 | - Normalizes to: A Python list
62 | - Error message keys: `not_field`, `not_list`, `min_length`, `max_length`
63 | - Required arguments:
64 | - `field`: Instance of a form field representing children
65 | - Optional arguments:
66 | - `min_length`: Minimum length of field size as integer
67 | - `max_length`: Maximum length of field size as integer
68 |
69 | **JSON example**
70 |
71 | ```json
72 | {
73 | "numbers": [
74 | 0,
75 | 1,
76 | 1,
77 | 2,
78 | 3,
79 | 5,
80 | 8,
81 | 13
82 | ]
83 | }
84 | ```
85 |
86 | **Python representation**
87 |
88 | ```python
89 | from django_api_forms import Form, FieldList
90 | from django.forms import fields
91 |
92 |
93 | class FibonacciForm(Form):
94 | numbers = FieldList(field=fields.IntegerField())
95 | ```
96 |
97 | ## FormField
98 |
99 | Field used for embedded objects represented as another API form.
100 |
101 | - Normalizes to: A Python dictionary
102 | - Required arguments:
103 | - `form`: Type of a nested form
104 |
105 | **JSON example**
106 |
107 | ```json
108 | {
109 | "title": "Unknown Pleasures",
110 | "year": 1979,
111 | "artist": {
112 | "name": "Joy Division",
113 | "genres": [
114 | "rock",
115 | "punk"
116 | ],
117 | "members": 4
118 | }
119 | }
120 | ```
121 |
122 | **Python representation**
123 |
124 | ```python
125 | from django_api_forms import Form, FormField, FieldList
126 | from django.forms import fields
127 |
128 |
129 | class ArtistForm(Form):
130 | name = fields.CharField(required=True, max_length=100)
131 | genres = FieldList(field=fields.CharField(max_length=30))
132 | members = fields.IntegerField()
133 |
134 |
135 | class AlbumForm(Form):
136 | title = fields.CharField(max_length=100)
137 | year = fields.IntegerField()
138 | artist = FormField(form=ArtistForm)
139 | ```
140 |
141 | ## FormFieldList
142 |
143 | Field used for embedded objects represented as another API form.
144 |
145 | - Normalizes to: A Python list of dictionaries
146 | - Error message keys: `not_list`, `min_length`, `max_length`
147 | - Required arguments:
148 | - `form`: Type of a nested form
149 | - Optional arguments:
150 | - `min_length`: Minimum length of field size as integer
151 | - `max_length`: Maximum length of field size as integer
152 |
153 | **JSON example**
154 |
155 | ```json
156 | {
157 | "title": "Rock For People",
158 | "artists": [
159 | {
160 | "name": "Joy Division",
161 | "genres": [
162 | "rock",
163 | "punk"
164 | ],
165 | "members": 4
166 | }
167 | ]
168 | }
169 | ```
170 |
171 | **Python representation**
172 |
173 | ```python
174 | from django_api_forms import Form, FormFieldList, FieldList
175 | from django.forms import fields
176 |
177 |
178 | class ArtistForm(Form):
179 | name = fields.CharField(required=True, max_length=100)
180 | genres = FieldList(field=fields.CharField(max_length=30))
181 | members = fields.IntegerField()
182 |
183 |
184 | class FestivalForm(Form):
185 | title = fields.CharField(max_length=100)
186 | year = fields.IntegerField()
187 | artists = FormFieldList(form=ArtistForm)
188 | ```
189 |
190 | ## EnumField
191 |
192 | **Tip**: Django has pretty cool implementation of the
193 | [enumeration types](https://docs.djangoproject.com/en/4.1/ref/models/fields/#enumeration-types).
194 |
195 | - Normalizes to: A Python `Enum` object
196 | - Error message keys: `not_enum`, `invalid`
197 | - Required arguments:
198 | - `enum`: Enum class
199 |
200 | **JSON example**
201 |
202 | ```json
203 | {
204 | "title": "Rock For People",
205 | "type": "vinyl"
206 | }
207 | ```
208 |
209 | **Python representation**
210 |
211 | ```python
212 | from django_api_forms import Form, EnumField
213 | from django.forms import fields
214 | from django.db.models import TextChoices
215 |
216 |
217 | class AlbumType(TextChoices):
218 | CD = 'cd', 'CD'
219 | VINYL = 'vinyl', 'Vinyl'
220 |
221 |
222 | class AlbumForm(Form):
223 | title = fields.CharField(required=True, max_length=100)
224 | type = EnumField(enum=AlbumType)
225 | ```
226 |
227 | ## DictionaryField
228 |
229 | Field created for containing typed value pairs.
230 | Due to inverted key, value parameters in `__init__` method, `value_field` is forced keyword arguments.
231 |
232 | - Normalizes to: A Python dictionary
233 | - Error message keys: `not_dict`, `not_field`
234 | - Required arguments:
235 | - `value_field`: Type of a nested form
236 | - Optional arguments:
237 | - `key_field`: Type of a nested form
238 |
239 | **JSON example**
240 |
241 | ```json
242 | {
243 | "my_dict": {
244 | "b061bb03-1eaa-47d0-948f-3ce1f15bf3bb": 2.718,
245 | "0a8912f0-6c10-4505-bc27-bbb099d2e395": 42
246 | }
247 | }
248 | ```
249 |
250 | **Python representation**
251 |
252 | ```python
253 | from django_api_forms import Form, DictionaryField
254 | from django.forms import fields
255 |
256 |
257 | class DictionaryForm(Form):
258 | my_typed_dict = DictionaryField(value_field=fields.DecimalField(), key_field=fields.UUIDField())
259 | ```
260 |
261 | ## AnyField
262 |
263 | Field without default validators.
264 |
265 | - Normalizes to: Type according to the
266 | [chosen request payload parser](https://github.com/Sibyx/django_api_forms/blob/master/django_api_forms/forms.py#L19)
267 |
268 | **JSON example**
269 |
270 | ```json
271 | {
272 | "singer": {
273 | "name": "Johnny",
274 | "surname": "Rotten",
275 | "age": 64,
276 | "born_at": "1956-01-31"
277 | }
278 | }
279 | ```
280 |
281 | **Python representation**
282 |
283 | ```python
284 | from django_api_forms import Form, DictionaryField, AnyField
285 |
286 |
287 | class BandForm(Form):
288 | singer = DictionaryField(value_field=AnyField())
289 | ```
290 |
291 | ## FileField
292 |
293 | This field contains [BASE64](https://tools.ietf.org/html/rfc4648) encoded file.
294 |
295 | - Normalizes to: A Django [File](https://docs.djangoproject.com/en/4.1/ref/files/file/) object
296 | - Error message keys: `max_length`, `invalid_uri`, `invalid_mime`
297 | - Arguments:
298 | - `max_length`: Maximum files size in bytes (optional)
299 | - `mime`: Tuple of allowed mime types (optional - if present, value must be in form of
300 | [Data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs))
301 | - Extra normalised attributes:
302 | - `file_field.clean(payload).content_type`: Mime type (`str` - e.g. `audio/mpeg`) of containing file (`None` if
303 | unable to detect - if payload is not in DATA URI format)
304 |
305 | **JSON example**
306 |
307 | ```json
308 | {
309 | "title": "Disorder",
310 | "type": "data:audio/mpeg;base64,SGVsbG8sIFdvcmxkIQ=="
311 | }
312 | ```
313 |
314 | **Python representation**
315 |
316 | ```python
317 | from django_api_forms import Form, FileField
318 | from django.conf import settings
319 | from django.forms import fields
320 |
321 |
322 | class SongForm(Form):
323 | title = fields.CharField(required=True, max_length=100)
324 | audio = FileField(max_length=settings.DATA_UPLOAD_MAX_MEMORY_SIZE, mime=('audio/mpeg',))
325 | ```
326 |
327 | ## ImageField
328 |
329 | This field contains [BASE64](https://tools.ietf.org/html/rfc4648) encoded image. Depends on
330 | [Pillow](https://pypi.org/project/Pillow/) because normalized value contains `Image` object. Pillow is also used for
331 | image validation [Image.verify()](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.verify)
332 | is called.
333 |
334 | - Normalizes to: A Django [File](https://docs.djangoproject.com/en/4.1/ref/files/file/) object
335 | - Error message keys: `max_length`, `invalid_uri`, `invalid_mime`, `invalid_image` (if Image.verify() failed)
336 | - Arguments:
337 | - `max_length`: Maximum files size in bytes (optional)
338 | - `mime`: Tuple of allowed mime types (optional, value must be in
339 | [Data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs))
340 | - Extra normalised attributes:
341 | - `image_field.clean(payload).content_type`: Mime type (`str` - e.g. `audio/mpeg`) of containing file (`None` if
342 | unable to detect - if payload is not in DATA URI format). Value is filled using Pillow
343 | `Image.MIME.get(image.format)`)
344 | - `image_field.clean(payload).image`: A Pillow
345 | [Image](https://pillow.readthedocs.io/en/stable/reference/Image.html) object instance
346 |
347 | **JSON example**
348 |
349 | ```json
350 | {
351 | "title": "Unknown pleasures",
352 | "cover": "data:image/png;base64,SGVsbG8sIFdvcmxkIQ=="
353 | }
354 | ```
355 |
356 | **Python representation**
357 |
358 | ```python
359 | from django_api_forms import Form, ImageField
360 | from django.conf import settings
361 | from django.forms import fields
362 |
363 |
364 | class AlbumForm(Form):
365 | title = fields.CharField(required=True, max_length=100)
366 | cover = ImageField(max_length=settings.DATA_UPLOAD_MAX_MEMORY_SIZE, mime=('image/png',))
367 | ```
368 |
369 | ## RRule Field
370 |
371 | This field contains [RRule](https://dateutil.readthedocs.io/en/stable/rrule.html) object.
372 |
373 | - Normalizes to a Dateutil [RRule](https://dateutil.readthedocs.io/en/stable/rrule.html) object.
374 | - Error message keys: `not_rrule`
375 |
376 | **Python representation**
377 | ```python
378 | from django_api_forms import Form, RRuleField
379 |
380 |
381 | class VacationForm(Form):
382 | rrule = RRuleField(required=True)
383 | ```
384 |
385 | ## GeoJSON Field
386 |
387 | This field contains [GEOSGeometry](https://docs.djangoproject.com/en/4.1/ref/contrib/gis/geos/#geosgeometry)
388 | django GEOS object. Translates [GeoJSON format](https://datatracker.ietf.org/doc/html/rfc7946.html) into
389 | [geodjango](https://docs.djangoproject.com/en/4.1/ref/contrib/gis/model-api/#spatial-field-types) fields.
390 | Depends on [gdal](https://pypi.org/project/GDAL/) because of spatial reference system (SRS) which is specified
391 | in this library.
392 |
393 | - Error message keys: `not_dict`, `not_geojson`, `not_int`, `transform_error`
394 | - Arguments:
395 | - `srid`: spatial reference identifier (optional - default 4326)
396 | - `transform`: transform to different spatial reference identifier (optional)
397 |
398 | **GeoJSON example**
399 | ```json
400 | {
401 | "geometry": {
402 | "type": "Point",
403 | "coordinates": [90.0, 40.0]
404 | }
405 | }
406 | ```
407 |
408 | **Python representation**
409 | ```python
410 |
411 | from django_api_forms import Form, GeoJSONField
412 |
413 |
414 | class VacationForm(Form):
415 | geometry = GeoJSONField(required=True, srid=4326, transform=5514)
416 | ```
417 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Django API Forms
2 |
3 | **Django API Forms** is a Python library that brings the [Django Forms](https://docs.djangoproject.com/en/4.1/topics/forms/) approach to processing RESTful HTTP request payloads (such as [JSON](https://www.json.org/) or [MessagePack](https://msgpack.org/)) without the HTML front-end overhead.
4 |
5 | ## Overview
6 |
7 | Django API Forms provides a declarative way to:
8 |
9 | - Define request validation schemas using a familiar Django-like syntax
10 | - Parse and validate incoming API requests
11 | - Handle nested data structures and complex validation rules
12 | - Convert validated data into Python objects
13 | - Populate Django models or other objects with validated data
14 |
15 | The library is designed to work seamlessly with Django REST APIs while maintaining the elegant syntax and validation capabilities of Django Forms.
16 |
17 | ## Key Features
18 |
19 | - **Declarative Form Definition**: Define your API request schemas using a familiar Django Forms-like syntax
20 | - **Request Validation**: Validate incoming requests against your defined schemas
21 | - **Nested Data Structures**: Handle complex nested JSON objects and arrays
22 | - **Custom Field Types**: Specialized fields for common API use cases (BooleanField, EnumField, etc.)
23 | - **File Uploads**: Support for BASE64-encoded file and image uploads
24 | - **Object Population**: Easily populate Django models or other objects with validated data
25 | - **Customizable Validation**: Define custom validation rules at the field or form level
26 | - **Multiple Content Types**: Support for JSON, MessagePack, and extensible to other formats
27 |
28 | ## Motivation
29 |
30 | The main idea was to create a simple and declarative way to specify the format of expected requests with the ability
31 | to validate them. Firstly, I tried to use [Django Forms](https://docs.djangoproject.com/en/4.1/topics/forms/) to
32 | validate my API requests (I use pure Django in my APIs). I encountered a problem with nesting my requests without
33 | a huge boilerplate. Also, the whole HTML thing was pretty useless in my RESTful APIs.
34 |
35 | I wanted to:
36 |
37 | - Define my requests as objects (`Form`)
38 | - Pass the request to my defined object (`form = Form.create_from_request(request, param=param))`)
39 | - With the ability to pass any extra optional arguments
40 | - Validate my request (`form.is_valid()`)
41 | - Extract data (`form.cleaned_data` property)
42 |
43 | I wanted to keep:
44 |
45 | - Friendly declarative Django syntax
46 | ([DeclarativeFieldsMetaclass](https://github.com/django/django/blob/master/django/forms/forms.py#L22) is beautiful)
47 | - [Validators](https://docs.djangoproject.com/en/4.1/ref/validators/)
48 | - [ValidationError](https://docs.djangoproject.com/en/4.1/ref/exceptions/#validationerror)
49 | - [Form fields](https://docs.djangoproject.com/en/4.1/ref/forms/fields/) (In the end, I had to "replace" some of them)
50 |
51 | So I created this Python package to cover all these expectations.
52 |
53 | ## Quick Example
54 |
55 | ```python
56 | from django.forms import fields
57 | from django_api_forms import Form, FormField, FieldList
58 |
59 | # Define a nested form
60 | class ArtistForm(Form):
61 | name = fields.CharField(required=True, max_length=100)
62 | genres = FieldList(field=fields.CharField(max_length=30))
63 | members = fields.IntegerField()
64 |
65 | # Define the main form
66 | class AlbumForm(Form):
67 | title = fields.CharField(max_length=100)
68 | year = fields.IntegerField()
69 | artist = FormField(form=ArtistForm)
70 |
71 | # In your view
72 | def create_album(request):
73 | form = AlbumForm.create_from_request(request)
74 | if not form.is_valid():
75 | # Handle validation errors
76 | return JsonResponse({"errors": form.errors}, status=400)
77 |
78 | # Access validated data
79 | album_data = form.cleaned_data
80 | # Do something with the data...
81 |
82 | return JsonResponse({"status": "success"})
83 | ```
84 |
85 | ## Running Tests
86 |
87 | ```shell
88 | # install all dependencies
89 | poetry install
90 |
91 | # run code-style check
92 | poetry run flake8 .
93 |
94 | # run the tests
95 | poetry run python runtests.py
96 | ```
97 |
98 | ## License
99 |
100 | Django API Forms is released under the MIT License.
101 |
--------------------------------------------------------------------------------
/docs/install.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | Django API Forms is published on the PyPI index as [django-api-forms](https://pypi.org/project/django-api-forms/). You can add it to your project using your favorite package manager.
4 |
5 | ## Basic Installation
6 |
7 | Choose one of the following methods to install the basic package:
8 |
9 | ```shell
10 | # Using pip
11 | pip install django-api-forms
12 |
13 | # Using poetry
14 | poetry add django-api-forms
15 |
16 | # Using pipenv
17 | pipenv install django-api-forms
18 |
19 | # Local installation from source
20 | python -m pip install .
21 | ```
22 |
23 | ## Requirements
24 |
25 | - Python 3.9+
26 | - Django 2.0+
27 |
28 | ## Optional Dependencies
29 |
30 | Django API Forms supports additional functionality through optional dependencies. You can install these dependencies individually or as extras.
31 |
32 | ### MessagePack Support
33 |
34 | To handle `application/x-msgpack` HTTP content type, you need to install the [msgpack](https://pypi.org/project/msgpack/) package:
35 |
36 | ```shell
37 | # Install with the msgpack extra
38 | pip install django-api-forms[msgpack]
39 |
40 | # Or install msgpack separately
41 | pip install msgpack
42 | ```
43 |
44 | ### File and Image Fields
45 |
46 | The library provides `FileField` and `ImageField` which are similar to [Django's native implementation](https://docs.djangoproject.com/en/4.1/ref/models/fields/#filefield). These fields require [Pillow](https://pypi.org/project/Pillow/) to be installed:
47 |
48 | ```shell
49 | # Install with the Pillow extra
50 | pip install django-api-forms[Pillow]
51 |
52 | # Or install Pillow separately
53 | pip install Pillow
54 | ```
55 |
56 | ### RRule Field
57 |
58 | To use the `RRuleField` for recurring date rules, you need to install [python-dateutil](https://pypi.org/project/python-dateutil/):
59 |
60 | ```shell
61 | # Install with the rrule extra
62 | pip install django-api-forms[rrule]
63 |
64 | # Or install python-dateutil separately
65 | pip install python-dateutil
66 | ```
67 |
68 | ### GeoJSON Field
69 |
70 | To use the `GeoJSONField` for geographic data, you need to install [GDAL](https://pypi.org/project/GDAL/):
71 |
72 | ```shell
73 | # Install with the gdal extra
74 | pip install django-api-forms[gdal]
75 |
76 | # Or install GDAL separately
77 | pip install gdal
78 | ```
79 |
80 | ## Installing Multiple Extras
81 |
82 | You can install multiple extras in a single command:
83 |
84 | ```shell
85 | # Install all extras
86 | pip install django-api-forms[Pillow,msgpack,rrule,gdal]
87 |
88 | # Or just the ones you need
89 | pip install django-api-forms[Pillow,msgpack]
90 | ```
91 |
92 | ## Django Settings
93 |
94 | No specific Django settings are required to use Django API Forms, but you can customize its behavior by adding settings to your `settings.py` file. See the [Example](example.md) page for available settings.
95 |
--------------------------------------------------------------------------------
/docs/navicat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sibyx/django_api_forms/e27be3924cd1f8c86b0a1c9c3acfc3407148fdf6/docs/navicat.png
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | # Project information
2 | site_name: 'Django API Forms'
3 | site_description: 'Declarative Django request validation for APIs'
4 | site_author: 'Jakub Dubec'
5 | site_url: 'https://sibyx.github.io/django_api_forms'
6 |
7 | # Navigation
8 | nav:
9 | - Home: index.md
10 | - Installation: install.md
11 | - Tutorial: tutorial.md
12 | - Fields: fields.md
13 | - Example: example.md
14 | - API Reference: api_reference.md
15 | - Contributing: contributing.md
16 |
17 | # Repository
18 | repo_name: 'Sibyx/django_api_forms'
19 | repo_url: 'https://github.com/Sibyx/django_api_forms'
20 |
21 | # Configuration
22 | theme:
23 | name: 'material'
24 | custom_dir: docs/custom/
25 | language: 'en'
26 | features:
27 | - instant
28 |
29 | # Extensions
30 | markdown_extensions:
31 | - codehilite:
32 | guess_lang: false
33 | - toc:
34 | permalink: true
35 | - pymdownx.highlight:
36 | anchor_linenums: true
37 | - pymdownx.inlinehilite
38 | - pymdownx.snippets
39 | - pymdownx.superfences
40 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "django-api-forms"
3 | version = "1.0.0-rc.11"
4 | description = "Declarative Django request validation for RESTful APIs"
5 | authors = [
6 | "Jakub Dubec ",
7 | "Paul Brown ",
8 | "Erik Belák "
9 | ]
10 | license = "MIT"
11 | keywords = [
12 | "django",
13 | "forms",
14 | "request",
15 | "validation",
16 | "rest",
17 | ]
18 | classifiers = [
19 | "Development Status :: 4 - Beta",
20 | "Programming Language :: Python",
21 | "Programming Language :: Python :: 3",
22 | "Programming Language :: Python :: 3 :: Only",
23 | "Programming Language :: Python :: 3.7",
24 | "Programming Language :: Python :: 3.8",
25 | "Programming Language :: Python :: 3.9",
26 | "Programming Language :: Python :: 3.10",
27 | "Programming Language :: Python :: 3.11",
28 | "Programming Language :: Python :: Implementation :: PyPy",
29 | "Framework :: Django :: 2.0",
30 | "Framework :: Django :: 2.1",
31 | "Framework :: Django :: 2.2",
32 | "Framework :: Django :: 3.0",
33 | "Framework :: Django :: 3.1",
34 | "Framework :: Django :: 3.2",
35 | "Framework :: Django :: 4.0",
36 | "Framework :: Django :: 4.1",
37 | "Intended Audience :: Developers",
38 | "License :: OSI Approved :: MIT License",
39 | "Operating System :: OS Independent",
40 | "Topic :: Internet :: WWW/HTTP",
41 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
42 | "Topic :: Software Development :: Libraries :: Python Modules",
43 | "Environment :: Web Environment",
44 | ]
45 | readme = 'README.md'
46 |
47 | [tool.poetry.urls]
48 | Repository = "https://github.com/Sibyx/django_api_forms"
49 | Issues = "https://github.com/Sibyx/django_api_forms/issues"
50 | Documentation = "https://sibyx.github.io/django_api_forms/"
51 | Changelog = "https://github.com/Sibyx/django_api_forms/blob/master/CHANGELOG.md"
52 |
53 | [tool.poetry.dependencies]
54 | python = "^3.9"
55 | Django = ">=2.0"
56 | Pillow = {version = ">=2.1", optional = true}
57 | msgpack = {version = "*", optional = true}
58 | python-dateutil = {version = "^2.8.2", optional = true}
59 | gdal = {version = "3.8.4", optional = true}
60 |
61 | [tool.poetry.dev-dependencies]
62 | flake8 = "^6.0"
63 | mkdocs-material = "^9.1"
64 | toml = "^0.10.2"
65 | coverage = {version = "^7", extras = ["toml"]}
66 |
67 | [tool.poetry.extras]
68 | Pillow = ["Pillow"]
69 | msgpack = ["msgpack"]
70 | rrule = ["python-dateutil"]
71 | gdal = ["gdal"]
72 |
73 | [tool.coverage.run]
74 | omit = [
75 | '*/tests/*', 'docs/', 'venv/*', 'build/', 'dist/', '.github/', 'django_api_forms.egg-info/', 'runtests.py'
76 | ]
77 |
78 | [build-system]
79 | requires = ["poetry>=1.0"]
80 | build-backend = "poetry.masonry.api"
81 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | import django
6 | from django.conf import settings
7 | from django.test.utils import get_runner
8 |
9 | if __name__ == "__main__":
10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
11 | django.setup()
12 | TestRunner = get_runner(settings)
13 | test_runner = TestRunner()
14 | failures = test_runner.run_tests(["tests"])
15 | sys.exit(bool(failures))
16 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sibyx/django_api_forms/e27be3924cd1f8c86b0a1c9c3acfc3407148fdf6/tests/__init__.py
--------------------------------------------------------------------------------
/tests/data/invalid.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Unknown Pleasures",
3 | "type": "vinyl",
4 | "artist": {
5 | "name": "Nirvana",
6 | "genres": [
7 | "rock",
8 | "punk"
9 | ],
10 | "members": 4
11 | },
12 | "year": 1998,
13 | "songs": [
14 | {
15 | "metadata": {
16 | "_section": {
17 | "type": "ID3v2",
18 | "offset": 0,
19 | "byteLength": 2048
20 | }
21 | }
22 | },
23 | {
24 | "duration": "3:29"
25 | },
26 | {
27 | "title": "Day of the Lords",
28 | "duration": "4:48",
29 | "metadata": {
30 | "_section": {
31 | "type": "ID3v2",
32 | "offset": 0,
33 | "byteLength": 2048
34 | },
35 | "header": {
36 | "majorVersion": 3,
37 | "minorRevision": 0,
38 | "flagsOctet": 0,
39 | "unsynchronisationFlag": false,
40 | "extendedHeaderFlag": false,
41 | "experimentalIndicatorFlag": false,
42 | "size": 2038
43 | }
44 | }
45 | }
46 | ],
47 | "metadata": {
48 | "created_at": "2019-10-21T18:57:03+0100",
49 | "updated_at": "2019-10-21T18:57:03+0100",
50 | "error_at": "blah"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/data/invalid_concert.json:
--------------------------------------------------------------------------------
1 | {
2 | "place": "Bratislava",
3 | "emails": [
4 | "not valid email",
5 | "valid@email.com",
6 | "v@lid.com"
7 | ],
8 | "organizer_id": 1,
9 | "bands":[
10 | {
11 | "name": "Queen",
12 | "formed": 1970,
13 | "has_award": false,
14 | "emails": {
15 | "0": "not valid email",
16 | "1": "valid@email.com",
17 | "2": "v@lid.com"
18 | },
19 | "albums": [
20 | {
21 | "title": "Unknown Pleasures",
22 | "year": 1979,
23 | "type": "vinyl",
24 | "artist": {
25 | "name": "Joy Division",
26 | "genres": [
27 | "rock",
28 | "punk"
29 | ],
30 | "members": 4
31 | },
32 | "songs": [
33 | {
34 | "title": "Disorder",
35 | "duration": "3:29"
36 | },
37 | {
38 | "title": "Day of the Lords",
39 | "duration": "4:48",
40 | "metadata": {
41 | "_section": {
42 | "type": "ID3v2",
43 | "offset": 0,
44 | "byteLength": 2048
45 | }
46 | }
47 | }
48 | ],
49 | "metadata": {
50 | "created_at": "2019-10-21T18:57:03+0100",
51 | "updated_at": "2019-10-21T18:57:03+0100"
52 | }
53 | }
54 | ]
55 | },
56 | {
57 | "name": "The Beatles",
58 | "formed": 1960,
59 | "has_award": true,
60 | "albums": [
61 | {
62 | "title": "Unknown Pleasures",
63 | "type": "vinyl",
64 | "artist": {
65 | "name": "Nirvana",
66 | "genres": [
67 | "rock",
68 | "punk"
69 | ],
70 | "members": 4
71 | },
72 | "year": 1998,
73 | "songs": [
74 | {
75 | "metadata": {
76 | "_section": {
77 | "type": "ID3v2",
78 | "offset": 0,
79 | "byteLength": 2048
80 | }
81 | }
82 | },
83 | {
84 | "duration": "3:29"
85 | },
86 | {
87 | "title": "Day of the Lords",
88 | "duration": "4:48",
89 | "metadata": {
90 | "_section": {
91 | "type": "ID3v2",
92 | "offset": 0,
93 | "byteLength": 2048
94 | }
95 | }
96 | }
97 | ],
98 | "metadata": {
99 | "created_at": "2019-10-21T18:57:03+0100",
100 | "updated_at": "2019-10-21T18:57:03+0100",
101 | "error_at": "blah"
102 | }
103 | }
104 | ]
105 | }
106 | ]
107 | }
108 |
--------------------------------------------------------------------------------
/tests/data/invalid_image.txt:
--------------------------------------------------------------------------------
1 | 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
2 |
--------------------------------------------------------------------------------
/tests/data/kitten.txt:
--------------------------------------------------------------------------------
1 | data:image/jpeg;base64,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
2 |
--------------------------------------------------------------------------------
/tests/data/kitten_missing.txt:
--------------------------------------------------------------------------------
1 | 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
2 |
--------------------------------------------------------------------------------
/tests/data/valid.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Unknown Pleasures",
3 | "type": "vinyl",
4 | "artist": {
5 | "name": "Joy Division",
6 | "genres": [
7 | "rock",
8 | "punk"
9 | ],
10 | "members": 4
11 | },
12 | "year": 1979,
13 | "songs": [
14 | {
15 | "title": "Disorder",
16 | "duration": "3:29"
17 | },
18 | {
19 | "title": "Day of the Lords",
20 | "duration": "4:48",
21 | "metadata": {
22 | "_section": {
23 | "type": "ID3v2",
24 | "offset": 0,
25 | "byteLength": 2048
26 | },
27 | "header": {
28 | "majorVersion": 3,
29 | "minorRevision": 0,
30 | "flagsOctet": 0,
31 | "unsynchronisationFlag": false,
32 | "extendedHeaderFlag": false,
33 | "experimentalIndicatorFlag": false,
34 | "size": 2038
35 | }
36 | }
37 | }
38 | ],
39 | "metadata": {
40 | "created_at": "2019-10-21T18:57:03+0100",
41 | "updated_at": "2019-10-21T18:57:03+0100"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/data/valid_artist.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Joy Division",
3 | "genres": [
4 | "rock",
5 | "punk"
6 | ],
7 | "members": 4
8 | }
9 |
--------------------------------------------------------------------------------
/tests/data/valid_concert.json:
--------------------------------------------------------------------------------
1 | {
2 | "place": "Bratislava",
3 | "emails": [
4 | "em@il.com",
5 | "v@lid.com"
6 | ],
7 | "organizer_id": 1,
8 | "bands":[
9 | {
10 | "name": "Queen",
11 | "formed": 1970,
12 | "has_award": false,
13 | "emails": {
14 | "0": "em@il.com",
15 | "1": "v@lid.com"
16 | },
17 | "albums": [
18 | {
19 | "title": "Unknown Pleasures",
20 | "year": 1979,
21 | "type": "vinyl",
22 | "artist": {
23 | "name": "Joy Division",
24 | "genres": [
25 | "rock",
26 | "punk"
27 | ],
28 | "members": 4
29 | },
30 | "songs": [
31 | {
32 | "title": "Disorder",
33 | "duration": "3:29"
34 | },
35 | {
36 | "title": "Day of the Lords",
37 | "duration": "4:48",
38 | "metadata": {
39 | "_section": {
40 | "type": "ID3v2",
41 | "offset": 0,
42 | "byteLength": 2048
43 | }
44 | }
45 | }
46 | ],
47 | "metadata": {
48 | "created_at": "2019-10-21T18:57:03+0100",
49 | "updated_at": "2019-10-21T18:57:03+0100"
50 | }
51 | }
52 | ]
53 | },
54 | {
55 | "name": "The Beatles",
56 | "formed": 1960,
57 | "has_award": true,
58 | "albums": [
59 | {
60 | "title": "Unknown Pleasures",
61 | "type": "vinyl",
62 | "artist": {
63 | "name": "Nirvana",
64 | "genres": [
65 | "rock",
66 | "punk"
67 | ],
68 | "members": 4
69 | },
70 | "year": 1997,
71 | "songs": [
72 | {
73 | "title": "Hey jude",
74 | "duration": "2:20"
75 | },
76 | {
77 | "title": "Let it be",
78 | "duration": "2:51"
79 | },
80 | {
81 | "title": "Day of the Lords",
82 | "duration": "4:48",
83 | "metadata": {
84 | "_section": {
85 | "type": "ID3v2",
86 | "offset": 0,
87 | "byteLength": 2048
88 | }
89 | }
90 | }
91 | ],
92 | "metadata": {
93 | "created_at": "2019-10-21T18:57:03+0100",
94 | "updated_at": "2019-10-21T18:57:03+0100"
95 | }
96 | }
97 | ]
98 | }
99 | ]
100 | }
101 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__))
4 |
5 | SECRET_KEY = ')dajq1#olz2*y&$1x0y&pd0ev-a_h2*j%ed0j5ych2^oy%*2%e'
6 |
7 | DATETIME_INPUT_FORMATS = ('%Y-%m-%dT%H:%M:%S%z',)
8 |
9 | INSTALLED_APPS = (
10 | 'django.contrib.contenttypes',
11 | 'django_api_forms',
12 | 'tests.testapp',
13 | )
14 |
15 | DATABASES = {
16 | 'default': {
17 | 'ENGINE': 'django.db.backends.sqlite3',
18 | 'NAME': ':memory:',
19 | }
20 | }
21 |
22 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
23 | GDAL_LIBRARY_PATH = os.getenv('GDAL_LIBRARY_PATH')
24 | GEOS_LIBRARY_PATH = os.getenv('GEOS_LIBRARY_PATH')
25 |
--------------------------------------------------------------------------------
/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Optional
3 |
4 | import msgpack
5 | from django.core.exceptions import ValidationError
6 | from django.forms import fields
7 | from django.test import TestCase
8 | from django.test.client import RequestFactory
9 | from django_api_forms import Form, BooleanField
10 | from django_api_forms.exceptions import UnsupportedMediaType
11 | from tests.testapp.models import Band
12 |
13 |
14 | class FormTests(TestCase):
15 | def test_create_from_request(self):
16 | # TEST: Form.create_from_request with VALID JSON data
17 | request_factory = RequestFactory()
18 | valid_test_data = {'message': ['turned', 'into', 'json']}
19 | request = request_factory.post(
20 | '/test/',
21 | data=valid_test_data,
22 | content_type='application/json'
23 | )
24 | form = Form.create_from_request(request)
25 | self.assertEqual(form._data, valid_test_data)
26 |
27 | # TEST: Form.create_from_request with INVALID JSON data
28 | request_factory = RequestFactory()
29 | invalid_test_data = '[1, 2,'
30 | request = request_factory.post(
31 | '/test/',
32 | data=invalid_test_data,
33 | content_type='application/json'
34 | )
35 | with self.assertRaises(json.JSONDecodeError):
36 | form = Form.create_from_request(request)
37 |
38 | # TEST: Form.create_from_request with VALID msgpack data
39 | request_factory = RequestFactory()
40 | valid_test_data = [1, 2, 3]
41 | packed_valid_test_data = msgpack.packb(valid_test_data)
42 | request = request_factory.post(
43 | '/test/',
44 | data=packed_valid_test_data,
45 | content_type='application/x-msgpack'
46 | )
47 | form = Form.create_from_request(request)
48 | self.assertEqual(form._data, valid_test_data)
49 |
50 | # TEST: Form.create_from_request with INVALID msgpack data
51 | request_factory = RequestFactory()
52 | invalid_test_data = 'invalid msgpack'
53 | request = request_factory.post(
54 | '/test/',
55 | data=invalid_test_data,
56 | content_type='application/x-msgpack'
57 | )
58 | with self.assertRaises(msgpack.exceptions.ExtraData):
59 | form = Form.create_from_request(request)
60 |
61 | # TEST: Form.create_from_request with unsupported content_type
62 | request_factory = RequestFactory()
63 | request = request_factory.post(
64 | '/test/',
65 | data='blah',
66 | content_type='blah'
67 | )
68 | with self.assertRaises(UnsupportedMediaType):
69 | form = Form.create_from_request(request)
70 |
71 | # TEST: Form.create_from_request with VALID JSON data and charset
72 | request_factory = RequestFactory()
73 | valid_test_data = {'message': ['turned', 'into', 'json']}
74 | request = request_factory.post(
75 | '/test/',
76 | data=valid_test_data,
77 | content_type='application/json; charset=utf-8'
78 | )
79 | form = Form.create_from_request(request)
80 | self.assertEqual(form._data, valid_test_data)
81 |
82 | def test_clean_data_keys(self):
83 | class FunnyForm(Form):
84 | title = fields.CharField(required=True)
85 | code = fields.CharField(required=True)
86 | url = fields.CharField(required=False)
87 | description = fields.CharField(required=False)
88 |
89 | @classmethod
90 | def _normalize_url(cls, url: str) -> Optional[str]:
91 | if not url:
92 | return None
93 | if url.startswith('http://'):
94 | url = url.replace('http://', '')
95 |
96 | if not url.startswith('https://'):
97 | url = f"https://{url}"
98 |
99 | return url
100 |
101 | def clean_url(self):
102 | return self._normalize_url(self.cleaned_data['url'])
103 |
104 | request_factory = RequestFactory()
105 | request = request_factory.post(
106 | '/test/',
107 | data={
108 | 'title': "The Question",
109 | 'code': 'the-question',
110 | 'url': ''
111 | },
112 | content_type='application/json'
113 | )
114 | form = FunnyForm.create_from_request(request)
115 | self.assertTrue(form.is_valid())
116 | self.assertTrue(len(form.cleaned_data.keys()) == 3)
117 | self.assertIsNone(form.cleaned_data['url'])
118 |
119 | def test_meta_class_mapping(self):
120 | class FunnyForm(Form):
121 | class Meta:
122 | # source:destination
123 | mapping = {
124 | 'kode': 'code',
125 | 'titul': 'title'
126 | }
127 |
128 | title = fields.CharField(required=True)
129 | code = fields.CharField(required=True)
130 | url = fields.CharField(required=False)
131 | description = fields.CharField(required=False)
132 |
133 | @classmethod
134 | def _normalize_url(cls, url: str) -> Optional[str]:
135 | if not url:
136 | return None
137 | if url.startswith('http://'):
138 | url = url.replace('http://', '')
139 |
140 | if not url.startswith('https://'):
141 | url = f"https://{url}"
142 |
143 | return url
144 |
145 | def clean_url(self):
146 | return self._normalize_url(self.cleaned_data['url'])
147 |
148 | request_factory = RequestFactory()
149 | request = request_factory.post(
150 | '/test/',
151 | data={
152 | 'titul': "The Question",
153 | 'kode': 'the-question',
154 | 'url': ''
155 | },
156 | content_type='application/json'
157 | )
158 | form = FunnyForm.create_from_request(request)
159 | self.assertTrue(form.is_valid())
160 | self.assertTrue(len(form.cleaned_data.keys()) == 3)
161 | self.assertIsNone(form.cleaned_data['url'])
162 |
163 | def test_meta_class(self):
164 | class FunnyForm(Form):
165 | class Meta:
166 | # source:destination
167 | mapping = {
168 | '_name': 'name',
169 | 'created': 'formed'
170 | }
171 |
172 | field_type_strategy = {
173 | 'django_api_forms.fields.BooleanField': 'tests.testapp.population_strategies.BooleanField'
174 | }
175 |
176 | field_strategy = {
177 | 'formed': 'tests.testapp.population_strategies.FormedStrategy'
178 | }
179 |
180 | name = fields.CharField(max_length=100)
181 | formed = fields.IntegerField()
182 | has_award = BooleanField()
183 |
184 | @classmethod
185 | def _normalize_url(cls, url: str) -> Optional[str]:
186 | if not url:
187 | return None
188 | if url.startswith('http://'):
189 | url = url.replace('http://', '')
190 |
191 | if not url.startswith('https://'):
192 | url = f"https://{url}"
193 |
194 | return url
195 |
196 | def clean_url(self):
197 | return self._normalize_url(self.cleaned_data['url'])
198 |
199 | request_factory = RequestFactory()
200 | request = request_factory.post(
201 | '/test/',
202 | data={
203 | '_name': 'Queen',
204 | 'created': '1870',
205 | 'has_award': 'True'
206 | },
207 | content_type='application/json'
208 | )
209 | form = FunnyForm.create_from_request(request)
210 | self.assertTrue(form.is_valid())
211 |
212 | # Populate form
213 | band = Band()
214 | form.populate(band)
215 |
216 | self.assertTrue(len(form.cleaned_data.keys()) == 3)
217 | self.assertEqual(band.name, form.cleaned_data['name'])
218 | self.assertEqual(band.formed, 2000)
219 | self.assertEqual(band.has_award, False)
220 |
221 | def test_empty_payload(self):
222 | class FunnyForm(Form):
223 | title = fields.CharField(required=False)
224 |
225 | class DummyObject:
226 | title = None
227 |
228 | request_factory = RequestFactory()
229 | request = request_factory.post(
230 | '/test/',
231 | data={},
232 | content_type='application/json'
233 | )
234 | form = FunnyForm.create_from_request(request)
235 | my_object = DummyObject()
236 |
237 | self.assertTrue(form.is_valid())
238 | form.populate(my_object)
239 |
240 | def test_create_from_request_kwargs(self):
241 | # TEST: Form.create_from_request valid kwargs from request GET parameters
242 | request_factory = RequestFactory()
243 | valid_test_data = {'message': ['turned', 'into', 'json']}
244 | valid_test_extras = {'param1': 'param1', 'param2': 'param2'}
245 | request = request_factory.post(
246 | '/test/?param1=param1¶m2=param2',
247 | data=valid_test_data,
248 | content_type='application/json'
249 | )
250 |
251 | form = Form.create_from_request(request, param1=request.GET.get('param1'), param2=request.GET.get('param2'))
252 | self.assertEqual(form._data, valid_test_data)
253 | self.assertEqual(form.extras, valid_test_extras)
254 |
255 | # TEST: extras in clean method
256 | valid_test_extras = {'param1': 'param3', 'param2': 'param4', 'param3': 'test'}
257 |
258 | class FunnyForm(Form):
259 | title = fields.CharField(required=True)
260 | code = fields.CharField(required=True)
261 | url = fields.CharField(required=False)
262 | description = fields.CharField(required=False)
263 |
264 | @classmethod
265 | def _normalize_url(cls, url: str) -> Optional[str]:
266 | if not url:
267 | return None
268 | if url.startswith('http://'):
269 | url = url.replace('http://', '')
270 |
271 | if not url.startswith('https://'):
272 | url = f"https://{url}"
273 |
274 | return url
275 |
276 | def clean_url(self):
277 | return self._normalize_url(self.cleaned_data['url'])
278 |
279 | def clean_title(self):
280 | if 'param1' in self.extras and 'param2' in self.extras:
281 | self.extras['param1'] = 'param3'
282 | return self.cleaned_data['title']
283 |
284 | def clean(self):
285 | if 'param1' in self.extras and 'param2' in self.extras:
286 | self.extras['param2'] = 'param4'
287 | return self.cleaned_data
288 | else:
289 | raise ValidationError("Missing params!", code='missing-params')
290 |
291 | request_factory = RequestFactory()
292 | request = request_factory.post(
293 | '/test?param1=param1¶m2=param2',
294 | data={
295 | 'title': "The Question",
296 | 'code': 'the-question',
297 | 'url': ''
298 | },
299 | content_type='application/json'
300 | )
301 | form = FunnyForm.create_from_request(
302 | request, param1=request.GET.get('param1'), param2=request.GET.get('param2'), param3='test'
303 | )
304 | self.assertTrue(form.is_valid())
305 | self.assertTrue(len(form.cleaned_data.keys()) == 3)
306 | self.assertIsNone(form.cleaned_data['url'])
307 | self.assertEqual(form.extras, valid_test_extras)
308 |
--------------------------------------------------------------------------------
/tests/test_modelchoicefield.py:
--------------------------------------------------------------------------------
1 | from django.forms import ModelChoiceField, fields
2 | from django.test import TestCase, RequestFactory
3 | from django_api_forms import Form, EnumField
4 | from tests.testapp.models import Album, Artist
5 |
6 |
7 | class FormTests(TestCase):
8 | def setUp(self) -> None:
9 | self._my_artist = Artist.objects.create(
10 | id=1,
11 | name='Joy Division',
12 | genres=['rock', 'punk'],
13 | members=4
14 | )
15 |
16 | def test_no_prefix(self):
17 | class MyAlbumForm(Form):
18 | title = fields.CharField(max_length=100)
19 | year = fields.IntegerField()
20 | artist = ModelChoiceField(queryset=Artist.objects.all())
21 | type = EnumField(enum=Album.AlbumType, required=True)
22 |
23 | request_factory = RequestFactory()
24 | data = {
25 | 'title': 'Unknown Pleasures',
26 | 'year': 1979,
27 | 'artist': 1,
28 | 'type': 'vinyl'
29 | }
30 |
31 | request = request_factory.post(
32 | '/test/',
33 | data=data,
34 | content_type='application/json'
35 | )
36 |
37 | my_model = Album()
38 | form = MyAlbumForm.create_from_request(request)
39 | self.assertTrue(form.is_valid())
40 |
41 | form.populate(my_model)
42 | self.assertIsInstance(my_model.artist, Artist)
43 | self.assertEqual(my_model.artist.pk, self._my_artist.pk)
44 | self.assertEqual(my_model.artist, self._my_artist)
45 |
46 | def test_field_name(self):
47 | class MyAlbumForm(Form):
48 | title = fields.CharField(max_length=100)
49 | year = fields.IntegerField()
50 | artist_name = ModelChoiceField(queryset=Artist.objects.all(), to_field_name='name')
51 | type = EnumField(enum=Album.AlbumType, required=True)
52 |
53 | request_factory = RequestFactory()
54 | data = {
55 | 'title': 'Unknown Pleasures',
56 | 'year': 1979,
57 | 'artist_name': 'Joy Division',
58 | 'type': 'vinyl'
59 | }
60 |
61 | request = request_factory.post(
62 | '/test/',
63 | data=data,
64 | content_type='application/json'
65 | )
66 |
67 | my_model = Album()
68 | form = MyAlbumForm.create_from_request(request)
69 | self.assertTrue(form.is_valid())
70 |
71 | form.populate(my_model)
72 | self.assertIsInstance(my_model.artist, Artist)
73 | self.assertEqual(my_model.artist.pk, self._my_artist.pk)
74 | self.assertEqual(my_model.artist, self._my_artist)
75 |
76 | def test_pk(self):
77 | class MyAlbumForm(Form):
78 | title = fields.CharField(max_length=100)
79 | year = fields.IntegerField()
80 | artist_id = ModelChoiceField(queryset=Artist.objects.all())
81 | type = EnumField(enum=Album.AlbumType, required=True)
82 |
83 | request_factory = RequestFactory()
84 | data = {
85 | 'title': 'Unknown Pleasures',
86 | 'year': 1979,
87 | 'artist_id': 1,
88 | 'type': 'vinyl'
89 | }
90 |
91 | request = request_factory.post(
92 | '/test/',
93 | data=data,
94 | content_type='application/json'
95 | )
96 |
97 | my_model = Album()
98 | form = MyAlbumForm.create_from_request(request)
99 | self.assertTrue(form.is_valid())
100 |
101 | form.populate(my_model)
102 | self.assertIsInstance(my_model.artist, Artist)
103 | self.assertEqual(my_model.artist.pk, self._my_artist.pk)
104 | self.assertEqual(my_model.artist, self._my_artist)
105 |
--------------------------------------------------------------------------------
/tests/test_modelforms.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.test import RequestFactory, TestCase
3 |
4 | from tests.testapp.forms import ArtistModelForm
5 |
6 |
7 | class ValidationTests(TestCase):
8 | def test_valid(self):
9 | rf = RequestFactory()
10 | expected = {
11 | 'name': "Joy Division",
12 | 'genres': ['rock', 'punk'],
13 | 'members': 4
14 | }
15 |
16 | with open(f"{settings.BASE_DIR}/data/valid_artist.json") as f:
17 | request = rf.post('/foo/bar', data=f.read(), content_type='application/json')
18 |
19 | form = ArtistModelForm.create_from_request(request)
20 |
21 | self.assertTrue(form.is_valid())
22 | self.assertEqual(form.cleaned_data, expected)
23 |
--------------------------------------------------------------------------------
/tests/test_nested_forms.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.test import TestCase, RequestFactory
4 | from django.conf import settings
5 |
6 | from tests.testapp.forms import ConcertForm
7 | from tests.testapp.models import Artist, Album
8 |
9 |
10 | class NestedFormsTests(TestCase):
11 |
12 | def setUp(self) -> None:
13 | self._my_artist = Artist.objects.create(
14 | id=1,
15 | name='Organizer',
16 | genres=['rock', 'punk'],
17 | members=4
18 | )
19 |
20 | def test_invalid(self):
21 | expected = {
22 | "errors": [
23 | {
24 | 'code': 'invalid',
25 | 'message': 'Enter a valid email address.',
26 | 'path': ['bands', 0, 'emails', '0']
27 | },
28 | {
29 | 'code': 'max_length',
30 | 'message': '',
31 | 'path': ['bands', 0, 'emails', '0']
32 | },
33 | {
34 | 'code': 'max_length',
35 | 'message': '',
36 | 'path': ['bands', 0, 'emails', '1']
37 | },
38 | {
39 | "code": "required",
40 | "message": "This field is required.",
41 | "path": ["bands", 1, "albums", 0, "songs", 0, "title"]
42 | },
43 | {
44 | "code": "required",
45 | "message": "This field is required.",
46 | "path": ["bands", 1, "albums", 0, "songs", 0, "duration"]
47 | },
48 | {
49 | "code": "required",
50 | "message": "This field is required.",
51 | "path": ["bands", 1, "albums", 0, "songs", 1, "title"]
52 | },
53 | {
54 | "code": "invalid",
55 | "message": "Enter a valid date/time.",
56 | "path": ["bands", 1, "albums", 0, "metadata", "error_at"]
57 | },
58 | {
59 | "code": "invalid",
60 | "message": "Enter a valid email address.",
61 | "path": ["emails", 0]
62 | },
63 | {
64 | "code": "max_length",
65 | "message": "",
66 | "path": ["emails", 0]
67 | },
68 | {
69 | "code": "max_length",
70 | "message": "",
71 | "path": ["emails", 1]
72 | }
73 | ]
74 | }
75 | rf = RequestFactory()
76 |
77 | with open(f"{settings.BASE_DIR}/data/invalid_concert.json") as f:
78 | request = rf.post('/foo/bar', data=f.read(), content_type='application/json')
79 |
80 | form = ConcertForm.create_from_request(request)
81 |
82 | self.assertFalse(form.is_valid())
83 | error = {
84 | 'errors': [item.to_dict() for item in form._errors]
85 | }
86 | self.assertEqual(error, expected)
87 |
88 | def test_valid(self):
89 | expected = {
90 | "place": "Bratislava",
91 | "bands": [
92 | {
93 | "name": "Queen",
94 | "formed": 1970,
95 | "has_award": False,
96 | "emails": {
97 | "0": "em@il.com",
98 | "1": "v@lid.com"
99 | },
100 | "albums": [
101 | {
102 | "title": "Unknown Pleasures",
103 | "year": 1979,
104 | "artist": {
105 | "name": "Joy Division",
106 | "genres": [
107 | "rock",
108 | "punk"
109 | ],
110 | "members": 4
111 | },
112 | "songs": [
113 | {
114 | "title": "Disorder",
115 | "duration": datetime.timedelta(seconds=209)
116 | },
117 | {
118 | "title": "Day of the Lords",
119 | "duration": datetime.timedelta(seconds=288),
120 | "metadata": {
121 | "_section": {
122 | "type": "ID3v2",
123 | "offset": 0,
124 | "byteLength": 2048
125 | }
126 | }
127 | }
128 | ],
129 | "type": Album.AlbumType.VINYL,
130 | "metadata": {
131 | "created_at": datetime.datetime.strptime(
132 | "2019-10-21T18:57:03+0100", "%Y-%m-%dT%H:%M:%S%z"
133 | ),
134 | "updated_at": datetime.datetime.strptime(
135 | "2019-10-21T18:57:03+0100", "%Y-%m-%dT%H:%M:%S%z"
136 | ),
137 | }
138 | }
139 | ]
140 | },
141 | {
142 | "name": "The Beatles",
143 | "formed": 1960,
144 | "has_award": True,
145 | "albums": [
146 | {
147 | "title": "Unknown Pleasures",
148 | "year": 1997,
149 | "artist": {
150 | "name": "Nirvana",
151 | "genres": [
152 | "rock",
153 | "punk"
154 | ],
155 | "members": 4
156 | },
157 | "songs": [
158 | {
159 | "title": "Hey jude",
160 | "duration": datetime.timedelta(seconds=140)
161 | },
162 | {
163 | "title": "Let it be",
164 | "duration": datetime.timedelta(seconds=171)
165 | },
166 | {
167 | "title": "Day of the Lords",
168 | "duration": datetime.timedelta(seconds=288),
169 | "metadata": {
170 | "_section": {
171 | "type": "ID3v2",
172 | "offset": 0,
173 | "byteLength": 2048
174 | }
175 | }
176 | }
177 | ],
178 | "type": Album.AlbumType.VINYL,
179 | "metadata": {
180 | "created_at": datetime.datetime.strptime(
181 | "2019-10-21T18:57:03+0100", "%Y-%m-%dT%H:%M:%S%z"
182 | ),
183 | "updated_at": datetime.datetime.strptime(
184 | "2019-10-21T18:57:03+0100", "%Y-%m-%dT%H:%M:%S%z"
185 | ),
186 | }
187 | }
188 | ]
189 | }
190 | ],
191 | "organizer_id": self._my_artist,
192 | "emails": [
193 | "em@il.com",
194 | "v@lid.com"
195 | ]
196 | }
197 |
198 | rf = RequestFactory()
199 |
200 | with open(f"{settings.BASE_DIR}/data/valid_concert.json") as f:
201 | request = rf.post('/foo/bar', data=f.read(), content_type='application/json')
202 |
203 | form = ConcertForm.create_from_request(request)
204 |
205 | self.assertTrue(form.is_valid())
206 | self.assertEqual(form.cleaned_data, expected)
207 |
--------------------------------------------------------------------------------
/tests/test_population.py:
--------------------------------------------------------------------------------
1 | from django.forms import fields
2 | from django.test import TestCase
3 | from django.test.client import RequestFactory
4 |
5 | from django_api_forms import Form, EnumField, FormField
6 | from django_api_forms.exceptions import ApiFormException
7 | from tests import settings
8 | from tests.testapp.forms import AlbumForm, BandForm, ArtistForm
9 | from tests.testapp.models import Album, Artist, Band
10 |
11 |
12 | class PopulationTests(TestCase):
13 | def test_populate(self):
14 | # Create form from request
15 | with open(f"{settings.BASE_DIR}/data/valid.json") as f:
16 | payload = f.read()
17 | request_factory = RequestFactory()
18 | request = request_factory.post(
19 | '/test/',
20 | data=payload,
21 | content_type='application/json'
22 | )
23 | form = AlbumForm.create_from_request(request)
24 | self.assertTrue(form.is_valid())
25 |
26 | # Populate form
27 | album = Album()
28 | form.populate(album)
29 |
30 | self.assertEqual(album.title, form.cleaned_data['title'])
31 | self.assertEqual(album.year, form.cleaned_data['year'])
32 | self.assertEqual(album.type, form.cleaned_data['type'])
33 | self.assertIsInstance(album.type, Album.AlbumType)
34 | self.assertEqual(album.metadata, form.cleaned_data['metadata'])
35 |
36 | # Populate method tests
37 | self.assertIsInstance(album.artist, Artist)
38 | self.assertEqual(album.artist.name, "Joy Division")
39 |
40 | def test_meta_class_populate(self):
41 | # Create form from request
42 | request_factory = RequestFactory()
43 | request = request_factory.post(
44 | '/test/',
45 | data={
46 | 'name': 'Queen',
47 | 'formed': '1870',
48 | 'has_award': False
49 | },
50 | content_type='application/json'
51 | )
52 | form = BandForm.create_from_request(request)
53 | self.assertTrue(form.is_valid())
54 |
55 | # Populate form
56 | band = Band()
57 | form.populate(band)
58 |
59 | self.assertEqual(band.name, form.cleaned_data['name'])
60 | self.assertEqual(band.formed, 2000)
61 | self.assertEqual(band.has_award, True)
62 |
63 | def test_invalid_populate(self):
64 | # Create form from request
65 | with open(f"{settings.BASE_DIR}/data/valid.json") as f:
66 | payload = f.read()
67 | request_factory = RequestFactory()
68 | request = request_factory.post(
69 | '/test/',
70 | data=payload,
71 | content_type='application/json'
72 | )
73 | form = AlbumForm.create_from_request(request)
74 |
75 | album = Album()
76 | with self.assertRaisesMessage(ApiFormException, str('No clean data provided! Try to call is_valid() first.')):
77 | form.populate(album)
78 |
79 | def test_form_method_populate(self):
80 | class MyAlbumForm(Form):
81 | title = fields.CharField(max_length=100)
82 | year = fields.IntegerField()
83 | artist = FormField(form=ArtistForm)
84 | type = EnumField(enum=Album.AlbumType, required=True)
85 |
86 | def populate_year(self, obj, value: int) -> int:
87 | return 2020
88 |
89 | def populate_artist(self, obj, value: dict) -> Artist:
90 | artist = Artist()
91 |
92 | artist.name = value['name']
93 | artist.genres = value['genres']
94 | artist.members = value['members']
95 |
96 | obj.artist = artist
97 |
98 | return artist
99 |
100 | request_factory = RequestFactory()
101 | data = {
102 | 'title': 'Unknown Pleasures',
103 | 'year': 1979,
104 | 'artist': {
105 | "name": "Punk Pineapples",
106 | "genres": ["Punk", "Tropical Rock"],
107 | "members": 5
108 | },
109 | 'type': 'vinyl'
110 | }
111 |
112 | request = request_factory.post(
113 | '/test/',
114 | data=data,
115 | content_type='application/json'
116 | )
117 |
118 | my_model = Album()
119 | form = MyAlbumForm.create_from_request(request)
120 | self.assertTrue(form.is_valid())
121 |
122 | form.populate(my_model)
123 | self.assertIsInstance(my_model.artist, Artist)
124 | self.assertEqual(my_model.year, 2020)
125 | self.assertEqual(my_model.artist.name, 'Punk Pineapples')
126 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, override_settings
2 |
3 | from django_api_forms.settings import Settings, DEFAULTS
4 |
5 |
6 | class SettingsTests(TestCase):
7 | def test_invalid_attribute(self):
8 | settings = Settings()
9 | self.assertRaises(AttributeError, lambda: settings.INVALID_SETTING)
10 |
11 | @override_settings(DJANGO_API_FORMS_PARSERS={
12 | 'application/json': 'json.loads',
13 | 'application/x-msgpack': 'msgpack.unpackb',
14 | 'application/bson': 'bson.loads'
15 | })
16 | def test_extend_dict(self):
17 | settings = Settings()
18 | self.assertEqual(settings.PARSERS['application/x-msgpack'], 'msgpack.unpackb')
19 | self.assertEqual(settings.PARSERS['application/bson'], 'bson.loads')
20 |
21 | @override_settings(
22 | DJANGO_API_FORMS_DEFAULT_POPULATION_STRATEGY='django_api_forms.population_strategies.IgnoreStrategy'
23 | )
24 | def test_override_simple(self):
25 | settings = Settings()
26 | self.assertEqual(
27 | settings.DEFAULT_POPULATION_STRATEGY, 'django_api_forms.population_strategies.IgnoreStrategy'
28 | )
29 |
30 | def test_default(self):
31 | settings = Settings()
32 | self.assertEqual(settings.POPULATION_STRATEGIES, DEFAULTS['POPULATION_STRATEGIES'])
33 |
--------------------------------------------------------------------------------
/tests/test_validation.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.conf import settings
4 | from django.test import RequestFactory, TestCase
5 |
6 | from tests.testapp.forms import AlbumForm
7 | from tests.testapp.models import Album
8 |
9 |
10 | class ValidationTests(TestCase):
11 | def test_invalid(self):
12 | rf = RequestFactory()
13 |
14 | expected = {
15 | "errors": [
16 | {
17 | "code": "required",
18 | "message": "This field is required.",
19 | "path": [
20 | "songs",
21 | 0,
22 | "title"
23 | ]
24 | },
25 | {
26 | "code": "required",
27 | "message": "This field is required.",
28 | "path": [
29 | "songs",
30 | 0,
31 | "duration"
32 | ]
33 | },
34 | {
35 | "code": "required",
36 | "message": "This field is required.",
37 | "path": [
38 | "songs",
39 | 1,
40 | "title"
41 | ]
42 | },
43 | {
44 | "code": "invalid",
45 | "message": "Enter a valid date/time.",
46 | "path": [
47 | "metadata",
48 | "error_at"
49 | ]
50 | }
51 | ]
52 | }
53 |
54 | with open(f"{settings.BASE_DIR}/data/invalid.json") as f:
55 | request = rf.post('/foo/bar', data=f.read(), content_type='application/json')
56 |
57 | form = AlbumForm.create_from_request(request)
58 |
59 | self.assertFalse(form.is_valid())
60 | error = {
61 | 'errors': [item.to_dict() for item in form._errors]
62 | }
63 | self.assertEqual(error, expected)
64 |
65 | def test_valid(self):
66 | rf = RequestFactory()
67 | expected = {
68 | 'title': "Unknown Pleasures",
69 | 'year': 1979,
70 | 'type': Album.AlbumType.VINYL,
71 | 'artist': {
72 | 'name': "Joy Division",
73 | 'genres': ['rock', 'punk'],
74 | 'members': 4
75 | },
76 | 'songs': [
77 | {
78 | 'title': "Disorder",
79 | 'duration': datetime.timedelta(seconds=209)
80 | },
81 | {
82 | 'title': "Day of the Lords",
83 | 'duration': datetime.timedelta(seconds=288),
84 | 'metadata': {
85 | '_section': {
86 | "type": "ID3v2",
87 | "offset": 0,
88 | "byteLength": 2048
89 | },
90 | 'header': {
91 | "majorVersion": 3,
92 | "minorRevision": 0,
93 | "flagsOctet": 0,
94 | "unsynchronisationFlag": False,
95 | "extendedHeaderFlag": False,
96 | "experimentalIndicatorFlag": False,
97 | "size": 2038
98 | }
99 | }
100 | }
101 | ],
102 | 'metadata': {
103 | 'created_at': datetime.datetime.strptime('2019-10-21T18:57:03+0100', "%Y-%m-%dT%H:%M:%S%z"),
104 | 'updated_at': datetime.datetime.strptime('2019-10-21T18:57:03+0100', "%Y-%m-%dT%H:%M:%S%z"),
105 | }
106 | }
107 |
108 | with open(f"{settings.BASE_DIR}/data/valid.json") as f:
109 | request = rf.post('/foo/bar', data=f.read(), content_type='application/json')
110 |
111 | form = AlbumForm.create_from_request(request)
112 |
113 | self.assertTrue(form.is_valid())
114 | self.assertEqual(form.cleaned_data, expected)
115 |
116 | def test_default_clean(self):
117 | data = {
118 | "title": "Unknown Pleasures",
119 | "type": "vinyl",
120 | "artist": {
121 | "name": "Joy Division",
122 | "genres": [
123 | "rock",
124 | "punk"
125 | ],
126 | "members": 4
127 | },
128 | "year": 1998,
129 | "songs": [
130 | {
131 | "title": "Disorder",
132 | "duration": "3:29"
133 | },
134 | {
135 | "title": "Day of the Lords",
136 | "duration": "4:48",
137 | "metadata": {
138 | "_section": {
139 | "type": "ID3v2",
140 | "offset": 0,
141 | "byteLength": 2048
142 | },
143 | "header": {
144 | "majorVersion": 3,
145 | "minorRevision": 0,
146 | "flagsOctet": 0,
147 | "unsynchronisationFlag": False,
148 | "extendedHeaderFlag": False,
149 | "experimentalIndicatorFlag": False,
150 | "size": 2038
151 | }
152 | }
153 | }
154 | ],
155 | "metadata": {
156 | "created_at": "2019-10-21T18:57:03+0100",
157 | "updated_at": "2019-10-21T18:57:03+0100"
158 | }
159 | }
160 |
161 | rf = RequestFactory()
162 |
163 | expected = {
164 | "errors": [
165 | {
166 | "code": "time-traveling",
167 | "message": "Sounds like a bullshit",
168 | "path": [
169 | "$body"
170 | ]
171 | }
172 | ]
173 | }
174 |
175 | request = rf.post('/foo/bar', data=data, content_type='application/json')
176 |
177 | form = AlbumForm.create_from_request(request)
178 |
179 | self.assertFalse(form.is_valid())
180 | error = {
181 | 'errors': [item.to_dict() for item in form._errors]
182 | }
183 | self.assertEqual(error, expected)
184 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sibyx/django_api_forms/e27be3924cd1f8c86b0a1c9c3acfc3407148fdf6/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 | verbose_name = 'TestApp'
7 |
--------------------------------------------------------------------------------
/tests/testapp/forms.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ValidationError
2 | from django.forms import fields, ModelChoiceField
3 |
4 | from django_api_forms import Form, FieldList, AnyField, FormField, FormFieldList, EnumField, DictionaryField, \
5 | ModelForm, BooleanField
6 | from tests.testapp.models import Album, Artist
7 |
8 |
9 | class ArtistForm(Form):
10 | name = fields.CharField(required=True, max_length=100)
11 | genres = FieldList(field=fields.CharField(max_length=30))
12 | members = fields.IntegerField()
13 |
14 |
15 | class SongForm(Form):
16 | title = fields.CharField(required=True, max_length=100)
17 | duration = fields.DurationField(required=True)
18 | metadata = AnyField(required=False)
19 |
20 |
21 | class AlbumForm(Form):
22 | class Meta:
23 | field_strategy = {
24 | 'artist': 'tests.testapp.population_strategies.PopulateArtistStrategy'
25 | }
26 |
27 | title = fields.CharField(max_length=100)
28 | year = fields.IntegerField()
29 | artist = FormField(form=ArtistForm)
30 | songs = FormFieldList(form=SongForm)
31 | type = EnumField(enum=Album.AlbumType, required=True)
32 | metadata = DictionaryField(value_field=fields.DateTimeField())
33 |
34 | def clean_year(self):
35 | if self.cleaned_data['year'] == 1992:
36 | raise ValidationError("Year 1992 is forbidden!", 'forbidden-value')
37 | return self.cleaned_data['year']
38 |
39 | def clean(self):
40 | if (self.cleaned_data['year'] == 1998) and (self.cleaned_data['artist']['members'] == 4):
41 | raise ValidationError("Sounds like a bullshit", code='time-traveling')
42 | else:
43 | return self.cleaned_data
44 |
45 |
46 | class ArtistModelForm(ModelForm):
47 | class Meta:
48 | model = Artist
49 |
50 |
51 | class BandForm(Form):
52 | class Meta:
53 | field_type_strategy = {
54 | 'django_api_forms.fields.BooleanField': 'tests.testapp.population_strategies.BooleanField'
55 | }
56 |
57 | field_strategy = {
58 | 'formed': 'tests.testapp.population_strategies.FormedStrategy'
59 | }
60 |
61 | name = fields.CharField(max_length=100)
62 | formed = fields.IntegerField()
63 | has_award = BooleanField()
64 | emails = DictionaryField(value_field=fields.EmailField(max_length=14), required=False)
65 | albums = FormFieldList(form=AlbumForm, required=False)
66 |
67 |
68 | class ConcertForm(Form):
69 | class Meta:
70 | field_type_strategy = {
71 | 'django_api_forms.fields.BooleanField': 'tests.testapp.population_strategies.BooleanField'
72 | }
73 |
74 | field_strategy = {
75 | 'artist': 'tests.testapp.population_strategies.PopulateArtistStrategy',
76 | 'formed': 'tests.testapp.population_strategies.FormedStrategy'
77 | }
78 |
79 | place = fields.CharField(max_length=15)
80 | bands = FormFieldList(form=BandForm)
81 | organizer_id = ModelChoiceField(queryset=Artist.objects.all())
82 | emails = FieldList(fields.EmailField(max_length=14))
83 |
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class Artist(models.Model):
5 | class Meta:
6 | db_table = 'artists'
7 | app_label = 'testapp'
8 |
9 | name = models.CharField(max_length=100, unique=True)
10 | genres = models.JSONField()
11 | members = models.PositiveIntegerField()
12 |
13 |
14 | class Album(models.Model):
15 | class Meta:
16 | db_table = 'albums'
17 | app_label = 'testapp'
18 |
19 | class AlbumType(models.TextChoices):
20 | CD = 'cd', 'CD'
21 | VINYL = 'vinyl', 'Vinyl'
22 |
23 | title = models.CharField(max_length=100)
24 | year = models.PositiveIntegerField()
25 | artist = models.ForeignKey(Artist, on_delete=models.CASCADE)
26 | type = models.CharField(choices=AlbumType.choices, max_length=10)
27 | metadata = models.JSONField(null=True)
28 |
29 |
30 | class Song(models.Model):
31 | class Meta:
32 | db_table = 'songs'
33 | app_label = 'testapp'
34 |
35 | album = models.ForeignKey(Album, on_delete=models.CASCADE, related_name='songs')
36 | title = models.CharField(max_length=100)
37 | duration = models.DurationField(null=False)
38 | metadata = models.JSONField(null=False)
39 |
40 |
41 | class Band(models.Model):
42 | class Meta:
43 | db_table = 'bands'
44 | app_label = 'testapp'
45 |
46 | name = models.CharField(max_length=100)
47 | formed = models.PositiveIntegerField()
48 | has_award = models.BooleanField()
49 |
--------------------------------------------------------------------------------
/tests/testapp/population_strategies.py:
--------------------------------------------------------------------------------
1 | from django_api_forms.population_strategies import BaseStrategy
2 | from tests.testapp.models import Artist
3 |
4 |
5 | class BooleanField(BaseStrategy):
6 | def __call__(self, field, obj, key: str, value):
7 | if value is True:
8 | value = False
9 | else:
10 | value = True
11 |
12 | setattr(obj, key, value)
13 |
14 |
15 | class FormedStrategy(BaseStrategy):
16 | def __call__(self, field, obj, key: str, value):
17 | if value > 2021 or value < 1900:
18 | value = 2000
19 |
20 | setattr(obj, key, value)
21 |
22 |
23 | class PopulateArtistStrategy(BaseStrategy):
24 | def __call__(self, field, obj, key: str, value):
25 | artist = Artist(
26 | name=value.get('name'),
27 | genres=value.get('genres'),
28 | members=value.get('members')
29 | )
30 |
31 | setattr(obj, key, artist)
32 |
--------------------------------------------------------------------------------