├── tests ├── __init__.py ├── test_django_rest_tsg.py ├── settings.py ├── tsgconfig.py ├── test_enum.py ├── serializers.py ├── test_dataclass.py ├── models.py ├── test_serializer.py └── test_build.py ├── django_rest_tsg ├── py.typed ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── buildtypescript.py ├── __init__.py ├── apps.py ├── templates.py ├── build.py └── typescript.py ├── .github └── workflows │ └── coverage.yml ├── conftest.py ├── LICENSE ├── CHANGELOG.rst ├── .gitignore ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rest_tsg/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rest_tsg/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rest_tsg/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_django_rest_tsg.py: -------------------------------------------------------------------------------- 1 | from django_rest_tsg import __version__ 2 | 3 | 4 | def test_version(): 5 | assert __version__ == "0.1.10" 6 | -------------------------------------------------------------------------------- /django_rest_tsg/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Django REST TypeScript Generator" 2 | __version__ = "0.1.10" 3 | __author__ = "Yinian Chin" 4 | __license__ = "MIT License" 5 | __copyright__ = "Copyright 2021-2024 Yinian Chin" 6 | 7 | VERSION = __version__ 8 | -------------------------------------------------------------------------------- /django_rest_tsg/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TypeScriptGeneratorConfig(AppConfig): 5 | name = "django_rest_tsg" 6 | verbose = "Django REST TypeScript Generator" 7 | 8 | def ready(self): 9 | pass 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | INSTALLED_APPS = ( 4 | "django.contrib.admin", 5 | "django.contrib.auth", 6 | "django.contrib.contenttypes", 7 | "django.contrib.sessions", 8 | "django.contrib.sites", 9 | "django.contrib.staticfiles", 10 | "rest_framework", 11 | "rest_framework.authtoken", 12 | "django_rest_tsg", 13 | "tests", 14 | ) 15 | -------------------------------------------------------------------------------- /tests/tsgconfig.py: -------------------------------------------------------------------------------- 1 | from django_rest_tsg.build import build 2 | from tests.models import PermissionFlag, User 3 | from tests.serializers import ParentSerializer, PathSerializer, ChildSerializer, DepartmentSerializer 4 | 5 | BUILD_TASKS = [ 6 | build(PathSerializer), 7 | build(ParentSerializer, options={"alias": "FoobarParent"}), 8 | build(ChildSerializer, options={"alias": "FoobarChild"}), 9 | build(PermissionFlag, options={"enforce_uppercase": True}), 10 | build(User), 11 | build(DepartmentSerializer), 12 | ] 13 | -------------------------------------------------------------------------------- /django_rest_tsg/templates.py: -------------------------------------------------------------------------------- 1 | from string import Template 2 | 3 | INTERFACE_TEMPLATE = Template( 4 | """export interface $name { 5 | $fields 6 | }""" 7 | ) 8 | INTERFACE_FIELD_TEMPLATE = Template(" $name: $type;") 9 | ENUM_TEMPLATE = Template( 10 | """export enum $name { 11 | $members 12 | }""" 13 | ) 14 | ENUM_MEMBER_TEMPLATE = Template(" $name = $value") 15 | IMPORT_TEMPLATE = Template("import { $type } from '$filename';\n") 16 | HEADER_TEMPLATE = Template( 17 | """// This file is generated by $generator@$version. 18 | // You are strongly advised not to manually change this file for backend consistency. 19 | // Source Type: $type 20 | // Digest: $digest 21 | // Last modified on: $date 22 | """ 23 | ) 24 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python: ["3.9", "3.10", "3.11", "3.12"] 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Setup Python ${{ matrix.python }} 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: ${{ matrix.python }} 16 | - name: Install dependencies 17 | run: pip install poetry tox tox-gh-actions codecov 18 | - name: Run tox 19 | run: tox 20 | - name: Upload coverage 21 | uses: codecov/codecov-action@v3 22 | with: 23 | files: ./coverage.xml 24 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def pytest_configure(): 5 | settings.configure( 6 | SECRET_KEY="test", 7 | DATABASES={ 8 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 9 | "secondary": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 10 | }, 11 | INSTALLED_APPS=( 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "django.contrib.staticfiles", 18 | "rest_framework", 19 | "rest_framework.authtoken", 20 | "django_rest_tsg", 21 | "tests", 22 | ), 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_enum.py: -------------------------------------------------------------------------------- 1 | from django_rest_tsg import typescript 2 | from tests.models import PermissionFlag, ButtonType 3 | 4 | 5 | PERMISSION_FLAG_ENUM = """export enum PermissionFlag { 6 | EE = 1, 7 | EW = 2, 8 | ER = 4, 9 | GE = 8, 10 | GW = 16, 11 | GR = 32, 12 | OE = 64, 13 | OW = 128, 14 | OR = 256 15 | }""" 16 | 17 | 18 | def test_int_enum(): 19 | code = typescript.build_enum(PermissionFlag, enforce_uppercase=True) 20 | assert code.content == PERMISSION_FLAG_ENUM 21 | assert code.type == typescript.TypeScriptCodeType.ENUM 22 | assert code.source == PermissionFlag 23 | 24 | 25 | def test_str_enum(): 26 | button_type = """export enum ButtonType { 27 | Primary = 'primary', 28 | DisabledPrimary = 'primary disabled', 29 | Secondary = 'secondary', 30 | DisabledSecondary = 'secondary disabled' 31 | }""" 32 | code = typescript.build_enum(ButtonType) 33 | assert code.content == button_type 34 | assert code.type == typescript.TypeScriptCodeType.ENUM 35 | assert code.source == ButtonType 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yinian Chin 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 | -------------------------------------------------------------------------------- /django_rest_tsg/management/commands/buildtypescript.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | from pathlib import Path 4 | 5 | from django.core.management import BaseCommand, CommandError 6 | 7 | from django_rest_tsg.build import TypeScriptBuilder, TypeScriptBuilderConfig 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Build typescript codes from DRF things." 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("package", nargs="?", type=str) 15 | parser.add_argument("--build-dir", type=str) 16 | 17 | def handle(self, *args, **options): 18 | package_option = options.get("package") 19 | build_dir_option = options.get("build_dir") 20 | if not package_option: 21 | package_option = os.environ.get("DJANGO_SETTINGS_MODULE").rpartition(".")[0] 22 | module = importlib.import_module(package_option + ".tsgconfig") 23 | build_dir: Path = getattr(module, "BUILD_DIR", build_dir_option) 24 | if isinstance(build_dir, str): 25 | build_dir = Path(build_dir) 26 | if not build_dir: 27 | raise CommandError("No build_dir is specified.") 28 | config = TypeScriptBuilderConfig( 29 | tasks=getattr(module, "BUILD_TASKS", []), build_dir=build_dir 30 | ) 31 | builder = TypeScriptBuilder(config) 32 | builder.build_all() 33 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.1.10 2 | ------------- 3 | 4 | * Bump python version to 3.12. 5 | * Bump django version to 5.0. 6 | * Bump djangorestframework version to 3.15. 7 | 8 | 9 | 0.1.9 10 | ------------- 11 | 12 | * Bump djangorestframework-dataclasses version to 1.3.0. 13 | * Fix nullable representation for field with allow_null as true. 14 | 15 | 16 | 0.1.8 17 | ------------- 18 | * Bump django version to 4.2. 19 | 20 | 0.1.7 21 | ------------- 22 | * Bump django version to 4.1. 23 | * Bump djangorestframework version to 3.14. 24 | 25 | 0.1.6 26 | ------------- 27 | * Fix missing dependencies when flatten DataclassSerializer. 28 | * Write to file only if task content changes. 29 | 30 | 0.1.5 31 | ------------- 32 | 33 | * Fix dependency path of import statements when build_dir is specified in options. 34 | 35 | 0.1.4 36 | ------------- 37 | 38 | * Add task-level build_dir support. 39 | 40 | 0.1.3 41 | ------------- 42 | * Enrich README content. 43 | * Add builder logging. 44 | * Fix alias failure. 45 | * Fix aliases in typescript import statements. 46 | 47 | 0.1.2 48 | ------------- 49 | * Get dataclass of DataclassSerializer from dataclass attribute when Meta is missing. 50 | * Fix field type of trivial serializer. 51 | 52 | 53 | 0.1.1 54 | ------------- 55 | * Fix annotated trivial type fallback. 56 | * Skip ``typing`` types on dependency resolving. 57 | 58 | 0.1.0 59 | ------------- 60 | Initial version. 61 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework_dataclasses.serializers import DataclassSerializer 3 | 4 | from tests.models import Parent, Child, User, Department 5 | 6 | 7 | class ParentSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = Parent 10 | fields = "__all__" 11 | 12 | 13 | class ChildSerializer(serializers.ModelSerializer): 14 | parent = ParentSerializer() 15 | parents = ParentSerializer(many=True) 16 | 17 | class Meta: 18 | model = Child 19 | fields = "__all__" 20 | 21 | 22 | class PathSerializer(serializers.Serializer): 23 | name = serializers.CharField() 24 | suffix = serializers.CharField() 25 | suffixes = serializers.ListField(child=serializers.CharField()) 26 | stem = serializers.CharField() 27 | is_directory = serializers.BooleanField(source="is_dir") 28 | size = serializers.IntegerField(source="stat.st_size") 29 | 30 | 31 | class PathWrapperSerializer(serializers.Serializer): 32 | path = PathSerializer() 33 | meta = serializers.JSONField() 34 | 35 | 36 | class DepartmentSerializer(DataclassSerializer): 37 | class Meta: 38 | dataclass = Department 39 | 40 | 41 | class UserSerializer(DataclassSerializer): 42 | primary_department = DepartmentSerializer() 43 | departments = DepartmentSerializer(many=True) 44 | data_path = PathSerializer() 45 | 46 | class Meta: 47 | dataclass = User 48 | -------------------------------------------------------------------------------- /tests/test_dataclass.py: -------------------------------------------------------------------------------- 1 | from django_rest_tsg import typescript 2 | from tests.models import User, Department, UserList 3 | 4 | 5 | USER_INTERFACE = """export interface User { 6 | id: number; 7 | name: string; 8 | profile: object; 9 | birth: Date; 10 | lastLoggedIn: Date; 11 | followers: any[]; 12 | status: 'active' | 'disabled'; 13 | signature: string; 14 | publicKeys: Array; 15 | matrix: Array>; 16 | configs: Array; 17 | isStaff: boolean | null; 18 | eloRank: {[key: string]: number}; 19 | magicNumber: 42; 20 | buttonType: ButtonType; 21 | }""" 22 | 23 | 24 | def test_dataclass(): 25 | typescript.register(Department, "Department") 26 | code = typescript.build_interface_from_dataclass(User) 27 | assert code.content == USER_INTERFACE 28 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 29 | assert code.source == User 30 | 31 | department_interface = """export interface Department { 32 | id: number; 33 | name: string; 34 | permissions: Array; 35 | principals: Array; 36 | }""" 37 | code = typescript.build_interface_from_dataclass(Department) 38 | assert code.content == department_interface 39 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 40 | assert code.source == Department 41 | 42 | user_list_interface = """export interface UserList { 43 | id: number; 44 | users: Array; 45 | }""" 46 | code = typescript.build_interface_from_dataclass(UserList) 47 | assert code.content == user_list_interface 48 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 49 | assert code.source == UserList 50 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date, datetime 3 | from enum import IntEnum, Enum 4 | from typing import Literal, Annotated, List, Optional, Dict, Union 5 | 6 | from django.db import models 7 | 8 | from django_rest_tsg import typescript 9 | 10 | 11 | class PermissionFlag(IntEnum): 12 | EE = 1 13 | EW = 1 << 1 14 | ER = 1 << 2 15 | GE = 1 << 3 16 | GW = 1 << 4 17 | GR = 1 << 5 18 | OE = 1 << 6 19 | OW = 1 << 7 20 | OR = 1 << 8 21 | 22 | 23 | class ButtonType(Enum): 24 | PRIMARY = "primary" 25 | DISABLED_PRIMARY = "primary disabled" 26 | SECONDARY = "secondary" 27 | DISABLED_SECONDARY = "secondary disabled" 28 | 29 | 30 | class Parent(models.Model): 31 | question_text = models.CharField(max_length=200) 32 | pub_date = models.DateTimeField("date published") 33 | 34 | 35 | class Child(models.Model): 36 | parent = models.ForeignKey(Parent, on_delete=models.CASCADE) 37 | parents = models.ManyToManyField(Parent, related_name="+") 38 | text = models.TextField() 39 | int_number = models.IntegerField() 40 | uuid = models.UUIDField() 41 | url = models.URLField() 42 | description = models.TextField() 43 | config = models.JSONField() 44 | time = models.TimeField() 45 | slug = models.SlugField() 46 | ip_address = models.GenericIPAddressField() 47 | email = models.EmailField() 48 | bool_value = models.BooleanField() 49 | float_number = models.FloatField() 50 | 51 | 52 | @typescript.register 53 | @dataclass 54 | class User: 55 | id: int 56 | name: str 57 | profile: dict 58 | birth: date 59 | last_logged_in: datetime 60 | followers: list 61 | status: Literal["active", "disabled"] 62 | signature: Annotated[str, "Something I can't explain"] 63 | public_keys: Annotated[List[str], "SSH Keys"] 64 | matrix: List[list] 65 | configs: List[dict] 66 | is_staff: Optional[bool] 67 | elo_rank: Dict[str, float] 68 | magic_number: Literal[42] 69 | button_type: ButtonType 70 | 71 | 72 | @dataclass 73 | class Department: 74 | id: int 75 | name: str 76 | permissions: List[str] 77 | principals: List[User] 78 | 79 | 80 | @dataclass 81 | class UserList: 82 | id: int 83 | users: List[Union[User, int, str]] 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDE 132 | .idea 133 | .vscode 134 | 135 | # Poetry 136 | poetry.lock 137 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from django_rest_tsg import typescript 2 | from tests.serializers import ( 3 | ChildSerializer, 4 | ParentSerializer, 5 | PathSerializer, 6 | UserSerializer, 7 | DepartmentSerializer, 8 | ) 9 | 10 | 11 | PATH_INTERFACE = """export interface Path { 12 | name: string; 13 | suffix: string; 14 | suffixes: string[]; 15 | stem: string; 16 | isDirectory: boolean; 17 | size: number; 18 | }""" 19 | 20 | CHOICE_INTERFACE = """export interface Child { 21 | id: number; 22 | parent: Parent; 23 | parents: Parent[]; 24 | text: string; 25 | intNumber: number; 26 | uuid: string; 27 | url: string; 28 | description: string; 29 | config: any; 30 | time: string; 31 | slug: string; 32 | ipAddress: string; 33 | email: string; 34 | boolValue: boolean; 35 | floatNumber: number; 36 | }""" 37 | 38 | USER_INTERFACE = """export interface User { 39 | primaryDepartment: Department; 40 | departments: Department[]; 41 | dataPath: Path; 42 | id: number; 43 | name: string; 44 | profile: {[index: string]: any}; 45 | birth: Date; 46 | lastLoggedIn: Date; 47 | followers: any[]; 48 | status: 'active' | 'disabled'; 49 | signature: string; 50 | publicKeys: string[]; 51 | matrix: any[][]; 52 | configs: {[index: string]: any}[]; 53 | isStaff: boolean | null; 54 | eloRank: {[index: string]: number}; 55 | magicNumber: 42; 56 | buttonType: ButtonType; 57 | }""" 58 | 59 | DEPARTMENT_INTERFACE = """export interface Department { 60 | id: number; 61 | name: string; 62 | permissions: string[]; 63 | principals: User[]; 64 | }""" 65 | 66 | 67 | def test_serializer(): 68 | code = typescript.build_interface_from_serializer(PathSerializer) 69 | assert code.content == PATH_INTERFACE 70 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 71 | assert code.source == PathSerializer 72 | 73 | 74 | def test_model_serializer(): 75 | code = typescript.build_interface_from_serializer(ChildSerializer) 76 | assert len(code.dependencies) == 1 77 | assert code.dependencies[0] == ParentSerializer 78 | assert code.content == CHOICE_INTERFACE 79 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 80 | assert code.source == ChildSerializer 81 | 82 | 83 | def test_dataclass_serializer(): 84 | code = typescript.build_interface_from_serializer(UserSerializer) 85 | assert code.content == USER_INTERFACE 86 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 87 | assert code.source == UserSerializer 88 | code = typescript.build_interface_from_serializer(DepartmentSerializer) 89 | assert code.content == DEPARTMENT_INTERFACE 90 | assert code.type == typescript.TypeScriptCodeType.INTERFACE 91 | assert code.source == DepartmentSerializer 92 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-rest-tsg" 3 | version = "0.1.10" 4 | license = "MIT" 5 | description = "A typescript code generator for Django Rest Framework." 6 | readme = "README.rst" 7 | repository = "https://github.com/jinkanhq/django-rest-tsg" 8 | authors = ["Yinian Chin "] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Environment :: Web Environment", 12 | "Framework :: Django", 13 | "Framework :: Django :: 3.0", 14 | "Framework :: Django :: 3.1", 15 | "Framework :: Django :: 3.2", 16 | "Framework :: Django :: 4.0", 17 | "Framework :: Django :: 4.1", 18 | "Framework :: Django :: 4.2", 19 | "Framework :: Django :: 5.0", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Topic :: Software Development :: Code Generators", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Typing :: Typed", 31 | ] 32 | include = ["README.rst", "CHANGELOG.rst", "LICENSE"] 33 | 34 | [tool.poetry.dependencies] 35 | python = "^3.9.0" 36 | django = [ 37 | { version = ">=3.0, <5.0", python = ">= 3.9.0, <3.10" }, 38 | { version = ">=3.0, <6.0", python = "^3.10.0" }, 39 | ] 40 | djangorestframework = "^3.13" 41 | djangorestframework-dataclasses = "^1.3.0" 42 | inflection = "^0.5.1" 43 | 44 | [tool.poetry.dev-dependencies] 45 | pytest = "^6.2.5" 46 | pytest-django = "^4.4.0" 47 | pytest-cov = "^3.0.0" 48 | 49 | [build-system] 50 | requires = ["poetry-core>=1.0.0"] 51 | build-backend = "poetry.core.masonry.api" 52 | 53 | [tool.tox] 54 | legacy_tox_ini = """ 55 | [tox] 56 | isolated_build = true 57 | envlist = py{39,310}-django{32}-drf{313,314} 58 | py{39,310,311}-django{40}-drf{313,314} 59 | py{39,310,311}-django{41,42}-drf{314} 60 | py{310,311,312}-django{50}-drf{314,315} 61 | 62 | [gh-actions] 63 | python = 64 | 3.9: py39 65 | 3.10: py310 66 | 3.11: py311 67 | 3.12: py312 68 | 69 | [testenv] 70 | deps = 71 | pytest>=6.2.5 72 | pytest-django>=4.4.0 73 | pytest-cov>=3.0 74 | djangorestframework-dataclasses>=1.3.0 75 | inflection>=0.5.1 76 | django32: Django>=3.2,<3.3 77 | django40: Django>=4.0,<4.1 78 | django41: Django>=4.1,<4.2 79 | django42: Django>=4.2,<4.3 80 | django50: Django>=5.0,<5.1 81 | drf313: djangorestframework>=3.13,<3.14 82 | drf314: djangorestframework>=3.14,<3.15 83 | drf315: djangorestframework>=3.15,<3.16 84 | allowlist_externals = poetry 85 | commands = 86 | poetry run pytest --cov=./ --cov-report=xml 87 | """ 88 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. |coverage-passing| image:: https://github.com/jinkanhq/django-rest-tsg/actions/workflows/coverage.yml/badge.svg 2 | :target: https://github.com/jinkanhq/django-rest-tsg/actions/workflows/coverage.yml 3 | 4 | .. |coverage| image:: https://codecov.io/gh/jinkanhq/django-rest-tsg/branch/main/graph/badge.svg?token=LX8E3QB541 5 | :target: https://codecov.io/gh/jinkanhq/django-rest-tsg 6 | 7 | .. |pypi| image:: https://badge.fury.io/py/django-rest-tsg.svg 8 | :target: https://badge.fury.io/py/django-rest-tsg 9 | 10 | |coverage-passing| |coverage| |pypi| 11 | 12 | django-rest-tsg 13 | ==================== 14 | 15 | A TypeScript code generator for Django Rest Framework, which saved your hand-working and guaranteed consistency 16 | between Python codes and modern frontend codes written in TypeScript. 17 | 18 | Features 19 | ---------- 20 | 21 | It generates TypeScript codes from following Python types. 22 | 23 | * Django REST Framework serializers: manual working on ``Serializer``, ``ModelSerializer`` 24 | derived from Django ORM models, ``DataclassSerializer`` via `djangorestframework-dataclasses`_. 25 | * Python dataclasses: Classes decorated by ``dataclasses.dataclass``. 26 | * Python enums: Subclasses of ``enum.Enum``. 27 | 28 | It also supports nested types and composite types. 29 | 30 | .. _djangorestframework-dataclasses: https://github.com/oxan/djangorestframework-dataclasses 31 | 32 | Requirements 33 | -------------- 34 | 35 | * Python >=3.9 36 | * Django >=3.2 37 | * Django REST Framework >=3.12 38 | 39 | Usage 40 | -------- 41 | 42 | Install using ``pip``. 43 | 44 | .. code-block:: bash 45 | 46 | $ pip install django_rest_tsg 47 | 48 | Put a ``tsgconfig.py`` file with build tasks into your django project's root. 49 | 50 | .. code-block:: python 51 | 52 | from django.conf import settings 53 | from django_rest_tsg.build import build 54 | 55 | BUILD_DIR = settings.BASE_DIR / "app/src/core" 56 | 57 | BUILD_TASKS = [ 58 | build(Foo), 59 | build(BarSerializer, {"alias": "Foobar"}), 60 | ] 61 | 62 | Add ``django_rest_tsg`` to your ``INSTALLED_APPS``. 63 | 64 | .. code-block:: python 65 | 66 | INSTALLED_APPS = [ 67 | ... 68 | "django_rest_tsg" 69 | ] 70 | 71 | Run ``buildtypescript`` command on ``manage.py``. 72 | 73 | .. code-block:: bash 74 | 75 | $ python manage.py buildtypescript 76 | 77 | Or you can switch to another place. 78 | 79 | .. code-block:: bash 80 | 81 | $ python manage.py buildtypescript --build-dir /somewhere/you/cannot/explain 82 | 83 | Examples 84 | ----------------- 85 | 86 | Input: Serializer 87 | 88 | .. code-block:: python 89 | 90 | class PathSerializer(serializers.Serializer): 91 | name = serializers.CharField() 92 | suffix = serializers.CharField() 93 | suffixes = serializers.ListField(child=serializers.CharField()) 94 | stem = serializers.CharField() 95 | is_directory = serializers.BooleanField(source="is_dir") 96 | size = serializers.IntegerField(source="stat.st_size") 97 | 98 | Output: Interface 99 | 100 | .. code-block:: typescript 101 | 102 | export interface Path { 103 | name: string; 104 | suffix: string; 105 | suffixes: string[]; 106 | stem: string; 107 | isDirectory: boolean; 108 | size: number; 109 | } 110 | 111 | There are more examples in `test cases`_. 112 | 113 | .. _test cases: https://github.com/jinkanhq/django-rest-tsg/tree/main/tests 114 | 115 | Build Options 116 | ----------------- 117 | 118 | All options are listed in the table below. 119 | 120 | +--------------------+-------------+--------------------+ 121 | | Name | Context | Value | 122 | +====================+=============+====================+ 123 | | alias | All | ``str`` | 124 | +--------------------+-------------+--------------------+ 125 | | build_dir | All | ``str`` | ``Path`` | 126 | +--------------------+-------------+--------------------+ 127 | | enforce_uppercase | Enum | ``bool`` (False) | 128 | +--------------------+-------------+--------------------+ 129 | -------------------------------------------------------------------------------- /tests/test_build.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | import time 4 | 5 | import pytest 6 | from pathlib import Path 7 | from itertools import chain 8 | 9 | from django.core.management import call_command 10 | from rest_framework import serializers 11 | 12 | from django_rest_tsg.build import ( 13 | TypeScriptBuilder, 14 | TypeScriptBuilderConfig, 15 | build, 16 | get_relative_path, 17 | get_digest, 18 | ) 19 | from tests.serializers import PathSerializer, PathWrapperSerializer 20 | from tests.test_dataclass import USER_INTERFACE 21 | from tests.tsgconfig import BUILD_TASKS 22 | from tests.test_serializer import PATH_INTERFACE, DEPARTMENT_INTERFACE 23 | from tests.test_enum import PERMISSION_FLAG_ENUM 24 | 25 | 26 | FOOBAR_CHILD_INTERFACE = """import { FoobarParent } from './foobar-parent'; 27 | 28 | export interface FoobarChild { 29 | id: number; 30 | parent: Parent; 31 | parents: Parent[]; 32 | text: string; 33 | intNumber: number; 34 | uuid: string; 35 | url: string; 36 | description: string; 37 | config: any; 38 | time: string; 39 | slug: string; 40 | ipAddress: string; 41 | email: string; 42 | boolValue: boolean; 43 | floatNumber: number; 44 | }""" 45 | 46 | PATH_WRAPPER_INTERFACE = """import { Path } from '../path'; 47 | 48 | export interface PathWrapper { 49 | path: Path; 50 | meta: any; 51 | }""" 52 | 53 | DEPARTMENT_INTERFACE = ( 54 | """import { User } from './user'; 55 | 56 | """ 57 | + DEPARTMENT_INTERFACE 58 | ) 59 | 60 | PATH_V2_INTERFACE = """export interface Path { 61 | name: string; 62 | suffix: string; 63 | suffixes: string[]; 64 | stem: string; 65 | isDirectory: boolean; 66 | size: number; 67 | metadata: any; 68 | }""" 69 | 70 | 71 | @pytest.fixture() 72 | def another_build_dir(): 73 | d = tempfile.TemporaryDirectory(prefix="django-rest-tsg") 74 | path = Path(d.name) 75 | subdir = path / "sub" 76 | subdir.mkdir(exist_ok=True) 77 | yield path 78 | shutil.rmtree(path, ignore_errors=True) 79 | 80 | 81 | def skip_lines(content: str, lines: int = 6): 82 | return "\n".join(content.splitlines()[lines:]) 83 | 84 | 85 | def test_get_relative_path(): 86 | path = Path("/var/tmp/django-rest-tsg/foo/bar.ts") 87 | dependency_path = Path("/var/tmp/cache/django-rest-tsg/bar/foo.ts") 88 | assert ( 89 | get_relative_path(path, dependency_path) 90 | == "../../cache/django-rest-tsg/bar/foo.ts" 91 | ) 92 | path = Path("/var/tmp/django-rest-tsg/foo.ts") 93 | dependency_path = Path("/var/tmp/django-rest-tsg/foo/bar/foobar.ts") 94 | assert get_relative_path(path, dependency_path) == "./foo/bar/foobar.ts" 95 | path = Path("/var/tmp/django-rest-tsg/foo.ts") 96 | dependency_path = Path("/var/tmp/django-rest-tsg/bar.ts") 97 | assert get_relative_path(path, dependency_path) == "./bar.ts" 98 | 99 | 100 | def test_builder(tmp_path: Path, another_build_dir: Path): 101 | sub_dir = another_build_dir / "sub" 102 | tasks = BUILD_TASKS[1:] 103 | tasks.insert(0, build(PathSerializer, options={"build_dir": another_build_dir})) 104 | tasks.append(build(PathWrapperSerializer, options={"build_dir": sub_dir})) 105 | config = TypeScriptBuilderConfig(build_dir=tmp_path, tasks=tasks) 106 | builder = TypeScriptBuilder(config) 107 | builder.build_all() 108 | tmp_files = { 109 | file.name: file.read_text() 110 | for file in chain( 111 | tmp_path.iterdir(), 112 | iter(f for f in another_build_dir.iterdir() if f.is_file()), 113 | sub_dir.iterdir(), 114 | ) 115 | } 116 | assert len(tmp_files) == len(tasks) 117 | assert "path.ts" in tmp_files 118 | assert skip_lines(tmp_files["path.ts"]) == PATH_INTERFACE 119 | assert "foobar-child.ts" in tmp_files 120 | assert skip_lines(tmp_files["foobar-child.ts"]) == FOOBAR_CHILD_INTERFACE 121 | assert "permission-flag.enum.ts" in tmp_files 122 | assert skip_lines(tmp_files["permission-flag.enum.ts"], 6) == PERMISSION_FLAG_ENUM 123 | assert "user.ts" in tmp_files 124 | assert skip_lines(tmp_files["user.ts"], 8) == USER_INTERFACE 125 | assert "path-wrapper.ts" in tmp_files 126 | assert skip_lines(tmp_files["path-wrapper.ts"]) == PATH_WRAPPER_INTERFACE 127 | assert "department.ts" in tmp_files 128 | assert skip_lines(tmp_files["department.ts"]) == DEPARTMENT_INTERFACE 129 | 130 | 131 | def test_command(tmp_path: Path): 132 | call_command("buildtypescript", "tests", "--build-dir", str(tmp_path)) 133 | tmp_files = { 134 | file.name: file.read_text() 135 | for file in chain(tmp_path.iterdir(), tmp_path.iterdir()) 136 | } 137 | assert len(tmp_files) == len(BUILD_TASKS) 138 | assert "path.ts" in tmp_files 139 | assert "foobar-child.ts" in tmp_files 140 | assert "permission-flag.enum.ts" in tmp_files 141 | 142 | 143 | def test_content_change(tmp_path: Path): 144 | tasks = [build(PathSerializer)] 145 | config = TypeScriptBuilderConfig(build_dir=tmp_path, tasks=tasks) 146 | builder = TypeScriptBuilder(config) 147 | builder.build_all() 148 | build_file = tmp_path / "path.ts" 149 | digest = get_digest(build_file) 150 | content = build_file.read_text() 151 | last_modified_on = build_file.stat().st_mtime 152 | # no change 153 | builder.build_all() 154 | same_digest = get_digest(build_file) 155 | same_content = build_file.read_text() 156 | same_last_modified_on = build_file.stat().st_mtime 157 | assert digest == same_digest 158 | assert content == same_content 159 | assert last_modified_on == same_last_modified_on 160 | # add field 161 | class PathVersion2Serializer(PathSerializer): 162 | metadata = serializers.JSONField() 163 | 164 | tasks = [build(PathVersion2Serializer, {"alias": "Path"})] 165 | config = TypeScriptBuilderConfig(build_dir=tmp_path, tasks=tasks) 166 | builder = TypeScriptBuilder(config) 167 | time.sleep(0.1) 168 | builder.build_all() 169 | digest_v2 = get_digest(build_file) 170 | last_modified_on_v2 = build_file.stat().st_mtime 171 | 172 | assert skip_lines(build_file.read_text()) == PATH_V2_INTERFACE 173 | assert digest != digest_v2 174 | assert last_modified_on_v2 > last_modified_on 175 | -------------------------------------------------------------------------------- /django_rest_tsg/build.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import hashlib 3 | from dataclasses import dataclass, is_dataclass 4 | from datetime import datetime 5 | from enum import EnumMeta 6 | from pathlib import Path 7 | from typing import Type, List, Dict, TypedDict, Union 8 | 9 | from django.conf import settings 10 | from inflection import dasherize, underscore 11 | from rest_framework.serializers import Serializer 12 | 13 | from django_rest_tsg import VERSION 14 | from django_rest_tsg.templates import HEADER_TEMPLATE, IMPORT_TEMPLATE 15 | from django_rest_tsg.typescript import ( 16 | TypeScriptCode, 17 | TypeScriptCodeType, 18 | build_enum, 19 | build_interface_from_dataclass, 20 | build_interface_from_serializer, 21 | get_serializer_prefix, 22 | register, 23 | ) 24 | 25 | 26 | class BuildException(Exception): 27 | pass 28 | 29 | 30 | @dataclass 31 | class TypeScriptBuildTask: 32 | type: Type 33 | code: TypeScriptCode 34 | options: dict 35 | 36 | @property 37 | def filename(self): 38 | if issubclass(self.type, Serializer): 39 | default_stem = get_serializer_prefix(self.type) 40 | else: 41 | default_stem = self.type.__name__ 42 | stem = dasherize(underscore(self.options.get("alias", default_stem))) 43 | if self.code.type == TypeScriptCodeType.ENUM: 44 | result = f"{stem}.enum.ts" 45 | else: 46 | result = f"{stem}.ts" 47 | return result 48 | 49 | 50 | class TypeScriptBuildOptions(TypedDict, total=False): 51 | alias: str 52 | build_dir: Union[str, Path] 53 | enforce_uppercase: bool 54 | 55 | 56 | @dataclass 57 | class TypeScriptBuilderConfig: 58 | tasks: List[TypeScriptBuildTask] 59 | build_dir: Union[str, Path] 60 | 61 | 62 | def build( 63 | tp: Type, 64 | options: TypeScriptBuildOptions = None, 65 | ) -> TypeScriptBuildTask: 66 | """ 67 | Shortcut factory for TypeScriptBuildTask. 68 | """ 69 | if options is None: 70 | options = {} 71 | alias = options.get("alias") 72 | if alias: 73 | register(tp, alias) 74 | code: TypeScriptCode 75 | if not options: 76 | options = {} 77 | if issubclass(tp, Serializer): 78 | code = build_interface_from_serializer(tp, interface_name=alias) 79 | elif isinstance(tp, EnumMeta): 80 | code = build_enum( 81 | tp, 82 | enum_name=alias, 83 | enforce_uppercase=options.get("enforce_uppercase", False), 84 | ) 85 | elif is_dataclass(tp): 86 | code = build_interface_from_dataclass(tp, interface_name=alias) 87 | else: 88 | raise BuildException(f"Unsupported build type: {tp.__name__}") 89 | build_dir = options.get("build_dir") 90 | if build_dir and isinstance(build_dir, str): 91 | options["build_dir"] = Path(build_dir) 92 | return TypeScriptBuildTask(type=tp, code=code, options=options) 93 | 94 | 95 | def get_relative_path(path: Path, dependency_path: Path) -> str: 96 | path_length = len(path.parts) 97 | dependency_path_length = len(dependency_path.parts) 98 | common_path_length = min(path_length, dependency_path_length) 99 | break_idx = 0 100 | for i in range(common_path_length): 101 | if path.parts[i] != dependency_path.parts[i]: 102 | break_idx = i 103 | break 104 | if common_path_length == dependency_path_length and break_idx == 0: 105 | return f"./{dependency_path.name}" 106 | levels = path_length - break_idx - 1 107 | if levels > 0: 108 | parents = levels * "../" 109 | else: 110 | parents = "./" 111 | return parents + "/".join(dependency_path.parts[break_idx:]) 112 | 113 | 114 | def get_digest(typescript_file: Path) -> str: 115 | with typescript_file.open("r") as f: 116 | for i, line in enumerate(f): 117 | line: str 118 | if i == 3 and line.startswith("// Digest: ") and len(line) == 64 + 11 + 1: 119 | return line[11:-1] 120 | if i > 3: 121 | break 122 | return "" 123 | 124 | 125 | class TypeScriptBuilder: 126 | def __init__(self, config: TypeScriptBuilderConfig): 127 | self.logger = logging.getLogger("django-rest-tsg") 128 | log_level = logging.DEBUG if settings.DEBUG else logging.INFO 129 | self.logger.setLevel(log_level) 130 | handler = logging.StreamHandler() 131 | handler.setLevel(log_level) 132 | formatter = logging.Formatter("%(asctime)s|%(name)s|%(levelname)s|%(message)s") 133 | handler.setFormatter(formatter) 134 | self.logger.addHandler(handler) 135 | self.tasks = config.tasks 136 | self.build_dir = config.build_dir 137 | self.type_options_mapping: Dict[Type, TypeScriptBuildOptions] = {} 138 | self.logger.info(f"{len(self.tasks)} build tasks found.") 139 | for task in self.tasks: 140 | self.logger.debug(f'Build task found: "{task.type.__name__}".') 141 | self.type_options_mapping[task.type] = task.options 142 | 143 | def build_all(self): 144 | for task in self.tasks: 145 | self.logger.info(f'Building "{task.type.__name__}"...') 146 | self.build_task(task) 147 | 148 | def build_task(self, task: TypeScriptBuildTask): 149 | type_options = self.type_options_mapping.get(task.type, {}) 150 | build_dir = type_options.get("build_dir", self.build_dir) 151 | build_dir.mkdir(parents=True, exist_ok=True) 152 | typescript_file = build_dir / task.filename 153 | hexdigest = None 154 | if typescript_file.exists(): 155 | hexdigest = get_digest(typescript_file) 156 | import_statements = self.build_import_statements(task) 157 | content_without_header = import_statements + task.code.content 158 | content_without_header_hexdigest = hashlib.sha256( 159 | content_without_header.encode("utf8") 160 | ).hexdigest() 161 | if hexdigest == content_without_header_hexdigest: 162 | self.logger.info( 163 | f'No change in content. Skip saving task "{task.type.__name__}".' 164 | ) 165 | return 166 | 167 | header = self.build_header(task, content_without_header_hexdigest) 168 | typescript_file.write_text(header + content_without_header) 169 | self.logger.debug( 170 | f'Typescript code for "{task.type.__name__}" saved as "{typescript_file}".' 171 | ) 172 | 173 | def build_header(self, task: TypeScriptBuildTask, hexdigest: str): 174 | header = HEADER_TEMPLATE.substitute( 175 | generator="django-rest-tsg", 176 | version=VERSION, 177 | type=".".join((task.type.__module__, task.type.__qualname__)), 178 | date=datetime.now().isoformat(), 179 | digest=hexdigest, 180 | ) 181 | header += "\n" 182 | return header 183 | 184 | def build_import_statements(self, task: TypeScriptBuildTask): 185 | result = "" 186 | for dependency in task.code.dependencies: 187 | dependency_options = self.type_options_mapping.get(dependency, {}) 188 | if "alias" in dependency_options: 189 | dependency_name = dependency_options["alias"] 190 | elif issubclass(dependency, Serializer): 191 | dependency_name = get_serializer_prefix(dependency) 192 | else: 193 | dependency_name = dependency.__name__ 194 | dependency_filename = dasherize(underscore(dependency_name)) 195 | if isinstance(dependency, EnumMeta): 196 | dependency_filename += ".enum" 197 | build_dir = task.options.get("build_dir", self.build_dir) 198 | dependency_build_dir = dependency_options.get("build_dir", self.build_dir) 199 | dependency_path = get_relative_path( 200 | build_dir / "foobar", dependency_build_dir / dependency_filename 201 | ) 202 | result += IMPORT_TEMPLATE.substitute( 203 | type=dependency_name, filename=dependency_path 204 | ) 205 | if result: 206 | result += "\n" 207 | return result 208 | -------------------------------------------------------------------------------- /django_rest_tsg/typescript.py: -------------------------------------------------------------------------------- 1 | from collections import ChainMap 2 | from dataclasses import is_dataclass, fields, dataclass 3 | from datetime import datetime, date 4 | from enum import EnumMeta, IntEnum 5 | from typing import ( 6 | get_origin, 7 | get_args, 8 | Annotated, 9 | Union, 10 | Any, 11 | Type, 12 | Dict, 13 | Optional, 14 | List, 15 | Tuple, 16 | Literal, 17 | _Final, 18 | ) 19 | 20 | import rest_framework 21 | from inflection import camelize 22 | from inspect import isclass 23 | from rest_framework.serializers import ( 24 | Serializer, 25 | BooleanField, 26 | CharField, 27 | ChoiceField, 28 | DateField, 29 | DateTimeField, 30 | DecimalField, 31 | DictField, 32 | EmailField, 33 | Field, 34 | FilePathField, 35 | FloatField, 36 | HStoreField, 37 | IPAddressField, 38 | IntegerField, 39 | ModelSerializer, 40 | ManyRelatedField, 41 | JSONField, 42 | ListField, 43 | ListSerializer, 44 | MultipleChoiceField, 45 | ReadOnlyField, 46 | RegexField, 47 | SerializerMethodField, 48 | SlugField, 49 | TimeField, 50 | URLField, 51 | UUIDField, 52 | ) 53 | from rest_framework_dataclasses.fields import EnumField 54 | from rest_framework_dataclasses.serializers import DataclassSerializer 55 | 56 | if rest_framework.VERSION >= "3.14.0": 57 | NullBooleanField = "RemovedInDRF314" 58 | else: 59 | from rest_framework.serializers import NullBooleanField 60 | 61 | from django_rest_tsg.templates import ( 62 | INTERFACE_TEMPLATE, 63 | INTERFACE_FIELD_TEMPLATE, 64 | ENUM_TEMPLATE, 65 | ENUM_MEMBER_TEMPLATE, 66 | ) 67 | 68 | LEFT_BRACKET = "[" 69 | RIGHT_BRACKET = "]" 70 | UNION_SEPARATOR = " | " 71 | 72 | TYPESCRIPT_NULLABLE = " | null" 73 | TYPESCRIPT_ANY = "any" 74 | TYPESCRIPT_STRING = "string" 75 | TYPESCRIPT_NUMBER = "number" 76 | TYPESCRIPT_BOOLEAN = "boolean" 77 | TYPESCRIPT_DATE = "Date" 78 | 79 | GENERICS = (tuple, list, dict, Union, Literal) 80 | GENERIC_FALLBACK_MAPPING = { 81 | list: "any[]", 82 | tuple: "any[]", 83 | dict: "object", 84 | } 85 | TRIVIAL_TYPE_MAPPING: Dict[Type, str] = { 86 | int: TYPESCRIPT_NUMBER, 87 | float: TYPESCRIPT_NUMBER, 88 | str: TYPESCRIPT_STRING, 89 | bool: TYPESCRIPT_BOOLEAN, 90 | datetime: TYPESCRIPT_DATE, 91 | date: TYPESCRIPT_DATE, 92 | type(None): TYPESCRIPT_NULLABLE, 93 | Any: TYPESCRIPT_ANY, 94 | } 95 | DRF_FIELD_MAPPING: Dict[Type[Field], str] = { 96 | BooleanField: TYPESCRIPT_BOOLEAN, 97 | CharField: TYPESCRIPT_STRING, 98 | DateField: TYPESCRIPT_DATE, 99 | DateTimeField: TYPESCRIPT_DATE, 100 | DecimalField: TYPESCRIPT_STRING, 101 | EmailField: TYPESCRIPT_STRING, 102 | FilePathField: TYPESCRIPT_STRING, 103 | FloatField: TYPESCRIPT_NUMBER, 104 | HStoreField: "{[index: string]: string | null}", 105 | IPAddressField: TYPESCRIPT_STRING, 106 | IntegerField: TYPESCRIPT_NUMBER, 107 | JSONField: TYPESCRIPT_ANY, 108 | MultipleChoiceField: TYPESCRIPT_ANY + "[]", 109 | NullBooleanField: TYPESCRIPT_BOOLEAN + TYPESCRIPT_NULLABLE, 110 | RegexField: TYPESCRIPT_STRING, 111 | ReadOnlyField: TYPESCRIPT_ANY, 112 | SerializerMethodField: TYPESCRIPT_ANY, 113 | SlugField: TYPESCRIPT_STRING, 114 | TimeField: TYPESCRIPT_STRING, 115 | URLField: TYPESCRIPT_STRING, 116 | UUIDField: TYPESCRIPT_STRING, 117 | } 118 | USER_DEFINED_TYPE_MAPPING: Dict[Type, str] = {} 119 | TYPE_MAPPING = ChainMap(TRIVIAL_TYPE_MAPPING, USER_DEFINED_TYPE_MAPPING) 120 | TYPE_MAPPING_WITH_GENERIC_FALLBACK = ChainMap( 121 | TRIVIAL_TYPE_MAPPING, USER_DEFINED_TYPE_MAPPING, GENERIC_FALLBACK_MAPPING 122 | ) 123 | 124 | 125 | class TypeScriptCodeType(IntEnum): 126 | INTERFACE = 0 127 | ENUM = 1 128 | 129 | 130 | @dataclass 131 | class TypeScriptCode: 132 | """ 133 | TypeScript code snippet. 134 | """ 135 | name: str 136 | type: TypeScriptCodeType 137 | source: Type[Any] 138 | content: str 139 | dependencies: List[Type] 140 | 141 | 142 | def register(tp: Type, name: Optional[str] = None): 143 | """ 144 | Register user-defined type. 145 | 146 | If no name is specified, use type name as default. 147 | """ 148 | if name: 149 | USER_DEFINED_TYPE_MAPPING[tp] = name 150 | else: 151 | USER_DEFINED_TYPE_MAPPING[tp] = tp.__name__ 152 | return tp 153 | 154 | 155 | def tokenize_python_type(tp) -> list[Union[type, str]]: 156 | """ 157 | Flatten a python type to a token list. 158 | 159 | Tokens can be of the following types: 160 | 161 | * Literals: 'foo', 42 162 | * Built-in types: list, dict 163 | * Types in typing module: Union, Literal 164 | * Brackets: '[' or ']' 165 | * User defined types 166 | """ 167 | if get_origin(tp) is Annotated: 168 | tp = get_args(tp)[0] 169 | 170 | # non-generic fallback 171 | origin = get_origin(tp) 172 | if not origin: 173 | return [tp] 174 | 175 | current_type = tp 176 | result = [] 177 | stack = [] 178 | while True: 179 | if stack: 180 | top = stack.pop() 181 | if top == RIGHT_BRACKET: 182 | result.append(top) 183 | continue 184 | elif ( 185 | isinstance(top, str) 186 | or isinstance(top, int) 187 | or isinstance(top, float) 188 | or top in TYPE_MAPPING 189 | ): 190 | result.append(top) 191 | current_type = top 192 | continue 193 | else: 194 | current_type = top 195 | origin = get_origin(current_type) 196 | if len(stack) == 0 and not origin: 197 | break 198 | if origin is Annotated: 199 | current_type = get_args(current_type)[0] 200 | continue 201 | if origin in GENERICS: 202 | result.append(origin) 203 | result.append(LEFT_BRACKET) 204 | stack.append(RIGHT_BRACKET) 205 | args = get_args(current_type) 206 | for arg in reversed(args): 207 | stack.append(arg) 208 | elif origin in TYPE_MAPPING: 209 | result.append(origin) 210 | # generic fallback 211 | elif current_type in (list, tuple): 212 | result += [current_type, LEFT_BRACKET, Any, RIGHT_BRACKET] 213 | elif current_type is dict: 214 | result.append("object") 215 | return result 216 | 217 | 218 | def _build_type(tokens) -> str: 219 | """ 220 | Build typescript type from tokens. 221 | """ 222 | generic_stack: List = [] 223 | children_stack: List[List] = [] 224 | 225 | # fallback 226 | if len(tokens) == 1: 227 | tp = tokens[0] 228 | return TYPE_MAPPING_WITH_GENERIC_FALLBACK.get(tp, tp.__name__) 229 | 230 | for token in tokens: 231 | if token in GENERICS: 232 | generic_stack.append(token) 233 | elif token is LEFT_BRACKET: 234 | children_stack.append([]) 235 | elif token is RIGHT_BRACKET: 236 | generic = generic_stack.pop() 237 | children = children_stack.pop() 238 | generic_children = [] 239 | for child in children: 240 | if child not in generic_children: 241 | generic_children.append(child) 242 | generic_type = _build_generic_type(generic, generic_children) 243 | if len(children_stack) > 0: 244 | children_stack[-1].append(generic_type) 245 | else: 246 | return generic_type 247 | else: 248 | children_stack[-1].append(TYPE_MAPPING.get(token, token)) 249 | 250 | 251 | def _build_generic_type(tp, children=None) -> str: 252 | """ 253 | Build typescript type from python generic type with children. 254 | """ 255 | if not children: 256 | children = [] 257 | if tp is Union: 258 | if len(children) == 2 and TYPESCRIPT_NULLABLE in children: 259 | for child in children: 260 | if child == TYPESCRIPT_NULLABLE: 261 | continue 262 | return "".join((child, TYPESCRIPT_NULLABLE)) 263 | return " | ".join(children) 264 | elif tp in (list, tuple) and children: 265 | return f"Array<{children[0]}>" 266 | elif tp is dict and children: 267 | return f"{{[key: {children[0]}]: {children[1]}}}" 268 | elif tp is Literal: 269 | parts = [] 270 | for child in children: 271 | if isinstance(child, str): 272 | part = f"'{child}'" 273 | else: 274 | part = str(child) 275 | parts.append(part) 276 | return " | ".join(parts) 277 | 278 | 279 | def build_type(tp) -> Tuple[str, List[Type]]: 280 | """ 281 | Build typescript type from python type. 282 | """ 283 | tokens = tokenize_python_type(tp) 284 | dependencies = [ 285 | token 286 | for token in tokens 287 | if token not in TYPE_MAPPING_WITH_GENERIC_FALLBACK 288 | and not type(token) in TRIVIAL_TYPE_MAPPING 289 | and not isinstance(token, _Final) 290 | ] 291 | return _build_type(tokens), dependencies 292 | 293 | 294 | def build_enum( 295 | enum_tp: EnumMeta, enum_name: str = None, enforce_uppercase: bool = False 296 | ) -> TypeScriptCode: 297 | """ 298 | Build typescript enum from python enum. 299 | """ 300 | enum_members = [] 301 | for name, member in enum_tp.__members__.items(): 302 | member_type = type(member.value) 303 | member_value = member.value 304 | if member_type is str: 305 | member_value = f"'{member.value}'" 306 | if enforce_uppercase: 307 | member_name = name.upper() 308 | else: 309 | member_name = camelize(name.lower(), uppercase_first_letter=False) 310 | member_name = member_name[0].upper() + member_name[1:] 311 | enum_members.append( 312 | ENUM_MEMBER_TEMPLATE.substitute(name=member_name, value=member_value) 313 | ) 314 | if not enum_name: 315 | enum_name = enum_tp.__name__ 316 | return TypeScriptCode( 317 | type=TypeScriptCodeType.ENUM, 318 | source=enum_tp, 319 | name=enum_name, 320 | dependencies=[], 321 | content=ENUM_TEMPLATE.substitute( 322 | members=",\n".join(enum_members), name=enum_tp.__name__ 323 | ), 324 | ) 325 | 326 | 327 | def build_interface_from_dataclass( 328 | data_cls, interface_name: str = None 329 | ) -> TypeScriptCode: 330 | """ 331 | Build typescript interface from python dataclass. 332 | """ 333 | assert is_dataclass(data_cls) 334 | if not interface_name: 335 | interface_name = data_cls.__name__ 336 | interface_fields = [] 337 | interface_dependencies = set() 338 | for field in fields(data_cls): 339 | field_type_representation, field_dependencies = build_type(field.type) 340 | interface_dependencies |= set(field_dependencies) 341 | interface_fields.append( 342 | INTERFACE_FIELD_TEMPLATE.substitute( 343 | name=camelize(field.name, uppercase_first_letter=False), 344 | type=field_type_representation, 345 | ) 346 | ) 347 | return TypeScriptCode( 348 | type=TypeScriptCodeType.INTERFACE, 349 | source=data_cls, 350 | name=interface_name, 351 | dependencies=list(interface_dependencies), 352 | content=INTERFACE_TEMPLATE.substitute( 353 | fields="\n".join(interface_fields), name=interface_name 354 | ), 355 | ) 356 | 357 | 358 | def get_serializer_prefix(serializer_class: Type[Serializer]): 359 | """FooSerializer -> Foo""" 360 | return serializer_class.__name__[:-10] 361 | 362 | 363 | def _get_serializer_field_type(field: Field) -> Tuple[str, Optional[Type]]: 364 | """ 365 | Get typescript type from trivial serializer field. 366 | """ 367 | field_type: str 368 | dependency = None 369 | if type(field) in DRF_FIELD_MAPPING: 370 | field_type = DRF_FIELD_MAPPING[type(field)] 371 | elif isinstance(field, ModelSerializer): 372 | field_type = field.Meta.model.__name__ 373 | elif isinstance(field, DataclassSerializer): 374 | if field.dataclass: 375 | dependency = field.dataclass 376 | field_type = dependency.__name__ 377 | else: 378 | dependency = field.Meta.dataclass 379 | field_type = dependency.__name__ 380 | elif isinstance(field, EnumField): 381 | field_type = field.enum_class.__name__ 382 | dependency = field.enum_class 383 | elif isinstance(field, ChoiceField): 384 | parts = [] 385 | for value in field.choices.values(): 386 | if isinstance(value, str): 387 | part = f"'{value}'" 388 | else: 389 | part = str(value) 390 | parts.append(part) 391 | field_type = " | ".join(parts) 392 | elif isinstance(field, ManyRelatedField): 393 | raise Exception("No explicit type hinting.") 394 | elif isinstance(field, ListSerializer): 395 | field_type = get_serializer_prefix(type(field.child)) + "[]" 396 | dependency = type(field.child) 397 | elif isinstance(field, Serializer): 398 | field_type = get_serializer_prefix(type(field)) 399 | dependency = type(field) 400 | else: 401 | field_type = TYPESCRIPT_ANY 402 | if field_type != TYPESCRIPT_ANY and field.allow_null: 403 | field_type += TYPESCRIPT_NULLABLE 404 | return field_type, dependency 405 | 406 | 407 | def get_serializer_field_type(field: Field) -> Tuple[str, list]: 408 | """ 409 | Get typescript type from serializer field 410 | 411 | Composite fields will be flattened. 412 | """ 413 | stack = [] 414 | result = "" 415 | dependencies = set() 416 | while True: 417 | if isinstance(field, (DictField, ListField)): 418 | stack.append(type(field)) 419 | field = field.child 420 | else: 421 | field_type, field_dependency = _get_serializer_field_type(field) 422 | if field_dependency: 423 | dependencies.add(field_dependency) 424 | stack.append(field_type) 425 | break 426 | for item in reversed(stack): 427 | if isclass(item): 428 | if issubclass(item, ListField): 429 | result += "[]" 430 | elif issubclass(item, DictField): 431 | result = f"{{[index: string]: {result}}}" 432 | else: 433 | result = item 434 | return result, sorted(list(dependencies), key=lambda tp: tp.__name__) 435 | 436 | 437 | def build_interface_from_serializer( 438 | serializer_class: Type[Serializer], interface_name: Optional[str] = None 439 | ) -> TypeScriptCode: 440 | """ 441 | Build typescript interface from django rest framework serializer. 442 | """ 443 | assert issubclass(serializer_class, Serializer) 444 | serializer: Serializer = serializer_class() 445 | interface_fields = [] 446 | interface_dependencies = set() 447 | for field_name, field_instance in serializer.get_fields().items(): 448 | field_instance: Field 449 | field_type = type(field_instance) 450 | if field_type in DRF_FIELD_MAPPING: 451 | field_type = DRF_FIELD_MAPPING[field_type] 452 | if field_instance.allow_null: 453 | field_type += TYPESCRIPT_NULLABLE 454 | else: 455 | field_type, field_dependencies = get_serializer_field_type(field_instance) 456 | for dependency in field_dependencies: 457 | interface_dependencies.add(dependency) 458 | interface_fields.append( 459 | INTERFACE_FIELD_TEMPLATE.substitute( 460 | name=camelize(field_name, uppercase_first_letter=False), type=field_type 461 | ) 462 | ) 463 | 464 | if not interface_name: 465 | interface_name = get_serializer_prefix(serializer_class) 466 | return TypeScriptCode( 467 | type=TypeScriptCodeType.INTERFACE, 468 | source=serializer_class, 469 | name=interface_name, 470 | dependencies=sorted(list(interface_dependencies), key=lambda tp: tp.__name__), 471 | content=INTERFACE_TEMPLATE.substitute( 472 | fields="\n".join(interface_fields), name=interface_name 473 | ), 474 | ) 475 | --------------------------------------------------------------------------------