From 219005952a1d143fce6363be3d4d3bc8f8d0ecc5 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:29:33 +0100 Subject: [PATCH 01/48] Don't execute on GET for GraphiQL We can also now return GraphiQL earlier in the request handling. --- graphene_django/views.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index be7ccf9..9a530de 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -124,6 +124,12 @@ class GraphQLView(View): data = self.parse_body(request) show_graphiql = self.graphiql and self.can_display_graphiql(request, data) + if show_graphiql: + return self.render_graphiql( + request, + graphiql_version=self.graphiql_version, + ) + if self.batch: responses = [self.get_response(request, entry) for entry in data] result = "[{}]".format( @@ -137,19 +143,6 @@ class GraphQLView(View): else: result, status_code = self.get_response(request, data, show_graphiql) - if show_graphiql: - query, variables, operation_name, id = self.get_graphql_params( - request, data - ) - return self.render_graphiql( - request, - graphiql_version=self.graphiql_version, - query=query or "", - variables=json.dumps(variables) or "", - operation_name=operation_name or "", - result=result or "", - ) - return HttpResponse( status=status_code, content=result, content_type="application/json" ) From 3755850c2e5de11eb80f7d39d0f8fd31f3cc5d66 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:47:48 +0100 Subject: [PATCH 02/48] Use the fragment for the URL --- graphene_django/templates/graphene/graphiql.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 1ba0613..5bc5e04 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -32,7 +32,7 @@ add "&raw" to the end of the URL within a browser. // Collect the URL parameters var parameters = {}; - window.location.search.substr(1).split('&').forEach(function (entry) { + window.location.hash.substr(1).split('&').forEach(function (entry) { var eq = entry.indexOf('='); if (eq >= 0) { parameters[decodeURIComponent(entry.slice(0, eq))] = @@ -41,7 +41,7 @@ add "&raw" to the end of the URL within a browser. }); // Produce a Location query string from a parameter object. function locationQuery(params) { - return '?' + Object.keys(params).map(function (key) { + return '#' + Object.keys(params).map(function (key) { return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); }).join('&'); From 0d8f9db3fbeef93be194d386f87dea627f69715e Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:48:21 +0100 Subject: [PATCH 03/48] Pass options from the fragment, not the template context --- .../templates/graphene/graphiql.html | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 5bc5e04..6515da8 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -100,22 +100,27 @@ add "&raw" to the end of the URL within a browser. function updateURL() { history.replaceState(null, null, locationQuery(parameters)); } - // Render into the body. - ReactDOM.render( - React.createElement(GraphiQL, { - fetcher: graphQLFetcher, + // If there are any fragment parameters, confirm the user wants to use them. + if (Object.keys(parameters).length + && !window.confirm("An untrusted query has been loaded, continue loading query?")) { + parameters = {}; + } + var options = { + fetcher: graphQLFetcher, onEditQuery: onEditQuery, onEditVariables: onEditVariables, onEditOperationName: onEditOperationName, - query: '{{ query|escapejs }}', - response: '{{ result|escapejs }}', - {% if variables %} - variables: '{{ variables|escapejs }}', - {% endif %} - {% if operation_name %} - operationName: '{{ operation_name|escapejs }}', - {% endif %} - }), + query: parameters.query, + } + if (parameters.variables) { + options.variables = parameters.variables; + } + if (parameters.operation_name) { + options.operationName = parameters.operation_name; + } + // Render into the body. + ReactDOM.render( + React.createElement(GraphiQL, options), document.body ); From 9a5b3556d3d13ec3c46ea4bc953f1df844f0925d Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:48:38 +0100 Subject: [PATCH 04/48] Special case reloads as allowed if we can --- graphene_django/templates/graphene/graphiql.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 6515da8..0303883 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -101,7 +101,9 @@ add "&raw" to the end of the URL within a browser. history.replaceState(null, null, locationQuery(parameters)); } // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; if (Object.keys(parameters).length + && !isReload && !window.confirm("An untrusted query has been loaded, continue loading query?")) { parameters = {}; } From d1b734f07df87f97f3acc557d990952e3a250e7d Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:31:39 +0100 Subject: [PATCH 05/48] Allow the user to see the query before prompting This also allows the introspection query through so that the user can edit with intellisense before being prompted. --- .../templates/graphene/graphiql.html | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 0303883..de3126a 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -58,9 +58,31 @@ add "&raw" to the end of the URL within a browser. otherParams[k] = parameters[k]; } } + + // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; + var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; + var fetchURL = locationQuery(otherParams); + // Defines a GraphQL fetcher using the fetch API. function graphQLFetcher(graphQLParams) { + var isIntrospectionQuery = ( + graphQLParams.query !== parameters.query + && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 + ); + + if (!isQueryTrusted + && !isIntrospectionQuery + && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { + return Promise.resolve('Aborting query.'); + } + + // We don't want to set this for the introspection query + if (!isIntrospectionQuery) { + isQueryTrusted = true; + } + var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -100,13 +122,6 @@ add "&raw" to the end of the URL within a browser. function updateURL() { history.replaceState(null, null, locationQuery(parameters)); } - // If there are any fragment parameters, confirm the user wants to use them. - var isReload = window.performance ? performance.navigation.type === 1 : false; - if (Object.keys(parameters).length - && !isReload - && !window.confirm("An untrusted query has been loaded, continue loading query?")) { - parameters = {}; - } var options = { fetcher: graphQLFetcher, onEditQuery: onEditQuery, From 24ebc20bf449b4688220e2dac0b43240d0fc5a6c Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:32:38 +0100 Subject: [PATCH 06/48] Fix comment --- graphene_django/templates/graphene/graphiql.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index de3126a..cf61686 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -39,7 +39,7 @@ add "&raw" to the end of the URL within a browser. decodeURIComponent(entry.slice(eq + 1)); } }); - // Produce a Location query string from a parameter object. + // Produce a Location fragment string from a parameter object. function locationQuery(params) { return '#' + Object.keys(params).map(function (key) { return encodeURIComponent(key) + '=' + From e50e12bc9fa693bde1af1b61f2a97eaba90007bd Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:36:26 +0100 Subject: [PATCH 07/48] Move GraphiQL's JS into a separate file for ease of CSP --- .../static/graphene_django/graphiql.js | 119 +++++++++++++++++ .../templates/graphene/graphiql.html | 120 +----------------- 2 files changed, 121 insertions(+), 118 deletions(-) create mode 100644 graphene_django/static/graphene_django/graphiql.js diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js new file mode 100644 index 0000000..ad55e03 --- /dev/null +++ b/graphene_django/static/graphene_django/graphiql.js @@ -0,0 +1,119 @@ +(function() { + + // Parse the cookie value for a CSRF token + var csrftoken; + var cookies = ('; ' + document.cookie).split('; csrftoken='); + if (cookies.length == 2) + csrftoken = cookies.pop().split(';').shift(); + + // Collect the URL parameters + var parameters = {}; + window.location.hash.substr(1).split('&').forEach(function (entry) { + var eq = entry.indexOf('='); + if (eq >= 0) { + parameters[decodeURIComponent(entry.slice(0, eq))] = + decodeURIComponent(entry.slice(eq + 1)); + } + }); + // Produce a Location fragment string from a parameter object. + function locationQuery(params) { + return '#' + Object.keys(params).map(function (key) { + return encodeURIComponent(key) + '=' + + encodeURIComponent(params[key]); + }).join('&'); + } + // Derive a fetch URL from the current URL, sans the GraphQL parameters. + var graphqlParamNames = { + query: true, + variables: true, + operationName: true + }; + var otherParams = {}; + for (var k in parameters) { + if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) { + otherParams[k] = parameters[k]; + } + } + + // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; + var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; + + var fetchURL = locationQuery(otherParams); + + // Defines a GraphQL fetcher using the fetch API. + function graphQLFetcher(graphQLParams) { + var isIntrospectionQuery = ( + graphQLParams.query !== parameters.query + && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 + ); + + if (!isQueryTrusted + && !isIntrospectionQuery + && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { + return Promise.resolve('Aborting query.'); + } + + // We don't want to set this for the introspection query + if (!isIntrospectionQuery) { + isQueryTrusted = true; + } + + var headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + if (csrftoken) { + headers['X-CSRFToken'] = csrftoken; + } + return fetch(fetchURL, { + method: 'post', + headers: headers, + body: JSON.stringify(graphQLParams), + credentials: 'include', + }).then(function (response) { + return response.text(); + }).then(function (responseBody) { + try { + return JSON.parse(responseBody); + } catch (error) { + return responseBody; + } + }); + } + // When the query and variables string is edited, update the URL bar so + // that it can be easily shared. + function onEditQuery(newQuery) { + parameters.query = newQuery; + updateURL(); + } + function onEditVariables(newVariables) { + parameters.variables = newVariables; + updateURL(); + } + function onEditOperationName(newOperationName) { + parameters.operationName = newOperationName; + updateURL(); + } + function updateURL() { + history.replaceState(null, null, locationQuery(parameters)); + } + var options = { + fetcher: graphQLFetcher, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditOperationName: onEditOperationName, + query: parameters.query, + } + if (parameters.variables) { + options.variables = parameters.variables; + } + if (parameters.operation_name) { + options.operationName = parameters.operation_name; + } + // Render into the body. + ReactDOM.render( + React.createElement(GraphiQL, options), + document.body + ); +})(); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index cf61686..7bd1178 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -5,6 +5,7 @@ exploring GraphQL. If you wish to receive JSON, provide the header "Accept: application/json" or add "&raw" to the end of the URL within a browser. --> +{% load static %} @@ -23,123 +24,6 @@ add "&raw" to the end of the URL within a browser. - + From 7e8f6dbd4ec0fe633507e5ba6bc22bda4a0e59fc Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:58:00 +0100 Subject: [PATCH 08/48] Change quotes to improve some syntax highlighting --- graphene_django/templates/graphene/graphiql.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 7bd1178..af11274 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -24,6 +24,6 @@ add "&raw" to the end of the URL within a browser. - + From cb87f4016546aac5f4973e6d01fb31b0cbc10d4e Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:59:09 +0100 Subject: [PATCH 09/48] Document that staticfiles is now a dependency. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4e0b01d..ef3f40c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ pip install "graphene-django>=2.0" ```python INSTALLED_APPS = ( # ... + 'django.contrib.staticfiles', # Required for GraphiQL 'graphene_django', ) From 2b08e59bea2a0d84dc68832dc6d2deae98e77d3a Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Sun, 9 Sep 2018 21:44:30 +0100 Subject: [PATCH 10/48] Revert to default query execution behaviour The only security risk here is persuading a user to execute a mutation, which is probably not a big risk. To mitigate this risk and still keep the same UX (that is so valuable), would require more work than is proportionate for this PR. --- .../static/graphene_django/graphiql.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index ad55e03..2be7e3c 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -35,30 +35,10 @@ } } - // If there are any fragment parameters, confirm the user wants to use them. - var isReload = window.performance ? performance.navigation.type === 1 : false; - var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; - var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. function graphQLFetcher(graphQLParams) { - var isIntrospectionQuery = ( - graphQLParams.query !== parameters.query - && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 - ); - - if (!isQueryTrusted - && !isIntrospectionQuery - && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { - return Promise.resolve('Aborting query.'); - } - - // We don't want to set this for the introspection query - if (!isIntrospectionQuery) { - isQueryTrusted = true; - } - var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' From 19ef9a094ad8dbf20234624b21d567fe86f7b413 Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 11:33:53 -0700 Subject: [PATCH 11/48] Making the example working for Django 2.1.2 --- .../cookbook/ingredients/models.py | 4 +++- .../cookbook/ingredients/schema.py | 18 +++++++++--------- .../cookbook-plain/cookbook/recipes/models.py | 9 +++++---- .../cookbook-plain/cookbook/recipes/schema.py | 16 ++++++++-------- examples/cookbook-plain/cookbook/settings.py | 3 +-- examples/cookbook-plain/cookbook/urls.py | 6 +++--- examples/cookbook-plain/requirements.txt | 2 +- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py index 2f0eba3..5836949 100644 --- a/examples/cookbook-plain/cookbook/ingredients/models.py +++ b/examples/cookbook-plain/cookbook/ingredients/models.py @@ -2,6 +2,8 @@ from django.db import models class Category(models.Model): + class Meta: + verbose_name_plural = 'Categories' name = models.CharField(max_length=100) def __str__(self): @@ -11,7 +13,7 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name='ingredients') + category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE) def __str__(self): return self.name diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py index 1f3bb18..51f25ed 100644 --- a/examples/cookbook-plain/cookbook/ingredients/schema.py +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -1,7 +1,7 @@ import graphene from graphene_django.types import DjangoObjectType -from cookbook.ingredients.models import Category, Ingredient +from .models import Category, Ingredient class CategoryType(DjangoObjectType): @@ -25,16 +25,16 @@ class Query(object): name=graphene.String()) all_ingredients = graphene.List(IngredientType) - def resolve_all_categories(self, args, context, info): + def resolve_all_categories(self, context, **kwargs): return Category.objects.all() - def resolve_all_ingredients(self, args, context, info): + def resolve_all_ingredients(self, context, **kwargs): # We can easily optimize query count in the resolve method return Ingredient.objects.select_related('category').all() - def resolve_category(self, args, context, info): - id = args.get('id') - name = args.get('name') + def resolve_category(self, context, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') if id is not None: return Category.objects.get(pk=id) @@ -44,9 +44,9 @@ class Query(object): return None - def resolve_ingredient(self, args, context, info): - id = args.get('id') - name = args.get('name') + def resolve_ingredient(self, context, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') if id is not None: return Ingredient.objects.get(pk=id) diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py index ca12fac..382b88e 100644 --- a/examples/cookbook-plain/cookbook/recipes/models.py +++ b/examples/cookbook-plain/cookbook/recipes/models.py @@ -1,17 +1,18 @@ from django.db import models -from cookbook.ingredients.models import Ingredient +from ..ingredients.models import Ingredient class Recipe(models.Model): title = models.CharField(max_length=100) instructions = models.TextField() - __unicode__ = lambda self: self.title + def __str__(self): + return self.title class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name='amounts') - ingredient = models.ForeignKey(Ingredient, related_name='used_by') + recipe = models.ForeignKey(Recipe, related_name='amounts', on_delete=models.CASCADE) + ingredient = models.ForeignKey(Ingredient, related_name='used_by', on_delete=models.CASCADE) amount = models.FloatField() unit = models.CharField(max_length=20, choices=( ('unit', 'Units'), diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py index 040c985..620d510 100644 --- a/examples/cookbook-plain/cookbook/recipes/schema.py +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -1,7 +1,7 @@ import graphene from graphene_django.types import DjangoObjectType -from cookbook.recipes.models import Recipe, RecipeIngredient +from .models import Recipe, RecipeIngredient class RecipeType(DjangoObjectType): @@ -24,9 +24,9 @@ class Query(object): id=graphene.Int()) all_recipeingredients = graphene.List(RecipeIngredientType) - def resolve_recipe(self, args, context, info): - id = args.get('id') - title = args.get('title') + def resolve_recipe(self, context, **kwargs): + id = kwargs.get('id') + title = kwargs.get('title') if id is not None: return Recipe.objects.get(pk=id) @@ -36,17 +36,17 @@ class Query(object): return None - def resolve_recipeingredient(self, args, context, info): - id = args.get('id') + def resolve_recipeingredient(self, context, **kwargs): + id = kwargs.get('id') if id is not None: return RecipeIngredient.objects.get(pk=id) return None - def resolve_all_recipes(self, args, context, info): + def resolve_all_recipes(self, context, **kwargs): return Recipe.objects.all() - def resolve_all_recipeingredients(self, args, context, info): + def resolve_all_recipeingredients(self, context, **kwargs): related = ['recipe', 'ingredient'] return RecipeIngredient.objects.select_related(*related).all() diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index 948292d..d846db4 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -44,13 +44,12 @@ INSTALLED_APPS = [ 'cookbook.recipes.apps.RecipesConfig', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py index 9f8755b..4f87da0 100644 --- a/examples/cookbook-plain/cookbook/urls.py +++ b/examples/cookbook-plain/cookbook/urls.py @@ -1,10 +1,10 @@ -from django.conf.urls import url +from django.urls import path from django.contrib import admin from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + path('admin/', admin.site.urls), + path('graphql/', GraphQLView.as_view(graphiql=True)), ] diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 362a39a..539fd67 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.9 +django==2.1.2 From 4359e1f312d3d27b203dec2a5bfd4c9a8346d42b Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 11:36:35 -0700 Subject: [PATCH 12/48] Making the example working for Django 2.1.2 --- .../migrations/0003_auto_20181018_1746.py | 17 +++++++++++++++++ .../migrations/0003_auto_20181018_1728.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py create mode 100644 examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py new file mode 100644 index 0000000..184e79e --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0 on 2018-10-18 17:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ingredients', '0002_auto_20161104_0050'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'verbose_name_plural': 'Categories'}, + ), + ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py new file mode 100644 index 0000000..7a8df49 --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0 on 2018-10-18 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipes', '0002_auto_20161104_0106'), + ] + + operations = [ + migrations.AlterField( + model_name='recipeingredient', + name='unit', + field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20), + ), + ] From 905b4249f3ff71b35105638242c3f908ead70324 Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 12:27:23 -0700 Subject: [PATCH 13/48] Updated ingredients/schema.py and recipes/schema.py to be more readable. --- .../cookbook-plain/cookbook/ingredients/schema.py | 14 ++++---------- examples/cookbook-plain/cookbook/recipes/schema.py | 13 ++++--------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py index 51f25ed..e7ef688 100644 --- a/examples/cookbook-plain/cookbook/ingredients/schema.py +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -25,17 +25,14 @@ class Query(object): name=graphene.String()) all_ingredients = graphene.List(IngredientType) - def resolve_all_categories(self, context, **kwargs): + def resolve_all_categories(self, context): return Category.objects.all() - def resolve_all_ingredients(self, context, **kwargs): + def resolve_all_ingredients(self, context): # We can easily optimize query count in the resolve method return Ingredient.objects.select_related('category').all() - def resolve_category(self, context, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - + def resolve_category(self, context, id=None, name=None): if id is not None: return Category.objects.get(pk=id) @@ -44,10 +41,7 @@ class Query(object): return None - def resolve_ingredient(self, context, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - + def resolve_ingredient(self, context, id=None, name=None): if id is not None: return Ingredient.objects.get(pk=id) diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py index 620d510..74692f8 100644 --- a/examples/cookbook-plain/cookbook/recipes/schema.py +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -24,10 +24,7 @@ class Query(object): id=graphene.Int()) all_recipeingredients = graphene.List(RecipeIngredientType) - def resolve_recipe(self, context, **kwargs): - id = kwargs.get('id') - title = kwargs.get('title') - + def resolve_recipe(self, context, id=None, title=None): if id is not None: return Recipe.objects.get(pk=id) @@ -36,17 +33,15 @@ class Query(object): return None - def resolve_recipeingredient(self, context, **kwargs): - id = kwargs.get('id') - + def resolve_recipeingredient(self, context, id=None): if id is not None: return RecipeIngredient.objects.get(pk=id) return None - def resolve_all_recipes(self, context, **kwargs): + def resolve_all_recipes(self, context): return Recipe.objects.all() - def resolve_all_recipeingredients(self, context, **kwargs): + def resolve_all_recipeingredients(self, context): related = ['recipe', 'ingredient'] return RecipeIngredient.objects.select_related(*related).all() From ae126a6dc3d7ebc24121e6201ad078f413c81455 Mon Sep 17 00:00:00 2001 From: Charles Bradshaw Date: Tue, 19 Mar 2019 16:20:26 -0400 Subject: [PATCH 14/48] Add Introspection Schema Link in Documentation (#489) By the end of the Graphene and Django Tutorial using Relay, one might think they have finished everything needed server side for Relay, but six sections later, in a section that doesn't mention Relay in the title, the final required step for Relay is documented. --- docs/tutorial-relay.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index f2502d7..188a108 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -345,3 +345,10 @@ Or you can get only 'meat' ingredients containing the letter 'e': } } } + + + +Final Steps +^^^^^^^^^^^ + +We have created a GraphQL endpoint that will work with Relay, but for Relay to work it needs access to a (non python) schema. Instructions to export the schema can be found on the `Introspection Schema `__ part of this guide. From 75bf39852326c06236efe93f50c8d2ade3cd933f Mon Sep 17 00:00:00 2001 From: Liam O'Flynn Date: Tue, 19 Mar 2019 13:22:04 -0700 Subject: [PATCH 15/48] Ensure code example is fully functional (#477) Simple change, it took me a while to figure out why the documentation's code was throwing an AssertionError :) --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 188a108..3627311 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -147,7 +147,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: interfaces = (relay.Node, ) - class Query(object): + class Query(graphene.ObjectType): category = relay.Node.Field(CategoryNode) all_categories = DjangoFilterConnectionField(CategoryNode) From 263c7267cbc3ba1511a28234edf484896f38d3d2 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Tue, 19 Mar 2019 16:24:24 -0400 Subject: [PATCH 16/48] Fix code errors in form-mutations.rst (#499) This fixes what appear to be some code errors/typos: * The `FormMutation` class was renamed to `DjangoFormMutation`, and `ModelFormMutation` to `DjangoModelFormMutation`, in 40610c64a3be003719d88db439e442085ed18072. * `form_valid` was renamed to `perform_mutate` in 463ce68b16b070c0a49637dc71755a33acdf9d49. It also clarifies a few things that I found confusing: * It explicitly mentions that `perform_mutate` is a class method. * The code samples now import the form classes from their packages, so readers know where to import them from too. --- docs/form-mutations.rst | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst index e721a78..bbaadb1 100644 --- a/docs/form-mutations.rst +++ b/docs/form-mutations.rst @@ -4,27 +4,31 @@ Integration with Django forms Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. *Note: the API is experimental and will likely change in the future.* -FormMutation ------------- +DjangoFormMutation +------------------ .. code:: python + from graphene_django.forms.mutation import DjangoFormMutation + class MyForm(forms.Form): name = forms.CharField() - class MyMutation(FormMutation): + class MyMutation(DjangoFormMutation): class Meta: form_class = MyForm ``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. -ModelFormMutation ------------------ +DjangoModelFormMutation +----------------------- -``ModelFormMutation`` will pull the fields from a ``ModelForm``. +``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. .. code:: python + from graphene_django.forms.mutation import DjangoModelFormMutation + class Pet(models.Model): name = models.CharField() @@ -61,8 +65,8 @@ Form validation Form mutations will call ``is_valid()`` on your forms. -If the form is valid then ``form_valid(form, info)`` is called on the mutation. Override this method to change how -the form is saved or to return a different Graphene object type. +If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method +to change how the form is saved or to return a different Graphene object type. If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. From ea2cd9894fa1c8a65c365d1b7c042e27df519324 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 19 Mar 2019 20:34:10 +0000 Subject: [PATCH 17/48] Always use HTTPS for CDN files (#498) * Always use HTTPS for CDN files There's no point using insecure, deprecated HTTP even if the current page is on HTTP. * add integrity and crossorigin attributes --- .../templates/graphene/graphiql.html | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index af11274..c0c9af1 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -17,11 +17,20 @@ add "&raw" to the end of the URL within a browser. width: 100%; } - - - - - + + + + + From 367c077a49699ce822e37ce43432e47ce1d10260 Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Mon, 25 Mar 2019 12:45:43 -0400 Subject: [PATCH 18/48] Add static files to MANIFEST.in At the moment, static files are not included in the package data when installing using setuptools. This is necessary for the GraphiQL view. --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 3c3d4f9..4677330 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include README.md LICENSE recursive-include graphene_django/templates * +recursive-include graphene_django/static * From d5d0c519ceaf18e470cd4e2e9cf5270d7a9b079d Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Wed, 27 Mar 2019 15:21:15 +0100 Subject: [PATCH 19/48] Replaced a copy-paste error causing one test case not to run --- graphene_django/tests/test_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 5dc0184..eac5851 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -83,7 +83,7 @@ def test_should_image_convert_string(): assert_conversion(models.ImageField, graphene.String) -def test_should_url_convert_string(): +def test_should_file_path_field_convert_string(): assert_conversion(models.FilePathField, graphene.String) @@ -91,7 +91,7 @@ def test_should_auto_convert_id(): assert_conversion(models.AutoField, graphene.ID, primary_key=True) -def test_should_auto_convert_id(): +def test_should_uuid_convert_id(): assert_conversion(models.UUIDField, graphene.UUID) From 547a4cb5767d97ba5d2c364bd525c14330f6330d Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Wed, 27 Mar 2019 16:30:35 +0100 Subject: [PATCH 20/48] Missing LOC in django model form documentation (fixes #602) --- docs/form-mutations.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst index bbaadb1..85b89e8 100644 --- a/docs/form-mutations.rst +++ b/docs/form-mutations.rst @@ -43,6 +43,8 @@ DjangoModelFormMutation model = Pet class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm From b491878c27eb117ac72958c72333b7a9b10e6d16 Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 29 Mar 2019 11:51:40 +0100 Subject: [PATCH 21/48] * Added test class for django api unittests and documentation how to use it --- docs/index.rst | 1 + docs/testing.rst | 60 ++++++++++++++++++++++++++++ graphene_django/tests/base_test.py | 64 ++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 docs/testing.rst create mode 100644 graphene_django/tests/base_test.py diff --git a/docs/index.rst b/docs/index.rst index 7c64ae7..9469c29 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,3 +14,4 @@ Contents: rest-framework form-mutations introspection + testing diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..a4d5518 --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,60 @@ +Testing API calls with django +============================= + +If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. + +Usage: + +.. code:: python + + import json + + from graphene_django.tests.base_test import GraphQLTestCase + from my_project.config.schema import schema + + class MyFancyTestCase(GraphQLTestCase): + # Here you need to inject your test case's schema + GRAPHQL_SCHEMA = schema + + def test_some_query(self): + response = self.query( + ''' + query { + myModel { + id + name + } + } + ''', + op_name='myModel' + ) + + content = json.loads(response.content) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... + + def test_some_mutation(self): + response = self.query( + ''' + mutation myMutation($input: MyMutationInput!) { + myMutation(input: $input) { + my-model { + id + name + } + } + } + ''', + op_name='myMutation', + input_data={'my_field': 'foo', 'other_field': 'bar'} + ) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py new file mode 100644 index 0000000..8ec2fae --- /dev/null +++ b/graphene_django/tests/base_test.py @@ -0,0 +1,64 @@ +import json + +from django.http import HttpResponse +from django.test import Client +from django.test import TestCase + + +class GraphQLTestCase(TestCase): + """ + Based on: https://www.sam.today/blog/testing-graphql-with-graphene-django/ + """ + + # URL to graphql endpoint + GRAPHQL_URL = '/graphql/' + # Here you need to set your graphql schema for the tests + GRAPHQL_SCHEMA = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + + if not cls.GRAPHQL_SCHEMA: + raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') + + cls._client = Client(cls.GRAPHQL_SCHEMA) + + def query(self, query: str, op_name: str = None, input_data: dict = None): + """ + Args: + query (string) - GraphQL query to run + op_name (string) - If the query is a mutation or named query, you must + supply the op_name. For annon queries ("{ ... }"), + should be None (default). + input_data (dict) - If provided, the $input variable in GraphQL will be set + to this value + + Returns: + Response object from client + """ + body = {'query': query} + if op_name: + body['operation_name'] = op_name + if input_data: + body['variables'] = {'input': input_data} + + resp = self._client.post(self.GRAPHQL_URL, json.dumps(body), + content_type='application/json') + return resp + + def assertResponseNoErrors(self, resp: HttpResponse): + """ + Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, + the call was fine. + """ + content = json.loads(resp.content) + self.assertEqual(resp.status_code, 200) + self.assertNotIn('errors', list(content.keys())) + + def assertResponseHasErrors(self, resp: HttpResponse): + """ + Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200! + """ + content = json.loads(resp.content) + self.assertIn('errors', list(content.keys())) From 3c11a980febe976669dda08e4ef00c8152530f8e Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 29 Mar 2019 12:53:18 +0100 Subject: [PATCH 22/48] Python 2.7 syntax compat --- graphene_django/tests/base_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py index 8ec2fae..471dffe 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/tests/base_test.py @@ -17,14 +17,14 @@ class GraphQLTestCase(TestCase): @classmethod def setUpClass(cls): - super().setUpClass() + super(GraphQLTestCase, cls).setUpClass() if not cls.GRAPHQL_SCHEMA: raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') cls._client = Client(cls.GRAPHQL_SCHEMA) - def query(self, query: str, op_name: str = None, input_data: dict = None): + def query(self, query, op_name=None, input_data=None): """ Args: query (string) - GraphQL query to run @@ -47,18 +47,20 @@ class GraphQLTestCase(TestCase): content_type='application/json') return resp - def assertResponseNoErrors(self, resp: HttpResponse): + def assertResponseNoErrors(self, resp): """ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, the call was fine. + :resp HttpResponse: Response """ content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) self.assertNotIn('errors', list(content.keys())) - def assertResponseHasErrors(self, resp: HttpResponse): + def assertResponseHasErrors(self, resp): """ Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200! + :resp HttpResponse: Response """ content = json.loads(resp.content) self.assertIn('errors', list(content.keys())) From 8beadc759f1eb98174a65dd206d25f9835596827 Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Sat, 30 Mar 2019 19:38:20 +0100 Subject: [PATCH 23/48] Correctly propagate NonNull to inner connection type --- graphene_django/fields.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1ecce45..82c9c66 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,6 +1,7 @@ from functools import partial from django.db.models.query import QuerySet +from graphene import NonNull from promise import Promise @@ -45,17 +46,31 @@ class DjangoConnectionField(ConnectionField): from .types import DjangoObjectType _type = super(ConnectionField, self).type + non_null = False + if isinstance(_type, NonNull): + _type = _type.of_type + non_null = True assert issubclass( _type, DjangoObjectType ), "DjangoConnectionField only accepts DjangoObjectType types" assert _type._meta.connection, "The type {} doesn't have a connection".format( _type.__name__ ) - return _type._meta.connection + connection_type = _type._meta.connection + if non_null: + return NonNull(connection_type) + return connection_type + + @property + def connection_type(self): + type = self.type + if isinstance(type, NonNull): + return type.of_type + return type @property def node_type(self): - return self.type._meta.node + return self.connection_type._meta.node @property def model(self): @@ -103,15 +118,15 @@ class DjangoConnectionField(ConnectionField): @classmethod def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - root, - info, - **args + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + root, + info, + **args ): first = args.get("first") last = args.get("last") @@ -146,7 +161,7 @@ class DjangoConnectionField(ConnectionField): return partial( self.connection_resolver, parent_resolver, - self.type, + self.connection_type, self.get_manager(), self.max_limit, self.enforce_first_or_last, From fcc3de2a90bb81a7a02f9099029da3e4aa82b06e Mon Sep 17 00:00:00 2001 From: Gary Donovan Date: Sun, 31 Mar 2019 21:30:29 +1100 Subject: [PATCH 24/48] Allow graphql schema export to use a canonical representation (#439) When we use the `graphql_schema` management command, the output can vary from run to run depending on arbitrary factors (because there is no guarantee made about the order used to output JSON dictionary keys). This makes it difficult to compare two schema's at different points in time. We address this by including a new `canonical` flag to the command, which uses standard `json.dump` funcitonality to sort dictionary keys and force pretty-printed output. --- docs/introspection.rst | 4 +++- .../management/commands/graphql_schema.py | 4 ++-- graphene_django/settings.py | 2 +- graphene_django/tests/test_command.py | 15 ++++++++++++++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index 0d30ee4..bd80f26 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -11,7 +11,7 @@ data to ``schema.json`` that is compatible with babel-relay-plugin. Usage ----- -Include ``graphene_django`` to ``INSTALLED_APPS`` in you project +Include ``graphene_django`` to ``INSTALLED_APPS`` in your project settings: .. code:: python @@ -29,6 +29,8 @@ It dumps your full introspection schema to ``schema.json`` inside your project root directory. Point ``babel-relay-plugin`` to this file and you're ready to use Relay with Graphene GraphQL implementation. +The schema file is sorted to create a reproducible canonical representation. + Advanced Usage -------------- diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 4e526ec..d7f83da 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -39,7 +39,7 @@ class Command(CommandArguments): def save_file(self, out, schema_dict, indent): with open(out, "w") as outfile: - json.dump(schema_dict, outfile, indent=indent) + json.dump(schema_dict, outfile, indent=indent, sort_keys=True) def handle(self, *args, **options): options_schema = options.get("schema") @@ -65,7 +65,7 @@ class Command(CommandArguments): indent = options.get("indent") schema_dict = {"data": schema.introspect()} if out == '-': - self.stdout.write(json.dumps(schema_dict, indent=indent)) + self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: self.save_file(out, schema_dict, indent) diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 7cd750a..e5fad78 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -28,7 +28,7 @@ except ImportError: DEFAULTS = { "SCHEMA": None, "SCHEMA_OUTPUT": "schema.json", - "SCHEMA_INDENT": None, + "SCHEMA_INDENT": 2, "MIDDLEWARE": (), # Set to True if the connection fields must have # either the first or last argument diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index ff6e6e1..fa78aec 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -1,5 +1,5 @@ from django.core import management -from mock import patch +from mock import patch, mock_open from six import StringIO @@ -8,3 +8,16 @@ def test_generate_file_on_call_graphql_schema(savefile_mock, settings): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() + + +@patch('json.dump') +def test_files_are_canonical(dump_mock): + open_mock = mock_open() + with patch('graphene_django.management.commands.graphql_schema.open', open_mock): + management.call_command('graphql_schema', schema='') + + open_mock.assert_called_once() + + dump_mock.assert_called_once() + assert dump_mock.call_args[1]["sort_keys"], "json.mock() should be used to sort the output" + assert dump_mock.call_args[1]["indent"] > 0, "output should be pretty-printed by default" From 0a5020bee15b2ef04ddbd86ec903b6d87c59aa05 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Sun, 31 Mar 2019 04:01:17 -0700 Subject: [PATCH 25/48] Get queryset (#528) * first attempt at adding get_queryset * add queryset_resolver to DjangoConnectionField and fix test failures * cleanup get_queryset API to match proposal as close as possible * pep8 fix: W293 * document get_queryset usage * add test for when get_queryset is defined on DjangoObjectType --- docs/authorization.rst | 23 +++++++++++++++ graphene_django/fields.py | 7 ++++- graphene_django/tests/test_query.py | 44 +++++++++++++++++++++++++++++ graphene_django/types.py | 7 ++++- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 86ad66a..3b34326 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -96,6 +96,29 @@ schema is simple. result = schema.execute(query, context_value=request) + +Global Filtering +---------------- + +If you are using ``DjangoObjectType`` you can define a custom `get_queryset`. + +.. code:: python + + from graphene import relay + from graphene_django.types import DjangoObjectType + from .models import Post + + class PostNode(DjangoObjectType): + class Meta: + model = Post + + @classmethod + def get_queryset(cls, queryset, info): + if info.context.user.is_anonymous: + return queryset.filter(published=True) + return queryset + + Filtering ID-based Node Access ------------------------------ diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1ecce45..9b27f70 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -67,6 +67,10 @@ class DjangoConnectionField(ConnectionField): else: return self.model._default_manager + @classmethod + def resolve_queryset(cls, connection, queryset, info, args): + return connection._meta.node.get_queryset(queryset, info) + @classmethod def merge_querysets(cls, default_queryset, queryset): if default_queryset.query.distinct and not queryset.query.distinct: @@ -135,7 +139,8 @@ class DjangoConnectionField(ConnectionField): args["last"] = min(last, max_limit) iterable = resolver(root, info, **args) - on_resolve = partial(cls.resolve_connection, connection, default_manager, args) + queryset = cls.resolve_queryset(connection, default_manager, info, args) + on_resolve = partial(cls.resolve_connection, connection, queryset, args) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 1716034..58f46c7 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1007,3 +1007,47 @@ def test_proxy_model_fails(): result = schema.execute(query) assert result.errors + + +def test_should_resolve_get_queryset_connectionfields(): + reporter_1 = Reporter.objects.create( + first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 + ) + reporter_2 = CNNReporter.objects.create( + first_name="Some", + last_name="Guy", + email="someguy@cnn.com", + a_choice=1, + reporter_type=2, # set this guy to be CNN + ) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.filter(reporter_type=2) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery { + allReporters(first: 1) { + edges { + node { + id + } + } + } + } + """ + + expected = {"allReporters": {"edges": [{"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene_django/types.py b/graphene_django/types.py index 4441a9a..4d55b53 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -133,9 +133,14 @@ class DjangoObjectType(ObjectType): model = root._meta.model._meta.concrete_model return model == cls._meta.model + @classmethod + def get_queryset(cls, queryset, info): + return queryset + @classmethod def get_node(cls, info, id): + queryset = cls.get_queryset(cls._meta.model.objects, info) try: - return cls._meta.model.objects.get(pk=id) + return queryset.get(pk=id) except cls._meta.model.DoesNotExist: return None From 923d8282c771244751d483553e173a15230a84b5 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Sun, 31 Mar 2019 12:01:43 +0100 Subject: [PATCH 26/48] Fix duplicated ErrorType declaration (#539) * Add failing test case * Fix duplicated ErrorType declaration --- graphene_django/forms/mutation.py | 2 +- graphene_django/rest_framework/mutation.py | 2 +- graphene_django/rest_framework/types.py | 5 --- graphene_django/tests/issues/__init__.py | 0 graphene_django/tests/issues/test_520.py | 44 ++++++++++++++++++++++ graphene_django/types.py | 6 +++ 6 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 graphene_django/tests/issues/__init__.py create mode 100644 graphene_django/tests/issues/test_520.py diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 63ea089..0851a75 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -14,7 +14,7 @@ from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry from .converter import convert_form_field -from .types import ErrorType +from ..types import ErrorType def fields_for_form(form, only_fields, exclude_fields): diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 5e343aa..b8025f6 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -9,7 +9,7 @@ from graphene.relay.mutation import ClientIDMutation from graphene.types.objecttype import yank_fields_from_attrs from .serializer_converter import convert_serializer_field -from .types import ErrorType +from ..types import ErrorType class SerializerMutationOptions(MutationOptions): diff --git a/graphene_django/rest_framework/types.py b/graphene_django/rest_framework/types.py index 4c84c69..2472c32 100644 --- a/graphene_django/rest_framework/types.py +++ b/graphene_django/rest_framework/types.py @@ -2,11 +2,6 @@ import graphene from graphene.types.unmountedtype import UnmountedType -class ErrorType(graphene.ObjectType): - field = graphene.String(required=True) - messages = graphene.List(graphene.NonNull(graphene.String), required=True) - - class DictType(UnmountedType): key = graphene.String() value = graphene.String() diff --git a/graphene_django/tests/issues/__init__.py b/graphene_django/tests/issues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py new file mode 100644 index 0000000..60c5b54 --- /dev/null +++ b/graphene_django/tests/issues/test_520.py @@ -0,0 +1,44 @@ +# https://github.com/graphql-python/graphene-django/issues/520 + +import datetime + +from django import forms + +import graphene + +from graphene import Field, ResolveInfo +from graphene.types.inputobjecttype import InputObjectType +from py.test import raises +from py.test import mark +from rest_framework import serializers + +from ...types import DjangoObjectType +from ...rest_framework.models import MyFakeModel +from ...rest_framework.mutation import SerializerMutation +from ...forms.mutation import DjangoFormMutation + + +class MyModelSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeModel + fields = "__all__" + + +class MyForm(forms.Form): + text = forms.CharField() + + +def test_can_use_form_and_serializer_mutations(): + class MyMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + + class MyFormMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + class Mutation(graphene.ObjectType): + my_mutation = MyMutation.Field() + my_form_mutation = MyFormMutation.Field() + + graphene.Schema(mutation=Mutation) diff --git a/graphene_django/types.py b/graphene_django/types.py index 4d55b53..3f99cef 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.db.models import Model from django.utils.functional import SimpleLazyObject +import graphene from graphene import Field from graphene.relay import Connection, Node from graphene.types.objecttype import ObjectType, ObjectTypeOptions @@ -144,3 +145,8 @@ class DjangoObjectType(ObjectType): return queryset.get(pk=id) except cls._meta.model.DoesNotExist: return None + + +class ErrorType(ObjectType): + field = graphene.String(required=True) + messages = graphene.List(graphene.NonNull(graphene.String), required=True) From 090ce6e1f1076372f5ab513edbc46276964b4e7a Mon Sep 17 00:00:00 2001 From: Edi Santoso Date: Sun, 31 Mar 2019 18:13:07 +0700 Subject: [PATCH 27/48] Fix invalid url django-filter docs (#589) --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 3627311..630898e 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -158,7 +158,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: The filtering functionality is provided by `django-filter `__. See the `usage -documentation `__ +documentation `__ for details on the format for ``filter_fields``. While optional, this tutorial makes use of this functionality so you will need to install ``django-filter`` for this tutorial to work: From 29b8ea8398217c0d1c62ad0fbd4c3e5c225e67a9 Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 5 Apr 2019 14:27:53 +0200 Subject: [PATCH 28/48] Bugfix: FormMutation was always causing boolean fields to be required --- graphene_django/forms/converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 87180b2..8916456 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -43,7 +43,7 @@ def convert_form_field_to_int(field): @convert_form_field.register(forms.BooleanField) def convert_form_field_to_boolean(field): - return Boolean(description=field.help_text, required=True) + return Boolean(description=field.help_text, required=field.required) @convert_form_field.register(forms.NullBooleanField) From 6acd917cf7076397009d0ba77901f4c1c8e190fe Mon Sep 17 00:00:00 2001 From: David Sanders Date: Mon, 15 Apr 2019 05:53:30 -0700 Subject: [PATCH 29/48] Drop old Django compatibility code --- graphene_django/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/graphene_django/utils.py b/graphene_django/utils.py index 560f604..532be06 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils.py @@ -25,8 +25,7 @@ def get_reverse_fields(model, local_field_names): if name in local_field_names: continue - # Django =>1.9 uses 'rel', django <1.9 uses 'related' - related = getattr(attr, "rel", None) or getattr(attr, "related", None) + related = getattr(attr, "rel", None) if isinstance(related, models.ManyToOneRel): yield (name, related) elif isinstance(related, models.ManyToManyRel) and not related.symmetrical: From 2ae897187cec9119de3753bc646e806b69487188 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 13:14:28 +0100 Subject: [PATCH 30/48] Add Makefile and better CONTRIBUTING.md --- CONTRIBUTING.md | 32 ++++++++++++++++++++++++++++++++ Makefile | 5 +++++ README.md | 12 +----------- README.rst | 12 +----------- 4 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 Makefile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9731c03 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Thanks for helping to make graphene-django great! + +We welcome all kinds of contributions: + +- Bug fixes +- Documentation improvements +- New features +- Refactoring & tidying + + +## Getting started + +If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphene-django/issues) and [projects](https://github.com/graphql-python/graphene-django/projects) in progress - someone could already be working on something similar and you can help out. + + +## Project setup + +After cloning this repo, ensure dependencies are installed by running: + +```sh +make dev-setup +``` + +## Running tests + +After developing, the full test suite can be evaluated by running: + +```sh +make tests +``` \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c174ac --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +dev-setup: + pip install -e ".[test]" + +tests: + py.test graphene_django --cov=graphene_django -vv \ No newline at end of file diff --git a/README.md b/README.md index ef3f40c..9b8916a 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,7 @@ To learn more check out the following [examples](examples/): ## Contributing -After cloning this repo, ensure dependencies are installed by running: - -```sh -pip install -e ".[test]" -``` - -After developing, the full test suite can be evaluated by running: - -```sh -py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode -``` +See [CONTRIBUTING.md](contributing.md) ### Documentation diff --git a/README.rst b/README.rst index a96e60f..e884a40 100644 --- a/README.rst +++ b/README.rst @@ -105,17 +105,7 @@ To learn more check out the following `examples `__: Contributing ------------ -After cloning this repo, ensure dependencies are installed by running: - -.. code:: sh - - pip install -e ".[test]" - -After developing, the full test suite can be evaluated by running: - -.. code:: sh - - py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode +See `CONTRIBUTING.md `__. Documentation ~~~~~~~~~~~~~ From bba8377a8209235fadb07848e11429c2d18aeff4 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 14:08:44 +0100 Subject: [PATCH 31/48] Move documentation to CONTRIBUTING.md --- CONTRIBUTING.md | 17 +++++++++++++++++ README.md | 20 +------------------- README.rst | 19 ------------------- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9731c03..4a650d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,4 +29,21 @@ After developing, the full test suite can be evaluated by running: ```sh make tests +``` + +## Documentation + +The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. + +The documentation dependencies are installed by running: + +```sh +cd docs +pip install -r requirements.txt +``` + +Then to produce a HTML version of the documentation: + +```sh +make html ``` \ No newline at end of file diff --git a/README.md b/README.md index 9b8916a..fffa1d3 100644 --- a/README.md +++ b/README.md @@ -96,22 +96,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](contributing.md) - - -### Documentation - -The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. - -The documentation dependencies are installed by running: - -```sh -cd docs -pip install -r requirements.txt -``` - -Then to produce a HTML version of the documentation: - -```sh -make html -``` +See [CONTRIBUTING.md](contributing.md) \ No newline at end of file diff --git a/README.rst b/README.rst index e884a40..2e0593d 100644 --- a/README.rst +++ b/README.rst @@ -107,25 +107,6 @@ Contributing See `CONTRIBUTING.md `__. -Documentation -~~~~~~~~~~~~~ - -The `documentation `__ is generated using the excellent -`Sphinx `__ and a custom theme. - -The documentation dependencies are installed by running: - -.. code:: sh - - cd docs - pip install -r requirements.txt - -Then to produce a HTML version of the documentation: - -.. code:: sh - - make html - .. |Graphene Logo| image:: http://graphene-python.org/favicon.png .. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master :target: https://travis-ci.org/graphql-python/graphene-django From d720b47c8da0eb4ca1e1ca8ba9cda72b56750295 Mon Sep 17 00:00:00 2001 From: Eran Kampf <205185+ekampf@users.noreply.github.com> Date: Tue, 30 Apr 2019 09:55:28 -0700 Subject: [PATCH 32/48] Test docs build --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 2e0593d..8afb5ce 100644 --- a/README.rst +++ b/README.rst @@ -114,3 +114,4 @@ See `CONTRIBUTING.md `__. :target: https://badge.fury.io/py/graphene-django .. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github :target: https://coveralls.io/github/graphql-python/graphene-django?branch=master + From 05c89c19fb48c26fd96a4b3d590aeb98ad11dfc0 Mon Sep 17 00:00:00 2001 From: Eran Kampf <205185+ekampf@users.noreply.github.com> Date: Tue, 30 Apr 2019 09:57:17 -0700 Subject: [PATCH 33/48] Test docs integration webhook --- README.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/README.rst b/README.rst index 8afb5ce..2e0593d 100644 --- a/README.rst +++ b/README.rst @@ -114,4 +114,3 @@ See `CONTRIBUTING.md `__. :target: https://badge.fury.io/py/graphene-django .. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github :target: https://coveralls.io/github/graphql-python/graphene-django?branch=master - From b49d315a39c3a771a98ab35b6c35dbe56cb8ab3a Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Wed, 1 May 2019 15:49:54 +0200 Subject: [PATCH 34/48] 4 spaces --- graphene_django/fields.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 82c9c66..35bd8a4 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -118,15 +118,15 @@ class DjangoConnectionField(ConnectionField): @classmethod def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - root, - info, - **args + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + root, + info, + **args ): first = args.get("first") last = args.get("last") From e6ad5887caebf818b0c61d09924c51a9f0f9b406 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 30 Apr 2019 10:02:23 +0100 Subject: [PATCH 35/48] Introduce Black formatting, additional tests --- .travis.yml | 14 +++++++-- CONTRIBUTING.md | 13 +++++++++ Makefile | 10 +++++-- README.md | 2 +- graphene_django/compat.py | 8 +++-- graphene_django/debug/sql/types.py | 29 +++++-------------- graphene_django/debug/types.py | 5 +--- graphene_django/filter/filterset.py | 4 +-- graphene_django/forms/tests/test_mutation.py | 9 +++--- .../management/commands/graphql_schema.py | 2 +- graphene_django/tests/base_test.py | 21 ++++++++------ graphene_django/tests/test_command.py | 14 +++++---- graphene_django/tests/test_converter.py | 6 ++-- graphene_django/views.py | 3 +- setup.py | 8 ++++- 15 files changed, 86 insertions(+), 62 deletions(-) diff --git a/.travis.yml b/.travis.yml index a8375ee..07ee59f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,21 @@ language: python -sudo: false +sudo: required +dist: xenial python: - 2.7 - 3.4 - 3.5 - 3.6 +- 3.7 install: - | if [ "$TEST_TYPE" = build ]; then pip install -e .[test] - pip install psycopg2 # Required for Django postgres fields testing + pip install psycopg2==2.8.2 # Required for Django postgres fields testing pip install django==$DJANGO_VERSION python setup.py develop elif [ "$TEST_TYPE" = lint ]; then - pip install flake8 + pip install flake8==3.7.7 fi script: - | @@ -45,10 +47,16 @@ matrix: env: TEST_TYPE=build DJANGO_VERSION=2.1 - python: '3.6' env: TEST_TYPE=build DJANGO_VERSION=2.1 + - python: '3.6' + env: TEST_TYPE=build DJANGO_VERSION=2.2 + - python: '3.7' + env: TEST_TYPE=build DJANGO_VERSION=2.2 - python: '2.7' env: TEST_TYPE=lint - python: '3.6' env: TEST_TYPE=lint + - python: '3.7' + env: TEST_TYPE=lint deploy: provider: pypi user: syrusakbary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a650d6..f9428e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,19 @@ After developing, the full test suite can be evaluated by running: make tests ``` +## Opening Pull Requests + +Please fork the project and open a pull request against the master branch. + +This will trigger a series of test and lint checks. + +We advise that you format and run lint locally before doing this to save time: + +```sh +make format +make lint +``` + ## Documentation The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. diff --git a/Makefile b/Makefile index 5c174ac..061ad4e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,11 @@ dev-setup: - pip install -e ".[test]" + pip install -e ".[dev]" tests: - py.test graphene_django --cov=graphene_django -vv \ No newline at end of file + py.test graphene_django --cov=graphene_django -vv + +format: + black graphene_django + +lint: + flake8 graphene_django diff --git a/README.md b/README.md index fffa1d3..d2fe4b6 100644 --- a/README.md +++ b/README.md @@ -96,4 +96,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](contributing.md) \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) \ No newline at end of file diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 4a51de8..59fab30 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -5,7 +5,11 @@ class MissingType(object): try: # Postgres fields are only available in Django with psycopg2 installed # and we cannot have psycopg2 on PyPy - from django.contrib.postgres.fields import (ArrayField, HStoreField, - JSONField, RangeField) + from django.contrib.postgres.fields import ( + ArrayField, + HStoreField, + JSONField, + RangeField, + ) except ImportError: ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 diff --git a/graphene_django/debug/sql/types.py b/graphene_django/debug/sql/types.py index 850ced4..eeef482 100644 --- a/graphene_django/debug/sql/types.py +++ b/graphene_django/debug/sql/types.py @@ -3,9 +3,7 @@ from graphene import Boolean, Float, ObjectType, String class DjangoDebugSQL(ObjectType): class Meta: - description = ( - "Represents a single database query made to a Django managed DB." - ) + description = "Represents a single database query made to a Django managed DB." vendor = String( required=True, @@ -14,37 +12,26 @@ class DjangoDebugSQL(ObjectType): ), ) alias = String( - required=True, - description="The Django database alias (e.g. 'default').", + required=True, description="The Django database alias (e.g. 'default')." ) sql = String(description="The actual SQL sent to this database.") duration = Float( - required=True, - description="Duration of this database query in seconds.", + required=True, description="Duration of this database query in seconds." ) raw_sql = String( - required=True, - description="The raw SQL of this query, without params.", + required=True, description="The raw SQL of this query, without params." ) params = String( - required=True, - description="JSON encoded database query parameters.", - ) - start_time = Float( - required=True, - description="Start time of this database query.", - ) - stop_time = Float( - required=True, - description="Stop time of this database query.", + required=True, description="JSON encoded database query parameters." ) + start_time = Float(required=True, description="Start time of this database query.") + stop_time = Float(required=True, description="Stop time of this database query.") is_slow = Boolean( required=True, description="Whether this database query took more than 10 seconds.", ) is_select = Boolean( - required=True, - description="Whether this database query was a SELECT.", + required=True, description="Whether this database query was a SELECT." ) # Postgres diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py index cda5725..1cd816d 100644 --- a/graphene_django/debug/types.py +++ b/graphene_django/debug/types.py @@ -7,7 +7,4 @@ class DjangoDebug(ObjectType): class Meta: description = "Debugging information for the current query." - sql = List( - DjangoDebugSQL, - description="Executed SQL queries for this API query.", - ) + sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.") diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index 4059083..7676ea8 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -45,8 +45,7 @@ class GrapheneFilterSetMixin(BaseFilterSet): FILTER_DEFAULTS = dict( itertools.chain( - FILTER_FOR_DBFIELD_DEFAULTS.items(), - GRAPHENE_FILTER_SET_OVERRIDES.items() + FILTER_FOR_DBFIELD_DEFAULTS.items(), GRAPHENE_FILTER_SET_OVERRIDES.items() ) ) @@ -59,7 +58,6 @@ if VERSION[0] < 2: from django.utils.text import capfirst class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin): - @classmethod def filter_for_reverse_field(cls, f, name): """Handles retrieving filters for reverse relationships diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index df0ffd5..543e89e 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -13,7 +13,7 @@ class MyForm(forms.Form): class PetForm(forms.ModelForm): class Meta: model = Pet - fields = '__all__' + fields = "__all__" def test_needs_form_class(): @@ -66,7 +66,7 @@ class ModelFormMutationTests(TestCase): class PetMutation(DjangoModelFormMutation): class Meta: form_class = PetForm - exclude_fields = ['id'] + exclude_fields = ["id"] self.assertEqual(PetMutation._meta.model, Pet) self.assertEqual(PetMutation._meta.return_field_name, "pet") @@ -102,7 +102,9 @@ class ModelFormMutationTests(TestCase): pet = Pet.objects.create(name="Axel", age=10) - result = PetMutation.mutate_and_get_payload(None, None, id=pet.pk, name="Mia", age=10) + result = PetMutation.mutate_and_get_payload( + None, None, id=pet.pk, name="Mia", age=10 + ) self.assertEqual(Pet.objects.count(), 1) pet.refresh_from_db() @@ -132,7 +134,6 @@ class ModelFormMutationTests(TestCase): # A pet was not created self.assertEqual(Pet.objects.count(), 0) - fields_w_error = [e.field for e in result.errors] self.assertEqual(len(result.errors), 2) self.assertIn("name", fields_w_error) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index d7f83da..9f8689e 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -64,7 +64,7 @@ class Command(CommandArguments): indent = options.get("indent") schema_dict = {"data": schema.introspect()} - if out == '-': + if out == "-": self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: self.save_file(out, schema_dict, indent) diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py index 471dffe..84e1dc5 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/tests/base_test.py @@ -11,7 +11,7 @@ class GraphQLTestCase(TestCase): """ # URL to graphql endpoint - GRAPHQL_URL = '/graphql/' + GRAPHQL_URL = "/graphql/" # Here you need to set your graphql schema for the tests GRAPHQL_SCHEMA = None @@ -20,7 +20,9 @@ class GraphQLTestCase(TestCase): super(GraphQLTestCase, cls).setUpClass() if not cls.GRAPHQL_SCHEMA: - raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') + raise AttributeError( + "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase." + ) cls._client = Client(cls.GRAPHQL_SCHEMA) @@ -37,14 +39,15 @@ class GraphQLTestCase(TestCase): Returns: Response object from client """ - body = {'query': query} + body = {"query": query} if op_name: - body['operation_name'] = op_name + body["operation_name"] = op_name if input_data: - body['variables'] = {'input': input_data} + body["variables"] = {"input": input_data} - resp = self._client.post(self.GRAPHQL_URL, json.dumps(body), - content_type='application/json') + resp = self._client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) return resp def assertResponseNoErrors(self, resp): @@ -55,7 +58,7 @@ class GraphQLTestCase(TestCase): """ content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) - self.assertNotIn('errors', list(content.keys())) + self.assertNotIn("errors", list(content.keys())) def assertResponseHasErrors(self, resp): """ @@ -63,4 +66,4 @@ class GraphQLTestCase(TestCase): :resp HttpResponse: Response """ content = json.loads(resp.content) - self.assertIn('errors', list(content.keys())) + self.assertIn("errors", list(content.keys())) diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index fa78aec..dbabafa 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -10,14 +10,18 @@ def test_generate_file_on_call_graphql_schema(savefile_mock, settings): assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() -@patch('json.dump') +@patch("json.dump") def test_files_are_canonical(dump_mock): open_mock = mock_open() - with patch('graphene_django.management.commands.graphql_schema.open', open_mock): - management.call_command('graphql_schema', schema='') + with patch("graphene_django.management.commands.graphql_schema.open", open_mock): + management.call_command("graphql_schema", schema="") open_mock.assert_called_once() dump_mock.assert_called_once() - assert dump_mock.call_args[1]["sort_keys"], "json.mock() should be used to sort the output" - assert dump_mock.call_args[1]["indent"] > 0, "output should be pretty-printed by default" + assert dump_mock.call_args[1][ + "sort_keys" + ], "json.mock() should be used to sort the output" + assert ( + dump_mock.call_args[1]["indent"] > 0 + ), "output should be pretty-printed by default" diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index eac5851..bb176b3 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -241,8 +241,7 @@ def test_should_manytoone_convert_connectionorlist(): class Meta: model = Article - graphene_field = convert_django_field(Reporter.articles.rel, - A._meta.registry) + graphene_field = convert_django_field(Reporter.articles.rel, A._meta.registry) assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) @@ -255,8 +254,7 @@ def test_should_onetoone_reverse_convert_model(): class Meta: model = FilmDetails - graphene_field = convert_django_field(Film.details.related, - A._meta.registry) + graphene_field = convert_django_field(Film.details.related, A._meta.registry) assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) diff --git a/graphene_django/views.py b/graphene_django/views.py index 9a530de..0b840f9 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -126,8 +126,7 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( - request, - graphiql_version=self.graphiql_version, + request, graphiql_version=self.graphiql_version ) if self.batch: diff --git a/setup.py b/setup.py index 3431cd5..e622a71 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,12 @@ tests_require = [ "pytest-django>=3.3.2", ] + rest_framework_require + +dev_requires = [ + "black==19.3b0", + "flake8==3.7.7", +] + tests_require + setup( name="graphene-django", version=version, @@ -58,7 +64,7 @@ setup( setup_requires=["pytest-runner"], tests_require=tests_require, rest_framework_require=rest_framework_require, - extras_require={"test": tests_require, "rest_framework": rest_framework_require}, + extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires}, include_package_data=True, zip_safe=False, platforms="any", From 6f03597a5e5d09a894ae93fc9cb97b963fcbe8a9 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Mon, 6 May 2019 13:28:02 +0100 Subject: [PATCH 36/48] Create CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5560ba2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at me@syrusakbary.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq From 31468f56874532c4628905063a891f8260198990 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 16:48:37 +0100 Subject: [PATCH 37/48] Rebuild documentation --- README.md | 8 +- README.rst | 12 +- docs/authorization.rst | 6 +- docs/conf.py | 89 +++++----- docs/debug.rst | 1 + docs/filtering.rst | 48 ++++- docs/form-mutations.rst | 74 -------- docs/index.rst | 23 ++- docs/installation.rst | 69 ++++++++ docs/introspection.rst | 4 +- docs/mutations.rst | 229 ++++++++++++++++++++++++ docs/queries.rst | 270 +++++++++++++++++++++++++++++ docs/rest-framework.rst | 64 ------- docs/schema.rst | 50 ++++++ docs/tutorial-plain.rst | 24 +-- docs/tutorial-relay.rst | 6 +- examples/cookbook/cookbook/urls.py | 2 +- 17 files changed, 766 insertions(+), 213 deletions(-) delete mode 100644 docs/form-mutations.rst create mode 100644 docs/installation.rst create mode 100644 docs/mutations.rst create mode 100644 docs/queries.rst delete mode 100644 docs/rest-framework.rst create mode 100644 docs/schema.rst diff --git a/README.md b/README.md index d2fe4b6..159a592 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/ma A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/). -## Installation +## Documentation + +[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/) + +## Quickstart For installing graphene, just run this command in your shell @@ -39,7 +43,7 @@ from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] ``` diff --git a/README.rst b/README.rst index 2e0593d..44feaee 100644 --- a/README.rst +++ b/README.rst @@ -10,8 +10,14 @@ to learn how to upgrade to Graphene ``2.0``. A `Django `__ integration for `Graphene `__. -Installation ------------- + +Documentation +------------- + +`Visit the documentation to get started! `__ + +Quickstart +---------- For installing graphene, just run this command in your shell @@ -46,7 +52,7 @@ serve the queries. urlpatterns = [ # ... - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] Examples diff --git a/docs/authorization.rst b/docs/authorization.rst index 3b34326..3d0bb8a 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -155,7 +155,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR .. code:: python #views.py - + from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView @@ -171,9 +171,9 @@ For Django 1.9 and below: urlpatterns = [ # some other urls - url(r'^graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), ] - + For Django 2.0 and above: .. code:: python diff --git a/docs/conf.py b/docs/conf.py index 2ea2d55..a485d5b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,6 @@ import os -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" # -*- coding: utf-8 -*- # @@ -34,46 +34,44 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", ] if not on_rtd: - extensions += [ - 'sphinx.ext.githubpages', - ] + extensions += ["sphinx.ext.githubpages"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Graphene Django' -copyright = u'Graphene 2017' -author = u'Syrus Akbary' +project = u"Graphene Django" +copyright = u"Graphene 2017" +author = u"Syrus Akbary" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'1.0' +version = u"1.0" # The full version, including alpha/beta/rc tags. -release = u'1.0.dev' +release = u"1.0.dev" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -94,7 +92,7 @@ language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -116,7 +114,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -175,7 +173,7 @@ html_theme_path = [sphinx_graphene_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -255,34 +253,30 @@ html_static_path = ['_static'] # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'Graphenedoc' +htmlhelp_basename = "Graphenedoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'Graphene.tex', u'Graphene Documentation', - u'Syrus Akbary', 'manual'), + (master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -323,8 +317,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'graphene_django', u'Graphene Django Documentation', - [author], 1) + (master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -338,9 +331,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'Graphene-Django', u'Graphene Django Documentation', - author, 'Graphene Django', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "Graphene-Django", + u"Graphene Django Documentation", + author, + "Graphene Django", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. @@ -414,7 +413,7 @@ epub_copyright = copyright # epub_post_files = [] # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # The depth of the table of contents in toc.ncx. # @@ -446,4 +445,4 @@ epub_exclude_files = ['search.html'] # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {"https://docs.python.org/": None} diff --git a/docs/debug.rst b/docs/debug.rst index 8ef2e86..8e67c23 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -34,6 +34,7 @@ And in your ``settings.py``: .. code:: python GRAPHENE = { + ... 'MIDDLEWARE': [ 'graphene_django.debug.DjangoDebugMiddleware', ] diff --git a/docs/filtering.rst b/docs/filtering.rst index feafd40..d02366f 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -136,7 +136,7 @@ pre-filter animals owned by the authenticated user (set in ``context.user``). class AnimalFilter(django_filters.FilterSet): # Do case-insensitive lookups on 'name' - name = django_filters.CharFilter(lookup_type='iexact') + name = django_filters.CharFilter(lookup_type=['iexact']) class Meta: model = Animal @@ -146,3 +146,49 @@ pre-filter animals owned by the authenticated user (set in ``context.user``). def qs(self): # The query context can be found in self.request. return super(AnimalFilter, self).qs.filter(owner=self.request.user) + + +Ordering +-------- + +You can use ``OrderFilter`` to define how you want your returned results to be ordered. + +Extend the tuple of fields if you want to order by more than one field. + +.. code:: python + + from django_filters import FilterSet, OrderingFilter + + class UserFilter(FilterSet): + class Meta: + model = UserModel + + order_by = OrderingFilter( + fields=( + ('created_at', 'created_at'), + ) + ) + + class Group(DjangoObjectType): + users = DjangoFilterConnectionField(Ticket, filterset_class=UserFilter) + + class Meta: + name = 'Group' + model = GroupModel + interfaces = (relay.Node,) + + def resolve_users(self, info, **kwargs): + return UserFilter(kwargs).qs + + +with this set up, you can now order the users under group: + +.. code:: + + query { + group(id: "xxx") { + users(orderBy: "-created_at") { + xxx + } + } + } \ No newline at end of file diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst deleted file mode 100644 index 85b89e8..0000000 --- a/docs/form-mutations.rst +++ /dev/null @@ -1,74 +0,0 @@ -Integration with Django forms -============================= - -Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. -*Note: the API is experimental and will likely change in the future.* - -DjangoFormMutation ------------------- - -.. code:: python - - from graphene_django.forms.mutation import DjangoFormMutation - - class MyForm(forms.Form): - name = forms.CharField() - - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm - -``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. - -DjangoModelFormMutation ------------------------ - -``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. - -.. code:: python - - from graphene_django.forms.mutation import DjangoModelFormMutation - - class Pet(models.Model): - name = models.CharField() - - class PetForm(forms.ModelForm): - class Meta: - model = Pet - fields = ('name',) - - # This will get returned when the mutation completes successfully - class PetType(DjangoObjectType): - class Meta: - model = Pet - - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - -``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation -will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will -return a list of errors. - -You can change the input name (default is ``input``) and the return field name (default is the model name lowercase). - -.. code:: python - - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - input_field_name = 'data' - return_field_name = 'my_pet' - -Form validation ---------------- - -Form mutations will call ``is_valid()`` on your forms. - -If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method -to change how the form is saved or to return a different Graphene object type. - -If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string -containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. diff --git a/docs/index.rst b/docs/index.rst index 9469c29..c7820cf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,34 @@ Graphene-Django =============== -Contents: +Welcome to the Graphene-Django docs. + +Graphene-Django is built on top of `Graphene `__. +Graphene-Django provides some additional abstractions that make it easy to add GraphQL functionality to your Django project. + +First time? We recommend you start with the installation guide to get set up and the basic tutorial. +It is worth reading the `core graphene docs `__ to familiarize yourself with the basic utilities. + +Core tenants +------------ + +If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section. + + +For more advanced use, check out the Relay tutorial. .. toctree:: - :maxdepth: 0 + :maxdepth: 1 + installation tutorial-plain tutorial-relay + schema + queries + mutations filtering authorization debug rest-framework - form-mutations introspection testing diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..8f3e550 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,69 @@ +Installation +============ + +Graphene-Django takes a few seconds to install and set up. + +Requirements +------------ + +Graphene-Django currently supports the following versions of Django: + +* Django 2.X + +Installation +------------ + +.. code:: bash + + pip install graphene-django + +**We strongly recommend pinning against a specific version of Graphene-Django because new versions could introduce breaking changes to your project.** + +Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of your Django project: + +.. code:: python + + INSTALLED_APPS = [ + ... + 'django.contrib.staticfiles', # Required for GraphiQL + 'graphene_django' + ] + + +We need to add a graphql URL to the ``urls.py`` of your Django project: + +.. code:: python + + from django.conf.urls import url + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + ] + +(Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.) + +Finally, define the schema location for Graphene in the ``settings.py`` file of your Django project: + +.. code:: python + + GRAPHENE = { + 'SCHEMA': 'django_root.schema.schema' + } + +Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project. + +The most basic ``schema.py`` looks like this: + +.. code:: python + + import graphene + + class Query(graphene.ObjectType): + pass + + schema = graphene.Schema(query=Query) + + +To learn how to extend the schema object for your project, read the basic tutorial. \ No newline at end of file diff --git a/docs/introspection.rst b/docs/introspection.rst index bd80f26..92e3612 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -5,8 +5,8 @@ Relay uses `Babel Relay Plugin `__ that requires you to provide your GraphQL schema data. -Graphene comes with a management command for Django to dump your schema -data to ``schema.json`` that is compatible with babel-relay-plugin. +Graphene comes with a Django management command to dump your schema +data to ``schema.json`` which is compatible with babel-relay-plugin. Usage ----- diff --git a/docs/mutations.rst b/docs/mutations.rst new file mode 100644 index 0000000..f6c6f14 --- /dev/null +++ b/docs/mutations.rst @@ -0,0 +1,229 @@ +Mutations +========= + +Introduction +------------ + +Graphene-Django makes it easy to perform mutations. + +With Graphene-Django we can take advantage of pre-existing Django features to +quickly build CRUD functionality, while still using the core `graphene mutation `__ +features to add custom mutations to a Django project. + +Simple example +-------------- + +.. code:: python + + import graphene + + from graphene_django import DjangoObjectType + + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + + class QuestionMutation(graphene.Mutation): + class Arguments: + # The input arguments for this mutation + text = graphene.String(required=True) + id = graphene.ID() + + # The class attributes define the response of the mutation + question = graphene.Field(QuestionType) + + def mutate(self, info, text, id): + question = Question.objects.get(pk=id) + question.text = text + question.save() + # Notice we return an instance of this mutation + return QuestionMutation(question=question) + + + class Mutation: + update_question = QuestionMutation.Field() + + +Django Forms +------------ + +Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. + +DjangoFormMutation +~~~~~~~~~~~~~~~~~~ + +.. code:: python + + from graphene_django.forms.mutation import DjangoFormMutation + + class MyForm(forms.Form): + name = forms.CharField() + + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + +``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. + +DjangoModelFormMutation +~~~~~~~~~~~~~~~~~~~~~~~ + +``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. + +.. code:: python + + from graphene_django.forms.mutation import DjangoModelFormMutation + + class Pet(models.Model): + name = models.CharField() + + class PetForm(forms.ModelForm): + class Meta: + model = Pet + fields = ('name',) + + # This will get returned when the mutation completes successfully + class PetType(DjangoObjectType): + class Meta: + model = Pet + + class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + + class Meta: + form_class = PetForm + +``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation +will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will +return a list of errors. + +You can change the input name (default is ``input``) and the return field name (default is the model name lowercase). + +.. code:: python + + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + input_field_name = 'data' + return_field_name = 'my_pet' + +Form validation +~~~~~~~~~~~~~~~ + +Form mutations will call ``is_valid()`` on your forms. + +If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method +to change how the form is saved or to return a different Graphene object type. + +If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string +containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. + + +Django REST Framework +--------------------- + +You can re-use your Django Rest Framework serializer with Graphene Django mutations. + +You can create a Mutation based on a serializer by using the `SerializerMutation` base class: + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + + class MyAwesomeMutation(SerializerMutation): + class Meta: + serializer_class = MySerializer + + +Create/Update Operations +~~~~~~~~~~~~~~~~~~~~~~~~ + +By default ModelSerializers accept create and update operations. To +customize this use the `model_operations` attribute on the ``SerializerMutation`` class. + +The update operation looks up models by the primary key by default. You can +customize the look up with the ``lookup_field`` attribute on the ``SerializerMutation`` class. + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + from .serializers imoprt MyModelSerializer + + + class AwesomeModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + model_operations = ['create', 'update'] + lookup_field = 'id' + +Overriding Update Queries +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the method ``get_serializer_kwargs`` to override how updates are applied. + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + from .serializers imoprt MyModelSerializer + + + class AwesomeModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + + @classmethod + def get_serializer_kwargs(cls, root, info, **input): + if 'id' in input: + instance = Post.objects.filter( + id=input['id'], owner=info.context.user + ).first() + if instance: + return {'instance': instance, 'data': input, 'partial': True} + + else: + raise http.Http404 + + return {'data': input, 'partial': True} + + + +Relay +----- + +You can use relay with mutations. A Relay mutation must inherit from +``ClientIDMutation`` and implement the ``mutate_and_get_payload`` method: + +.. code:: python + + import graphene import relay, DjangoObjectType + from graphql_relay import from_global_id + + from .queries import QuestionType + + + class QuestionMutation(relay.ClientIDMutation): + class Input: + text = graphene.String(required=True) + id = graphene.ID() + + question = graphene.Field(QuestionType) + + @classmethod + def mutate_and_get_payload(cls, root, info, text, id): + question = Question.objects.get(pk=from_global_id(id)) + question.text = text + question.save() + return QuestionMutation(question=question) + +Notice that the ``class Arguments`` is renamed to ``class Input`` with relay. +This is due to a deprecation of ``class Arguments`` in graphene 2.0. + +Relay ClientIDMutation accept a ``clientIDMutation`` argument. +This argument is also sent back to the client with the mutation result +(you do not have to do anything). For services that manage +a pool of many GraphQL requests in bulk, the ``clientIDMutation`` +allows you to match up a specific mutation with the response. \ No newline at end of file diff --git a/docs/queries.rst b/docs/queries.rst new file mode 100644 index 0000000..d54c908 --- /dev/null +++ b/docs/queries.rst @@ -0,0 +1,270 @@ +Queries & ObjectTypes +===================== + +Introduction +------------ + +Graphene-Django offers a host of features for performing GraphQL queries. + +Graphene-Django ships with a special ``DjangoObjectType`` that automatically transforms a Django Model +into a ``ObjectType`` for you. + + +Full example +~~~~~~~~~~~~ + +.. code:: python + + # my_app/schema.py + + import graphene + + from graphene_django.types import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + + class Query: + questions = graphene.List(QuestionType) + question = graphene.Field(Question, question_id=graphene.String()) + + def resolve_questions(self, info, **kwargs): + # Querying a list + return Question.objects.all() + + def resolve_question(self, info, question_id): + # Querying a single question + return Question.objects.get(pk=question_id) + + +Fields +------ + +By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL. +If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``. + +only_fields +~~~~~~~~~~~ + +Show **only** these fields on the model: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + only_fields = ('question_text') + + +exclude_fields +~~~~~~~~~~~~~~ + +Show all fields **except** those in ``exclude_fields``: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + exclude_fields = ('question_text') + + +Customised fields +~~~~~~~~~~~~~~~~~ + +You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver: + +.. code:: python + + class QuestionType(DjangoObjectType): + + class Meta: + model = Question + exclude_fields = ('question_text') + + extra_field = graphene.String() + + def resolve_extra_field(self, info): + return 'hello!' + + +Related models +-------------- + +Say you have the following models: + +.. code:: python + + class Category(models.Model): + foo = models.CharField(max_length=256) + + class Question(models.Model): + category = models.ForeignKey(Category, on_delete=models.CASCADE) + + +When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``Category`` as a query-able field like so: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + only_fields = ('category',) + +Then all query-able related models must be defined as DjangoObjectType subclass, +or they will fail to show if you are trying to query those relation fields. You only +need to create the most basic class for this to work: + +.. code:: python + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + +Default QuerySet +----------------- + +If you are using ``DjangoObjectType`` you can define a custom `get_queryset` method. +Use this to control filtering on the ObjectType level instead of the Query object level. + +.. code:: python + + from graphene_django.types import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + @classmethod + def get_queryset(cls, queryset, info): + if info.context.user.is_anonymous: + return queryset.filter(published=True) + return queryset + +Resolvers +--------- + +When a GraphQL query is received by the ``Schema`` object, it will map it to a "Resolver" related to it. + +This resolve method should follow this format: + +.. code:: python + + def resolve_foo(self, info, **kwargs): + +Where "foo" is the name of the field declared in the ``Query`` object. + +.. code:: python + + class Query: + foo = graphene.List(QuestionType) + + def resolve_foo(self, info, **kwargs): + id = kwargs.get('id') + return QuestionModel.objects.get(id) + +Arguments +~~~~~~~~~ + +Additionally, Resolvers will receive **any arguments declared in the field definition**. This allows you to provide input arguments in your GraphQL server and can be useful for custom queries. + +.. code:: python + + class Query: + question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int()) + + def resolve_question(self, info, foo, bar): + # If `foo` or `bar` are declared in the GraphQL query they will be here, else None. + return Question.objects.filter(foo=foo, bar=bar).first() + + +Info +~~~~ + +The ``info`` argument passed to all resolve methods holds some useful information. +For Graphene-Django, the ``info.context`` attribute is the ``HTTPRequest`` object +that would be familiar to any Django developer. This gives you the full functionality +of Django's ``HTTPRequest`` in your resolve methods, such as checking for authenticated users: + +.. code:: python + + def resolve_questions(self, info, **kwargs): + # See if a user is authenticated + if info.context.user.is_authenticated(): + return Question.objects.all() + else: + return Question.objects.none() + + +Plain ObjectTypes +----------------- + +With Graphene-Django you are not limited to just Django Models - you can use the standard +``ObjectType`` to create custom fields or to provide an abstraction between your internal +Django models and your external API. + +.. code:: python + + import graphene + from .models import Question + + + class MyQuestion(graphene.ObjectType): + text = graphene.String() + + + class Query: + question = graphene.Field(MyQuestion, question_id=graphene.String()) + + def resolve_question(self, info, question_id): + question = Question.objects.get(pk=question_id) + return MyQuestion( + text=question.question_text + ) + +For more information and more examples, please see the `core object type documentation `__. + + +Relay +----- + +`Relay `__ with Graphene-Django gives us some additional features: + +- Pagination and slicing. +- An abstract ``id`` value which contains enough info for the server to know its type and its id. + +There is one additional import and a single line of code needed to adopt this: + +Full example +~~~~~~~~~~~~ + +.. code:: python + + from graphene import relay + from graphene_django import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + interaces = (relay.Node,) + + + class QuestionConnection(relay.Connection): + class Meta: + node = QuestionType + + + class Query: + question = graphene.Field(QuestionType) + questions = relay.ConnectionField(QuestionConnection) + +See the `Relay documentation `__ on +the core graphene pages for more information on customing the Relay experience. \ No newline at end of file diff --git a/docs/rest-framework.rst b/docs/rest-framework.rst deleted file mode 100644 index ce666de..0000000 --- a/docs/rest-framework.rst +++ /dev/null @@ -1,64 +0,0 @@ -Integration with Django Rest Framework -====================================== - -You can re-use your Django Rest Framework serializer with -graphene django. - - -Mutation --------- - -You can create a Mutation based on a serializer by using the -`SerializerMutation` base class: - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class MyAwesomeMutation(SerializerMutation): - class Meta: - serializer_class = MySerializer - -Create/Update Operations ---------------------- - -By default ModelSerializers accept create and update operations. To -customize this use the `model_operations` attribute. The update -operation looks up models by the primary key by default. You can -customize the look up with the lookup attribute. - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class AwesomeModelMutation(SerializerMutation): - class Meta: - serializer_class = MyModelSerializer - model_operations = ['create', 'update'] - lookup_field = 'id' - -Overriding Update Queries -------------------------- - -Use the method `get_serializer_kwargs` to override how -updates are applied. - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class AwesomeModelMutation(SerializerMutation): - class Meta: - serializer_class = MyModelSerializer - - @classmethod - def get_serializer_kwargs(cls, root, info, **input): - if 'id' in input: - instance = Post.objects.filter(id=input['id'], owner=info.context.user).first() - if instance: - return {'instance': instance, 'data': input, 'partial': True} - - else: - raise http.Http404 - - return {'data': input, 'partial': True} diff --git a/docs/schema.rst b/docs/schema.rst new file mode 100644 index 0000000..9f0c283 --- /dev/null +++ b/docs/schema.rst @@ -0,0 +1,50 @@ +Schema +====== + +The ``graphene.Schema`` object describes your data model and provides a GraphQL server with an associated set of resolve methods that know how to fetch data. The most basic schema you can create looks like this: + +.. code:: python + + import graphene + + class Query(graphene.ObjectType): + pass + + class Mutation(graphene.ObjectType): + pass + + schema = graphene.Schema(query=Query, mutation=Mutation) + + +This schema doesn't do anything yet, but it is ready to accept new Query or Mutation fields. + + +Adding to the schema +-------------------- + +If you have defined a ``Query`` or ``Mutation``, you can register them with the schema: + +.. code:: python + + import graphene + + import my_app.schema.Query + import my_app.schema.Mutation + + class Query( + my_app.schema.Query, # Add your Query objects here + graphene.ObjectType + ): + pass + + class Mutation( + my_app.schema.Mutation, # Add your Mutation objects here + graphene.ObjectType + ): + pass + + schema = graphene.Schema(query=Query, mutation=Mutation) + +You can add as many mixins to the base ``Query`` and ``Mutation`` objects as you like. + +Read more about Schema on the `core graphene docs `__ \ No newline at end of file diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index a87b011..29df56e 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -1,12 +1,9 @@ -Introduction tutorial - Graphene and Django +Basic Tutorial =========================================== -Graphene has a number of additional features that are designed to make -working with Django *really simple*. - -Our primary focus here is to give a good understanding of how to connect models from Django ORM to graphene object types. - -A good idea is to check the `graphene `__ documentation first. +Graphene Django has a number of additional features that are designed to make +working with Django easy. Our primary focus in this tutorial is to give a good +understanding of how to connect models from Django ORM to graphene object types. Set up the Django project ------------------------- @@ -91,7 +88,7 @@ Don't forget to create & run migrations: python manage.py makemigrations python manage.py migrate - + Load some test data ^^^^^^^^^^^^^^^^^^^ @@ -108,7 +105,7 @@ following: $ python ./manage.py loaddata ingredients Installed 6 object(s) from 1 fixture(s) - + Alternatively you can use the Django admin interface to create some data yourself. You'll need to run the development server (see below), and create a login for yourself too (``./manage.py createsuperuser``). @@ -255,7 +252,7 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True`` urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] @@ -273,7 +270,7 @@ as explained above, we can do so here using: urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), ] @@ -487,7 +484,7 @@ Now, with the code in place, we can query for single objects. For example, lets query ``category``: -.. code:: +.. code:: query { category(id: 1) { @@ -536,3 +533,6 @@ Summary As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** + +A good idea is to check the `graphene `__ +documentation but it is not essential to understand and use Graphene-Django in your project. \ No newline at end of file diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 630898e..5f8bd64 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -1,4 +1,4 @@ -Graphene and Django Tutorial using Relay +Relay tutorial ======================================== Graphene has a number of additional features that are designed to make @@ -244,7 +244,7 @@ aforementioned GraphiQL we specify that on the params with ``graphiql=True``. urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] @@ -262,7 +262,7 @@ as explained above, we can do so here using: urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), ] diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py index 9f8755b..4bf6003 100644 --- a/examples/cookbook/cookbook/urls.py +++ b/examples/cookbook/cookbook/urls.py @@ -6,5 +6,5 @@ from graphene_django.views import GraphQLView urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] From 15b5e6ae246b8626f9338ea89e101794fe457d38 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 19:26:19 +0100 Subject: [PATCH 38/48] Fix security issues --- examples/cookbook-plain/requirements.txt | 2 +- examples/cookbook/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 539fd67..2154fd8 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.2 +django==2.1.6 diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index b2ace1f..3fed30f1 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.9 +django==1.11.19 django-filter>=2 From df4a07982f553cb8dbbe7e66c47fd48cdd0a3486 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 20:22:08 +0100 Subject: [PATCH 39/48] Add documentation for settings --- docs/index.rst | 2 +- docs/settings.rst | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/settings.rst diff --git a/docs/index.rst b/docs/index.rst index c7820cf..602f8dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,6 @@ For more advanced use, check out the Relay tutorial. filtering authorization debug - rest-framework introspection testing + settings diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..bd09886 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,103 @@ +Settings +======== + +Graphene-Django can be customised using settings. This page explains each setting and their defaults. + +Usage +----- + +Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``: + +.. code:: python + + GRAPHENE = { + ... + } + + +``SCHEMA`` +---------- + +The location of the top-level ``Schema`` class. + +Default: ``None`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA': 'path.to.schema.schema', + } + + +``SCHEMA_OUTPUT`` +---------- + +The name of the file where the GraphQL schema output will go. + +Default: ``schema.json`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA_OUTPUT': 'schema.json', + } + + +``SCHEMA_INDENT`` +---------- + +The indentation level of the schema output. + +Default: ``2`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA_INDENT': 2, + } + + +``MIDDLEWARE`` +---------- + +A tuple of middleware that will be executed for each GraphQL query. + +See the `middleware documentation `__ for more information. + +Default: ``()`` + +.. code:: python + + GRAPHENE = { + 'MIDDLEWARE': ( + 'path.to.my.middleware.class', + ), + } + + +``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` +---------- + +Enforces relay queries to have the ``first`` or ``last`` argument. + +Default: ``False`` + +.. code:: python + + GRAPHENE = { + 'RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST': False, + } + + +``RELAY_CONNECTION_MAX_LIMIT`` +---------- + +The maximum size of objects that can be requested through a relay connection. + +Default: ``100`` + +.. code:: python + + GRAPHENE = { + 'RELAY_CONNECTION_MAX_LIMIT': 100, + } \ No newline at end of file From bd53940d2322762d86d950361c61e63755972af9 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 20:23:26 +0100 Subject: [PATCH 40/48] newline --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index bd09886..547e77f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -100,4 +100,4 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, - } \ No newline at end of file + } From 2bf7e7f66daf3f17c0ec8e599a32769eac24d849 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Wed, 8 May 2019 22:45:28 +0100 Subject: [PATCH 41/48] Fix importing error for GraphQLTestCase --- docs/testing.rst | 2 +- graphene_django/utils/__init__.py | 19 +++++++++++++++++++ .../{tests/base_test.py => utils/testing.py} | 4 +--- graphene_django/{ => utils}/utils.py | 7 ------- 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 graphene_django/utils/__init__.py rename graphene_django/{tests/base_test.py => utils/testing.py} (95%) rename graphene_django/{ => utils}/utils.py (96%) diff --git a/docs/testing.rst b/docs/testing.rst index a4d5518..b111642 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -9,7 +9,7 @@ Usage: import json - from graphene_django.tests.base_test import GraphQLTestCase + from graphene_django.utils.testing import GraphQLTestCase from my_project.config.schema import schema class MyFancyTestCase(GraphQLTestCase): diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py new file mode 100644 index 0000000..f9c388d --- /dev/null +++ b/graphene_django/utils/__init__.py @@ -0,0 +1,19 @@ +from .utils import ( + DJANGO_FILTER_INSTALLED, + get_reverse_fields, + maybe_queryset, + get_model_fields, + is_valid_django_model, + import_single_dispatch, +) +from .testing import GraphQLTestCase + +__all__ = [ + "DJANGO_FILTER_INSTALLED", + "get_reverse_fields", + "maybe_queryset", + "get_model_fields", + "is_valid_django_model", + "import_single_dispatch", + "GraphQLTestCase", +] diff --git a/graphene_django/tests/base_test.py b/graphene_django/utils/testing.py similarity index 95% rename from graphene_django/tests/base_test.py rename to graphene_django/utils/testing.py index 84e1dc5..47f8d04 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/utils/testing.py @@ -1,8 +1,6 @@ import json -from django.http import HttpResponse -from django.test import Client -from django.test import TestCase +from django.test import TestCase, Client class GraphQLTestCase(TestCase): diff --git a/graphene_django/utils.py b/graphene_django/utils/utils.py similarity index 96% rename from graphene_django/utils.py rename to graphene_django/utils/utils.py index 532be06..02c47ee 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils/utils.py @@ -4,13 +4,6 @@ from django.db import models from django.db.models.manager import Manager -# from graphene.utils import LazyList - - -class LazyList(object): - pass - - try: import django_filters # noqa From ce9d989bcdabc5e7a4bb3fa5559de0f4604b6a74 Mon Sep 17 00:00:00 2001 From: David Beitey Date: Mon, 13 May 2019 07:01:44 +0000 Subject: [PATCH 42/48] Update install docs for Django 2.x This uses the new URL routing syntax introduced in Django 2.0 (https://docs.djangoproject.com/en/2.2/releases/2.0/#simplified-url-routing-syntax). The older `url()` syntax will deprecated at some point in future https://docs.djangoproject.com/en/2.2/ref/urls/#url --- docs/installation.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 8f3e550..a2dc665 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -30,16 +30,16 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of ] -We need to add a graphql URL to the ``urls.py`` of your Django project: +We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: .. code:: python - from django.conf.urls import url + from django.urls import path from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path("graphql", GraphQLView.as_view(graphiql=True)), ] (Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.) From ba64bceab09c33370d5ab8a0c8076abeced57a71 Mon Sep 17 00:00:00 2001 From: zorig Date: Wed, 15 May 2019 17:22:29 +0800 Subject: [PATCH 43/48] graphiql version upgrade --- graphene_django/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index 0b840f9..72cca88 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.11.10" + graphiql_version = "0.11.11" graphiql_template = "graphene/graphiql.html" schema = None From 884c4cce0c3342cf0b27c22ec08e6ee6145f3a49 Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 16:27:35 -0500 Subject: [PATCH 44/48] Correct Babel Relay Plugin docs link per Issue 358. See graphql-python#358. --- docs/introspection.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index 92e3612..0fc6776 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -1,9 +1,7 @@ Introspection Schema ==================== -Relay uses `Babel Relay -Plugin `__ -that requires you to provide your GraphQL schema data. +Relay Modern uses `Babel Relay Plugin `__ which requires you to provide your GraphQL schema data. Graphene comes with a Django management command to dump your schema data to ``schema.json`` which is compatible with babel-relay-plugin. From 2edf7f4ec0926159347c45df3ba1ef327d465706 Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 17:02:26 -0500 Subject: [PATCH 45/48] Correct examples/cookbook settings.py. See https://github.com/graphql-python/graphene-django/issues/455. --- examples/cookbook-plain/cookbook/settings.py | 6 +----- examples/cookbook/cookbook/settings.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index d846db4..bce2bab 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -56,6 +56,7 @@ MIDDLEWARE = [ GRAPHENE = { 'SCHEMA': 'cookbook.schema.schema', + 'SCHEMA_INDENT': 2, 'MIDDLEWARE': ( 'graphene_django.debug.DjangoDebugMiddleware', ) @@ -130,8 +131,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = '/static/' - -GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, -} diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 948292d..0b3207e 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -57,6 +57,7 @@ MIDDLEWARE_CLASSES = [ GRAPHENE = { 'SCHEMA': 'cookbook.schema.schema', + 'SCHEMA_INDENT': 2, 'MIDDLEWARE': ( 'graphene_django.debug.DjangoDebugMiddleware', ) @@ -131,8 +132,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = '/static/' - -GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, -} From 04fe299a6e71b15d638dbbb3a48e6c8f8dc50d3d Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 19:50:55 -0500 Subject: [PATCH 46/48] Corrected docs/queries.rst. (#633) * Corrected typos in docs/queries.rst. * Add basic resolvers to Relay Full example in docs/queries.rst. Added basic resolvers to Full example in Relay section. * Remove question and question resolver. * Add query example to queries.rst. Added query example in Relay section. Minor clean-up. --- docs/queries.rst | 72 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index d54c908..0edd1dd 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -30,7 +30,7 @@ Full example class Query: questions = graphene.List(QuestionType) - question = graphene.Field(Question, question_id=graphene.String()) + question = graphene.Field(QuestionType, question_id=graphene.String()) def resolve_questions(self, info, **kwargs): # Querying a list @@ -243,6 +243,8 @@ There is one additional import and a single line of code needed to adopt this: Full example ~~~~~~~~~~~~ +See the `Relay documentation `__ on +the core graphene pages for more information on customizing the Relay experience. .. code:: python @@ -254,7 +256,7 @@ Full example class QuestionType(DjangoObjectType): class Meta: model = Question - interaces = (relay.Node,) + interfaces = (relay.Node,) class QuestionConnection(relay.Connection): @@ -263,8 +265,68 @@ Full example class Query: - question = graphene.Field(QuestionType) questions = relay.ConnectionField(QuestionConnection) -See the `Relay documentation `__ on -the core graphene pages for more information on customing the Relay experience. \ No newline at end of file + def resolve_questions(root, info, **kwargs): + return Question.objects.all() + + +You can now execute queries like: + + +.. code:: python + + { + questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + cursor + node { + id + question_text + } + } + } + } + +Which returns: + +.. code:: python + + { + "data": { + "questions": { + "pageInfo": { + "startCursor": "YXJyYXljb25uZWN0aW9uOjEwNg==", + "endCursor": "YXJyYXljb25uZWN0aW9uOjEwNw==", + "hasNextPage": true, + "hasPreviousPage": false + }, + "edges": [ + { + "cursor": "YXJyYXljb25uZWN0aW9uOjEwNg==", + "node": { + "id": "UGxhY2VUeXBlOjEwNw==", + "question_text": "How did we get here?" + } + }, + { + "cursor": "YXJyYXljb25uZWN0aW9uOjEwNw==", + "node": { + "id": "UGxhY2VUeXBlOjEwOA==", + "name": "Where are we?" + } + } + ] + } + } + } + +Note that relay implements :code:`pagination` capabilities automatically, adding a :code:`pageInfo` element, and including :code:`cursor` on nodes. These elements are included in the above example for illustration. + +To learn more about Pagination in general, take a look at `Pagination `__ on the GraphQL community site. From 49aedf171abc176bd20839dd309109c7400b9fc0 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 20 May 2019 17:48:28 +0800 Subject: [PATCH 47/48] bump graphiql to 0.13.0, and rename __debug to _debug due to __ limitations --- docs/debug.rst | 8 +++---- examples/cookbook-plain/cookbook/schema.py | 2 +- examples/cookbook/cookbook/schema.py | 2 +- graphene_django/debug/tests/test_query.py | 28 +++++++++++----------- graphene_django/views.py | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/debug.rst b/docs/debug.rst index 8e67c23..d1cbb21 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -15,7 +15,7 @@ For use the Django Debug plugin in Graphene: * Add ``graphene_django.debug.DjangoDebugMiddleware`` into ``MIDDLEWARE`` in the ``GRAPHENE`` settings. -* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='__debug')``. +* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='_debug')``. .. code:: python @@ -24,7 +24,7 @@ For use the Django Debug plugin in Graphene: class Query(graphene.ObjectType): # ... - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) @@ -59,11 +59,11 @@ the GraphQL request, like: } } # Here is the debug field that will output the SQL queries - __debug { + _debug { sql { rawSql } } } -Note that the ``__debug`` field must be the last field in your query. +Note that the ``_debug`` field must be the last field in your query. diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py index f8606a7..f91d62c 100644 --- a/examples/cookbook-plain/cookbook/schema.py +++ b/examples/cookbook-plain/cookbook/schema.py @@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug class Query(cookbook.ingredients.schema.Query, cookbook.recipes.schema.Query, graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index f8606a7..f91d62c 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug class Query(cookbook.ingredients.schema.Query, cookbook.recipes.schema.Query, graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index f2ef096..592899b 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -31,7 +31,7 @@ def test_should_query_field(): class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_reporter(self, info, **args): return Reporter.objects.first() @@ -41,7 +41,7 @@ def test_should_query_field(): reporter { lastName } - __debug { + _debug { sql { rawSql } @@ -50,7 +50,7 @@ def test_should_query_field(): """ expected = { "reporter": {"lastName": "ABA"}, - "__debug": { + "_debug": { "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}] }, } @@ -75,7 +75,7 @@ def test_should_query_list(): class Query(graphene.ObjectType): all_reporters = graphene.List(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -85,7 +85,7 @@ def test_should_query_list(): allReporters { lastName } - __debug { + _debug { sql { rawSql } @@ -94,7 +94,7 @@ def test_should_query_list(): """ expected = { "allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}], - "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, + "_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, } schema = graphene.Schema(query=Query) result = schema.execute( @@ -117,7 +117,7 @@ def test_should_query_connection(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -131,7 +131,7 @@ def test_should_query_connection(): } } } - __debug { + _debug { sql { rawSql } @@ -145,9 +145,9 @@ def test_should_query_connection(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query + assert result.data["_debug"]["sql"][1]["rawSql"] == query def test_should_query_connectionfilter(): @@ -166,7 +166,7 @@ def test_should_query_connectionfilter(): class Query(graphene.ObjectType): all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"]) s = graphene.String(resolver=lambda *_: "S") - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -180,7 +180,7 @@ def test_should_query_connectionfilter(): } } } - __debug { + _debug { sql { rawSql } @@ -194,6 +194,6 @@ def test_should_query_connectionfilter(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query + assert result.data["_debug"]["sql"][1]["rawSql"] == query diff --git a/graphene_django/views.py b/graphene_django/views.py index 72cca88..c9ac770 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.11.11" + graphiql_version = "0.13.0" graphiql_template = "graphene/graphiql.html" schema = None From 7690c2c0025f1537ee9c9c2971d3c9b1546b9ba6 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 20 May 2019 19:41:25 +0800 Subject: [PATCH 48/48] bump react to 16.8.6 --- graphene_django/templates/graphene/graphiql.html | 6 ++---- graphene_django/views.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index c0c9af1..d0fb5a8 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -23,11 +23,9 @@ add "&raw" to the end of the URL within a browser. - - diff --git a/graphene_django/views.py b/graphene_django/views.py index 0b840f9..2fc8c88 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -53,6 +53,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): graphiql_version = "0.11.10" graphiql_template = "graphene/graphiql.html" + react_version = "16.8.6" schema = None graphiql = False @@ -126,7 +127,9 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( - request, graphiql_version=self.graphiql_version + request, + graphiql_version=self.graphiql_version, + react_version=self.react_version, ) if self.batch: