├── .gitignore ├── LICENSE ├── README.md ├── drf_typescript_generator ├── __init__.py ├── globals.py ├── management │ └── commands │ │ └── generate_types.py └── utils.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── settings.py ├── test_drf_typescript_generator.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Django ### 2 | *.log 3 | *.pot 4 | *.pyc 5 | __pycache__/ 6 | local_settings.py 7 | db.sqlite3 8 | db.sqlite3-journal 9 | media 10 | 11 | ### Django.Python Stack ### 12 | # Byte-compiled / optimized / DLL files 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | pytestdebug.log 62 | 63 | # Translations 64 | *.mo 65 | 66 | # Django stuff: 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | doc/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # Celery stuff 93 | celerybeat-schedule 94 | celerybeat.pid 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | pythonenv* 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # pytype static type analyzer 128 | .pytype/ 129 | 130 | # profiling data 131 | .prof 132 | 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Remastr Solutions s.r.o and individual contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DRF Typescript generator 2 | 3 | This package allows you to generate typescript types / interfaces for Django REST framework 4 | serializers, which can be then simply used in frontend applications. 5 | 6 | ## Setup 7 | 8 | Install the package with your preferred dependency management tool: 9 | 10 | ```console 11 | $ poetry add drf-typescript-generator 12 | ``` 13 | 14 | Add `drf_typescript_generator` to `INSTALLED_APPS` in your django settings.py file: 15 | 16 | 17 | ```python 18 | INSTALLED_APPS = [ 19 | ... 20 | 'drf_typescript_generator', 21 | ... 22 | ] 23 | ``` 24 | 25 | ## Usage 26 | 27 | To generate types run django management command `generate_types` with the names of django apps 28 | you want the script to look for serializers in: 29 | 30 | ```console 31 | $ python manage.py generate_types my_app 32 | ``` 33 | 34 | Example serializer found in *my_app*: 35 | 36 | ```python 37 | class MySerializer(serializers.Serializer): 38 | some_string = serializers.CharField(max_length=100) 39 | some_number = serializers.IntegerField() 40 | some_boolean = serializers.BooleanField() 41 | choice = serializers.ChoiceField( 42 | choices=[1, 2, 3], 43 | allow_null=True 44 | ) 45 | multichoice = serializers.MultipleChoiceField( 46 | choices=[2, 3, 5] 47 | ) 48 | ``` 49 | 50 | Generated typescript type: 51 | 52 | ```typescript 53 | export type MySerializer = { 54 | choice: 1 | 2 | 3 | null 55 | multichoice: (2 | 3 | 5)[] 56 | someBoolean: boolean 57 | someNumber: number 58 | someString: string 59 | } 60 | ``` 61 | 62 | The script looks for classes that inherit from `APIView` in project urls.py file as well as urls.py 63 | files in given apps. It then extracts serializers which are used in modules of found classes. This approach 64 | cover `Views` as well as `ViewSets`. 65 | 66 | ### Arguments 67 | 68 | The `generate_types` command supports following arguments: 69 | 70 | | Argument | Value type | Description | Default value | 71 | | --- | --- | --- | --- | 72 | | `--format` | "type" \| "interface" | Whether to output typescript types or interfaces | "type" 73 | | `--semicolons` | boolean | If the argument is present semicolons will be added in output | False 74 | | `--spaces` | int | Output indentation will use given number of spaces (mutually exclusive with `--tabs`). Spaces are used if neither `--spaces` nor `--tabs` argument is present. | 2 75 | | `--tabs` | int | Output indentation will use given number of tabs (mutually exclusive with `--spaces`) | None 76 | | `--preserve-case` | boolean | If present properties in generated types will preserve serializer fields casing (camelcased otherwise) | False 77 | 78 | ## Features 79 | 80 | The package currently supports following features that are correctly transformed to typescript syntax: 81 | 82 | - [X] Basic serializers 83 | - [X] Model serializers 84 | - [X] Nested serializers 85 | - [X] Method fields (typed with correct type if python type hints are used) 86 | - [X] Required / optional fields 87 | - [X] List fields 88 | - [X] Choice and multiple choice fields (results in composite typescript type) 89 | - [X] allow_blank, allow_null (results in composite typescript type) 90 | 91 | More features are planned to add later on: 92 | 93 | - [ ] One to many and many to many fields correct typing 94 | - [ ] Differentiate between read / write only fields while generating type / interface 95 | - [ ] Integration with tools like [drf_yasg](https://github.com/axnsan12/drf-yasg) to allow downloading the 96 | generated type from the documentation of the API 97 | - [ ] Accept custom mappings 98 | -------------------------------------------------------------------------------- /drf_typescript_generator/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.0' 2 | -------------------------------------------------------------------------------- /drf_typescript_generator/globals.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | DEFAULT_TYPE = 'any' 5 | 6 | MAPPING = { 7 | # boolean fields 8 | serializers.BooleanField: 'boolean', 9 | serializers.NullBooleanField: 'boolean', 10 | 11 | # string fields 12 | serializers.CharField: 'string', 13 | serializers.EmailField: 'string', 14 | serializers.RegexField: 'string', 15 | serializers.SlugField: 'string', 16 | serializers.URLField: 'string', 17 | serializers.UUIDField: 'string', 18 | serializers.FilePathField: 'string', 19 | serializers.IPAddressField: 'string', 20 | 21 | # numeric fields 22 | serializers.IntegerField: 'number', 23 | serializers.FloatField: 'number', 24 | serializers.DecimalField: 'number', 25 | 26 | # date and time fields TODO: correct format depending on settings? 27 | serializers.DateTimeField: 'string', 28 | serializers.DateField: 'string', 29 | serializers.TimeField: 'string', 30 | serializers.DurationField: 'string', 31 | 32 | # choice selection fields TODO: export also choices? 33 | # TODO: file upload fields? 34 | 35 | # method return values 36 | str: 'string', 37 | int: 'number', 38 | float: 'number', 39 | bool: 'boolean' 40 | # TODO: add more 41 | } 42 | 43 | # field types which require special treatment 44 | SPECIAL_FIELD_TYPES = [ 45 | serializers.SerializerMethodField, 46 | serializers.ChoiceField, 47 | serializers.MultipleChoiceField 48 | ] 49 | 50 | 51 | CHOICES_TRANSFORM_FUNCTIONS_BY_TYPE = { 52 | str: lambda x: f'"{x}"', 53 | int: lambda x: x, 54 | float: lambda x: x, 55 | bool: lambda x: str(x).lower() 56 | } 57 | 58 | 59 | -------------------------------------------------------------------------------- /drf_typescript_generator/management/commands/generate_types.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.core.management.base import AppCommand 3 | 4 | from drf_typescript_generator.utils import ( 5 | export_serializer, 6 | get_module_serializers, 7 | get_nested_serializers, 8 | get_serializer_fields, 9 | get_project_api_views, 10 | get_app_api_views 11 | ) 12 | 13 | 14 | class Command(AppCommand): 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self.already_parsed = set() 19 | self.project_api_views = get_project_api_views() 20 | 21 | def add_arguments(self, parser): 22 | parser.add_argument( 23 | '--format', type=str, choices=['type', 'interface'], default='type', 24 | help='Specifies whether the result will be types or interfaces' 25 | ) 26 | parser.add_argument( 27 | '--semicolons', action='store_true', default=False, 28 | help='Semicolons will be added if this argument is present' 29 | ) 30 | parser.add_argument( 31 | '--preserve-case', action='store_true', default=False, 32 | help='Preserve field name case from serializers' 33 | ) 34 | whitespace_group = parser.add_mutually_exclusive_group() 35 | whitespace_group.add_argument('--spaces', type=int, default=2) 36 | whitespace_group.add_argument('--tabs', type=int) 37 | 38 | return super().add_arguments(parser) 39 | 40 | def handle_app_config(self, app_config: AppConfig, **options): 41 | views_modules = set() 42 | serializers = set() 43 | 44 | for _name, api_view_class in self.project_api_views + get_app_api_views(app_config.name): 45 | module = api_view_class.__module__ 46 | if module.split('.')[0] == app_config.name: 47 | views_modules.add(module) 48 | 49 | # extract all serializers found in views modules 50 | for module in views_modules: 51 | serializers = serializers.union(get_module_serializers(module)) 52 | 53 | for serializer_name, serializer in sorted(serializers): 54 | self.process_serializer(serializer_name, serializer, options) 55 | 56 | def process_serializer(self, serializer_name, serializer, options): 57 | if serializer_name not in self.already_parsed: 58 | # recursively process nested serializers first to ensure that 59 | # TS equivalent is generated even if they are not used in views module 60 | nested_serializers = get_nested_serializers(serializer) 61 | for nested_serializer_name, nested_serializer in nested_serializers.items(): 62 | self.process_serializer(nested_serializer_name, nested_serializer, options) 63 | 64 | fields = get_serializer_fields(serializer, options) 65 | ts_serializer = export_serializer(serializer_name, fields, options) 66 | self.already_parsed.add(serializer_name) 67 | self.stdout.write(ts_serializer) 68 | -------------------------------------------------------------------------------- /drf_typescript_generator/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import os 4 | from collections import OrderedDict 5 | 6 | from rest_framework import serializers, views 7 | 8 | from drf_typescript_generator.globals import ( 9 | CHOICES_TRANSFORM_FUNCTIONS_BY_TYPE, DEFAULT_TYPE, MAPPING, SPECIAL_FIELD_TYPES 10 | ) 11 | 12 | def _is_api_view(member): 13 | """ Returns whether the `member` is drf api view """ 14 | return inspect.isclass(member) and views.APIView in inspect.getmro(member) 15 | 16 | 17 | def _is_serializer_class(member): 18 | """ Returns whether the `member` is drf serializer class or not """ 19 | return inspect.isclass(member) and serializers.BaseSerializer in inspect.getmro(member) 20 | 21 | 22 | def _to_camelcase(s): 23 | parts = s.split('_') 24 | return parts[0] + ''.join([part.capitalize() for part in parts[1:]]) 25 | 26 | 27 | def _check_for_nullable(field, typescript_type): 28 | if field.allow_null: 29 | typescript_type += ' | null' 30 | return typescript_type 31 | 32 | 33 | def _get_project_name(): 34 | return os.environ['DJANGO_SETTINGS_MODULE'].split('.')[0] 35 | 36 | 37 | def _get_typescript_name(field, field_name, options={}): 38 | if options.get('preserve_case', False): 39 | typescript_field_name = field_name 40 | else: 41 | typescript_field_name = _to_camelcase(field_name) 42 | if not field.read_only and not field.required: 43 | typescript_field_name += '?' 44 | return typescript_field_name 45 | 46 | 47 | def _get_method_return_value_type(field, field_name, serializer_instance): 48 | """ 49 | For given method field function looks for return type of corresponding 50 | method in type annotations. 51 | """ 52 | method_name = field.method_name if field.method_name else f'get_{field_name}' 53 | method = getattr(serializer_instance, method_name) 54 | method_signature = inspect.signature(method) 55 | return MAPPING.get(method_signature.return_annotation, DEFAULT_TYPE), False 56 | 57 | 58 | def _get_choice_selection_fields_type(field): 59 | """ 60 | Returns composite typescript type for choice selection field 61 | by enumerating its choices. Also takes into account the 62 | allow_blank argument. 63 | """ 64 | def transform_choice(v): 65 | return str(CHOICES_TRANSFORM_FUNCTIONS_BY_TYPE[type(v)](v)) 66 | 67 | typescript_type = ' | '.join([transform_choice(choice) for choice in field.choices.keys()]) 68 | is_list = type(field) == serializers.MultipleChoiceField 69 | if field.allow_blank: 70 | typescript_type += ' | ""' 71 | 72 | return typescript_type, is_list 73 | 74 | 75 | def _handle_special_field_type(field, field_name, serializer_instance): 76 | """ 77 | This function is used for fields that require custom logic for 78 | deriving its type. Custom function based on field type is called 79 | in sake of finding typescript type for this field. 80 | """ 81 | if type(field) == serializers.SerializerMethodField: 82 | return _get_method_return_value_type(field, field_name, serializer_instance) 83 | elif type(field) in [serializers.ChoiceField, serializers.MultipleChoiceField]: 84 | return _get_choice_selection_fields_type(field) 85 | else: 86 | raise NotImplementedError( 87 | f'Handling of {type(field).__name__} special field is not implemented' 88 | ) 89 | 90 | 91 | def _handle_nonspecial_field_type(field): 92 | """ 93 | Function determines typescript type for field that does not 94 | require special logic to derive its type e.g. field in 95 | String fields category, Numeric fields category, ... 96 | """ 97 | # core type is type of child in listfield / nested serializer with many=True 98 | is_list = hasattr(field, 'child') 99 | field_type = type(field.child) if is_list else type(field) 100 | 101 | if _is_serializer_class(field_type): 102 | typescript_type = field_type.__name__ 103 | else: 104 | typescript_type = MAPPING.get(field_type, DEFAULT_TYPE) 105 | return typescript_type, is_list 106 | 107 | 108 | def _get_typescript_type(field, field_name, serializer_instance): 109 | """ 110 | Returns typescript type for given field based on global mapping. 111 | Supports also method fields, list like fields and nested serializers. 112 | Types derivation is not recursive (for nested serializer type 113 | {serializer.__name__} is returned). 114 | If mapping for field type was not found default type is returned. 115 | """ 116 | if type(field) in SPECIAL_FIELD_TYPES: 117 | typescript_type, is_list = _handle_special_field_type(field, field_name, serializer_instance) 118 | else: 119 | typescript_type, is_list = _handle_nonspecial_field_type(field) 120 | 121 | typescript_type = _check_for_nullable(field, typescript_type) 122 | 123 | if is_list and '|' in typescript_type: 124 | # composite type array needs to be in parenthesis e.g. (number | null)[] 125 | typescript_type = f'({typescript_type})' 126 | 127 | return typescript_type + ('[]' if is_list else '') 128 | 129 | 130 | def get_nested_serializers(serializer): 131 | """ 132 | Finds nested serializers in given serializer. Returns 133 | ordered dictionary with keys being name of the nested 134 | serializers and values nested serializer classes. 135 | """ 136 | serializer_instance = serializer() 137 | fields = serializer_instance.get_fields() 138 | nested_serializers = {} 139 | for field in fields.values(): 140 | is_list = hasattr(field, 'child') 141 | field_type = type(field.child) if is_list else type(field) 142 | if _is_serializer_class(field_type): 143 | nested_serializers[field_type.__name__] = field_type 144 | 145 | return OrderedDict(sorted(nested_serializers.items())) 146 | 147 | 148 | def get_serializer_fields(serializer, options={}): 149 | """ 150 | Determines a typescript type for every field in the serializer. 151 | Returns ordered dictionary with keys being transformed field names to 152 | typescript names (including `?` if field is optional) and values 153 | being typescript types. 154 | """ 155 | serializer_instance = serializer() 156 | fields = serializer_instance.get_fields() 157 | typescript_fields = {} 158 | for field_name, field in fields.items(): 159 | typescript_field_name = _get_typescript_name(field, field_name, options) 160 | typescript_type = _get_typescript_type(field, field_name, serializer_instance) 161 | typescript_fields[typescript_field_name] = typescript_type 162 | 163 | return OrderedDict(sorted(typescript_fields.items())) 164 | 165 | 166 | def get_app_api_views(app_name): 167 | """ Returns all api views classes found in {app_name}.urls module """ 168 | try: 169 | urls_module = importlib.import_module('.urls', package=app_name) 170 | return inspect.getmembers(urls_module, _is_api_view) 171 | except ImportError: 172 | return [] 173 | 174 | 175 | def get_project_api_views(): 176 | """ Returns all api views classes found in project urls module """ 177 | return get_app_api_views(_get_project_name()) 178 | 179 | 180 | def get_module_serializers(module): 181 | """ Returns all serializer classes found in given module """ 182 | try: 183 | urls_module = importlib.import_module(module) 184 | return inspect.getmembers(urls_module, _is_serializer_class) 185 | except ImportError: 186 | return [] 187 | 188 | 189 | def export_serializer(serializer_name, fields, options): 190 | def format_field(field, indent): 191 | formatted = f'{indent}{field[0]}: {field[1]}' 192 | if options['semicolons']: 193 | formatted += ';' 194 | return formatted 195 | 196 | indent = '\t' * options['tabs'] if options['tabs'] is not None else ' ' * options['spaces'] 197 | attributes = '\n'.join([format_field(field, indent) for field in fields.items()]) 198 | 199 | if options['format'] == 'type': 200 | template = 'export type {} = {{\n{}\n}}\n\n' 201 | else: 202 | template = 'export interface {} {{\n{}\n}}\n\n' 203 | 204 | return template.format(serializer_name, attributes) 205 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "asgiref" 3 | version = "3.4.0" 4 | description = "ASGI specs, helper code, and adapters" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [package.extras] 10 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 11 | 12 | [[package]] 13 | name = "atomicwrites" 14 | version = "1.4.0" 15 | description = "Atomic file writes." 16 | category = "dev" 17 | optional = false 18 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 19 | 20 | [[package]] 21 | name = "attrs" 22 | version = "21.2.0" 23 | description = "Classes Without Boilerplate" 24 | category = "dev" 25 | optional = false 26 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 27 | 28 | [package.extras] 29 | dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] 30 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 31 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] 32 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] 33 | 34 | [[package]] 35 | name = "colorama" 36 | version = "0.4.4" 37 | description = "Cross-platform colored terminal text." 38 | category = "dev" 39 | optional = false 40 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 41 | 42 | [[package]] 43 | name = "Django" 44 | version = "3.2.15" 45 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 46 | category = "main" 47 | optional = false 48 | python-versions = ">=3.6" 49 | 50 | [package.dependencies] 51 | asgiref = ">=3.3.2,<4" 52 | pytz = "*" 53 | sqlparse = ">=0.2.2" 54 | 55 | [package.extras] 56 | argon2 = ["argon2-cffi (>=19.1.0)"] 57 | bcrypt = ["bcrypt"] 58 | 59 | [[package]] 60 | name = "djangorestframework" 61 | version = "3.12.4" 62 | description = "Web APIs for Django, made easy." 63 | category = "main" 64 | optional = false 65 | python-versions = ">=3.5" 66 | 67 | [package.dependencies] 68 | django = ">=2.2" 69 | 70 | [[package]] 71 | name = "more-itertools" 72 | version = "8.8.0" 73 | description = "More routines for operating on iterables, beyond itertools" 74 | category = "dev" 75 | optional = false 76 | python-versions = ">=3.5" 77 | 78 | [[package]] 79 | name = "packaging" 80 | version = "20.9" 81 | description = "Core utilities for Python packages" 82 | category = "dev" 83 | optional = false 84 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 85 | 86 | [package.dependencies] 87 | pyparsing = ">=2.0.2" 88 | 89 | [[package]] 90 | name = "pluggy" 91 | version = "0.13.1" 92 | description = "plugin and hook calling mechanisms for python" 93 | category = "dev" 94 | optional = false 95 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 96 | 97 | [package.extras] 98 | dev = ["pre-commit", "tox"] 99 | 100 | [[package]] 101 | name = "py" 102 | version = "1.10.0" 103 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 104 | category = "dev" 105 | optional = false 106 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 107 | 108 | [[package]] 109 | name = "pyparsing" 110 | version = "2.4.7" 111 | description = "Python parsing module" 112 | category = "dev" 113 | optional = false 114 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 115 | 116 | [[package]] 117 | name = "pytest" 118 | version = "4.6.11" 119 | description = "pytest: simple powerful testing with Python" 120 | category = "dev" 121 | optional = false 122 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 123 | 124 | [package.dependencies] 125 | atomicwrites = ">=1.0" 126 | attrs = ">=17.4.0" 127 | colorama = {version = "*", markers = "sys_platform == \"win32\" and python_version != \"3.4\""} 128 | more-itertools = {version = ">=4.0.0", markers = "python_version > \"2.7\""} 129 | packaging = "*" 130 | pluggy = ">=0.12,<1.0" 131 | py = ">=1.5.0" 132 | six = ">=1.10.0" 133 | wcwidth = "*" 134 | 135 | [package.extras] 136 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests"] 137 | 138 | [[package]] 139 | name = "pytz" 140 | version = "2021.1" 141 | description = "World timezone definitions, modern and historical" 142 | category = "main" 143 | optional = false 144 | python-versions = "*" 145 | 146 | [[package]] 147 | name = "six" 148 | version = "1.16.0" 149 | description = "Python 2 and 3 compatibility utilities" 150 | category = "dev" 151 | optional = false 152 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 153 | 154 | [[package]] 155 | name = "sqlparse" 156 | version = "0.4.2" 157 | description = "A non-validating SQL parser." 158 | category = "main" 159 | optional = false 160 | python-versions = ">=3.5" 161 | 162 | [[package]] 163 | name = "wcwidth" 164 | version = "0.2.5" 165 | description = "Measures the displayed width of unicode strings in a terminal" 166 | category = "dev" 167 | optional = false 168 | python-versions = "*" 169 | 170 | [metadata] 171 | lock-version = "1.1" 172 | python-versions = "^3.8" 173 | content-hash = "4d9ec85109a8853c160c047939a50e1a6e8e91b7a7f754a12978d0529d50f3fc" 174 | 175 | [metadata.files] 176 | asgiref = [ 177 | {file = "asgiref-3.4.0-py3-none-any.whl", hash = "sha256:d36fa91dd90e3aa3c81a6bd426ccc8fb20bd3d22b0cf14a12800289e9c3e2563"}, 178 | {file = "asgiref-3.4.0.tar.gz", hash = "sha256:05914d0fa65a21711e732adc6572edad6c8da5f1435c3f0c060689ced5e85195"}, 179 | ] 180 | atomicwrites = [ 181 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 182 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 183 | ] 184 | attrs = [ 185 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 186 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 187 | ] 188 | colorama = [ 189 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 190 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 191 | ] 192 | Django = [ 193 | {file = "Django-3.2.15-py3-none-any.whl", hash = "sha256:115baf5049d5cf4163e43492cdc7139c306ed6d451e7d3571fe9612903903713"}, 194 | {file = "Django-3.2.15.tar.gz", hash = "sha256:f71934b1a822f14a86c9ac9634053689279cd04ae69cb6ade4a59471b886582b"}, 195 | ] 196 | djangorestframework = [ 197 | {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"}, 198 | {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"}, 199 | ] 200 | more-itertools = [ 201 | {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, 202 | {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, 203 | ] 204 | packaging = [ 205 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 206 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 207 | ] 208 | pluggy = [ 209 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 210 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 211 | ] 212 | py = [ 213 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 214 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 215 | ] 216 | pyparsing = [ 217 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 218 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 219 | ] 220 | pytest = [ 221 | {file = "pytest-4.6.11-py2.py3-none-any.whl", hash = "sha256:a00a7d79cbbdfa9d21e7d0298392a8dd4123316bfac545075e6f8f24c94d8c97"}, 222 | {file = "pytest-4.6.11.tar.gz", hash = "sha256:50fa82392f2120cc3ec2ca0a75ee615be4c479e66669789771f1758332be4353"}, 223 | ] 224 | pytz = [ 225 | {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, 226 | {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, 227 | ] 228 | six = [ 229 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 230 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 231 | ] 232 | sqlparse = [ 233 | {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, 234 | {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, 235 | ] 236 | wcwidth = [ 237 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 238 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 239 | ] 240 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "drf-typescript-generator" 3 | version = "0.1.1" 4 | description = "Package for generating TypeScript types from DRF serializers" 5 | authors = ["napmn "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/remastr/drf-typescript-generator" 9 | keywords = ["django", "drf"] 10 | classifiers = [ 11 | "Environment :: Web Environment", 12 | "Framework :: Django", 13 | ] 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.8" 17 | Django = ">=3.2.4" 18 | djangorestframework = ">=3.12.4" 19 | 20 | [tool.poetry.dev-dependencies] 21 | pytest = "^4.6" 22 | 23 | [build-system] 24 | requires = ["poetry-core>=1.0.0"] 25 | build-backend = "poetry.core.masonry.api" 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remastr/drf-typescript-generator/1071202c36626d6f3644c368adcd71ec10af240e/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # dummy settings file for rest_framework to be happy 2 | -------------------------------------------------------------------------------- /tests/test_drf_typescript_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | if 'DJANGO_SETTINGS_MODULE' not in os.environ: 3 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 4 | 5 | from drf_typescript_generator.utils import ( 6 | _get_method_return_value_type, _get_typescript_name, _get_typescript_type, export_serializer, 7 | get_serializer_fields 8 | ) 9 | from drf_typescript_generator.globals import DEFAULT_TYPE 10 | 11 | from tests.utils import ( 12 | ChoiceFieldTestSerializer, ListFieldTestSerializer, MethodOutputTestSerializer, 13 | ModelTestSerializer, NestedSerializersTestSerializer, TypescriptNameTestSerializer 14 | ) 15 | 16 | class BaseTest: 17 | def setup_method(self, test_method): 18 | self.serializer = self.serializer_class() 19 | self.fields = self.serializer.get_fields() 20 | 21 | 22 | class TestRequired(BaseTest): 23 | serializer_class = TypescriptNameTestSerializer 24 | 25 | def test_typescript_required_name(self): 26 | ts_name = _get_typescript_name(self.fields['required_field'], 'required_field') 27 | assert ts_name == 'requiredField' 28 | 29 | def test_typescript_not_required_name(self): 30 | ts_name = _get_typescript_name(self.fields['not_required_field'], 'not_required_field') 31 | assert ts_name == 'notRequiredField?' 32 | 33 | 34 | class TestMethodField(BaseTest): 35 | serializer_class = MethodOutputTestSerializer 36 | 37 | def test_method_unknown_return_type(self): 38 | ts_type, _ = _get_method_return_value_type( 39 | self.fields['unknown_output_type'], 'unknown_output_type', self.serializer 40 | ) 41 | assert ts_type == DEFAULT_TYPE 42 | ts_type, _ = _get_method_return_value_type( 43 | self.fields['known_output_type'], 'known_output_type', self.serializer 44 | ) 45 | assert ts_type == "number" 46 | 47 | def test_method_known_return_type(self): 48 | ts_type, _ = _get_method_return_value_type( 49 | self.fields['known_output_type'], 'known_output_type', self.serializer 50 | ) 51 | assert ts_type == "number" 52 | 53 | 54 | class TestChoiceField(BaseTest): 55 | serializer_class = ChoiceFieldTestSerializer 56 | 57 | def test_basic_choice_selection_fields(self): 58 | ts_type = _get_typescript_type(self.fields['choice_field_int'], 'choice_field_int', self.serializer) 59 | assert ts_type == '1 | 2 | 3' 60 | ts_type = _get_typescript_type(self.fields['choice_field_float'], 'choice_field_float', self.serializer) 61 | assert ts_type == '1.2 | 3.1' 62 | ts_type = _get_typescript_type(self.fields['choice_field_bool'], 'choice_field_bool', self.serializer) 63 | assert ts_type == 'true | false' 64 | ts_type = _get_typescript_type(self.fields['choice_field_str'], 'choice_field_str', self.serializer) 65 | assert ts_type == '"a" | "b"' 66 | 67 | def test_choice_selection_fields_with_empty_values(self): 68 | ts_type = _get_typescript_type(self.fields['choice_field_str_blank'], 'choice_field_str_blank', self.serializer) 69 | assert ts_type == '"a" | "b" | ""' 70 | ts_type = _get_typescript_type(self.fields['choice_field_int_null'], 'choice_field_int_null', self.serializer) 71 | assert ts_type == '1 | 2 | null' 72 | 73 | 74 | class TestListField(BaseTest): 75 | serializer_class = ListFieldTestSerializer 76 | 77 | def test_basic_list_field_type(self): 78 | ts_type = _get_typescript_type(self.fields['lst'], 'lst', self.serializer) 79 | assert ts_type == 'number[]' 80 | 81 | def test_composite_list_field_type(self): 82 | ts_type = _get_typescript_type(self.fields['composite_lst'], 'composite_lst', self.serializer) 83 | assert ts_type == '(number | null)[]' 84 | 85 | def test_multiple_choice_field(self): 86 | ts_type = _get_typescript_type(self.fields['multichoice'], 'multichoice', self.serializer) 87 | assert ts_type == '(1 | 2 | 3)[]' 88 | 89 | 90 | def test_model_serializer(): 91 | options = { 92 | 'format': 'type', 93 | 'semicolons': False, 94 | 'tabs': None, 95 | 'spaces': 2, 96 | 'preserve_case': False 97 | } 98 | fields = get_serializer_fields(ModelTestSerializer) 99 | ts_serializer = export_serializer('ModelTestSerializer', fields, options) 100 | assert ' '.join(ts_serializer.split()).strip() == ' '.join( 101 | """ 102 | export type ModelTestSerializer = { 103 | caseField: number 104 | field1?: string 105 | field2: number 106 | field3: number 107 | } 108 | """.split() 109 | ).strip() 110 | 111 | 112 | def test_model_serializer_preserve_case(): 113 | options = { 114 | 'format': 'type', 115 | 'semicolons': False, 116 | 'tabs': None, 117 | 'spaces': 2, 118 | 'preserve_case': True 119 | } 120 | fields = get_serializer_fields(ModelTestSerializer, options) 121 | ts_serializer = export_serializer('ModelTestSerializer', fields, options) 122 | assert ' '.join(ts_serializer.split()).strip() == ' '.join( 123 | """ 124 | export type ModelTestSerializer = { 125 | case_field: number 126 | field1?: string 127 | field2: number 128 | field3: number 129 | } 130 | """.split() 131 | ).strip() 132 | 133 | 134 | 135 | class TestNestedSerializers(BaseTest): 136 | serializer_class = NestedSerializersTestSerializer 137 | 138 | def test_single_object_nested_serializer(self): 139 | ts_type = _get_typescript_type(self.fields['model'], 'model', self.serializer) 140 | assert ts_type == 'ModelTestSerializer' 141 | 142 | def test_many_objects_nested_serializer(self): 143 | ts_type = _get_typescript_type(self.fields['models'], 'models', self.serializer) 144 | assert ts_type == 'ModelTestSerializer[]' 145 | 146 | def test_nullable_many_objects_nested_serializer(self): 147 | ts_type = _get_typescript_type(self.fields['models_nullable'], 'models_nullable', self.serializer) 148 | assert ts_type == '(ModelTestSerializer | null)[]' 149 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from rest_framework import serializers 4 | 5 | django.setup() 6 | 7 | 8 | class TypescriptNameTestSerializer(serializers.Serializer): 9 | required_field = serializers.CharField(max_length=255) 10 | not_required_field = serializers.CharField(max_length=255, required=False) 11 | 12 | 13 | class MethodOutputTestSerializer(serializers.Serializer): 14 | unknown_output_type = serializers.SerializerMethodField() 15 | known_output_type = serializers.SerializerMethodField() 16 | 17 | def get_unknown_output_type(self, obj): 18 | pass 19 | 20 | def get_known_output_type(self, obj) -> int: 21 | return 1 22 | 23 | 24 | class ChoiceFieldTestSerializer(serializers.Serializer): 25 | choice_field_int = serializers.ChoiceField(choices=[1, 2, 3]) 26 | choice_field_float = serializers.ChoiceField(choices=[1.2, 3.1]) 27 | choice_field_bool = serializers.ChoiceField(choices=[True, False]) 28 | choice_field_str = serializers.ChoiceField(choices=["a", "b"]) 29 | choice_field_str_blank = serializers.ChoiceField(choices=["a", "b"], allow_blank=True) 30 | choice_field_int_null = serializers.ChoiceField(choices=[1, 2], allow_null=True) 31 | 32 | 33 | class ListFieldTestSerializer(serializers.Serializer): 34 | lst = serializers.ListField(child=serializers.IntegerField()) 35 | composite_lst = serializers.ListField(child=serializers.IntegerField(), allow_null=True) 36 | multichoice = serializers.MultipleChoiceField(choices=[1, 2, 3]) 37 | 38 | 39 | class Model(models.Model): 40 | field1 = models.CharField(max_length=255, blank=True) 41 | field2 = models.IntegerField() 42 | case_field = models.IntegerField() 43 | 44 | class Meta: 45 | app_label = 'tests' 46 | 47 | 48 | class ModelTestSerializer(serializers.ModelSerializer): 49 | field3 = serializers.IntegerField() 50 | 51 | class Meta: 52 | model = Model 53 | fields = ['field1', 'field2', 'field3', 'case_field'] 54 | 55 | 56 | class NestedSerializersTestSerializer(serializers.Serializer): 57 | model = ModelTestSerializer() 58 | models = ModelTestSerializer(many=True) 59 | models_nullable = ModelTestSerializer(many=True, allow_null=True) 60 | --------------------------------------------------------------------------------