├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── hackernews ├── __init__.py ├── schema.py ├── settings.py ├── urls.py ├── utils.py └── wsgi.py ├── links ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── schema.py ├── tests.py └── views.py ├── manage.py ├── requirements.txt └── users ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── schema.py ├── tests.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | # It is recommended that django migrations NOT be excluded from commits, but 2 | # that makes no sense when we're excluding the database: 'git checkout ' 3 | # can replace one but not the other. 4 | db.sqlite3 5 | links/migrations/ 6 | users/migrations/ 7 | 8 | hackernews/__pycache__/ 9 | links/__pycache__/ 10 | users/__pycache__/ 11 | 12 | README.html 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | before_install: 5 | - sudo apt-get update -qq 6 | install: 7 | - pip install -q -r requirements.txt 8 | script: 9 | - python manage.py makemigrations && python manage.py test 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2017 Sean Bolton. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ++++++++++++++++++++++++++++++++++++++ 2 | howtographql-tutorial-graphene-backend 3 | ++++++++++++++++++++++++++++++++++++++ 4 | 5 | |license| |build| 6 | 7 | .. |license| image:: https://img.shields.io/badge/License-MIT-yellow.svg 8 | :target: https://en.wikipedia.org/wiki/MIT_License 9 | :alt: MIT Licensed 10 | 11 | .. |build| image:: https://travis-ci.org/smbolton/howtographql-tutorial-graphene-backend.svg?branch=master 12 | :target: https://travis-ci.org/smbolton/howtographql-tutorial-graphene-backend 13 | :alt: Documentation Status 14 | 15 | HowtoGraphQL.com_ hosts a series of tutorials whose aim is teaching GraphQL_ and a number of 16 | common software packages that use GraphQL, through the construction of a simple imitation of 17 | `Hacker News`_. Unfortunately, the `Python/Django/Graphene backend server tutorial`_ is incomplete 18 | in that it does not work with the `React+Relay frontend tutorial`_. 19 | 20 | .. _HowtoGraphQL.com: https://www.howtographql.com/ 21 | .. _GraphQL: http://graphql.org/ 22 | .. _Hacker News: https://news.ycombinator.com/ 23 | .. _Python/Django/Graphene backend server tutorial: https://www.howtographql.com/graphql-python/0-introduction/ 24 | .. _React+Relay frontend tutorial: https://www.howtographql.com/react-relay/0-introduction/ 25 | 26 | This project implements a backend server that it actually works with the frontend tutorial. 27 | 28 | Even if you're not looking for a working Graphene backend for the frontend tutorial, you may be 29 | interested in this project if: 30 | 31 | * You've wondered what the ``viewer`` field found in many GraphQL schemas is, and how to implement 32 | it in Graphene. See `The Viewer Field`_, below. 33 | 34 | * You want to implement a custom field (like ``totalCount``) on a Connection, but 35 | DjangoConnectionField won't let you. See `Custom Connections`_. 36 | 37 | * You want to use custom Enum types in you schema, but DjangoObjectType won't let you choose their 38 | names, and DjangoFilterConnectionField won't let you use them at all. See `Custom Enums`_. 39 | 40 | * You're looking for examples of how to use Graphene 2.0. (The backend tutorial is written for 41 | pre-2.0 Graphene.) 42 | 43 | **Warning** 44 | 45 | The GraphQL ecosystem is rapidly evolving, and at this time (November 2017), the newly-released 46 | Graphene 2.0 is a bit of a mess. While Graphene holds lots of promise, be aware that it currently 47 | has no API documentation (just some "how to" docs), almost no comments in the code, and much of 48 | the help you'll find online (e.g. on stackoverflow_) is written for pre-2.0 Graphene. If you're 49 | new to GraphQL, Python, or Graphene, working with it can be a challenge. But then, that's why I 50 | decided to publish this, in the hope that it will be helpful! 51 | 52 | .. _stackoverflow: https://stackoverflow.com/questions/tagged/graphene-python 53 | 54 | Note that because things are in such a flux, especially subscriptions, I have not yet implemented 55 | part 7 (video chapter 8) of the tutorial, which implements a subscription on the front end. Once 56 | the graphene-django subscription support stabilizes, I'll add that. 57 | 58 | Installation 59 | ============ 60 | 61 | .. code:: shell 62 | 63 | $ git clone https://github.com/smbolton/howtographql-tutorial-graphene-backend.git 64 | $ cd howtographql-tutorial-graphene-backend 65 | $ virtualenv --python=python3 venv 66 | $ source venv/bin/activate 67 | $ pip install -r requirements.txt 68 | $ ./manage.py makemigrations 69 | $ ./manage.py migrate 70 | $ ./manage.py test # all tests should pass 71 | $ ./manage.py runserver 72 | 73 | The server includes the GraphiQL_ schema-browser IDE, so once you have the server running, point 74 | your browser at: 75 | 76 | http://localhost:8000/graphql/ 77 | 78 | and you will be able to browse the schema and submit test queries. 79 | 80 | .. _GraphiQL: https://github.com/graphql/graphiql 81 | 82 | Required Changes to the Frontend Tutorial 83 | ========================================= 84 | The frontend tutorial assumes the use of Graphcool's backend prototyping service for the backend 85 | server. We want to replace that with this Graphene-based server, so there are two changes that need 86 | to be made to the tutorial frontend code. Both happen in the `Getting Started`_ section (Chapter 2 87 | in the videos). 88 | 89 | .. _Getting Started: https://www.howtographql.com/react-relay/1-getting-started/ 90 | 91 | 1. Once you have run ``create-react-app``, add the following to ``package.json``: 92 | 93 | .. code-block:: json 94 | 95 | "proxy": "http://localhost:8000", 96 | 97 | This tells the webpack development server to proxy any unexpected (i.e. non-Relay) requests to 98 | our Graphene server. Using proxying like this keeps things simpler by allowing us to avoid 99 | setting up Cross-Origin Resource Sharing (CORS). 100 | 101 | 2. When you get to the part were it has you find the Graphcool server Relay API endpoint: 102 | 103 | Open up a terminal ... and execute the following command: 104 | 105 | .. code:: shell 106 | 107 | graphcool endpoints 108 | 109 | ... 110 | 111 | Copy the endpoint from the terminal output and paste it into ``src/Environment.js`` replacing 112 | the current placeholder ``__RELAY_API_ENDPOINT__``. 113 | 114 | skip that, and use ``http://localhost:3000/graphql/`` for the endpoint, so the relevant line in 115 | ``src/Environment.js`` will look like this: 116 | 117 | .. code:: shell 118 | 119 | return fetch('http://localhost:3000/graphql/', { 120 | 121 | That's all the changes to the frontend tutorial that you need to make! (But remember that this 122 | back end does not yet implement the subscription feature covered in the tutorial part 7 (video 123 | chapter 8). You can work through part 7 without anything breaking, the live update just won't work, 124 | or you can skip over it and go directly to part 8.) 125 | 126 | The Viewer Field 127 | ================ 128 | A common question I've seen regarding Graphene, and GraphQL back-ends in general, is "what creates 129 | this 'viewer' field my front-end is expecting?" Many Relay applications have GraphQL schemas that 130 | include a 'viewer' field, but 'viewer' is not part of the GraphQL or Relay specifications. 131 | Instead, it is just a common and useful pattern for introducing user authentication and/or 132 | grouping top-level queries. 133 | 134 | Here is a simple viewer implementation, which creates a ``viewer`` field directly under the root 135 | query, and contains an ``allLinks`` field by which all link objects can be queried. It also 136 | includes the requisite Relay Node. Note that there's no Django involved at this level, just Graphene 137 | routing queries to the appropriate resolvers. 138 | 139 | .. code:: python 140 | 141 | class Viewer(graphene.ObjectType): 142 | class Meta: 143 | interfaces = (graphene.relay.Node, ) 144 | 145 | # add an 'allLinks' field to 'viewer' 146 | all_links = graphene_django.DjangoConnectionField(Link) 147 | 148 | class Query(object): 149 | viewer = graphene.Field(Viewer) 150 | node = graphene.relay.Node.Field() 151 | 152 | @staticmethod 153 | def resolve_viewer(self, info): 154 | return Viewer() 155 | 156 | You can find the full implementation of this Viewer in `links/schema.py`_ 157 | 158 | .. _`links/schema.py`: https://github.com/smbolton/howtographql-tutorial-graphene-backend/blob/links/schema.py#L316-338 159 | 160 | Custom Connections 161 | ================== 162 | In the above example, I used ``DjangoConnectionField`` as an easy way to add an ``allLinks`` 163 | Connection field to my ``Link`` Node type. This works really well, automatically building the 164 | Connection class with resolvers for our model and all the node and pagination fields that Relay 165 | needs. “Well”, that is, until we need to customize that connection. `Sometime 166 | `_ in the development of 2.0, Graphene lost the ability to use custom 167 | Connections without ugly monkey patching. 168 | 169 | .. _custom_connection_loss: https://github.com/graphql-python/graphene-django/commit/4cc46736bf7297d3f927115daedd1c332c7a38ef#diff-02f0e8baa98448ee267f8be14990558c 170 | 171 | Why would one need to customize a Connection? One example would be to implement the ``count`` or 172 | ``totalCount`` field that is so common in Relay applications: 173 | 174 | .. code-block:: graphql 175 | 176 | query { 177 | viewer { 178 | allVotes { 179 | count # give me the count of all Votes 180 | } 181 | } 182 | } 183 | 184 | Here is a simple example of using a custom connection to implement ``count``: 185 | 186 | .. code-block:: python 187 | 188 | class Vote(graphene_django.DjangoObjectType): 189 | class Meta: 190 | model = models.VoteModel 191 | interfaces = (graphene.relay.Node, ) 192 | # We are going to provide a custom Connection, so we need to tell 193 | # graphene-django not to create one. Failing to do this will result 194 | # in a error like "AssertionError: Found different types with the 195 | # same name in the schema: VoteConnection, VoteConnection." 196 | use_connection = False 197 | 198 | class VoteConnection(graphene.relay.Connection): 199 | """A custom Connection for queries on Vote, complete with custom field 'count'.""" 200 | class Meta: 201 | node = Vote 202 | 203 | count = graphene.Int() 204 | 205 | @staticmethod 206 | def resolve_count(self, info, **args): 207 | # self.iterable is the QuerySet returned by resolve_all_votes() 208 | return self.iterable.count() 209 | 210 | class Viewer(graphene.ObjectType): 211 | class Meta: 212 | interfaces = (graphene.relay.Node, ) 213 | 214 | all_votes = relay.ConnectionField(VoteConnection) 215 | 216 | @staticmethod 217 | def resolve_all_votes(_, info, **args): 218 | qs = models.VoteModel.objects.all() 219 | return qs 220 | 221 | Notice how the ``allVotes`` field is part of ``Viewer``, and so ``resolve_all_votes()`` pulls vote- 222 | related logic into ``Viewer``, instead of it being up with ``Vote`` and ``VoteConnection`` instead? 223 | Since Graphene resolvers are static methods anyway, I move them into the class they return (here 224 | ``VoteConnection``), instead of the class they are called from (``Viewer``), which feels a little 225 | odd at first, but allows me to keep everything much more organized and modular: 226 | 227 | .. code-block:: python 228 | 229 | class VoteConnection(graphene.relay.Connection): 230 | ... 231 | @staticmethod 232 | def resolve_all_votes(_, info, **args): 233 | """Resolver for the ``Viewer`` ``allLinks`` field.""" 234 | qs = models.VoteModel.objects.all() 235 | return qs 236 | 237 | class Viewer(graphene.ObjectType): 238 | ... 239 | all_votes = relay.ConnectionField( 240 | VoteConnection 241 | resolver=VoteConnection.resolve_all_votes 242 | ) 243 | 244 | # no Vote-related code here! 245 | 246 | For a more complex example, including the use of a django-filter_ ``FilterSet`` to filter the votes 247 | returned by ``allVotes``, see `links/schema.py `_. 248 | 249 | .. _django-filter: https://django-filter.readthedocs.io/en/master/ 250 | .. _vote_connection: https://github.com/smbolton/howtographql-tutorial-graphene-backend/blob/links/schema.py#L35-129 251 | 252 | Custom Enums 253 | ============ 254 | One more challenge presented by Graphene when trying to match the How To GraphQL tutorial schema, is 255 | the schema's use of custom GraphQL Enums to specify the sort order used by its connections, for 256 | example: 257 | 258 | .. code-block:: graphql 259 | 260 | enum LinkOrderBy { 261 | createdAt_ASC 262 | createdAt_DESC 263 | ... 264 | } 265 | 266 | query { 267 | viewer { 268 | allLinks(orderBy: createdAt_DESC) { 269 | edges { 270 | node { 271 | url 272 | } 273 | } 274 | } 275 | } 276 | } 277 | 278 | graphene_django has some provision for generating Enums from choice-containing fields in a 279 | ``DjangoObjectType``, but the Enum type name and value names are automatically generated with no way 280 | to control them. Furthermore, for the tutorial we need the Enum types for ordering the 281 | ``LinkConnection``, and ``DjangoFilterConnectionType`` makes no provision at all for custom enums in 282 | ``FilterSet`` s. So, we're back to using a custom Connection. 283 | 284 | Here is a simple example of using custom Enums on a connection: 285 | 286 | .. code-block:: python 287 | 288 | class LinkOrderBy(graphene.Enum): 289 | """This provides the schema's LinkOrderBy Enum type, for ordering LinkConnection.""" 290 | # The class name ('LinkOrderBy') is what the GraphQL schema Enum type 291 | # name should be, the left-hand side below is what the Enum values should 292 | # be, and the right-hand side is what our resolver will receive. 293 | createdAt_ASC = 'created_at' 294 | createdAt_DESC = '-created_at' 295 | 296 | class LinkConnection(graphene.relay.Connection): 297 | """A custom Connection for queries on Link.""" 298 | class Meta: 299 | node = Link 300 | 301 | @staticmethod 302 | def get_all_links_input_fields(): 303 | return { 304 | # this creates an input field using the LinkOrderBy custom enum 305 | 'order_by': graphene.Argument(LinkOrderBy) 306 | } 307 | 308 | @staticmethod 309 | def resolve_all_links(self, info, **args): 310 | qs = models.LinkModel.objects.all() 311 | order_by = args.get('order_by', None) 312 | if order_by: 313 | # Graphene has already translated the over-the-wire enum value 314 | # (e.g. 'createdAt_DESC') to our internal value ('-created_at') 315 | # needed by Django. 316 | qs = qs.order_by(order_by) 317 | return qs 318 | 319 | class Viewer(ObjectType): 320 | class Meta: 321 | interfaces = (graphene.relay.Node, ) 322 | 323 | all_links = graphene.relay.ConnectionField( 324 | LinkConnection, 325 | resolver=LinkConnection.resolve_all_links, 326 | **LinkConnection.get_all_links_input_fields() 327 | ) 328 | 329 | The full version of this can be found in `links/schema.py `_. 330 | 331 | .. _custom_enums: https://github.com/smbolton/howtographql-tutorial-graphene-backend/blob/links/schema.py#L179-251 332 | 333 | License 334 | ======= 335 | Copyright © 2017 Sean Bolton. 336 | 337 | Permission is hereby granted, free of charge, to any person obtaining 338 | a copy of this software and associated documentation files (the 339 | "Software"), to deal in the Software without restriction, including 340 | without limitation the rights to use, copy, modify, merge, publish, 341 | distribute, sublicense, and/or sell copies of the Software, and to 342 | permit persons to whom the Software is furnished to do so, subject to 343 | the following conditions: 344 | 345 | The above copyright notice and this permission notice shall be 346 | included in all copies or substantial portions of the Software. 347 | 348 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 349 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 350 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 351 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 352 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 353 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 354 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 355 | -------------------------------------------------------------------------------- /hackernews/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theabolton/howtographql-tutorial-graphene-backend/e27a928ff16c9a12d551a98f2f2a2025b09998f0/hackernews/__init__.py -------------------------------------------------------------------------------- /hackernews/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | import links.schema 4 | import users.schema 5 | 6 | 7 | class Query(links.schema.Query, users.schema.Query, graphene.ObjectType): 8 | pass 9 | 10 | 11 | class Mutation(links.schema.Mutation, users.schema.Mutation, graphene.ObjectType): 12 | pass 13 | 14 | 15 | schema = graphene.Schema(query=Query, mutation=Mutation) 16 | -------------------------------------------------------------------------------- /hackernews/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for hackernews project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'xyq2o-+2*yn9rn9_7^-m++mquohl)p^e-(u-qit0smf)4fy%70' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'graphene_django', 41 | 'links', 42 | 'users', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'hackernews.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'hackernews.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 102 | }, 103 | ] 104 | 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 108 | 109 | LANGUAGE_CODE = 'en-us' 110 | 111 | TIME_ZONE = 'UTC' 112 | 113 | USE_I18N = True 114 | 115 | USE_L10N = True 116 | 117 | USE_TZ = True 118 | 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 122 | 123 | STATIC_URL = '/static/' 124 | 125 | GRAPHENE = { 126 | 'SCHEMA': 'hackernews.schema.schema', 127 | } 128 | -------------------------------------------------------------------------------- /hackernews/urls.py: -------------------------------------------------------------------------------- 1 | """hackernews URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url 17 | from django.contrib import admin 18 | from django.views.decorators.csrf import csrf_exempt 19 | from graphene_django.views import GraphQLView 20 | 21 | from .settings import DEBUG 22 | 23 | urlpatterns = [ 24 | url(r'^admin/', admin.site.urls), 25 | ] 26 | 27 | # Disable CSRF protection only if we're in development mode. 28 | if DEBUG: 29 | urlpatterns.append(url(r'^graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True)))) 30 | else: 31 | urlpatterns.append(url(r'^graphql/', GraphQLView.as_view(graphiql=True))) 32 | -------------------------------------------------------------------------------- /hackernews/utils.py: -------------------------------------------------------------------------------- 1 | # howtographql-graphene-tutorial-fixed -- /utils.py 2 | # 3 | # Copyright © 2017 Sean Bolton. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import logging 25 | import sys 26 | import traceback 27 | 28 | from graphql.error import GraphQLError 29 | 30 | 31 | # ========== graphql-core exception reporting ========== 32 | 33 | # Ugh. graphql-core (2.0) unconditionally writes certain exception messages using sys.excepthook[1], 34 | # even when those exceptions are routine happypath GraphQL error returns. This abuse of 35 | # sys.excepthook significantly clutters the console (stderr) output, with information that is only 36 | # rarely useful for debugging, so this provides a means to disable it. 37 | # 38 | # Additionally, graphql-core logs other exceptions[2] without providing a NullHandler for them, so 39 | # unless the call application provides a handler, the messages get handled by the logging.lastResort 40 | # handler[3], which again clutters the logging output. If no handler exists when these tests are 41 | # run, this provides a NullHandler to quiet them. 42 | # 43 | # All of the writes and logging silenced here are redundant, given the information in the exception 44 | # itself, which can be nicely formatted as a string with format_graphql_errors(), below. 45 | # 46 | # [1] graphql/execution/base.py line 90, in ExecutionContext.report_error() 47 | # [2] graphql/execution/executor.py line 313, in resolve_or_error() 48 | # [3] https://docs.python.org/3/library/logging.html#module-level-attributes 49 | 50 | logger = None 51 | null_handler = None 52 | saved_excepthook = None 53 | 54 | def quiet_graphql(): 55 | """Silence the redundant exception reporting that graphql-core does.""" 56 | def null_excepthook(cls, exc, tb): 57 | pass 58 | global logger, null_handler, saved_excepthook 59 | saved_excepthook = sys.excepthook 60 | sys.excepthook = null_excepthook 61 | logger = logging.getLogger('graphql.execution.executor') 62 | null_handler = None 63 | if not logger.hasHandlers(): 64 | null_handler = logging.NullHandler() 65 | logger.addHandler(null_handler) 66 | 67 | 68 | def unquiet_graphql(): 69 | """Un-silence the graphql-core's redundant exception reporting.""" 70 | global null_handler 71 | sys.excepthook = saved_excepthook 72 | if null_handler: 73 | logger.removeHandler(null_handler) 74 | null_handler = None 75 | 76 | 77 | def format_graphql_errors(errors): 78 | """Return a string with the usual exception traceback, plus some extra fields that GraphQL 79 | provides. 80 | """ 81 | if not errors: 82 | return None 83 | text = [] 84 | for i, e in enumerate(errors): 85 | text.append('GraphQL schema execution error [{}]:\n'.format(i)) 86 | if isinstance(e, GraphQLError): 87 | for attr in ('args', 'locations', 'nodes', 'positions', 'source'): 88 | if hasattr(e, attr): 89 | if attr == 'source': 90 | text.append('source: {}:{}\n' 91 | .format(e.source.name, e.source.body)) 92 | else: 93 | text.append('{}: {}\n'.format(attr, repr(getattr(e, attr)))) 94 | if isinstance(e, Exception): 95 | text.append(''.join(traceback.format_exception(type(e), e, e.stack))) 96 | else: 97 | text.append(repr(e) + '\n') 98 | return ''.join(text) 99 | -------------------------------------------------------------------------------- /hackernews/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for hackernews project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hackernews.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /links/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theabolton/howtographql-tutorial-graphene-backend/e27a928ff16c9a12d551a98f2f2a2025b09998f0/links/__init__.py -------------------------------------------------------------------------------- /links/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import LinkModel 4 | 5 | admin.site.site_header = 'How to GraphQL graphql-python Tutorial Administration' 6 | admin.site.site_title = 'graphql-python site admin' 7 | 8 | admin.site.register(LinkModel) 9 | -------------------------------------------------------------------------------- /links/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LinksConfig(AppConfig): 5 | name = 'links' 6 | -------------------------------------------------------------------------------- /links/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class LinkModel(models.Model): 4 | description = models.TextField(null=True, blank=True) 5 | url = models.URLField() 6 | created_at = models.DateTimeField(auto_now_add=True) 7 | posted_by = models.ForeignKey('users.UserModel', null=True) 8 | 9 | 10 | class VoteModel(models.Model): 11 | user = models.ForeignKey('users.UserModel') 12 | link = models.ForeignKey('links.LinkModel', related_name='votes') 13 | -------------------------------------------------------------------------------- /links/schema.py: -------------------------------------------------------------------------------- 1 | # howtographql-graphene-tutorial-fixed -- links/schema.py 2 | # 3 | # Copyright © 2017 Sean Bolton. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import django_filters 25 | 26 | import graphene 27 | from graphene import ObjectType, relay 28 | from graphene.relay import Node 29 | from graphene_django import DjangoObjectType 30 | 31 | from links.models import LinkModel, VoteModel 32 | from users.schema import get_user_from_auth_token, User 33 | 34 | 35 | # ========== Vote ========== 36 | 37 | # There are two common GraphQL schema patterns that are difficult to implement with graphene-django 38 | # 2.0 without distasteful monkey-patching. These are: 39 | # 40 | # - DjangoConnectionField can no longer be given a custom Connection, which makes it difficult to 41 | # add custom fields (such as the ubiquitous 'count' or 'totalCount') on the connection, and 42 | # 43 | # - django_filters FilterSet can't use custom Enums for OrderingFilter choices. 44 | # 45 | # The VoteConnection here demonstrates how to use a custom Connection, with a 'count' field and 46 | # a non-Enum-using FilterSet, with no monkey patching. 47 | # 48 | # LinkConnection below demonstrates a way to use custom Enums in a custom Connection. 49 | 50 | class Vote(DjangoObjectType): 51 | class Meta: 52 | model = VoteModel 53 | interfaces = (relay.Node, ) 54 | # We are going to provide a custom Connection, so we need to tell graphene-django not to 55 | # create one. Failing to do this will result in a error like "AssertionError: Found 56 | # different types with the same name in the schema: VoteConnection, VoteConnection." 57 | use_connection = False 58 | 59 | 60 | class IdInput(graphene.InputObjectType): 61 | id = graphene.ID(required=True) 62 | 63 | 64 | class VoteFilter(graphene.InputObjectType): 65 | """The input object for filtered allVotes queries. The VoteFilter input type provided by the 66 | Graphcool server used in the front-end tutorial is quite a bit more complex than this, but this 67 | is all the tutorial itself needs. 68 | """ 69 | link = graphene.InputField(IdInput) 70 | user = graphene.InputField(IdInput) 71 | 72 | 73 | class VotesFilterSet(django_filters.FilterSet): 74 | """A basic FilterSet for filtering allVotes queries.""" 75 | class Meta: 76 | model = VoteModel 77 | fields = ['link', 'user'] 78 | 79 | 80 | class VoteConnection(relay.Connection): 81 | """A custom Connection for queries on Vote, complete with custom field 'count'.""" 82 | class Meta: 83 | node = Vote 84 | 85 | count = graphene.Int() 86 | 87 | def resolve_count(self, info, **args): 88 | """Return the count of votes in the VoteConnection query.""" 89 | # self.iterable is the QuerySet of VoteModels 90 | return self.iterable.count() 91 | 92 | # -------- Vote-related resolvers used by other classes -------- 93 | 94 | # 'allVotes' is actually a field on Viewer, and 'votes' is a field on Link, but rather than put 95 | # a bunch of Vote-related logic in Viewer and Link, I prefer to keep it here as static methods 96 | # on VoteConnection. 97 | @staticmethod 98 | def get_all_votes_input_fields(): 99 | """Return the input fields needed by allVotes.""" 100 | return { 101 | 'filter': graphene.Argument(VoteFilter), 102 | } 103 | 104 | @staticmethod 105 | def resolve_all_votes(_, info, **args): 106 | """Resolve a field returning a (possibly filtered view of) all Votes.""" 107 | qs = VoteModel.objects.all() 108 | filter = args.get('filter', None) 109 | if filter: 110 | # We don't get the free input marshalling that DjangoFilterConnectionField provides, so 111 | # we have to do that ourselves. 112 | for key, field in filter.items(): 113 | # collapse e.g.: 114 | # { 'link': { 'id': '' } } # what graphene provides 115 | # to: 116 | # { 'link': '' } # what our FilterSet expects 117 | if key in ('link', 'user'): 118 | id = field.get('id', None) 119 | if id: 120 | _, filter[key] = Node.from_global_id(id) 121 | qs = VotesFilterSet(data=filter, queryset=qs).qs 122 | return qs 123 | 124 | @staticmethod 125 | def resolve_votes(parent, info, **args): 126 | """Resolve the 'votes' field on Link by returning all votes made by this user.""" 127 | # parent is a LinkModel 128 | qs = VoteModel.objects.filter(link_id=parent.pk) 129 | return qs 130 | 131 | 132 | class CreateVote(relay.ClientIDMutation): 133 | # mutation CreateVoteMutation($input: CreateVoteInput!) { 134 | # createVote(input: $input) { 135 | # vote { 136 | # id 137 | # link { 138 | # id 139 | # votes { count } 140 | # } 141 | # user { id } 142 | # } 143 | # } 144 | # } 145 | # example variables: 146 | # input { 147 | # userId: 'VXNlcjox', 148 | # linkId: 'TGluazoy', 149 | # clientMutationId: '' 150 | # } 151 | 152 | vote = graphene.Field(Vote, required=True) 153 | 154 | class Input: 155 | user_id = graphene.ID() 156 | link_id = graphene.ID(required=True) 157 | 158 | @classmethod 159 | def mutate_and_get_payload(cls, root, info, link_id, user_id, client_mutation_id=None): 160 | user = get_user_from_auth_token(info.context) or None 161 | if not user: 162 | raise Exception('Only logged-in users may vote!') 163 | if user_id: 164 | user_from_id = Node.get_node_from_global_id(info, user_id) 165 | if (not user_from_id) or (user_from_id.pk != user.pk): 166 | raise Exception('Supplied user id does not match logged-in user!') 167 | link = Node.get_node_from_global_id(info, link_id) 168 | if not link: 169 | raise Exception('Requested link not found!') 170 | if VoteModel.objects.filter(user_id=user.pk, link_id=link.pk).count() > 0: 171 | raise Exception('A vote already exists for this user and link!') 172 | 173 | vote = VoteModel(user_id=user.pk, link_id=link.pk) 174 | vote.save() 175 | 176 | return CreateVote(vote=vote) 177 | 178 | 179 | # ========== Link ========== 180 | 181 | # The GraphQL schema used by the front-end tutorial uses GraphQL Enums for specifying the sort order 182 | # used by its connections, for example: 183 | # 184 | # enum LinkOrderBy { 185 | # createdAt_ASC 186 | # createdAt_DESC 187 | # description_ASC 188 | # description_DESC 189 | # id_ASC 190 | # id_DESC 191 | # updatedAt_ASC 192 | # updatedAt_DESC 193 | # url_ASC 194 | # url_DESC 195 | # } 196 | # 197 | # graphene_django has some provision for generating Enums from choice-containing fields in a 198 | # DjangoObjectType, but the Enum type name and value names are automatically generated with no way 199 | # to control them. Furthermore, for the tutorial we need the Enum types for ordering the 200 | # LinkConnection, and DjangoFilterConnectionType makes no provision for custom enums in FilterSets. 201 | # So, we're back to using a custom Connection. 202 | 203 | class Link(DjangoObjectType): 204 | class Meta: 205 | model = LinkModel 206 | interfaces = (Node, ) 207 | use_connection = False # a custom Connection will be provided 208 | 209 | votes = relay.ConnectionField( 210 | VoteConnection, 211 | resolver=VoteConnection.resolve_votes, 212 | #**VoteConnection.get_votes_input_fields() -- no input fields (yet) 213 | ) 214 | 215 | class LinkOrderBy(graphene.Enum): 216 | """This provides the schema's LinkOrderBy Enum type, for ordering LinkConnection.""" 217 | # The class name ('LinkOrderBy') is what the GraphQL schema Enum type name should be, the 218 | # left-hand side below is what the Enum values should be, and the right-hand side is what our 219 | # resolver will receive. 220 | createdAt_DESC = '-created_at' 221 | createdAt_ASC = 'created_at' 222 | description_ASC = 'description' 223 | description_DESC = '-description' 224 | id_ASC = 'id' 225 | id_DESC = '-id' 226 | #updatedAt_ASC = 'updated_at' -- these are present in the Graphcool schema, but not needed by 227 | #updatedAt_DESC = '-updated_at' the tutorial, nor implemented in LinkModel 228 | url_ASC = 'url' 229 | url_DESC = '-url' 230 | 231 | 232 | class LinkConnection(relay.Connection): 233 | """A custom Connection for queries on Link.""" 234 | class Meta: 235 | node = Link 236 | 237 | @staticmethod 238 | def get_all_links_input_fields(): 239 | return { 240 | # this creates an input field using the LinkOrderBy custom enum 241 | 'order_by': graphene.Argument(LinkOrderBy) 242 | } 243 | 244 | def resolve_all_links(self, info, **args): 245 | qs = LinkModel.objects.all() 246 | order_by = args.get('order_by', None) 247 | if order_by: 248 | # Graphene has already translated the over-the-wire enum value (e.g. 'createdAt_DESC') 249 | # to our internal value ('-created_at') needed by Django. 250 | qs = qs.order_by(order_by) 251 | return qs 252 | 253 | 254 | class CreateLink(relay.ClientIDMutation): 255 | # mutation CreateLinkMutation($input: CreateLinkInput!) { 256 | # createLink(input: $input) { 257 | # link { 258 | # id 259 | # createdAt 260 | # url 261 | # description 262 | # } 263 | # } 264 | # } 265 | # example variables: 266 | # input { 267 | # description: "New Link", 268 | # url: "http://example.com", 269 | # postedById: 1, 270 | # clientMutationId: "", 271 | # } 272 | 273 | link = graphene.Field(Link, required=True) 274 | 275 | class Input: 276 | description = graphene.String(required=True) 277 | url = graphene.String(required=True) 278 | posted_by_id = graphene.ID() 279 | 280 | @classmethod 281 | def mutate_and_get_payload(cls, root, info, url, description, posted_by_id=None, 282 | client_mutation_id=None): 283 | # In order to have this work with early stages of the front-end tutorial, this will allow 284 | # links to be created without a user auth token or postedById. If a postedById is present, 285 | # then the auth token must be as well. If both are present, then they must agree. 286 | user = get_user_from_auth_token(info.context) or None 287 | if user or posted_by_id: 288 | if not user: 289 | raise Exception('Only logged-in users may create links!') 290 | if posted_by_id: 291 | try: 292 | posted_by_user = Node.get_node_from_global_id(info, posted_by_id) 293 | assert posted_by_user.pk == user.pk 294 | except: 295 | raise Exception('postedById does not match user ID!') 296 | link = LinkModel( 297 | url=url, 298 | description=description, 299 | posted_by=user, 300 | ) 301 | link.save() 302 | 303 | return CreateLink(link=link) 304 | 305 | 306 | # ========== schema structure ========== 307 | 308 | # A common question I've seen regarding graphene, and GraphQL back-ends in general, is "what creates 309 | # this 'viewer' field my front-end is expecting?" Many Relay applications have GraphQL schemas that 310 | # include a 'viewer' field, but 'viewer' is not part of the GraphQL or Relay specifications. 311 | # Instead, it is just a common and useful pattern for introducing user authentication and/or 312 | # grouping top-level queries. Here is a simple viewer implementation, which includes the requisite 313 | # Relay Node and wraps our top-level queries. Note that there's no Django involved at this level, 314 | # just graphene routing queries to the appropriate resolvers. 315 | 316 | class Viewer(ObjectType): 317 | class Meta: 318 | interfaces = (Node, ) 319 | 320 | all_links = relay.ConnectionField( 321 | LinkConnection, 322 | resolver=LinkConnection.resolve_all_links, 323 | **LinkConnection.get_all_links_input_fields() 324 | ) 325 | 326 | all_votes = relay.ConnectionField( 327 | VoteConnection, 328 | resolver=VoteConnection.resolve_all_votes, 329 | **VoteConnection.get_all_votes_input_fields() 330 | ) 331 | 332 | instance = None # a lazily-initialized singleton for get_node() 333 | 334 | @classmethod 335 | def get_node(cls, info, id): 336 | if cls.instance is None: 337 | cls.instance = Viewer() 338 | return cls.instance 339 | 340 | 341 | class Query(object): 342 | viewer = graphene.Field(Viewer) 343 | node = Node.Field() 344 | 345 | def resolve_viewer(self, info): 346 | return not None # none of Viewer's resolvers need Viewer() 347 | 348 | 349 | class Mutation(object): 350 | create_link = CreateLink.Field() 351 | create_vote = CreateVote.Field() 352 | -------------------------------------------------------------------------------- /links/tests.py: -------------------------------------------------------------------------------- 1 | # howtographql-graphene-tutorial-fixed -- links/tests.py 2 | # 3 | # Copyright © 2017 Sean Bolton. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | from django.test import TestCase 25 | 26 | import graphene 27 | from graphene.relay import Node 28 | 29 | from hackernews.schema import Mutation, Query 30 | from hackernews.utils import format_graphql_errors, quiet_graphql, unquiet_graphql 31 | from links.models import LinkModel, VoteModel 32 | from users.tests import create_test_user 33 | 34 | 35 | # ========== graphql-core exception reporting during tests ========== 36 | 37 | # graphql-core (2.0) is rather obnoxious about reporting exceptions, nearly all of which are 38 | # expected ones, so hush it up during tests. Use format_graphql_errors() to report the information 39 | # when and where you want. 40 | def setUpModule(): 41 | quiet_graphql() 42 | 43 | def tearDownModule(): 44 | unquiet_graphql() 45 | 46 | 47 | # ========== GraphQL schema general tests ========== 48 | 49 | class RootTests(TestCase): 50 | def test_root_query(self): 51 | """Make sure the root query is 'Query'. 52 | 53 | This test is pretty redundant, given that every other query in this file will fail if this 54 | is not the case, but it's a nice simple example of testing query execution. 55 | """ 56 | query = ''' 57 | query RootQueryQuery { 58 | __schema { 59 | queryType { 60 | name # returns the type of the root query 61 | } 62 | } 63 | } 64 | ''' 65 | expected = { 66 | '__schema': { 67 | 'queryType': { 68 | 'name': 'Query' 69 | } 70 | } 71 | } 72 | schema = graphene.Schema(query=Query) 73 | result = schema.execute(query) 74 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 75 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 76 | 77 | 78 | class ViewerTests(TestCase): 79 | def test_viewer_schema(self): 80 | """Check the Viewer type schema contains the fields we need.""" 81 | query = ''' 82 | query ViewerSchemaTest { 83 | __type(name: "Viewer") { 84 | name 85 | fields { 86 | name 87 | type { 88 | name 89 | kind 90 | ofType { 91 | name 92 | } 93 | } 94 | } 95 | } 96 | } 97 | ''' 98 | expected = { 99 | '__type': { 100 | 'name': 'Viewer', 101 | 'fields': [ 102 | { 103 | 'name': 'id', 104 | 'type': { 105 | 'name': None, 106 | 'kind': 'NON_NULL', 107 | 'ofType': { 108 | 'name': 'ID', 109 | } 110 | } 111 | }, 112 | { 113 | 'name': 'allLinks', 114 | 'type': { 115 | 'name': 'LinkConnection', 116 | 'kind': 'OBJECT', 117 | 'ofType': None, 118 | } 119 | }, 120 | { 121 | 'name': 'allVotes', 122 | 'type': { 123 | 'name': 'VoteConnection', 124 | 'kind': 'OBJECT', 125 | 'ofType': None, 126 | } 127 | }, 128 | ] 129 | } 130 | } 131 | schema = graphene.Schema(query=Query) 132 | result = schema.execute(query) 133 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 134 | # Check that the fields we need are there, but don't fail on extra fields. 135 | NEEDED_FIELDS = ('id', 'allLinks', 'allVotes') 136 | result.data['__type']['fields'] = list(filter( 137 | lambda f: f['name'] in NEEDED_FIELDS, 138 | result.data['__type']['fields'] 139 | )) 140 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 141 | 142 | 143 | # ========== Relay Node tests ========== 144 | 145 | class RelayNodeTests(TestCase): 146 | """Test that model nodes can be retreived via the Relay Node interface.""" 147 | def test_node_for_link(self): 148 | link = LinkModel.objects.create(description='Test', url='http://a.com') 149 | link_gid = Node.to_global_id('Link', link.pk) 150 | query = ''' 151 | query { 152 | node(id: "%s") { 153 | id 154 | ...on Link { 155 | url 156 | } 157 | } 158 | } 159 | ''' % link_gid 160 | expected = { 161 | 'node': { 162 | 'id': link_gid, 163 | 'url': 'http://a.com', 164 | } 165 | } 166 | schema = graphene.Schema(query=Query) 167 | result = schema.execute(query) 168 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 169 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 170 | 171 | def test_node_for_vote(self): 172 | link = LinkModel.objects.create(description='Test', url='http://a.com') 173 | user = create_test_user() 174 | vote = VoteModel.objects.create(link_id=link.pk, user_id=user.pk) 175 | vote_gid = Node.to_global_id('Vote', vote.pk) 176 | query = ''' 177 | query { 178 | node(id: "%s") { 179 | id 180 | ...on Vote { 181 | link { 182 | url 183 | } 184 | } 185 | } 186 | } 187 | ''' % vote_gid 188 | expected = { 189 | 'node': { 190 | 'id': vote_gid, 191 | 'link': { 192 | 'url': 'http://a.com', 193 | } 194 | } 195 | } 196 | schema = graphene.Schema(query=Query) 197 | result = schema.execute(query) 198 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 199 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 200 | 201 | def test_node_for_viewer(self): 202 | query = ''' 203 | query { 204 | viewer { 205 | id 206 | } 207 | } 208 | ''' 209 | schema = graphene.Schema(query=Query) 210 | result = schema.execute(query) 211 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 212 | viewer_gid = result.data['viewer']['id'] 213 | query = ''' 214 | query { 215 | node(id: "%s") { 216 | id 217 | } 218 | } 219 | ''' % viewer_gid 220 | expected = { 221 | 'node': { 222 | 'id': viewer_gid, 223 | } 224 | } 225 | result = schema.execute(query) 226 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 227 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 228 | 229 | 230 | # ========== allLinks query tests ========== 231 | 232 | def create_Link_orderBy_test_data(): 233 | """Create test data for LinkConnection orderBy tests. Create three links, 234 | with description, url, and created_at each having a different sort order.""" 235 | import datetime 236 | import pytz 237 | def dt(epoch): 238 | return datetime.datetime.fromtimestamp(epoch).replace(tzinfo=pytz.utc) 239 | link = LinkModel(description='Description C', url='http://a.com') 240 | link.save() # give 'auto_now_add' a chance to do its thing 241 | link.created_at = dt(1000000000) # new time stamp, least recent 242 | link.save() 243 | link = LinkModel(description='Description B', url='http://b.com') 244 | link.save() 245 | link.created_at = dt(1000000400) # most recent 246 | link.save() 247 | link = LinkModel(description='Description A', url='http://c.com') 248 | link.save() 249 | link.created_at = dt(1000000200) 250 | link.save() 251 | 252 | 253 | class LinkTests(TestCase): 254 | def test_all_links(self): 255 | link = LinkModel(description='Description', url='http://') 256 | link.save() 257 | query = ''' 258 | query AllLinksTest { 259 | viewer { 260 | allLinks { 261 | edges { 262 | node { 263 | id 264 | description 265 | url 266 | } 267 | } 268 | } 269 | } 270 | } 271 | ''' 272 | expected = { 273 | 'viewer': { 274 | 'allLinks': { 275 | 'edges': [ 276 | { 277 | 'node': { 278 | 'id': 'TGluazox', 279 | 'description': 'Description', 280 | 'url': 'http://', 281 | } 282 | } 283 | ] 284 | } 285 | } 286 | } 287 | schema = graphene.Schema(query=Query) 288 | result = schema.execute(query) 289 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 290 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 291 | 292 | def test_all_links_ordered_by(self): 293 | create_Link_orderBy_test_data() 294 | # descending order of creation: b.com, c.com, a.com 295 | query = ''' 296 | query AllLinksTest { 297 | viewer { 298 | allLinks(orderBy: createdAt_DESC) { 299 | edges { 300 | node { 301 | url 302 | } 303 | } 304 | } 305 | } 306 | } 307 | ''' 308 | expected = { 309 | 'viewer': { 310 | 'allLinks': { 311 | 'edges': [ 312 | { 'node': { 'url': 'http://b.com' } }, 313 | { 'node': { 'url': 'http://c.com' } }, 314 | { 'node': { 'url': 'http://a.com' } }, 315 | ] 316 | } 317 | } 318 | } 319 | schema = graphene.Schema(query=Query) 320 | result = schema.execute(query) 321 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 322 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 323 | # ascending order on description: c.com, b.com, a.com 324 | query = ''' 325 | query AllLinksTest { 326 | viewer { 327 | allLinks(orderBy: description_ASC) { 328 | edges { 329 | node { 330 | url 331 | } 332 | } 333 | } 334 | } 335 | } 336 | ''' 337 | expected = { 338 | 'viewer': { 339 | 'allLinks': { 340 | 'edges': [ 341 | { 'node': { 'url': 'http://c.com' } }, 342 | { 'node': { 'url': 'http://b.com' } }, 343 | { 'node': { 'url': 'http://a.com' } }, 344 | ] 345 | } 346 | } 347 | } 348 | schema = graphene.Schema(query=Query) 349 | result = schema.execute(query) 350 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 351 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 352 | 353 | def test_all_links_pagination(self): 354 | """Make sure that pagination still works on the custom LinkConnection.""" 355 | create_Link_orderBy_test_data() 356 | # retrieve the first two links, in url order, plus a cursor for the next page 357 | query = ''' 358 | query AllLinksTest { 359 | viewer { 360 | allLinks(orderBy: url_ASC, first: 2) { 361 | edges { 362 | node { 363 | url 364 | } 365 | } 366 | pageInfo { 367 | endCursor 368 | } 369 | } 370 | } 371 | } 372 | ''' 373 | expected = { 374 | 'viewer': { 375 | 'allLinks': { 376 | 'edges': [ 377 | { 'node': { 'url': 'http://a.com' } }, 378 | { 'node': { 'url': 'http://b.com' } }, 379 | ], 380 | 'pageInfo': { 381 | 'endCursor': 'REDACTED', 382 | } 383 | } 384 | } 385 | } 386 | schema = graphene.Schema(query=Query) 387 | result = schema.execute(query) 388 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 389 | # save cursor, and remove it from results (don't depend on cursor representation) 390 | cursor = result.data['viewer']['allLinks']['pageInfo']['endCursor'] 391 | result.data['viewer']['allLinks']['pageInfo']['endCursor'] = 'REDACTED' 392 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 393 | # get next page of results 394 | query = (''' 395 | query AllLinksTest { 396 | viewer { 397 | allLinks(orderBy: url_ASC, first: 1, after: "''' + 398 | cursor + 399 | '''") { 400 | edges { 401 | node { 402 | url 403 | } 404 | } 405 | } 406 | } 407 | } 408 | ''') 409 | expected = { 410 | 'viewer': { 411 | 'allLinks': { 412 | 'edges': [ 413 | { 'node': { 'url': 'http://c.com' } }, 414 | ], 415 | } 416 | } 417 | } 418 | schema = graphene.Schema(query=Query) 419 | result = schema.execute(query) 420 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 421 | assert result.data == expected, '\n'+repr(expected)+'\n'+repr(result.data) 422 | 423 | 424 | # ========== createLink mutation tests ========== 425 | 426 | class CreateLinkBasicTest(TestCase): 427 | def test_create_link(self): 428 | """Test link creation without user information (for early in the tutorial).""" 429 | query = ''' 430 | mutation CreateLinkMutation($input: CreateLinkInput!) { 431 | createLink(input: $input) { 432 | link { 433 | url 434 | description 435 | } 436 | clientMutationId 437 | } 438 | } 439 | ''' 440 | variables = { 441 | 'input': { 442 | 'description': 'Description', 443 | 'url': 'http://example.com', 444 | 'clientMutationId': 'give_this_back_to_me', 445 | } 446 | } 447 | class Context(object): 448 | META = {} 449 | expected = { 450 | 'createLink': { 451 | 'link': { 452 | 'description': 'Description', 453 | 'url': 'http://example.com', 454 | }, 455 | 'clientMutationId': 'give_this_back_to_me', 456 | } 457 | } 458 | schema = graphene.Schema(query=Query, mutation=Mutation) 459 | result = schema.execute(query, variable_values=variables, context_value=Context) 460 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 461 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 462 | # check that the link was created properly 463 | link = LinkModel.objects.get(description='Description') 464 | self.assertEqual(link.description, 'Description') 465 | self.assertEqual(link.url, 'http://example.com') 466 | 467 | 468 | class CreateLinkTests(TestCase): 469 | def setUp(self): 470 | self.user = create_test_user() 471 | self.user_gid = Node.to_global_id('User', self.user.pk) 472 | self.query = ''' 473 | mutation CreateLinkMutation($input: CreateLinkInput!) { 474 | createLink(input: $input) { 475 | link { 476 | url 477 | description 478 | postedBy { 479 | id 480 | } 481 | } 482 | clientMutationId 483 | } 484 | } 485 | ''' 486 | self.schema = graphene.Schema(query=Query, mutation=Mutation) 487 | 488 | @staticmethod 489 | def variables(gid): 490 | var = { 491 | 'input': { 492 | 'description': 'Description', 493 | 'url': 'http://example.com', 494 | 'clientMutationId': 'give_this_back_to_me', 495 | } 496 | } 497 | if gid: 498 | var['input']['postedById'] = gid 499 | return var 500 | 501 | def context_with_token(self): 502 | class Auth(object): 503 | META = {'HTTP_AUTHORIZATION': 'Bearer {}'.format(self.user.token)} 504 | return Auth 505 | 506 | @staticmethod 507 | def context_without_token(): 508 | class Auth(object): 509 | META = {} 510 | return Auth 511 | 512 | def expected(self, with_id=True): 513 | return { 514 | 'createLink': { 515 | 'link': { 516 | 'description': 'Description', 517 | 'url': 'http://example.com', 518 | 'postedBy': with_id and { 'id': self.user_gid } or None 519 | }, 520 | 'clientMutationId': 'give_this_back_to_me', 521 | } 522 | } 523 | 524 | def test_create_link_with_user_both(self): 525 | """createLink with both user auth token and postedById""" 526 | result = self.schema.execute(self.query, variable_values=self.variables(self.user_gid), 527 | context_value=self.context_with_token()) 528 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 529 | expected = self.expected() 530 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 531 | 532 | def test_create_link_with_only_token(self): 533 | """createLink with user auth token but not postedById""" 534 | result = self.schema.execute(self.query, variable_values=self.variables(None), 535 | context_value=self.context_with_token()) 536 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 537 | expected = self.expected() 538 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 539 | 540 | def test_create_link_with_only_postedById(self): 541 | """createLink with postedById but not user auth token, should not succeed""" 542 | result = self.schema.execute(self.query, variable_values=self.variables(self.user_gid), 543 | context_value=self.context_without_token()) 544 | self.assertIsNotNone(result.errors, 545 | msg='createLink should have failed: no auth token, yes postedById') 546 | self.assertIn('Only logged-in users may create links', repr(result.errors)) 547 | expected = { 'createLink': None } # empty result 548 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 549 | 550 | def test_create_link_with_neither(self): 551 | """createLink with neither user auth token nor postedById""" 552 | result = self.schema.execute(self.query, variable_values=self.variables(None), 553 | context_value=self.context_without_token()) 554 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 555 | expected = self.expected(with_id=False) 556 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 557 | 558 | def test_create_link_with_mismatch(self): 559 | """createLink with mismatched user auth token and postedById, should not succeed""" 560 | result = self.schema.execute(self.query, variable_values=self.variables(' invalid base64 '), 561 | context_value=self.context_with_token()) 562 | self.assertIsNotNone(result.errors, 563 | msg='createLink should have failed: mismatched auth token and postedById') 564 | self.assertIn('postedById does not match user ID', repr(result.errors)) 565 | expected = { 'createLink': None } # empty result 566 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 567 | 568 | 569 | # ========== Vote query tests ========== 570 | 571 | class VotesOnLinkTests(TestCase): 572 | def test_votes_count_on_link_test(self): 573 | """test count field on votes field on Link type""" 574 | # first link will have one vote, last link will have two 575 | create_Link_orderBy_test_data() # creates more than one link 576 | user = create_test_user() 577 | first_link_id = None 578 | for link in LinkModel.objects.all(): 579 | vote = VoteModel.objects.create(link_id=link.pk, user_id=user.pk) 580 | # save these for below 581 | first_link_id = first_link_id or link.pk 582 | last_link_id = link.pk 583 | user2 = create_test_user(name='Another User', password='zyz987', email='ano@user.com') 584 | VoteModel.objects.create(link_id=last_link_id, user_id=user2.pk) 585 | # check vote counts 586 | first_link_gid = Node.to_global_id('Link', first_link_id) 587 | last_link_gid = Node.to_global_id('Link', last_link_id) 588 | query = ''' 589 | query VotesOnLinkTest($linkId: ID!) { 590 | node(id: $linkId) { 591 | ... on Link { 592 | votes { 593 | count 594 | } 595 | } 596 | } 597 | } 598 | ''' 599 | variables = { 600 | 'linkId': first_link_gid, 601 | } 602 | expected = { 603 | 'node': { 604 | 'votes': { 605 | 'count': 1, 606 | } 607 | } 608 | } 609 | schema = graphene.Schema(query=Query) 610 | result = schema.execute(query, variable_values=variables) 611 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 612 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 613 | variables['linkId'] = last_link_gid 614 | expected['node']['votes']['count'] = 2 615 | result = schema.execute(query, variable_values=variables) 616 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 617 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 618 | 619 | 620 | class AdHocCheckVoteQueryTests(TestCase): 621 | def test_ad_hoc_check_vote_query(self): 622 | """As of 11/4/2017, the tutorial contains an query done outside Relay, to check whether a 623 | vote already exists. (On the client side? Really? Ask forgiveness rather than permisson, 624 | and save a round trip.) The query is done using a private API function 625 | (relay.environment._network.fetch) that, sure enough, went away in recent versions of Relay. 626 | Test that that query works (for older Relay versions, and in the event the tutorial is fixed 627 | for newer versions.) 628 | """ 629 | create_Link_orderBy_test_data() # creates more than one link 630 | user = create_test_user() 631 | user_gid = Node.to_global_id('User', user.pk) 632 | user2 = create_test_user(name='Another User', password='zyz987', email='ano@user.com') 633 | # create multiple votes 634 | for link in LinkModel.objects.all(): 635 | last_vote = VoteModel.objects.create(link_id=link.pk, user_id=user.pk) 636 | VoteModel.objects.create(link_id=link.pk, user_id=user2.pk) 637 | last_link = link 638 | link_gid = Node.to_global_id('Link', last_link.pk) 639 | vote_gid = Node.to_global_id('Vote', last_vote.pk) 640 | # make sure the query only returns one vote 641 | query = ''' 642 | query CheckVoteQuery($userId: ID!, $linkId: ID!) { 643 | viewer { 644 | allVotes(filter: { 645 | user: { id: $userId }, 646 | link: { id: $linkId } 647 | }) { 648 | edges { 649 | node { 650 | id 651 | } 652 | } 653 | } 654 | } 655 | } 656 | ''' 657 | variables = { 658 | 'userId': user_gid, 659 | 'linkId': link_gid, 660 | } 661 | expected = { 662 | 'viewer': { 663 | 'allVotes': { 664 | 'edges': [ 665 | { 666 | 'node': { 667 | 'id': vote_gid, 668 | } 669 | } 670 | ] 671 | } 672 | } 673 | } 674 | schema = graphene.Schema(query=Query) 675 | result = schema.execute(query, variable_values=variables) 676 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 677 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 678 | 679 | 680 | # ========== createVote mutation tests ========== 681 | 682 | class CreateVoteTests(TestCase): 683 | def setUp(self): 684 | create_Link_orderBy_test_data() 685 | self.link_gid = Node.to_global_id('Link', LinkModel.objects.latest('created_at').pk) 686 | self.user = create_test_user() 687 | self.user_gid = Node.to_global_id('User', self.user.pk) 688 | self.query = ''' 689 | mutation CreateVoteMutation($input: CreateVoteInput!) { 690 | createVote(input: $input) { 691 | vote { 692 | link { 693 | id 694 | votes { count } 695 | } 696 | } 697 | clientMutationId 698 | } 699 | } 700 | ''' 701 | self.schema = graphene.Schema(query=Query, mutation=Mutation) 702 | 703 | @staticmethod 704 | def variables(link_gid, user_gid): 705 | return { 706 | 'input': { 707 | 'linkId': link_gid, 708 | 'userId': user_gid, 709 | 'clientMutationId': 'give_this_back_to_me', 710 | } 711 | } 712 | 713 | def context_with_token(self): 714 | class Auth(object): 715 | META = {'HTTP_AUTHORIZATION': 'Bearer {}'.format(self.user.token)} 716 | return Auth 717 | 718 | @staticmethod 719 | def context_without_token(): 720 | class Auth(object): 721 | META = {} 722 | return Auth 723 | 724 | def expected(self): 725 | return { 726 | 'createVote': { 727 | 'vote': { 728 | 'link': { 729 | 'id': self.link_gid, 730 | 'votes': { 731 | 'count': 1, 732 | } 733 | } 734 | }, 735 | 'clientMutationId': 'give_this_back_to_me', 736 | } 737 | } 738 | 739 | def test_create_vote(self): 740 | """test normal vote creation, and that duplicate votes are not allowed""" 741 | result = self.schema.execute( 742 | self.query, 743 | variable_values=self.variables(self.link_gid, self.user_gid), 744 | context_value=self.context_with_token() 745 | ) 746 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 747 | expected = self.expected() 748 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 749 | # verify that a second vote can't be created 750 | result = self.schema.execute(self.query, 751 | variable_values=self.variables(self.link_gid, self.user_gid), 752 | context_value=self.context_with_token()) 753 | self.assertIsNotNone(result.errors, 754 | msg='createVote should have failed: duplicate votes not allowed') 755 | self.assertIn('vote already exists', repr(result.errors)) 756 | expected['createVote'] = None 757 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 758 | 759 | def test_create_vote_not_logged(self): 760 | """ensure createVote with no logged user fails""" 761 | result = self.schema.execute( 762 | self.query, 763 | variable_values=self.variables(self.link_gid, self.user_gid), 764 | context_value=self.context_without_token() 765 | ) 766 | self.assertIsNotNone(result.errors, 767 | msg='createVote should have failed: no user logged-in') 768 | self.assertIn('Only logged-in users may vote', repr(result.errors)) 769 | expected = { 'createVote': None } 770 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 771 | 772 | def test_create_vote_bad_userid(self): 773 | """ensure invalid userId causes failure""" 774 | result = self.schema.execute( 775 | self.query, 776 | variable_values=self.variables(self.link_gid, ' invalid base64 userId '), 777 | context_value=self.context_with_token() 778 | ) 779 | self.assertIsNotNone(result.errors, 780 | msg='createVote should have failed: invalid userId') 781 | self.assertIn('user id does not match logged-in user', repr(result.errors)) 782 | expected = { 'createVote': None } 783 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 784 | 785 | def test_create_vote_user_mismatch(self): 786 | """ensure logged user must match supplied userId""" 787 | user2 = create_test_user(name='Another User', password='zyz987', email='ano@user.com') 788 | user2_gid = Node.to_global_id('User', user2.pk) 789 | result = self.schema.execute( 790 | self.query, 791 | variable_values=self.variables(self.link_gid, user2_gid), 792 | context_value=self.context_with_token() 793 | ) 794 | self.assertIsNotNone(result.errors, 795 | msg='createVote should have failed: userId and logged user mismatch') 796 | self.assertIn('user id does not match logged-in user', repr(result.errors)) 797 | expected = { 'createVote': None } 798 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 799 | 800 | def test_create_vote_bad_link(self): 801 | """ensure invalid linkId causes failuer""" 802 | last_link_pk = LinkModel.objects.order_by('id').last().pk 803 | invalid_link_gid = Node.to_global_id('Link', last_link_pk + 1) 804 | result = self.schema.execute( 805 | self.query, 806 | variable_values=self.variables(invalid_link_gid, self.user_gid), 807 | context_value=self.context_with_token() 808 | ) 809 | self.assertIsNotNone(result.errors, 810 | msg='createVote should have failed: invalid linkId') 811 | self.assertIn('link not found', repr(result.errors)) 812 | expected = { 'createVote': None } 813 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 814 | -------------------------------------------------------------------------------- /links/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hackernews.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Use 'pip freeze --local --requirement requirements.txt' to preserve the comments and order! 2 | 3 | # These were installed by 'pip install django django-filter' 4 | Django==1.11.7 5 | django-filter==1.1.0 6 | pytz==2017.3 7 | 8 | # These were installed by 'pip install graphene graphene-django' 9 | graphene==2.0 10 | graphene-django==2.0.0 11 | graphql-core==2.0 12 | graphql-relay==0.4.5 13 | iso8601==0.1.12 14 | promise==2.1 15 | Rx==1.6.0 16 | singledispatch==3.4.0.3 17 | six==1.11.0 18 | typing==3.6.2 19 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theabolton/howtographql-tutorial-graphene-backend/e27a928ff16c9a12d551a98f2f2a2025b09998f0/users/__init__.py -------------------------------------------------------------------------------- /users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserModel 4 | 5 | 6 | admin.site.register(UserModel) 7 | -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = 'users' 6 | -------------------------------------------------------------------------------- /users/models.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import os 3 | 4 | from django.db import models 5 | 6 | 7 | # The How to GraphQL graphene back end tutorial 8 | # (https://www.howtographql.com/graphql-python/4-authentication/) extends 9 | # Django's user and authentication system. It's not easily possible to use 10 | # Django's authentication system without being stuck with a user name field of 11 | # 'username', rather than 'name' as the front-end tutorial expects. So here is 12 | # a simple User model, just capable enough to support the tutorial, but every 13 | # bit as naïve as the tutorial regarding security.... 14 | 15 | def new_token(): 16 | return binascii.b2a_hex(os.urandom(32)).decode() 17 | 18 | 19 | class UserModel(models.Model): 20 | name = models.CharField(max_length=150) 21 | password = models.CharField(max_length=128) 22 | email = models.EmailField(unique=True) 23 | token = models.CharField(max_length=64, default=new_token) 24 | -------------------------------------------------------------------------------- /users/schema.py: -------------------------------------------------------------------------------- 1 | # howtographql-graphene-tutorial-fixed -- users/schema.py 2 | # 3 | # Copyright © 2017 Sean Bolton. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | import graphene 25 | from graphene import relay 26 | from graphene.relay import Node 27 | from graphene_django import DjangoObjectType 28 | 29 | from users.models import UserModel 30 | 31 | 32 | def get_user_from_auth_token(context): 33 | # attempt to get the user from an authorization token in the HTTP headers 34 | auth = context.META.get('HTTP_AUTHORIZATION', None) 35 | if not auth or not auth.startswith('Bearer '): 36 | return None 37 | token = auth[7:] 38 | try: 39 | return UserModel.objects.get(token=token) 40 | except: 41 | raise Exception('User not found!') 42 | 43 | 44 | class User(DjangoObjectType): 45 | class Meta: 46 | model = UserModel 47 | interfaces = (Node, ) 48 | 49 | 50 | class Query(object): 51 | pass 52 | 53 | 54 | class AUTH_PROVIDER_EMAIL(graphene.InputObjectType): 55 | email = graphene.String(required=True) 56 | password = graphene.String(required=True) 57 | 58 | 59 | class AuthProviderSignupData(graphene.InputObjectType): 60 | email = graphene.InputField(AUTH_PROVIDER_EMAIL, required=True) 61 | 62 | 63 | class CreateUser(relay.ClientIDMutation): 64 | # This is as simplistic as the front-end tutorial's authentication model, and does not try to 65 | # e.g. cryptographically hash the password before storing it, or verify the user's email. 66 | # mutation CreateUserMutation($createUserInput: SignupUserInput!) { 67 | # createUser(input: $createUserInput) { 68 | # user { id } 69 | # } 70 | # } 71 | # example variables: 72 | # input: { 73 | # name: "Foo Bar", 74 | # authProvider: { 75 | # email: { 76 | # email: "foo@bar.com", 77 | # password: "abc123", 78 | # } 79 | # }, 80 | # clientMutationId: "", 81 | # } 82 | 83 | user = graphene.Field(User) 84 | 85 | # We need to rename the input type from the default 'CreateUserInput' to the 'SignupUserInput' 86 | # that the front-end expects. Graphene 2.0 has a bug that makes it so that this straightforward 87 | # approach doesn't work: 88 | # class Input: 89 | # class Meta: 90 | # name = 'SignupUserInput' 91 | # (The bug results in graphene/utils/subclass_with_meta.py, line 28, 'delattr(cls, "Meta")' 92 | # raising an AttributeError.) 93 | # Instead, we do this: 94 | class Input(graphene.types.base.BaseType): 95 | def __init_subclass__(cls, *args, **kwargs): 96 | # add Meta in a way that can be delattr()ed 97 | cls.Meta = { 'name': 'SignupUserInput' } 98 | super().__init_subclass__(**kwargs) 99 | 100 | name = graphene.String(required=True) 101 | auth_provider = graphene.InputField(AuthProviderSignupData, required=True) 102 | 103 | # Normally, it is convenient to destructure the inputs here like this: 104 | # def mutate_and_get_payload(cls, root, info, name, auth_provider, client_mutation_id=None): 105 | # but because AuthProviderSignupData has an 'email' field of type AUTH_PROVIDER_EMAIL, which 106 | # also has a field named 'email', the destructuring gets messed up, resulting in graphene 107 | # complaining about unknown fields. In these cases, use '**' to capture the inputs instead. 108 | @classmethod 109 | def mutate_and_get_payload(cls, root, info, **input): 110 | name = input.get('name') 111 | auth = input.get('auth_provider').get('email') 112 | email = auth.get('email') 113 | password = auth.get('password') 114 | if UserModel.objects.filter(email=email).exists(): 115 | raise Exception('A user with that email address already exists!') 116 | user = UserModel( 117 | name=name, 118 | email=email, 119 | password=password, 120 | ) 121 | user.save() 122 | return CreateUser(user=user) 123 | 124 | 125 | class SigninUser(relay.ClientIDMutation): 126 | # mutation SigninUserMutation($signinUserInput: SigninUserInput!) { 127 | # signinUser(input: $signinUserInput) { 128 | # token 129 | # user { id } 130 | # } 131 | # } 132 | # example variables: email: { email: "foo@bar.com", password: "abc123" } 133 | 134 | token = graphene.String() 135 | user = graphene.Field(User) 136 | 137 | class Input: 138 | email = graphene.InputField(AUTH_PROVIDER_EMAIL, required=True) 139 | 140 | # See the note about using '**input' vs. destructuring the input fields in CreateUser, above. 141 | @classmethod 142 | def mutate_and_get_payload(cls, root, info, **input): 143 | email = input.get('email').get('email') 144 | password = input.get('email').get('password') 145 | try: 146 | user = UserModel.objects.get(email=email) 147 | assert user.password == password 148 | except Exception: 149 | raise Exception('Invalid username or password!') 150 | 151 | return SigninUser(token=user.token, user=user) 152 | 153 | 154 | class Mutation(object): 155 | create_user = CreateUser.Field() 156 | signin_user = SigninUser.Field() 157 | -------------------------------------------------------------------------------- /users/tests.py: -------------------------------------------------------------------------------- 1 | # howtographql-graphene-tutorial-fixed -- users/tests.py 2 | # 3 | # Copyright © 2017 Sean Bolton. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | from django.test import TestCase 25 | 26 | import graphene 27 | from graphene.relay import Node 28 | 29 | from hackernews.schema import Mutation, Query 30 | from hackernews.utils import format_graphql_errors, quiet_graphql, unquiet_graphql 31 | from .models import UserModel 32 | from .schema import get_user_from_auth_token 33 | 34 | 35 | # ========== graphql-core exception reporting during tests ========== 36 | 37 | # graphql-core (2.0) is rather obnoxious about reporting exceptions, nearly all of which are 38 | # expected ones, so hush it up during tests. Use format_graphql_errors() to report the information 39 | # when and where you want. 40 | def setUpModule(): 41 | quiet_graphql() 42 | 43 | def tearDownModule(): 44 | unquiet_graphql() 45 | 46 | 47 | # ========== utility function ========== 48 | 49 | def create_test_user(name=None, password=None, email=None): 50 | user = UserModel.objects.create( 51 | name=name or 'Test User', 52 | password=password or 'abc123', 53 | email=email or 'test@user.com' 54 | ) 55 | return user 56 | 57 | 58 | # ========== user authentication token tests ========== 59 | 60 | class UserAuthTokenTests(TestCase): 61 | def test_token_creation(self): 62 | """ensure token is created for new UserModel""" 63 | user = create_test_user() 64 | self.assertIsInstance(user.token, str) 65 | self.assertRegex(user.token, r'^[0-9a-f]{40,}') 66 | 67 | def test_token_uniqueness(self): 68 | """check that users are not given the same token""" 69 | token1 = create_test_user().token 70 | user2 = UserModel(name='Test User 2', password='abc123', email='test2@user.com') 71 | user2.save() 72 | token2 = user2.token 73 | self.assertNotEqual(token1, token2) 74 | 75 | 76 | class GetUserTests(TestCase): 77 | def test_get_user_token_missing_or_invalid(self): 78 | """get_user_from_auth_token() with no or invalid HTTP_AUTHORIZATION header should 79 | return None 80 | """ 81 | create_test_user() 82 | class AuthEmpty(object): 83 | META = {} 84 | self.assertIsNone(get_user_from_auth_token(AuthEmpty)) 85 | class AuthInvalid(object): 86 | META = {'HTTP_AUTHORIZATION': 'ArgleBargle'} 87 | self.assertIsNone(get_user_from_auth_token(AuthInvalid)) 88 | 89 | def test_get_user_token_valid(self): 90 | """get_user_from_auth_token() with valid HTTP_AUTHORIZATION header should return user""" 91 | user = create_test_user() 92 | class AuthValid(object): 93 | META = {'HTTP_AUTHORIZATION': 'Bearer {}'.format(user.token)} 94 | self.assertEqual(get_user_from_auth_token(AuthValid), user) 95 | 96 | def test_get_user_token_wrong(self): 97 | """get_user_from_auth_token() with valid HTTP_AUTHORIZATION header but invalid token 98 | should raise 99 | """ 100 | user = create_test_user() 101 | class AuthWrong(object): 102 | META = {'HTTP_AUTHORIZATION': 'Bearer AbDbAbDbAbDbA'} 103 | with self.assertRaises(Exception): 104 | get_user_from_auth_token(AuthWrong) 105 | 106 | 107 | # ========== Relay Node tests ========== 108 | 109 | class RelayNodeTests(TestCase): 110 | """Test that model nodes can be retreived via the Relay Node interface.""" 111 | def test_node_for_user(self): 112 | user = create_test_user() 113 | user_gid = Node.to_global_id('User', user.pk) 114 | query = ''' 115 | query { 116 | node(id: "%s") { 117 | id 118 | ...on User { 119 | name 120 | } 121 | } 122 | } 123 | ''' % user_gid 124 | expected = { 125 | 'node': { 126 | 'id': user_gid, 127 | 'name': user.name, 128 | } 129 | } 130 | schema = graphene.Schema(query=Query) 131 | result = schema.execute(query) 132 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 133 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 134 | 135 | 136 | # ========== createUser mutation tests ========== 137 | 138 | class CreateUserTests(TestCase): 139 | def setUp(self): 140 | self.query = ''' 141 | mutation CreateUserMutation($createUserInput: SignupUserInput!) { 142 | createUser(input: $createUserInput) { 143 | user { name } 144 | clientMutationId 145 | } 146 | } 147 | ''' 148 | self.variables = { 149 | 'createUserInput': { 150 | 'name': 'Jim Kirk', 151 | 'authProvider': { 152 | 'email': { 153 | 'email': 'kirk@example.com', 154 | 'password': 'abc123', 155 | } 156 | }, 157 | 'clientMutationId': 'give_this_back_to_me', 158 | } 159 | } 160 | self.expected = { 161 | 'createUser': { 162 | 'user': { 163 | 'name': 'Jim Kirk', 164 | }, 165 | 'clientMutationId': 'give_this_back_to_me', 166 | } 167 | } 168 | self.schema = graphene.Schema(query=Query, mutation=Mutation) 169 | 170 | def test_create_user(self): 171 | """sucessfully create a user""" 172 | result = self.schema.execute(self.query, variable_values=self.variables) 173 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 174 | self.assertEqual(result.data, self.expected, 175 | msg='\n'+repr(self.expected)+'\n'+repr(result.data)) 176 | # check that the user was created properly 177 | user = UserModel.objects.get(name=result.data['createUser']['user']['name']) 178 | self.assertEqual(user.name, 'Jim Kirk') 179 | self.assertEqual(user.email, 'kirk@example.com') 180 | self.assertEqual(user.password, 'abc123') 181 | self.assertRegex(user.token, r'^[0-9a-f]{40,}') 182 | 183 | def test_create_user_duplicate(self): 184 | """should not be able to create two users with the same email""" 185 | result = self.schema.execute(self.query, variable_values=self.variables) 186 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 187 | self.assertEqual(result.data, self.expected, 188 | msg='\n'+repr(self.expected)+'\n'+repr(result.data)) 189 | # now try to create a second one 190 | self.variables['createUserInput']['name'] = 'Just Spock to Humans' 191 | auth = self.variables['createUserInput']['authProvider']['email'] 192 | auth['password'] = '26327790.8685354193060378' 193 | # -- email address stays the same 194 | result = self.schema.execute(self.query, variable_values=self.variables) 195 | self.assertIsNotNone(result.errors, 196 | msg='Creating user with duplicate email should have failed') 197 | self.assertIn('user with that email address already exists', repr(result.errors)) 198 | expected = { 'createUser': None } # empty result 199 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 200 | 201 | 202 | # ========== signUser mutation tests ========== 203 | 204 | class SigninUserTests(TestCase): 205 | def setUp(self): 206 | self.user = create_test_user() 207 | self.query = ''' 208 | mutation SigninUserMutation($signinUserInput: SigninUserInput!) { 209 | signinUser(input: $signinUserInput) { 210 | token 211 | user { name } 212 | clientMutationId 213 | } 214 | } 215 | ''' 216 | self.schema = graphene.Schema(query=Query, mutation=Mutation) 217 | 218 | def test_signin_user(self): 219 | """normal user sign-in""" 220 | variables = { 221 | 'signinUserInput': { 222 | 'email': { 223 | 'email': self.user.email, 224 | 'password': self.user.password, 225 | }, 226 | 'clientMutationId': 'give_this_back_to_me', 227 | } 228 | } 229 | expected = { 230 | 'signinUser': { 231 | 'token': 'REDACTED', 232 | 'user': { 233 | 'name': self.user.name, 234 | }, 235 | 'clientMutationId': 'give_this_back_to_me', 236 | } 237 | } 238 | result = self.schema.execute(self.query, variable_values=variables) 239 | self.assertIsNone(result.errors, msg=format_graphql_errors(result.errors)) 240 | try: 241 | token = result.data['signinUser']['token'] 242 | result.data['signinUser']['token'] = 'REDACTED' 243 | except KeyError: 244 | raise Exception('malformed mutation result') 245 | self.assertRegex(token, r'^[0-9a-f]{40,}') 246 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 247 | 248 | def test_signin_user_not_found(self): 249 | """unsuccessful sign-in: user not found""" 250 | variables = { 251 | 'signinUserInput': { 252 | 'email': { 253 | 'email': 'xxx' + self.user.email, # unknown email address 254 | 'password': 'irrelevant', 255 | } 256 | } 257 | } 258 | expected = {'signinUser': None} # empty result 259 | result = self.schema.execute(self.query, variable_values=variables) 260 | self.assertIsNotNone(result.errors, 261 | msg='Sign-in of user with unknown email should have failed') 262 | self.assertIn('Invalid username or password', repr(result.errors)) 263 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 264 | 265 | def test_signin_user_bad_password(self): 266 | """unsuccessful sign-in: incorrect password""" 267 | variables = { 268 | 'signinUserInput': { 269 | 'email': { 270 | 'email': self.user.email, 271 | 'password': 'xxx' + self.user.password, # incorrect password 272 | } 273 | } 274 | } 275 | expected = {'signinUser': None} # empty result 276 | result = self.schema.execute(self.query, variable_values=variables) 277 | self.assertIsNotNone(result.errors, 278 | msg='Sign-in of user with incorrect password should have failed') 279 | self.assertIn('Invalid username or password', repr(result.errors)) 280 | self.assertEqual(result.data, expected, msg='\n'+repr(expected)+'\n'+repr(result.data)) 281 | -------------------------------------------------------------------------------- /users/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | --------------------------------------------------------------------------------