├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── AUTHORS.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── djangorestframework_camel_case ├── __init__.py ├── middleware.py ├── parser.py ├── render.py ├── settings.py └── util.py ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11","3.12"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3.5.3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4.7.0 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip setuptools 27 | pip install tox tox-gh-actions 28 | - name: Test with tox 29 | run: tox 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Hidden files 2 | .* 3 | 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | .venv 20 | develop-eggs 21 | .installed.cfg 22 | lib 23 | lib64 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | 41 | # Complexity 42 | output/*.html 43 | output/*/index.html 44 | 45 | # Sphinx 46 | docs/_build 47 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Vitaly Babiy 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ======= 5 | 6 | 1.4.2 (2023-02-13) 7 | ------------------ 8 | - Middleware to underscorize query params #123 9 | 10 | 1.4.1 (2023-02-13) 11 | ------------------ 12 | - ORJSONRenderer #124 13 | 14 | 1.4.0 (2023-02-09) 15 | ------------------ 16 | - Merge pull request #110 17 | - Merge pull request #119 18 | - Merge pull request #122 19 | - Merge pull request #93 20 | 21 | 22 | 1.3.0 (2021-11-14) 23 | ------------------ 24 | - Merge pull request #104 25 | - Merge pull request #99 26 | - Merge pull request #100 27 | - Merge pull request #90 28 | - Merge pull request #92 29 | 30 | 31 | - added ignore_keys 32 | 33 | 1.2.0 (2020-06-16) 34 | ------------------ 35 | 36 | - added ignore_fields 37 | - Merge pull request #88 38 | - Merge pull request #84 39 | - Merge pull request #77 40 | - Merge pull request #73 41 | 42 | 1.1.2 (2019-10-22) 43 | ------------------ 44 | 45 | - Merge pull request #63 46 | - Merge pull request #70 47 | - Merge pull request #71 48 | 49 | 1.1.1 (2019-09-09) 50 | ------------------ 51 | 52 | - Add json_underscoreize as CamelCaseJSONParser class attribute #44 53 | 54 | 1.1.0 (2019-09-09) 55 | ------------------ 56 | 57 | Long awaited stable release: 58 | 59 | Changes can be viewed: 60 | https://github.com/vbabiy/djangorestframework-camel-case/compare/e6db468...39ae6bb 61 | 62 | 0.1.0 (2013-12-20) 63 | ------------------ 64 | 65 | * First release on PyPI. 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Vitaly Babiy 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of Django REST Framework JSON CamelCase nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include HISTORY.rst 3 | include LICENSE 4 | include README.rst 5 | include requirements.txt -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests quickly with the default Python" 8 | @echo "testall - run tests on every Python version with tox" 9 | @echo "coverage - check code coverage quickly with the default Python" 10 | @echo "release - package and upload a release" 11 | @echo "sdist - package" 12 | 13 | clean: clean-build clean-pyc 14 | 15 | clean-build: 16 | rm -fr build/ 17 | rm -fr dist/ 18 | rm -fr *.egg-info 19 | 20 | clean-pyc: 21 | find . -name '*.pyc' -exec rm -f {} + 22 | find . -name '*.pyo' -exec rm -f {} + 23 | find . -name '*~' -exec rm -f {} + 24 | 25 | lint: 26 | flake8 djangorestframework_camel_case tests 27 | 28 | test: 29 | python setup.py test 30 | 31 | test-all: 32 | tox 33 | 34 | coverage: 35 | coverage run --source djangorestframework_camel_case setup.py test 36 | coverage report -m 37 | coverage html 38 | open htmlcov/index.html 39 | 40 | release: clean 41 | python setup.py sdist upload 42 | 43 | sdist: clean 44 | python setup.py sdist 45 | ls -l dist -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Django REST Framework JSON CamelCase 3 | ==================================== 4 | 5 | .. image:: https://travis-ci.org/vbabiy/djangorestframework-camel-case.svg?branch=master 6 | :target: https://travis-ci.org/vbabiy/djangorestframework-camel-case 7 | 8 | .. image:: https://badge.fury.io/py/djangorestframework-camel-case.svg 9 | :target: https://badge.fury.io/py/djangorestframework-camel-case 10 | 11 | Camel case JSON support for Django REST framework. 12 | 13 | ============ 14 | Installation 15 | ============ 16 | 17 | At the command line:: 18 | 19 | $ pip install djangorestframework-camel-case 20 | 21 | Add the render and parser to your django settings file. 22 | 23 | .. code-block:: python 24 | 25 | # ... 26 | REST_FRAMEWORK = { 27 | 28 | 'DEFAULT_RENDERER_CLASSES': ( 29 | 'djangorestframework_camel_case.render.CamelCaseJSONRenderer', 30 | 'djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer', 31 | # Any other renders 32 | ), 33 | 34 | 'DEFAULT_PARSER_CLASSES': ( 35 | # If you use MultiPartFormParser or FormParser, we also have a camel case version 36 | 'djangorestframework_camel_case.parser.CamelCaseFormParser', 37 | 'djangorestframework_camel_case.parser.CamelCaseMultiPartParser', 38 | 'djangorestframework_camel_case.parser.CamelCaseJSONParser', 39 | # Any other parsers 40 | ), 41 | } 42 | # ... 43 | 44 | Add query param middleware to django settings file. 45 | 46 | .. code-block:: python 47 | 48 | # ... 49 | MIDDLEWARE = [ 50 | # Any other middleware 51 | 'djangorestframework_camel_case.middleware.CamelCaseMiddleWare', 52 | ] 53 | # ... 54 | 55 | ================= 56 | Swapping Renderer 57 | ================= 58 | 59 | By default the package uses `rest_framework.renderers.JSONRenderer`. If you want 60 | to use another renderer, the two possible are: 61 | 62 | `drf_orjson_renderer.renderers.ORJSONRenderer` or 63 | `drf_ujson.renderers.UJSONRenderer` or 64 | `rest_framework.renderers.UnicodeJSONRenderer` for DRF < 3.0,specify it in your django 65 | settings file. 66 | settings file. 67 | 68 | .. code-block:: python 69 | 70 | # ... 71 | JSON_CAMEL_CASE = { 72 | 'RENDERER_CLASS': 'drf_orjson_renderer.renderers.ORJSONRenderer' 73 | } 74 | # ... 75 | 76 | ===================== 77 | Underscoreize Options 78 | ===================== 79 | 80 | 81 | **No Underscore Before Number** 82 | 83 | 84 | As raised in `this comment `_ 85 | there are two conventions of snake case. 86 | 87 | .. code-block:: text 88 | 89 | # Case 1 (Package default) 90 | v2Counter -> v_2_counter 91 | fooBar2 -> foo_bar_2 92 | 93 | # Case 2 94 | v2Counter -> v2_counter 95 | fooBar2 -> foo_bar2 96 | 97 | 98 | By default, the package uses the first case. To use the second case, specify it in your django settings file. 99 | 100 | .. code-block:: python 101 | 102 | REST_FRAMEWORK = { 103 | # ... 104 | 'JSON_UNDERSCOREIZE': { 105 | 'no_underscore_before_number': True, 106 | }, 107 | # ... 108 | } 109 | 110 | Alternatively, you can change this behavior on a class level by setting `json_underscoreize`: 111 | 112 | .. code-block:: python 113 | 114 | from djangorestframework_camel_case.parser import CamelCaseJSONParser 115 | from rest_framework.generics import CreateAPIView 116 | 117 | class NoUnderscoreBeforeNumberCamelCaseJSONParser(CamelCaseJSONParser): 118 | json_underscoreize = {'no_underscore_before_number': True} 119 | 120 | class MyView(CreateAPIView): 121 | queryset = MyModel.objects.all() 122 | serializer_class = MySerializer 123 | parser_classes = (NoUnderscoreBeforeNumberCamelCaseJSONParser,) 124 | 125 | ============= 126 | Ignore Fields 127 | ============= 128 | 129 | You can also specify fields which should not have their data changed. 130 | The specified field(s) would still have their name change, but there would be no recursion. 131 | For example: 132 | 133 | .. code-block:: python 134 | 135 | data = {"my_key": {"do_not_change": 1}} 136 | 137 | Would become: 138 | 139 | .. code-block:: python 140 | 141 | {"myKey": {"doNotChange": 1}} 142 | 143 | However, if you set in your settings: 144 | 145 | .. code-block:: python 146 | 147 | REST_FRAMEWORK = { 148 | # ... 149 | "JSON_UNDERSCOREIZE": { 150 | # ... 151 | "ignore_fields": ("my_key",), 152 | # ... 153 | }, 154 | # ... 155 | } 156 | 157 | The `my_key` field would not have its data changed: 158 | 159 | .. code-block:: python 160 | 161 | {"myKey": {"do_not_change": 1}} 162 | 163 | =========== 164 | Ignore Keys 165 | =========== 166 | 167 | You can also specify keys which should *not* be renamed. 168 | The specified field(s) would still change (even recursively). 169 | For example: 170 | 171 | .. code-block:: python 172 | 173 | data = {"unchanging_key": {"change_me": 1}} 174 | 175 | Would become: 176 | 177 | .. code-block:: python 178 | 179 | {"unchangingKey": {"changeMe": 1}} 180 | 181 | However, if you set in your settings: 182 | 183 | .. code-block:: python 184 | 185 | REST_FRAMEWORK = { 186 | # ... 187 | "JSON_UNDERSCOREIZE": { 188 | # ... 189 | "ignore_keys": ("unchanging_key",), 190 | # ... 191 | }, 192 | # ... 193 | } 194 | 195 | The `unchanging_key` field would not be renamed: 196 | 197 | .. code-block:: python 198 | 199 | {"unchanging_key": {"changeMe": 1}} 200 | 201 | ignore_keys and ignore_fields can be applied to the same key if required. 202 | 203 | ============= 204 | Running Tests 205 | ============= 206 | 207 | To run the current test suite, execute the following from the root of he project:: 208 | 209 | $ python -m unittest discover 210 | 211 | 212 | ======= 213 | License 214 | ======= 215 | 216 | * Free software: BSD license 217 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vitaly Babiy" 2 | __email__ = "vbabiy86@gmail.com" 3 | __version__ = "1.4.2" 4 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/middleware.py: -------------------------------------------------------------------------------- 1 | from djangorestframework_camel_case.settings import api_settings 2 | from djangorestframework_camel_case.util import underscoreize 3 | 4 | class CamelCaseMiddleWare: 5 | 6 | def __init__(self, get_response): 7 | self.get_response = get_response 8 | 9 | def __call__(self, request): 10 | request.GET = underscoreize( 11 | request.GET, 12 | **api_settings.JSON_UNDERSCOREIZE 13 | ) 14 | 15 | response = self.get_response(request) 16 | return response 17 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.conf import settings 4 | from django.http.multipartparser import ( 5 | MultiPartParser as DjangoMultiPartParser, 6 | MultiPartParserError, 7 | ) 8 | from rest_framework.exceptions import ParseError 9 | from rest_framework.parsers import MultiPartParser, DataAndFiles 10 | from rest_framework.parsers import FormParser 11 | 12 | from djangorestframework_camel_case.settings import api_settings 13 | from djangorestframework_camel_case.util import underscoreize 14 | 15 | 16 | class CamelCaseJSONParser(api_settings.PARSER_CLASS): 17 | json_underscoreize = api_settings.JSON_UNDERSCOREIZE 18 | 19 | def parse(self, stream, media_type=None, parser_context=None): 20 | parser_context = parser_context or {} 21 | encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) 22 | 23 | try: 24 | data = stream.read().decode(encoding) 25 | return underscoreize(json.loads(data), **self.json_underscoreize) 26 | except ValueError as exc: 27 | raise ParseError("JSON parse error - %s" % str(exc)) 28 | 29 | 30 | class CamelCaseFormParser(FormParser): 31 | """ 32 | Parser for form data. 33 | """ 34 | 35 | def parse(self, stream, media_type=None, parser_context=None): 36 | return underscoreize( 37 | super().parse(stream, media_type, parser_context), 38 | **api_settings.JSON_UNDERSCOREIZE, 39 | ) 40 | 41 | 42 | class CamelCaseMultiPartParser(MultiPartParser): 43 | """ 44 | Parser for multipart form data, which may include file data. 45 | """ 46 | 47 | media_type = "multipart/form-data" 48 | 49 | def parse(self, stream, media_type=None, parser_context=None): 50 | """ 51 | Parses the incoming bytestream as a multipart encoded form, 52 | and returns a DataAndFiles object. 53 | 54 | `.data` will be a `QueryDict` containing all the form parameters. 55 | `.files` will be a `QueryDict` containing all the form files. 56 | """ 57 | parser_context = parser_context or {} 58 | request = parser_context["request"] 59 | encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) 60 | meta = request.META.copy() 61 | meta["CONTENT_TYPE"] = media_type 62 | upload_handlers = request.upload_handlers 63 | 64 | try: 65 | parser = DjangoMultiPartParser(meta, stream, upload_handlers, encoding) 66 | data, files = parser.parse() 67 | return DataAndFiles( 68 | underscoreize(data, **api_settings.JSON_UNDERSCOREIZE), 69 | underscoreize(files, **api_settings.JSON_UNDERSCOREIZE), 70 | ) 71 | except MultiPartParserError as exc: 72 | raise ParseError("Multipart form parse error - %s" % str(exc)) 73 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/render.py: -------------------------------------------------------------------------------- 1 | from rest_framework.renderers import BrowsableAPIRenderer 2 | 3 | from djangorestframework_camel_case.settings import api_settings 4 | from djangorestframework_camel_case.util import camelize 5 | 6 | 7 | class CamelCaseJSONRenderer(api_settings.RENDERER_CLASS): 8 | json_underscoreize = api_settings.JSON_UNDERSCOREIZE 9 | 10 | def render(self, data, *args, **kwargs): 11 | return super().render( 12 | camelize(data, **self.json_underscoreize), *args, **kwargs 13 | ) 14 | 15 | 16 | class CamelCaseBrowsableAPIRenderer(BrowsableAPIRenderer): 17 | def render(self, data, *args, **kwargs): 18 | return super(CamelCaseBrowsableAPIRenderer, self).render( 19 | camelize(data, **api_settings.JSON_UNDERSCOREIZE), *args, **kwargs 20 | ) 21 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from rest_framework.settings import APISettings 5 | 6 | USER_SETTINGS = getattr(settings, "JSON_CAMEL_CASE", {}) 7 | 8 | DEFAULTS = { 9 | "RENDERER_CLASS": "rest_framework.renderers.JSONRenderer", 10 | "PARSER_CLASS": "rest_framework.parsers.JSONParser", 11 | "JSON_UNDERSCOREIZE": {"no_underscore_before_number": False, "ignore_fields": None, "ignore_keys": None}, 12 | } 13 | 14 | # List of settings that may be in string import notation. 15 | IMPORT_STRINGS = ("RENDERER_CLASS", "PARSER_CLASS") 16 | 17 | VALID_SETTINGS = { 18 | "RENDERER_CLASS": ( 19 | "rest_framework.renderers.JSONRenderer", 20 | "drf_orjson_renderer.renderers.ORJSONRenderer", 21 | "drf_ujson.renderers.UJSONRenderer", 22 | "rest_framework.renderers.UnicodeJSONRenderer", 23 | ), 24 | "PARSER_CLASS": ("rest_framework.parsers.JSONParser",), 25 | } 26 | 27 | 28 | def validate_settings(input_settings, valid_settings): 29 | for setting_name, valid_values in valid_settings.items(): 30 | input_setting = input_settings.get(setting_name) 31 | if input_setting and input_setting not in valid_values: 32 | raise ImproperlyConfigured(setting_name) 33 | 34 | 35 | validate_settings(USER_SETTINGS, VALID_SETTINGS) 36 | 37 | api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) 38 | -------------------------------------------------------------------------------- /djangorestframework_camel_case/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | 4 | from django.core.files import File 5 | from django.http import QueryDict 6 | from django.utils.datastructures import MultiValueDict 7 | from django.utils.encoding import force_str 8 | from django.utils.functional import Promise 9 | 10 | from rest_framework.utils.serializer_helpers import ReturnDict 11 | 12 | camelize_re = re.compile(r"[a-z0-9]?_[a-z0-9]") 13 | 14 | 15 | def underscore_to_camel(match): 16 | group = match.group() 17 | if len(group) == 3: 18 | return group[0] + group[2].upper() 19 | else: 20 | return group[1].upper() 21 | 22 | 23 | def camelize(data, **options): 24 | # Handle lazy translated strings. 25 | ignore_fields = options.get("ignore_fields") or () 26 | ignore_keys = options.get("ignore_keys") or () 27 | if isinstance(data, Promise): 28 | data = force_str(data) 29 | if isinstance(data, dict): 30 | if isinstance(data, ReturnDict): 31 | new_dict = ReturnDict(serializer=data.serializer) 32 | else: 33 | new_dict = OrderedDict() 34 | for key, value in data.items(): 35 | if isinstance(key, Promise): 36 | key = force_str(key) 37 | if isinstance(key, str) and "_" in key: 38 | new_key = re.sub(camelize_re, underscore_to_camel, key) 39 | else: 40 | new_key = key 41 | 42 | if key not in ignore_fields and new_key not in ignore_fields: 43 | result = camelize(value, **options) 44 | else: 45 | result = value 46 | if key in ignore_keys or new_key in ignore_keys: 47 | new_dict[key] = result 48 | else: 49 | new_dict[new_key] = result 50 | return new_dict 51 | if is_iterable(data) and not isinstance(data, str): 52 | return [camelize(item, **options) for item in data] 53 | return data 54 | 55 | 56 | def get_underscoreize_re(options): 57 | if options.get("no_underscore_before_number"): 58 | pattern = r"([a-z0-9]|[A-Z]?(?=[A-Z](?=[a-z])))([A-Z])" 59 | else: 60 | pattern = r"([a-z0-9]|[A-Z]?(?=[A-Z0-9](?=[a-z0-9]|(?=3.13.0 2 | django>=3.2 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | from setuptools import setup 9 | 10 | if sys.argv[-1] == "publish": 11 | os.system("python setup.py sdist upload") 12 | sys.exit() 13 | 14 | readme = open("README.rst").read() 15 | history = open("HISTORY.rst").read().replace(".. :changelog:", "") 16 | import djangorestframework_camel_case 17 | 18 | def extract_requires(): 19 | with Path('requirements.txt').open() as reqs: 20 | return [req.strip() for req in reqs if not req.startswith(("#", "--", "-r")) and req.strip()] 21 | 22 | setup( 23 | name="djangorestframework-camel-case", 24 | version=djangorestframework_camel_case.__version__, 25 | description="Camel case JSON support for Django REST framework.", 26 | long_description=readme + "\n\n" + history, 27 | long_description_content_type="text/x-rst", 28 | author="Vitaly Babiy", 29 | author_email="vbabiy86@gmail.com", 30 | url="https://github.com/vbabiy/djangorestframework-camel-case", 31 | packages=["djangorestframework_camel_case"], 32 | package_dir={"djangorestframework_camel_case": "djangorestframework_camel_case"}, 33 | include_package_data=True, 34 | python_requires=">=3.7", 35 | install_requires=extract_requires(), 36 | license="BSD", 37 | zip_safe=False, 38 | keywords="djangorestframework_camel_case", 39 | classifiers=[ 40 | "Development Status :: 2 - Pre-Alpha", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: BSD License", 43 | "Natural Language :: English", 44 | "Programming Language :: Python :: 3", 45 | "Programming Language :: Python :: 3.7", 46 | "Programming Language :: Python :: 3.8", 47 | "Programming Language :: Python :: 3.9", 48 | "Programming Language :: Python :: 3.10", 49 | "Programming Language :: Python :: 3.11", 50 | "Programming Language :: Python :: 3.12", 51 | ], 52 | test_suite="tests", 53 | ) 54 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from unittest import TestCase, mock 3 | 4 | from django.conf import settings 5 | from django.http import QueryDict 6 | from django.utils.functional import lazy 7 | from django.test.client import RequestFactory 8 | from rest_framework.utils.serializer_helpers import ReturnDict 9 | 10 | from djangorestframework_camel_case.util import camelize, underscoreize 11 | 12 | settings.configure() 13 | 14 | from djangorestframework_camel_case.middleware import CamelCaseMiddleWare 15 | 16 | class ImportTest(TestCase): 17 | def test_import_all(self): 18 | """ 19 | A quick test that just imports everything, should crash in case any Django or DRF modules change 20 | """ 21 | from djangorestframework_camel_case import parser 22 | from djangorestframework_camel_case import render 23 | from djangorestframework_camel_case import settings 24 | from djangorestframework_camel_case import middleware 25 | 26 | assert parser 27 | assert render 28 | assert settings 29 | assert middleware 30 | 31 | 32 | class UnderscoreToCamelTestCase(TestCase): 33 | def test_under_to_camel_keys(self): 34 | data = { 35 | "two_word": 1, 36 | "long_key_with_many_underscores": 2, 37 | "only_1_key": 3, 38 | "only_one_letter_a": 4, 39 | "b_only_one_letter": 5, 40 | "only_c_letter": 6, 41 | "mix_123123a_and_letters": 7, 42 | "mix_123123aa_and_letters_complex": 8, 43 | "no_underscore_before123": 9, 44 | } 45 | output = { 46 | "twoWord": 1, 47 | "longKeyWithManyUnderscores": 2, 48 | "only1Key": 3, 49 | "onlyOneLetterA": 4, 50 | "bOnlyOneLetter": 5, 51 | "onlyCLetter": 6, 52 | "mix123123aAndLetters": 7, 53 | "mix123123aaAndLettersComplex": 8, 54 | "noUnderscoreBefore123": 9, 55 | } 56 | self.assertEqual(camelize(data), output) 57 | 58 | def test_tuples(self): 59 | data = {"multiple_values": (1, 2), "data": [1, 3, 4]} 60 | output = {"multipleValues": [1, 2], "data": [1, 3, 4]} 61 | self.assertEqual(camelize(data), output) 62 | 63 | def test_camel_to_under_input_untouched_for_sequence(self): 64 | data = [{"firstInput": 1}, {"secondInput": 2}] 65 | reference_input = deepcopy(data) 66 | camelize(data) 67 | self.assertEqual(data, reference_input) 68 | 69 | def test_recursive_camelize_with_ignored_fields_and_keys(self): 70 | ignore_fields = ("ignored_field", "newKeyIgnoredField", "ignored_field_and_key", "ignoredFieldAndKey2") 71 | ignore_keys = ("ignored_key", "newKeyIgnoredKey", "ignored_field_and_key", "ignoredFieldAndKey2") 72 | data = { 73 | "ignored_field": {"no_change_recursive": 1}, 74 | "change_me": {"change_recursive": 2}, 75 | "new_key_ignored_field": {"also_no_change": 3}, 76 | "ignored_key": {"also_change_recursive": 4}, 77 | "new_key_ignored_key": {"change_is_here_to_stay": 5}, 78 | "ignored_field_and_key": {"no_change_here": 6}, 79 | "ignored_field_and_key2": {"no_change_here_either": 7}, 80 | } 81 | output = { 82 | "ignoredField": {"no_change_recursive": 1}, 83 | "changeMe": {"changeRecursive": 2}, 84 | "newKeyIgnoredField": {"also_no_change": 3}, 85 | "ignored_key": {"alsoChangeRecursive": 4}, 86 | "new_key_ignored_key": {"changeIsHereToStay": 5}, 87 | "ignored_field_and_key": {"no_change_here": 6}, 88 | "ignored_field_and_key2": {"no_change_here_either": 7}, 89 | } 90 | self.assertEqual(camelize(data, ignore_fields=ignore_fields, ignore_keys=ignore_keys), output) 91 | 92 | 93 | class CamelToUnderscoreTestCase(TestCase): 94 | def test_camel_to_under_keys(self): 95 | data = { 96 | "twoWord": 1, 97 | "longKeyWithManyUnderscores": 2, 98 | "only1Key": 3, 99 | "onlyOneLetterA": 4, 100 | "bOnlyOneLetter": 5, 101 | "onlyCLetter": 6, 102 | "mix123123aAndLetters": 7, 103 | "mix123123aaAndLettersComplex": 8, 104 | "wordWITHCaps": 9, 105 | "key10": 10, 106 | "anotherKey1": 11, 107 | "anotherKey10": 12, 108 | "optionS1": 13, 109 | "optionS10": 14, 110 | "UPPERCASE": 15, 111 | "PascalCase": 16, 112 | "Pascal10Case": 17, 113 | } 114 | output = { 115 | "two_word": 1, 116 | "long_key_with_many_underscores": 2, 117 | "only_1_key": 3, 118 | "only_one_letter_a": 4, 119 | "b_only_one_letter": 5, 120 | "only_c_letter": 6, 121 | "mix_123123a_and_letters": 7, 122 | "mix_123123aa_and_letters_complex": 8, 123 | "word_with_caps": 9, 124 | "key_10": 10, 125 | "another_key_1": 11, 126 | "another_key_10": 12, 127 | "option_s_1": 13, 128 | "option_s_10": 14, 129 | "uppercase": 15, 130 | "pascal_case": 16, 131 | "pascal_10_case": 17, 132 | } 133 | self.assertEqual(underscoreize(data), output) 134 | 135 | def test_camel_to_under_keys_with_no_underscore_before_number(self): 136 | data = {"noUnderscoreBefore123": 1} 137 | output = {"no_underscore_before123": 1} 138 | options = {"no_underscore_before_number": True} 139 | self.assertEqual(underscoreize(data, **options), output) 140 | 141 | def test_under_to_camel_input_untouched_for_sequence(self): 142 | data = [{"first_input": 1}, {"second_input": 2}] 143 | reference_input = deepcopy(data) 144 | underscoreize(data) 145 | self.assertEqual(data, reference_input) 146 | 147 | def test_recursive_underscoreize_with_ignored_fields_and_keys(self): 148 | ignore_fields = ("ignoredField", "new_key_ignored_field", "ignoredFieldAndKey", "ignored_field_and_key_2") 149 | ignore_keys = ("ignoredKey", "new_key_ignored_key", "ignoredFieldAndKey", "ignored_field_and_key_2") 150 | data = { 151 | "ignoredField": {"noChangeRecursive": 1}, 152 | "changeMeField": {"changeRecursive": 2}, 153 | "newKeyIgnoredField": {"alsoNoChange": 3}, 154 | "ignoredKey": {"changeRecursiveAgain": 4}, 155 | "newKeyIgnoredKey": {"changeIsHereToStay": 5}, 156 | "ignoredFieldAndKey": {"noChangeHere": 6}, 157 | "ignoredFieldAndKey2": {"noChangeHereEither": 7}, 158 | } 159 | output = { 160 | "ignored_field": {"noChangeRecursive": 1}, 161 | "change_me_field": {"change_recursive": 2}, 162 | "new_key_ignored_field": {"alsoNoChange": 3}, 163 | "ignoredKey": {"change_recursive_again": 4}, 164 | "newKeyIgnoredKey": {"change_is_here_to_stay": 5}, 165 | "ignoredFieldAndKey": {"noChangeHere": 6}, 166 | "ignoredFieldAndKey2": {"noChangeHereEither": 7}, 167 | } 168 | self.assertEqual(underscoreize(data, ignore_fields=ignore_fields, ignore_keys=ignore_keys), output) 169 | 170 | 171 | class NonStringKeyTest(TestCase): 172 | def test_non_string_key(self): 173 | data = {1: "test"} 174 | self.assertEqual(underscoreize(camelize(data)), data) 175 | 176 | 177 | def return_string(text): 178 | return text 179 | 180 | 181 | lazy_func = lazy(return_string, str) 182 | 183 | 184 | class PromiseStringTest(TestCase): 185 | def test_promise_strings(self): 186 | data = {lazy_func("test_key"): lazy_func("test_value value")} 187 | camelized = camelize(data) 188 | self.assertEqual(camelized, {"testKey": "test_value value"}) 189 | result = underscoreize(camelized) 190 | self.assertEqual(result, {"test_key": "test_value value"}) 191 | 192 | 193 | class ReturnDictTest(TestCase): 194 | def test_return_dict(self): 195 | data = ReturnDict({"id": 3, "value": "val"}, serializer=object()) 196 | camelized = camelize(data) 197 | self.assertEqual(data, camelized) 198 | self.assertEqual(data.serializer, camelized.serializer) 199 | 200 | 201 | class NumberToCamelTestCase(TestCase): 202 | def test_dict_with_numbers_as_keys(self): 203 | data = {1: 'test', 'a': 'abc'} 204 | self.assertEqual(camelize(data), data) 205 | 206 | 207 | class GeneratorAsInputTestCase(TestCase): 208 | def _underscore_generator(self): 209 | yield {"simple_is_better": "than complex"} 210 | yield {"that_is": "correct"} 211 | 212 | def _camel_generator(self): 213 | yield {"simpleIsBetter": "than complex"} 214 | yield {"thatIs": "correct"} 215 | 216 | def test_camelize_iterates_over_generator(self): 217 | data = self._underscore_generator() 218 | output = [{"simpleIsBetter": "than complex"}, {"thatIs": "correct"}] 219 | self.assertEqual(camelize(data), output) 220 | 221 | def test_underscoreize_iterates_over_generator(self): 222 | data = self._camel_generator() 223 | output = [{"simple_is_better": "than complex"}, {"that_is": "correct"}] 224 | self.assertEqual(underscoreize(data), output) 225 | 226 | 227 | class CamelToUnderscoreQueryDictTestCase(TestCase): 228 | def test_camel_to_under_keys(self): 229 | query_dict = QueryDict("testList=1&testList=2", mutable=True) 230 | data = { 231 | "twoWord": 1, 232 | "longKeyWithManyUnderscores": 2, 233 | "only1Key": 3, 234 | "onlyOneLetterA": 4, 235 | "bOnlyOneLetter": 5, 236 | "onlyCLetter": 6, 237 | "mix123123aAndLetters": 7, 238 | "mix123123aaAndLettersComplex": 8, 239 | "wordWITHCaps": 9, 240 | "key10": 10, 241 | "anotherKey1": 11, 242 | "anotherKey10": 12, 243 | "optionS1": 13, 244 | "optionS10": 14, 245 | "UPPERCASE": 15, 246 | "PascalCase": 16, 247 | "Pascal10Case": 17, 248 | } 249 | query_dict.update(data) 250 | 251 | output_query = QueryDict("test_list=1&test_list=2", mutable=True) 252 | 253 | output = { 254 | "two_word": 1, 255 | "long_key_with_many_underscores": 2, 256 | "only_1_key": 3, 257 | "only_one_letter_a": 4, 258 | "b_only_one_letter": 5, 259 | "only_c_letter": 6, 260 | "mix_123123a_and_letters": 7, 261 | "mix_123123aa_and_letters_complex": 8, 262 | "word_with_caps": 9, 263 | "key_10": 10, 264 | "another_key_1": 11, 265 | "another_key_10": 12, 266 | "option_s_1": 13, 267 | "option_s_10": 14, 268 | "uppercase": 15, 269 | "pascal_case": 16, 270 | "pascal_10_case": 17, 271 | } 272 | output_query.update(output) 273 | self.assertEqual(underscoreize(query_dict), output_query) 274 | 275 | class CamelCaseMiddleWareTestCase(TestCase): 276 | def test_camel_case_to_underscore_query_params(self): 277 | get_response_mock = mock.MagicMock() 278 | middleware = CamelCaseMiddleWare(get_response_mock) 279 | query_dict = QueryDict("testList=1&testList=2", mutable=True) 280 | data = { 281 | "twoWord": "1", 282 | "longKeyWithManyUnderscores": "2", 283 | "only1Key": "3", 284 | "onlyOneLetterA": "4", 285 | "bOnlyOneLetter": "5", 286 | "onlyCLetter": "6", 287 | "mix123123aAndLetters": "7", 288 | "mix123123aaAndLettersComplex": "8", 289 | "wordWITHCaps": "9", 290 | "key10": "10", 291 | "anotherKey1": "11", 292 | "anotherKey10": "12", 293 | "optionS1": "13", 294 | "optionS10": "14", 295 | "UPPERCASE": "15", 296 | "PascalCase": "16", 297 | "Pascal10Case": "17", 298 | } 299 | query_dict.update(data) 300 | 301 | output_query = QueryDict("test_list=1&test_list=2", mutable=True) 302 | 303 | output = { 304 | "two_word": "1", 305 | "long_key_with_many_underscores": "2", 306 | "only_1_key": "3", 307 | "only_one_letter_a": "4", 308 | "b_only_one_letter": "5", 309 | "only_c_letter": "6", 310 | "mix_123123a_and_letters": "7", 311 | "mix_123123aa_and_letters_complex": "8", 312 | "word_with_caps": "9", 313 | "key_10": "10", 314 | "another_key_1": "11", 315 | "another_key_10": "12", 316 | "option_s_1": "13", 317 | "option_s_10": "14", 318 | "uppercase": "15", 319 | "pascal_case": "16", 320 | "pascal_10_case": "17", 321 | } 322 | output_query.update(output) 323 | request = RequestFactory().get("/", query_dict) 324 | 325 | middleware(request) 326 | (args, kwargs) = get_response_mock.call_args 327 | self.assertEqual(args[0].GET, output_query) 328 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, py39, py310, py311, py312 3 | 4 | [gh-actions] 5 | python = 6 | 3.7: py37 7 | 3.8: py38 8 | 3.9: py39 9 | 3.10: py310 10 | 3.11: py311 11 | 3.12: py312 12 | 13 | [testenv] 14 | setenv = 15 | PYTHONPATH = {toxinidir}:{toxinidir}/djangorestframework-camel-case 16 | commands = python setup.py test 17 | deps = 18 | -r{toxinidir}/requirements.txt 19 | setuptools 20 | --------------------------------------------------------------------------------