├── LICENSE └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jason Kraus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # real-world-graphql-with-django 2 | Real world GraphQL patterns with Django 3 | 4 | 1. [Querying](#querying) 5 | 2. [Customizing and Optimizing Queries](#customizing-and-optimizing-queries) 6 | 3. [Mutations](#mutations) 7 | 4. [Better Mutations](#better-mutations) 8 | 5. [Custom Field type](#custom-Field-types) 9 | 10 | 11 | # Querying 12 | 13 | All `id`s are serialized as a global id which are base 64 encoded `ModelName:PrimaryKey` 14 | 15 | For all Django models: 16 | 17 | * Define a `DjangoObjectType` 18 | * Use relay (for better resolve relationships) 19 | * `DjangoListField` for connecting models without pagination 20 | * resolve_FIELD_NAME receives request as `info.context` and optionally defined keyword arguments 21 | 22 | When defining the `Query` object: 23 | 24 | * `DjangoFilterConnectionField` generates a resolving field with arguments from `filter_fields` 25 | * [graphene-django-extras](https://github.com/eamigo86/graphene-django-extras) provides alternatives & other missing batteries like `DjangoObjectField` for single object lookup 26 | 27 | 28 | ```python 29 | from graphene import relay 30 | from graphene_django import DjangoObjectType 31 | from graphene_django.filter import DjangoFilterConnectionField 32 | from graphene_django.fields import DjangoConnectionField, DjangoListField 33 | 34 | 35 | class GroupNode(DjangoObjectType): 36 | class Meta: 37 | model = Group 38 | interfaces = (relay.Node, ) 39 | 40 | 41 | class UserNode(DjangoObjectType): 42 | groups = DjangoListField(GroupNode) 43 | 44 | class Meta: 45 | model = User 46 | filter_fields = ['username', 'email'] 47 | exclude_fields = ['password'] 48 | interfaces = (relay.Node, ) 49 | 50 | 51 | class Query(ObjectType): 52 | auth_self = Field(UserNode) 53 | user = DjangoObjectField(UserNode) 54 | users = DjangoFilterConnectionField(UserNode) 55 | 56 | def resolve_auth_self(self, info, **kwargs): 57 | user = info.context.user 58 | if user.is_anonymous: 59 | return None 60 | return user 61 | ``` 62 | 63 | # Customizing and Optimizing Queries 64 | 65 | Define a custom `Field` to be used by our Query 66 | 67 | * Replaces `DjangoFilterConnectionField` in our Query object, do not use inside `DjangoObjectType` 68 | * [graphene-django-optimizer](https://github.com/tfoxy/graphene-django-optimizer) to optimize queries across relationships 69 | * `Field.get_resolver` returns a callable which later receives a request as `info.context` 70 | * call custom auth_check inside resolver 71 | * Repeat for `DjangoObjectField` for securing single object lookups 72 | 73 | 74 | ```python 75 | from graphene import Field 76 | import graphene_django_optimizer as gql_optimizer 77 | from graphene_django.filter.utils import ( 78 | get_filtering_args_from_filterset, 79 | get_filterset_class 80 | ) 81 | from functools import partial 82 | 83 | #https://github.com/graphql-python/graphene-django/issues/206 84 | class DjangoFilterField(Field): 85 | ''' 86 | Custom field to use django-filter with graphene object types (without relay). 87 | ''' 88 | 89 | def __init__(self, _type, fields=None, extra_filter_meta=None, 90 | filterset_class=None, *args, **kwargs): 91 | _fields = _type._meta.filter_fields 92 | _model = _type._meta.model 93 | self.of_type = _type 94 | self.fields = fields or _fields 95 | meta = dict(model=_model, fields=self.fields) 96 | if extra_filter_meta: 97 | meta.update(extra_filter_meta) 98 | self.filterset_class = get_filterset_class(filterset_class, **meta) 99 | self.filtering_args = get_filtering_args_from_filterset( 100 | self.filterset_class, _type) 101 | kwargs.setdefault('args', {}) 102 | kwargs['args'].update(self.filtering_args) 103 | super().__init__(List(_type), *args, **kwargs) 104 | 105 | @staticmethod 106 | def list_resolver(manager, filterset_class, filtering_args, root, info, *args, **kwargs): 107 | auth_check(info.context) 108 | filter_kwargs = {k: v for k, 109 | v in kwargs.items() if k in filtering_args} 110 | qs = manager.get_queryset() 111 | qs = filterset_class(data=filter_kwargs, queryset=qs).qs 112 | return qs 113 | 114 | def get_resolver(self, parent_resolver): 115 | return partial(self.list_resolver, self.of_type._meta.model._default_manager, 116 | self.filterset_class, self.filtering_args) 117 | ``` 118 | 119 | 120 | # Mutations 121 | 122 | While `id`s from query are global, by default, they are not handled by mutations. https://github.com/graphql-python/graphene-django/issues/460 123 | 124 | Three ways to define a mutation: 125 | 126 | * Manually with a custom `mutate` method 127 | * Wrapping a Django Rest Framework Serializer 128 | * Wrapping a (model) form with `DjangoModelFormMutation` 129 | 130 | IMHO defining mutations based on Django Forms has struck a good balance between being DRY and having too many abstractions. 131 | Generally most of the customizing at the view level can go into two methods: `get_form_kwargs` and `perform_mutate`. 132 | 133 | ```python 134 | from django.contrib.auth.forms import AuthenticationForm 135 | from django.contrib.auth import login 136 | from graphene_django.forms.mutation import DjangoModelFormMutation 137 | 138 | 139 | class AuthenticationMutation(DjangoModelFormMutation): 140 | class Meta: 141 | form_class = AuthenticationForm 142 | 143 | authUser = Field(UserNode) 144 | 145 | @classmethod 146 | def get_form_kwargs(cls, root, info, **input): 147 | kwargs = {"data": input} 148 | kwargs['request'] = info.context 149 | return kwargs 150 | 151 | @classmethod 152 | def perform_mutate(cls, form, info): 153 | obj = form.get_user() 154 | if not form.request.COOKIES: 155 | assert False, 'Cookies are required' 156 | #TODO return auth token 157 | else: 158 | login(form.request, obj) 159 | return cls(errors=[], authUser=obj) 160 | ``` 161 | 162 | # Better Mutations 163 | 164 | We'll extend the `DjangoModelFormMutation` class to do the following: 165 | 166 | * generate a model form if none is provided 167 | * support partial updates 168 | * translate global ids 169 | * perform auth check 170 | 171 | By default `DjangoModelFormMutation` will create an object if no `id` is provided, we'll want to keep that behavior. 172 | 173 | ```python 174 | from graphene_django.forms.mutation import DjangoModelFormMutation 175 | from graphql_relay import from_global_id 176 | from django.forms.models import modelform_factory 177 | from django.forms import ModelChoiceField 178 | from django.forms.models import ModelMultipleChoiceField 179 | from stringcase import camelcase 180 | from .schema_tools import auth_check 181 | 182 | 183 | class DjangoModelMutation(DjangoModelFormMutation): 184 | ''' 185 | Automatically generates a model form that supports partial updates 186 | 187 | Works just like the regular DjangoModelFormMutation but may also specify the following in Meta: 188 | 189 | only_fields 190 | exclude_fields 191 | ''' 192 | class Meta: 193 | abstract = True 194 | 195 | @classmethod 196 | def __init_subclass_with_meta__( 197 | cls, 198 | **options 199 | ): 200 | if 'model' in options and 'form_class' not in options: 201 | options['form_class'] = form_class = modelform_factory(options['model'], 202 | fields=options.get('only_fields'), 203 | exclude=options.get('exclude_fields', []) 204 | ) 205 | for field in form_class.base_fields.values(): 206 | field.required = False 207 | super(DjangoModelMutation, cls).__init_subclass_with_meta__( 208 | **options 209 | ) 210 | 211 | @classmethod 212 | def get_form(cls, root, info, **input): 213 | auth_check(info) 214 | # convert global ids to database ids 215 | for fname, field in cls._meta.form_class.base_fields.items(): 216 | if isinstance(field, ModelMultipleChoiceField): 217 | global_ids = input[fname] 218 | input[fname] = [from_global_id(global_id)[1] for global_id in global_ids] 219 | 220 | elif isinstance(field, ModelChoiceField) and input.get(fname): 221 | # TODO assert models match 222 | _type, pk = from_global_id(input[fname]) 223 | input[fname] = pk 224 | if 'id' in input: 225 | input['id'] = from_global_id(input['id'])[1] 226 | form_kwargs = cls.get_form_kwargs(root, info, **input) 227 | form = cls._meta.form_class(**form_kwargs) 228 | if 'id' in input: 229 | for fname, field in list(form.fields.items()): 230 | #info.variable_values is the raw dictionary of values supplied by the client 231 | if not field.required and camelcase(fname) not in info.variable_values: 232 | del form.fields[fname] 233 | assert len(form.fields) 234 | return form 235 | ``` 236 | 237 | 238 | # Custom Field types 239 | 240 | For each new type, extend an existing type and do the following: 241 | 242 | * Define `serialize` & `deserialize` static methods 243 | * The class name will become the type name in GraphQL 244 | * Call `convert_django_field.register` to convert model fields 245 | * Call `convert_form_field.register` to convert form fields 246 | 247 | ```python 248 | from graphene_django.converter import convert_django_field 249 | from graphene_django.forms.converter import convert_form_field 250 | from graphene.types.generic import GenericScalar 251 | from django.contrib.gis.geos import GEOSGeometry 252 | from django.contrib.gis.db import models 253 | from django.contrib.gis.forms import fields 254 | import json 255 | 256 | 257 | class GeoJSON(GenericScalar): 258 | @staticmethod 259 | def geos_to_json(value): 260 | return json.loads(GEOSGeometry(value).geojson) 261 | 262 | @staticmethod 263 | def json_to_geos(value): 264 | return GEOSGeometry(value) 265 | 266 | serialize = geos_to_json 267 | deserialize = json_to_geos 268 | 269 | 270 | @convert_django_field.register(models.PolygonField) 271 | @convert_django_field.register(models.MultiPolygonField) 272 | def convert_geofield_to_string(field, registry=None): 273 | return GeoJSON(description=field.help_text, required=not field.null) 274 | 275 | 276 | @convert_form_field.register(fields.PolygonField) 277 | @convert_form_field.register(fields.MultiPolygonField) 278 | def convert_geofield_to_string(field, registry=None): 279 | return GeoJSON(description=field.help_text, required=field.required) 280 | ``` 281 | --------------------------------------------------------------------------------