├── tests ├── __init__.py ├── conftest.py ├── models.py ├── test_many.py ├── test_identity_field.py ├── test_backwards_ref.py └── test_forward_ref.py ├── hypermediachannels ├── __init__.py ├── serializers.py └── fields.py ├── MANIFEST.in ├── setup.cfg ├── .github └── FUNDING.yml ├── requirements.txt ├── .travis.yml ├── setup.py ├── LICENSE ├── .gitignore └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hypermediachannels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [tool:pytest] 5 | addopts = tests/ 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [hishnash] 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | channels>=3.0.0 2 | Django>=2.0.0 3 | djangochannelsrestframework==0.0.3 4 | channelsmultiplexer==0.0.2 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - "3.7.6" 7 | - "3.8" 8 | - "3.8.2" 9 | 10 | env: 11 | - DJANGO="Django>=2.2,<3.0.0" 12 | - DJANGO="Django>=3.0.4,<3.0.5" 13 | 14 | install: 15 | - pip install $DJANGO -e .[tests] 16 | - pip freeze 17 | 18 | script: 19 | - pytest 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def pytest_configure(): 5 | settings.configure( 6 | INSTALLED_APPS=( 7 | 'django.contrib.auth', 8 | 'django.contrib.contenttypes', 9 | 'django.contrib.sessions', 10 | 'channels', 11 | 'tests' 12 | ), 13 | SECRET_KEY='dog', 14 | 15 | DATABASES={ 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.sqlite3', 18 | } 19 | }, 20 | 21 | MIDDLEWARE_CLASSES=[] 22 | ) 23 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class User(models.Model): 5 | """Simple model to test with.""" 6 | 7 | username = models.CharField(max_length=255) 8 | 9 | 10 | class Team(models.Model): 11 | name = models.CharField(max_length=256, null=False, blank=False) 12 | 13 | 14 | class UserProfile(models.Model): 15 | created = models.DateTimeField(auto_now_add=True) 16 | user = models.ForeignKey( 17 | User, 18 | null=True, 19 | blank=True, 20 | related_name='profile', 21 | on_delete=models.CASCADE 22 | ) 23 | team = models.ForeignKey( 24 | Team, related_name='profiles', 25 | on_delete=models.CASCADE 26 | ) 27 | 28 | friends = models.ManyToManyField( 29 | 'UserProfile', 30 | related_name='friended' 31 | ) 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='hypermediachannels', 5 | version="0.0.5", 6 | url='https://github.com/hishnash/hypermediachannels', 7 | author='Matthaus Woolard', 8 | author_email='matthaus.woolard@gmail.com', 9 | description="Hyper Media Channels Rest Framework.", 10 | long_description=open('README.rst').read(), 11 | license='MIT', 12 | packages=find_packages(exclude=['tests']), 13 | include_package_data=True, 14 | install_requires=[ 15 | 'channels>=3.0.0', 16 | 'Django>=2.11', 17 | 'djangochannelsrestframework~=0.0.6', 18 | 'channelsmultiplexer~=0.0.2' 19 | ], 20 | extras_require={ 21 | 'tests': [ 22 | 'pytest~=3.7.1', 23 | "pytest-django~=3.4.1", 24 | "pytest-asyncio~=0.9", 25 | 'coverage~=4.4', 26 | ], 27 | }, 28 | classifiers=[ 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Matthaus Woolard 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_many.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channelsmultiplexer import AsyncJsonWebsocketDemultiplexer 3 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 4 | 5 | from hypermediachannels.serializers import HyperChannelsApiModelSerializer 6 | from tests.models import User 7 | 8 | 9 | class UserSerializer(HyperChannelsApiModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ( 13 | '@id', 14 | 'username' 15 | ) 16 | 17 | 18 | class UserConsumer(GenericAsyncAPIConsumer): 19 | queryset = User.objects.all() 20 | serializer_class = UserSerializer 21 | 22 | 23 | class MainDemultiplexer(AsyncJsonWebsocketDemultiplexer): 24 | applications = { 25 | 'users': UserConsumer, 26 | 'active_users': UserConsumer 27 | } 28 | 29 | 30 | @pytest.mark.django_db(transaction=True) 31 | def test_default_params(): 32 | 33 | user = User.objects.create( 34 | username='bob' 35 | ) 36 | 37 | data = UserSerializer( 38 | instance=User.objects.all(), 39 | many=True, 40 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 41 | ).data 42 | 43 | assert data == [ 44 | { 45 | 'stream': 'users', 46 | 'payload': { 47 | 'pk': user.pk, 48 | 'action': 'retrieve' 49 | } 50 | } 51 | ] 52 | 53 | 54 | @pytest.mark.django_db(transaction=True) 55 | def test_custom_mapping(): 56 | 57 | class UserSerializer(HyperChannelsApiModelSerializer): 58 | 59 | class Meta: 60 | model = User 61 | fields = ( 62 | '@id', 63 | 'username' 64 | ) 65 | 66 | many_stream_name = 'active_users' 67 | 68 | many_kwarg_mappings = { 69 | 'username': 'username' 70 | } 71 | 72 | user = User.objects.create( 73 | username='bob' 74 | ) 75 | 76 | data = UserSerializer( 77 | instance=User.objects.all(), 78 | many=True, 79 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 80 | ).data 81 | 82 | assert data == [ 83 | { 84 | 'stream': 'active_users', 85 | 'payload': { 86 | 'username': user.username, 87 | 'action': 'retrieve' 88 | } 89 | } 90 | ] -------------------------------------------------------------------------------- /tests/test_identity_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channelsmultiplexer import AsyncJsonWebsocketDemultiplexer 3 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 4 | 5 | from hypermediachannels.serializers import HyperChannelsApiModelSerializer 6 | from tests.models import User 7 | 8 | 9 | class UserSerializer(HyperChannelsApiModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ( 13 | '@id', 14 | 'username' 15 | ) 16 | 17 | 18 | class UserConsumer(GenericAsyncAPIConsumer): 19 | queryset = User.objects.all() 20 | serializer_class = UserSerializer 21 | 22 | 23 | class MainDemultiplexer(AsyncJsonWebsocketDemultiplexer): 24 | applications = { 25 | 'users': UserConsumer 26 | } 27 | 28 | 29 | @pytest.mark.django_db(transaction=True) 30 | def test_default_params(): 31 | 32 | user = User.objects.create( 33 | username='bob' 34 | ) 35 | 36 | with pytest.raises(ValueError, message='HyperlinkedIdentityField must be used on an DCRF view that is nested within a demultiplexer', ): 37 | UserSerializer(instance=user).data 38 | 39 | data = UserSerializer( 40 | instance=user, 41 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 42 | ).data 43 | 44 | assert data == { 45 | 'username': 'bob', 46 | '@id': { 47 | 'stream': 'users', 48 | 'payload': { 49 | 'pk': user.pk, 50 | 'action': 'retrieve' 51 | } 52 | } 53 | } 54 | 55 | 56 | @pytest.mark.django_db(transaction=True) 57 | def test_override_lookup(): 58 | 59 | user = User.objects.create( 60 | username='bob' 61 | ) 62 | 63 | class UserSerializer(HyperChannelsApiModelSerializer): 64 | class Meta: 65 | model = User 66 | fields = ( 67 | '@id', 68 | 'username' 69 | ) 70 | 71 | extra_kwargs = { 72 | '@id': { 73 | 'action_name': 'find_user', 74 | 'kwarg_mappings': { 75 | 'username': 'username' 76 | } 77 | }, 78 | } 79 | 80 | data = UserSerializer( 81 | instance=user, 82 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 83 | ).data 84 | 85 | assert data == { 86 | 'username': 'bob', 87 | '@id': { 88 | 'stream': 'users', 89 | 'payload': { 90 | 'username': 'bob', 91 | 'action': 'find_user' 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /hypermediachannels/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Dict, Any, List 2 | 3 | from django.db.models import QuerySet 4 | 5 | from rest_framework.serializers import ( 6 | ModelSerializer, 7 | SerializerMetaclass, 8 | ListSerializer 9 | ) 10 | 11 | from hypermediachannels.fields import ( 12 | HyperChannelsApiRelationField, 13 | HyperChannelsApiMixin 14 | ) 15 | 16 | 17 | class HyperChannelsApiListSerializer(HyperChannelsApiMixin, 18 | ListSerializer): 19 | @property 20 | def stream_name(self): 21 | return getattr(self.child.Meta, 'many_stream_name', super().stream_name) 22 | 23 | @property 24 | def action_name(self): 25 | return getattr(self.child.Meta, 'many_action_name', super().action_name) 26 | 27 | @property 28 | def kwarg_mappings(self): 29 | return getattr(self.child.Meta, 'many_kwarg_mappings', super().kwarg_mappings) 30 | 31 | def __init__(self, *args, **kwargs): 32 | super().__init__(*args, **kwargs) 33 | 34 | def to_representation(self, queryset: QuerySet) -> List[Dict]: 35 | return [ 36 | # strange but we need to use old style `super` here 37 | super(HyperChannelsApiListSerializer, self).to_representation(item) 38 | for item in queryset 39 | ] 40 | 41 | 42 | class HyperChannelsApiSerializerMetaclass(SerializerMetaclass): 43 | def __new__(mcs, name: str, bases: Iterable[type], 44 | attrs: Dict[str, Any]) -> 'HyperChannelsApiModelSerializer': 45 | 46 | if 'Meta' in attrs and getattr( 47 | attrs['Meta'], 48 | 'list_serializer_class', 49 | None 50 | ) is None: 51 | 52 | attrs['Meta'].list_serializer_class = HyperChannelsApiListSerializer 53 | return super().__new__(mcs, name, bases, attrs) 54 | 55 | 56 | class HyperlinkedIdentityField(HyperChannelsApiRelationField): 57 | """ 58 | A read-only field that represents the identity URL for an object, itself. 59 | 60 | This is in contrast to `HyperlinkedRelatedField` which represents the 61 | URL of relationships to other objects. 62 | """ 63 | 64 | def __init__(self, **kwargs): 65 | kwargs['read_only'] = True 66 | kwargs['source'] = '*' 67 | super().__init__(**kwargs) 68 | 69 | def use_pk_only_optimization(self): 70 | return False 71 | 72 | 73 | class HyperChannelsApiModelSerializer( 74 | ModelSerializer, 75 | metaclass=HyperChannelsApiSerializerMetaclass): 76 | 77 | url_field_name = '@id' 78 | serializer_related_field = HyperChannelsApiRelationField 79 | serializer_url_field = HyperlinkedIdentityField 80 | 81 | 82 | def get_default_field_names(self, declared_fields, model_info): 83 | """ 84 | Return the default list of field names that will be used if the 85 | `Meta.fields` option is not specified. 86 | """ 87 | return ( 88 | [self.url_field_name] + 89 | list(declared_fields.keys()) + 90 | list(model_info.fields.keys()) + 91 | list(model_info.forward_relations.keys()) 92 | ) 93 | 94 | def build_url_field(self, field_name, model_class): 95 | """ 96 | Create a field representing the object's own URL. 97 | """ 98 | field_class = self.serializer_url_field 99 | field_kwargs = {} 100 | 101 | return field_class, field_kwargs 102 | -------------------------------------------------------------------------------- /tests/test_backwards_ref.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channelsmultiplexer import AsyncJsonWebsocketDemultiplexer 3 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 4 | 5 | from hypermediachannels.serializers import HyperChannelsApiModelSerializer 6 | from tests.models import User, UserProfile, Team 7 | 8 | 9 | class UserSerializer(HyperChannelsApiModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ( 13 | '@id', 14 | 'username', 15 | 'profile' 16 | ) 17 | 18 | extra_kwargs = { 19 | 'profile': { 20 | 'kwarg_mappings': { 21 | 'user_pk': 'self.pk', 22 | } 23 | }, 24 | } 25 | 26 | 27 | class UserProfileSerializer(HyperChannelsApiModelSerializer): 28 | class Meta: 29 | model = UserProfile 30 | fields = ( 31 | '@id', 32 | 'user' 33 | ) 34 | 35 | 36 | class UserConsumer(GenericAsyncAPIConsumer): 37 | queryset = User.objects.all() 38 | serializer_class = UserSerializer 39 | 40 | 41 | class UserProfileConsumer(GenericAsyncAPIConsumer): 42 | queryset = UserProfile.objects.all() 43 | serializer_class = UserProfileSerializer 44 | 45 | 46 | class MainDemultiplexer(AsyncJsonWebsocketDemultiplexer): 47 | applications = { 48 | 'users': UserConsumer, 49 | 'profiles': UserProfileConsumer 50 | } 51 | 52 | 53 | @pytest.mark.django_db(transaction=True) 54 | def test_default_params(): 55 | 56 | user = User.objects.create( 57 | username='boby' 58 | ) 59 | 60 | team = Team.objects.create( 61 | name='The Team' 62 | ) 63 | 64 | profile = UserProfile.objects.create( 65 | user=user, 66 | team=team 67 | ) 68 | 69 | data = UserSerializer( 70 | instance=user, 71 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 72 | ).data 73 | 74 | assert data == { 75 | 'username': 'boby', 76 | '@id': { 77 | 'stream': 'users', 78 | 'payload': { 79 | 'pk': user.pk, 80 | 'action': 'retrieve' 81 | } 82 | }, 83 | 'profile': { 84 | 'payload': { 85 | 'action': 'list', 86 | 'user_pk': user.pk 87 | }, 88 | 'stream': 'profiles' 89 | } 90 | } 91 | 92 | 93 | @pytest.mark.django_db(transaction=True) 94 | def test_many_to_many(): 95 | 96 | class UserProfileSerializer(HyperChannelsApiModelSerializer): 97 | class Meta: 98 | model = UserProfile 99 | fields = ( 100 | '@id', 101 | 'friended' 102 | ) 103 | 104 | extra_kwargs = { 105 | 'friended': { 106 | 'action_name': 'friended_by_profiles', 107 | 'kwarg_mappings': { 108 | 'user_pk': 'self.user.pk', 109 | } 110 | }, 111 | } 112 | 113 | 114 | user = User.objects.create( 115 | username='boby' 116 | ) 117 | 118 | team = Team.objects.create( 119 | name='The Team' 120 | ) 121 | 122 | profile = UserProfile.objects.create( 123 | user=user, 124 | team=team 125 | ) 126 | 127 | data = UserProfileSerializer( 128 | instance=profile, 129 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 130 | ).data 131 | 132 | assert data == { 133 | '@id': { 134 | 'stream': 'profiles', 135 | 'payload': { 136 | 'pk': profile.pk, 137 | 'action': 'retrieve' 138 | } 139 | }, 140 | 'friended': { 141 | 'payload': { 142 | 'action': 'friended_by_profiles', 143 | 'user_pk': user.pk 144 | }, 145 | 'stream': 'profiles' 146 | } 147 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | ### JetBrains template 30 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 31 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 32 | 33 | # User-specific stuff: 34 | .idea/**/workspace.xml 35 | .idea/**/tasks.xml 36 | .idea/dictionaries 37 | 38 | # Sensitive or high-churn files: 39 | .idea/**/dataSources/ 40 | .idea/**/dataSources.ids 41 | .idea/**/dataSources.xml 42 | .idea/**/dataSources.local.xml 43 | .idea/**/sqlDataSources.xml 44 | .idea/**/dynamic.xml 45 | .idea/**/uiDesigner.xml 46 | 47 | # Gradle: 48 | .idea/**/gradle.xml 49 | .idea/**/libraries 50 | 51 | # CMake 52 | cmake-build-debug/ 53 | cmake-build-release/ 54 | 55 | # Mongo Explorer plugin: 56 | .idea/**/mongoSettings.xml 57 | 58 | ## File-based project format: 59 | *.iws 60 | 61 | ## Plugin-specific files: 62 | 63 | # IntelliJ 64 | out/ 65 | 66 | # mpeltonen/sbt-idea plugin 67 | .idea_modules/ 68 | 69 | # JIRA plugin 70 | atlassian-ide-plugin.xml 71 | 72 | # Cursive Clojure plugin 73 | .idea/replstate.xml 74 | 75 | # Crashlytics plugin (for Android Studio and IntelliJ) 76 | com_crashlytics_export_strings.xml 77 | crashlytics.properties 78 | crashlytics-build.properties 79 | fabric.properties 80 | ### Linux template 81 | *~ 82 | 83 | # temporary files which can be created if a process still has a handle open of a deleted file 84 | .fuse_hidden* 85 | 86 | # KDE directory preferences 87 | .directory 88 | 89 | # Linux trash folder which might appear on any partition or disk 90 | .Trash-* 91 | 92 | # .nfs files are created when an open file is removed but is still being accessed 93 | .nfs* 94 | ### Windows template 95 | # Windows thumbnail cache files 96 | Thumbs.db 97 | ehthumbs.db 98 | ehthumbs_vista.db 99 | 100 | # Dump file 101 | *.stackdump 102 | 103 | # Folder config file 104 | [Dd]esktop.ini 105 | 106 | # Recycle Bin used on file shares 107 | $RECYCLE.BIN/ 108 | 109 | # Windows Installer files 110 | *.cab 111 | *.msi 112 | *.msm 113 | *.msp 114 | 115 | # Windows shortcuts 116 | *.lnk 117 | ### Python template 118 | # Byte-compiled / optimized / DLL files 119 | __pycache__/ 120 | *.py[cod] 121 | *$py.class 122 | 123 | # C extensions 124 | *.so 125 | 126 | # Distribution / packaging 127 | .Python 128 | build/ 129 | develop-eggs/ 130 | dist/ 131 | downloads/ 132 | eggs/ 133 | .eggs/ 134 | lib/ 135 | lib64/ 136 | parts/ 137 | sdist/ 138 | var/ 139 | wheels/ 140 | *.egg-info/ 141 | .installed.cfg 142 | *.egg 143 | MANIFEST 144 | 145 | # PyInstaller 146 | # Usually these files are written by a python script from a template 147 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 148 | *.manifest 149 | *.spec 150 | 151 | # Installer logs 152 | pip-log.txt 153 | pip-delete-this-directory.txt 154 | 155 | # Unit test / coverage reports 156 | htmlcov/ 157 | .tox/ 158 | .coverage 159 | .coverage.* 160 | .cache 161 | nosetests.xml 162 | coverage.xml 163 | *.cover 164 | .hypothesis/ 165 | 166 | # Translations 167 | *.mo 168 | *.pot 169 | 170 | # Django stuff: 171 | *.log 172 | .static_storage/ 173 | .media/ 174 | local_settings.py 175 | 176 | # Flask stuff: 177 | instance/ 178 | .webassets-cache 179 | 180 | # Scrapy stuff: 181 | .scrapy 182 | 183 | # Sphinx documentation 184 | docs/_build/ 185 | 186 | # PyBuilder 187 | target/ 188 | 189 | # Jupyter Notebook 190 | .ipynb_checkpoints 191 | 192 | # pyenv 193 | .python-version 194 | 195 | # celery beat schedule file 196 | celerybeat-schedule 197 | 198 | # SageMath parsed files 199 | *.sage.py 200 | 201 | # Environments 202 | .env 203 | .venv 204 | env/ 205 | venv/ 206 | ENV/ 207 | env.bak/ 208 | venv.bak/ 209 | 210 | # Spyder project settings 211 | .spyderproject 212 | .spyproject 213 | 214 | # Rope project settings 215 | .ropeproject 216 | 217 | # mkdocs documentation 218 | /site 219 | 220 | # mypy 221 | .mypy_cache/ 222 | 223 | .idea 224 | .pytest_cache 225 | -------------------------------------------------------------------------------- /tests/test_forward_ref.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channelsmultiplexer import AsyncJsonWebsocketDemultiplexer 3 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 4 | 5 | from hypermediachannels.serializers import HyperChannelsApiModelSerializer 6 | from tests.models import User, UserProfile, Team 7 | 8 | 9 | class UserSerializer(HyperChannelsApiModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ( 13 | '@id', 14 | 'username' 15 | ) 16 | 17 | 18 | class UserProfileSerializer(HyperChannelsApiModelSerializer): 19 | class Meta: 20 | model = UserProfile 21 | fields = ( 22 | '@id', 23 | 'user' 24 | ) 25 | 26 | 27 | class UserConsumer(GenericAsyncAPIConsumer): 28 | queryset = User.objects.all() 29 | serializer_class = UserSerializer 30 | 31 | 32 | class UserProfileConsumer(GenericAsyncAPIConsumer): 33 | queryset = UserProfile.objects.all() 34 | serializer_class = UserProfileSerializer 35 | 36 | 37 | class MainDemultiplexer(AsyncJsonWebsocketDemultiplexer): 38 | applications = { 39 | 'users': UserConsumer, 40 | 'profiles': UserProfileConsumer 41 | } 42 | 43 | 44 | @pytest.mark.django_db(transaction=True) 45 | def test_default_params(): 46 | 47 | user = User.objects.create( 48 | username='bob' 49 | ) 50 | 51 | team = Team.objects.create( 52 | name='The Team' 53 | ) 54 | 55 | profile = UserProfile.objects.create( 56 | user=user, 57 | team=team 58 | ) 59 | 60 | data = UserProfileSerializer( 61 | instance=profile, 62 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 63 | ).data 64 | 65 | assert data == { 66 | 'user': {'payload': {'action': 'retrieve', 'pk': user.pk}, 'stream': 'users'}, 67 | '@id': { 68 | 'stream': 'profiles', 69 | 'payload': { 70 | 'pk': profile.pk, 71 | 'action': 'retrieve' 72 | } 73 | } 74 | } 75 | 76 | 77 | @pytest.mark.django_db(transaction=True) 78 | def test_override_lookup(): 79 | user = User.objects.create( 80 | username='bob' 81 | ) 82 | 83 | team = Team.objects.create( 84 | name='The Team' 85 | ) 86 | 87 | profile = UserProfile.objects.create( 88 | user=user, 89 | team=team 90 | ) 91 | 92 | class UserProfileSerializer(HyperChannelsApiModelSerializer): 93 | class Meta: 94 | model = UserProfile 95 | fields = ( 96 | '@id', 97 | 'user' 98 | ) 99 | 100 | extra_kwargs = { 101 | 'user': { 102 | 'kwarg_mappings': { 103 | 'username': 'username', 104 | 'profile_pk': 'self.pk', 105 | 'team_pk': 'self.team.pk' 106 | } 107 | }, 108 | } 109 | 110 | data = UserProfileSerializer( 111 | instance=profile, 112 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 113 | ).data 114 | 115 | assert data == { 116 | '@id': { 117 | 'stream': 'profiles', 118 | 'payload': { 119 | 'pk': profile.pk, 120 | 'action': 'retrieve' 121 | } 122 | }, 123 | 'user': { 124 | 'stream': 'users', 125 | 'payload': { 126 | 'username': 'bob', 127 | 'profile_pk': profile.pk, 128 | 'team_pk': team.pk, 129 | 'action': 'retrieve' 130 | } 131 | }, 132 | } 133 | 134 | 135 | @pytest.mark.django_db(transaction=True) 136 | def test_many_to_many(): 137 | 138 | class UserProfileSerializer(HyperChannelsApiModelSerializer): 139 | class Meta: 140 | model = UserProfile 141 | fields = ( 142 | '@id', 143 | 'friends' 144 | ) 145 | 146 | extra_kwargs = { 147 | 'friends': { 148 | 'action_name': 'friends_with_profiles', 149 | 'kwarg_mappings': { 150 | 'user_pk': 'self.user.pk', 151 | } 152 | }, 153 | } 154 | 155 | 156 | user = User.objects.create( 157 | username='boby' 158 | ) 159 | 160 | team = Team.objects.create( 161 | name='The Team' 162 | ) 163 | 164 | profile = UserProfile.objects.create( 165 | user=user, 166 | team=team 167 | ) 168 | 169 | data = UserProfileSerializer( 170 | instance=profile, 171 | context={'scope': {'demultiplexer_cls': MainDemultiplexer}} 172 | ).data 173 | 174 | assert data == { 175 | '@id': { 176 | 'stream': 'profiles', 177 | 'payload': { 178 | 'pk': profile.pk, 179 | 'action': 'retrieve' 180 | } 181 | }, 182 | 'friends': { 183 | 'payload': { 184 | 'action': 'friends_with_profiles', 185 | 'user_pk': user.pk 186 | }, 187 | 'stream': 'profiles' 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /hypermediachannels/fields.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Optional, Dict, Type, List, Any 2 | 3 | from channels.routing import get_default_application 4 | from django.db.models import QuerySet, Model, Manager 5 | from django.http import Http404 6 | 7 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 8 | from rest_framework.exceptions import ValidationError 9 | from rest_framework.fields import get_attribute 10 | from rest_framework.generics import get_object_or_404 11 | from rest_framework.relations import ( 12 | RelatedField, ManyRelatedField, 13 | MANY_RELATION_KWARGS 14 | ) 15 | 16 | 17 | class HyperChannelsApiMixin: 18 | kwarg_mappings = {'pk': 'pk'} 19 | action_name = 'retrieve' 20 | stream_name = None 21 | 22 | def __init__(self, action_name=None, **kwargs): 23 | 24 | kwarg_mappings = kwargs.pop('kwarg_mappings', None) 25 | 26 | stream_name = kwargs.pop('stream_name', None) 27 | 28 | super().__init__(**kwargs) 29 | 30 | if action_name is not None: 31 | self.action_name = action_name 32 | assert self.action_name is not None, 'The `action_name` argument is ' \ 33 | 'required.' 34 | 35 | if kwarg_mappings is not None: 36 | self.kwarg_mappings = kwarg_mappings 37 | 38 | if self.kwarg_mappings is None: 39 | self.kwarg_mappings = {} 40 | 41 | if stream_name is not None: 42 | self.stream_name = stream_name 43 | 44 | 45 | @property 46 | def api_demultiplexer(self): 47 | demultiplexer_cls = self.context.get( 48 | 'scope', {} 49 | ).get('demultiplexer_cls') 50 | if demultiplexer_cls is None: 51 | raise ValueError('{} must be used on an DCRF view that is nested ' 52 | 'within a demultiplexer'.format( 53 | self.__class__.__name__ 54 | )) 55 | return demultiplexer_cls 56 | 57 | def resolve(self, model: Type[Model]) -> Tuple[ 58 | Optional[str], Optional[GenericAsyncAPIConsumer]]: 59 | 60 | if self.api_demultiplexer is None: 61 | return None, None 62 | 63 | if self.stream_name is not None: 64 | consumer = self.api_demultiplexer.applications[ 65 | self.stream_name 66 | ] 67 | stream_name = self.stream_name 68 | else: 69 | stream_name, consumer = self._get_resolve(model) 70 | 71 | return stream_name, consumer 72 | 73 | def _get_resolve(self, instance: Type[Model]) -> Tuple[ 74 | Optional[str], Optional[Type[GenericAsyncAPIConsumer]]]: 75 | 76 | matches: List[Tuple[int, str, Type[GenericAsyncAPIConsumer]]] = [] 77 | 78 | for (stream, consumer) in self.api_demultiplexer.applications.items(): 79 | 80 | if consumer.consumer_class.queryset is None: 81 | continue 82 | 83 | match = self._get_model_distance(instance, consumer.consumer_class.queryset.model) 84 | 85 | if match is not None: 86 | matches.append( 87 | ( 88 | match, stream, consumer 89 | ) 90 | ) 91 | 92 | if matches: 93 | matches.sort(key=lambda x: x[0]) 94 | _, stream, consumer = matches[0] 95 | return stream, consumer 96 | return None, None 97 | 98 | def _get_model_distance(self, model_cls: Type[Model], other_model_cls: Type[Model]) -> Optional[int]: 99 | """ 100 | Return the distance (in the inheritance tree between to models) 101 | """ 102 | if model_cls == other_model_cls: 103 | return 0 104 | 105 | if not issubclass(model_cls, other_model_cls): 106 | return None 107 | 108 | return model_cls.__bases__.index(other_model_cls) 109 | 110 | def to_representation(self, instance: Model) -> Optional[Dict]: 111 | stream_name, consumer = self.resolve(type(instance)) 112 | if (stream_name, consumer) == (None, None): 113 | return 114 | 115 | payload = { 116 | 'action': self.action_name 117 | } 118 | 119 | payload.update( 120 | self.extract_lookups(instance) 121 | ) 122 | 123 | return { 124 | 'stream': stream_name, 125 | 'payload': payload 126 | } 127 | 128 | def extract_lookups(self, instance) -> Dict[str, Any]: 129 | payload = {} 130 | for (key, lookup) in self.kwarg_mappings.items(): 131 | payload[key] = self.extract_lookup(instance, key, lookup) 132 | return payload 133 | 134 | def extract_lookup(self, instance, key, lookup) -> Any: 135 | lookup_path = lookup.split('.') 136 | if lookup_path[0] == 'self': 137 | return get_attribute( 138 | self.parent.instance, 139 | lookup_path[1:] 140 | ) 141 | else: 142 | return get_attribute( 143 | instance, lookup_path 144 | ) 145 | 146 | 147 | class HyperChannelsApiManyRelationField(HyperChannelsApiMixin, 148 | ManyRelatedField): 149 | 150 | action_name = 'list' 151 | 152 | def to_representation(self, value: QuerySet) -> Dict: 153 | 154 | stream_name, consumer = self.resolve(value.model) 155 | 156 | instance = self.parent.instance 157 | 158 | payload = { 159 | 'action': self.action_name 160 | } 161 | 162 | payload.update( 163 | self.extract_lookups(instance) 164 | ) 165 | 166 | return { 167 | 'stream': stream_name, 168 | 'payload': payload 169 | } 170 | 171 | 172 | class HyperChannelsApiRelationField(HyperChannelsApiMixin, RelatedField): 173 | 174 | @classmethod 175 | def many_init(cls, *args, **kwargs) -> HyperChannelsApiManyRelationField: 176 | list_kwargs = {'child_relation': cls(*args, **kwargs)} 177 | 178 | for key in kwargs.keys(): 179 | if key in MANY_RELATION_KWARGS or key in ( 180 | 'kwarg_mappings', 181 | 'action_name', 182 | 'stream_name'): 183 | list_kwargs[key] = kwargs[key] 184 | 185 | return HyperChannelsApiManyRelationField(**list_kwargs) 186 | 187 | def to_internal_value(self, data): 188 | if isinstance(data, int): 189 | # assume it is a pk 190 | try: 191 | return get_object_or_404(self.get_queryset(), pk=data) 192 | except Http404: 193 | raise ValidationError("Not found") 194 | if isinstance(data, dict): 195 | stream = data.get('stream', None) 196 | payload = data.get('payload', None) 197 | if not (isinstance(stream, str) and isinstance(payload, dict)): 198 | raise ValidationError( 199 | detail='Must be of the format {stream: ...,' 200 | ' payload: {..}}' 201 | ) 202 | action = payload.get('action', None) 203 | 204 | if action is None: 205 | raise ValidationError( 206 | detail="must have an action key" 207 | ) 208 | 209 | consumer_cls = self.api_demultiplexer.applications.get( 210 | stream 211 | ) # type: Type[GenericAsyncAPIConsumer] 212 | 213 | if consumer_cls is None: 214 | raise ValidationError( 215 | detail=f"stream {stream} not found." 216 | ) 217 | 218 | if action not in consumer_cls.available_actions: 219 | raise ValidationError( 220 | detail=f"action {action} not supported on {stream}." 221 | ) 222 | 223 | consumer = consumer_cls(self.context.get('scope')) 224 | 225 | try: 226 | return consumer.get_object(**payload) 227 | except Http404: 228 | raise ValidationError("Not found") 229 | except AssertionError: 230 | raise ValidationError("Incorrect lookup arguments") 231 | raise ValidationError( 232 | detail="Must be either a hyper-media reference or a pk value" 233 | ) 234 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Hyper Media Channels Rest Framework 3 | =================================== 4 | 5 | A Hyper Media style serializer for DjangoChannelsRestFramework_. 6 | 7 | .. image:: https://travis-ci.org/hishnash/hypermediachannels.svg?branch=master 8 | :target: https://travis-ci.org/hishnash/hypermediachannels 9 | 10 | API USAGE 11 | ========= 12 | 13 | This is a collection of serialisers and serialiser fields that creats a 14 | hypermidea style like PK 15 | 16 | Setting up your consumers 17 | ------------------------- 18 | 19 | All of your consumers should be mapped through a `AsyncJsonWebsocketDemultiplexer`. 20 | 21 | .. code-block:: python 22 | 23 | from channelsmultiplexer import AsyncJsonWebsocketDemultiplexer 24 | from djangochannelsrestframework.generics import GenericAsyncAPIConsumer 25 | from hypermediachannels.serializers import HyperChannelsApiModelSerializer 26 | 27 | class UserSerializer(HyperChannelsApiModelSerializer): 28 | class Meta: 29 | model = User 30 | fields = ( 31 | '@id', 32 | 'username' 33 | ) 34 | 35 | class UserConsumer(GenericAsyncAPIConsumer): 36 | queryset = User.objects.all() 37 | serializer_class = UserSerializer 38 | 39 | class MainDemultiplexer(AsyncJsonWebsocketDemultiplexer): 40 | applications = { 41 | 'users': UserConsumer.as_asgi(), 42 | } 43 | 44 | Then when configuring your Channels Application add the ``MainDemultiplexer`` as your main consumer. This way all Websocket connections on that URL will run through the ``Demultiplexer``. See DjangoChannelsRestFramework_ for more deaitls on how to write consumers. 45 | 46 | 47 | HyperChannelsApiModelSerializer 48 | ------------------------------- 49 | 50 | This can be used inplace of the DRF ``ModelSerializer``. It will (if you 51 | include ``@id`` in the ``fields`` list) add a self reference to the 52 | model being displayed) 53 | 54 | eg ``User`` model Serialiser might respond like this if its fields are 55 | ``('@id', 'username', 'profile')`` 56 | 57 | .. code:: js 58 | 59 | { 60 | @id: { 61 | stream: 'user', 62 | payload: { 63 | action: 'retrieve', 64 | pk: 1023 65 | } 66 | }, 67 | username: 'bob@example.com', 68 | profile: { 69 | stream: 'profile', 70 | payload: { 71 | action: 'retrieve', 72 | pk: 23 73 | } 74 | } 75 | } 76 | 77 | This will under the hood use ``HyperlinkedIdentityField`` to create the 78 | ``@id`` and ``profile`` fields and they will (by default) return these 79 | ``retrieve`` objects. 80 | 81 | Why do we do this. 82 | ~~~~~~~~~~~~~~~~~~ 83 | 84 | this means that if we then need to lookup that ``profile`` for this user 85 | we can just send the msg: 86 | 87 | .. code:: js 88 | 89 | { 90 | stream: 'profile', 91 | payload: { 92 | action: 'retrieve', 93 | pk: 23 94 | } 95 | } 96 | 97 | Down the websocket and we will get that item, the frontend code does not 98 | need to track all of these lockup logic, (consider that some models 99 | might have lookup that is not based on ``pk`` for example). 100 | 101 | If you need to define a different set of lookup params. You can use the 102 | ``kwarg_mappings``, ``stream_name`` and ``action_name`` kwargs to 103 | override this. 104 | 105 | eg: 106 | 107 | .. code:: python 108 | 109 | class UserSerializer(HyperChannelsApiModelSerializer): 110 | class Meta: 111 | model = get_user_model() 112 | fields = ( 113 | '@id', 'username', 'profile' 114 | ) 115 | 116 | extra_kwargs = { 117 | 'profile': { 118 | 'action_name': 'user_profile', 119 | 'kwarg_mappings': { 120 | 'user_pk': 'self.pk', 121 | 'team_pk': 'team.pk' 122 | } 123 | }, 124 | } 125 | 126 | the ``kwarg_mappings`` will set the value in the response ``user_pk`` by 127 | extracting the ``pk`` value on from the ``User`` instance. 128 | 129 | (pre-appending ``self`` to the ``kwarg_mappings`` value means it will do 130 | the lookup based on the instance parsed to the parent ``Serializer`` 131 | rather than the instance for this field. In this case a user profile). 132 | 133 | so the above would return: 134 | 135 | .. code:: js 136 | 137 | { 138 | @id: { 139 | stream: 'user', 140 | payload: { 141 | action: 'retrieve', 142 | pk: 1023 143 | } 144 | }, 145 | username: 'bob@example.com', 146 | profile: { 147 | stream: 'user_profile', 148 | payload: { 149 | action: 'retrieve', 150 | user_pk: 1023, 151 | team_pk: 234234 152 | } 153 | } 154 | } 155 | 156 | You can use ``.`` to access nested values eg. ``profile.team.name``. 157 | 158 | Alternatively you can create fields as you would in DRF. 159 | '''''''''''''''''''''''''''''''''''''''''''''''''''''''' 160 | 161 | .. code:: python 162 | 163 | class UserSerializer(HyperChannelsApiModelSerializer): 164 | team = HyperChannelsApiRelationField( 165 | source='profile.team', 166 | kwarg_mappings={ 167 | 'member_username': 'self.username' 168 | } 169 | ) 170 | 171 | class Meta: 172 | model = get_user_model() 173 | fields = ( 174 | '@id', 'username', 'team' 175 | ) 176 | 177 | this will return: 178 | 179 | .. code:: js 180 | 181 | { 182 | @id: { 183 | stream: 'user', 184 | payload: { 185 | action: 'retrieve', 186 | pk: 1023 187 | } 188 | }, 189 | username: 'bob@example.com', 190 | team: { 191 | stream: 'team', 192 | payload: { 193 | action: 'retrieve', 194 | member_username: 'bob@example.com' 195 | } 196 | } 197 | } 198 | 199 | If you reference a Many field the ``HyperChannelsApiModelSerializer`` 200 | will do some magic so that: 201 | 202 | .. code:: python 203 | 204 | class UserSerializer(HyperChannelsApiModelSerializer): 205 | friends = HyperChannelsApiRelationField( 206 | source='profile.friends' 207 | ) 208 | 209 | class Meta: 210 | model = get_user_model() 211 | fields = ( 212 | '@id', 'username', 'friends' 213 | ) 214 | 215 | 216 | 217 | extra_kwargs = { 218 | 'friends': { 219 | 'kwarg_mappings': { 220 | 'user_pk': 'self.user.pk', 221 | } 222 | }, 223 | } 224 | 225 | Adding ``extra_kwargs`` for any ``Many`` field can be important so that 226 | you can controle the lookup params used. 227 | 228 | **NOTE** all ``Many`` fields (forwards and backwards) will extract 229 | values from the parent instance regardless of if you use ``self.`` in 230 | the ``kwarg_mappings`` value.) 231 | 232 | this will return: 233 | 234 | .. code:: js 235 | 236 | { 237 | @id: { 238 | stream: 'user', 239 | payload: { 240 | action: 'retrieve', 241 | pk: 1023 242 | } 243 | }, 244 | username: 'bob@example.com', 245 | friends: { 246 | stream: 'user_profile', payload: {action: 'list', user_pk: 1023} 247 | } 248 | } 249 | 250 | Remember you can also override the ``@id`` lookup/action and stream if 251 | needed, eg: 252 | 253 | .. code:: python 254 | 255 | extra_kwargs = { 256 | '@id': { 257 | 'action_name': 'subscribe_status', 258 | 'kwarg_mappings': { 259 | 'username': 'username' 260 | } 261 | }, 262 | } 263 | 264 | Returning Many items. 265 | --------------------- 266 | 267 | Expect to get: 268 | 269 | .. code:: js 270 | 271 | [ 272 | { 273 | stream: 'user', 274 | payload: { 275 | action: 'retrieve', 276 | pk: 1023 277 | } 278 | }, 279 | { 280 | stream: 'user', 281 | payload: { 282 | action: 'retrieve', 283 | pk: 234 284 | } 285 | }, 286 | { 287 | stream: 'user', 288 | payload: { 289 | action: 'retrieve', 290 | pk: 103223 291 | } 292 | }, 293 | ] 294 | 295 | Rather than getting a fully expanded value for each instance you will 296 | rather just get a list of ``hyper media paths`` you can use to lookup 297 | the instance you need. 298 | 299 | If you need to override the ``stream`` ``action`` or ``lookup`` do this: 300 | 301 | .. code:: python 302 | 303 | class UserSerializer(HyperChannelsApiModelSerializer): 304 | 305 | class Meta: 306 | model = User 307 | fields = ( 308 | '@id', 309 | 'username' 310 | ) 311 | 312 | many_stream_name = 'active_users' 313 | 314 | many_kwarg_mappings = { 315 | 'username': 'username' 316 | } 317 | 318 | many_action_name = 'subscribe' 319 | 320 | 321 | .. _DjangoChannelsRestFramework: https://github.com/hishnash/djangochannelsrestframework 322 | --------------------------------------------------------------------------------