├── .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 | [![PyPI version](https://badge.fury.io/py/django-api-forms.svg)](https://badge.fury.io/py/django-api-forms) 4 | [![codecov](https://codecov.io/gh/Sibyx/django_api_forms/branch/master/graph/badge.svg)](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 | --------------------------------------------------------------------------------