GraphQL on Django
Moving from Django REST Framework to graphene
REST
GraphQL
Used Django Rest Framework?
Seen talk on GraphQL?
Uses Django Rest Framework at work?
Used GraphQL?
Uses GraphQL at work?
Considers using GraphQL?
Agenda
About Otovo.
Why GraphQL.
How GraphQL.
Surprise benefits.
Authentication.
Performance.
Protecting against DoS.
FAQ.
Going solar should be easy
Lead
Address
Country
ZipCode
Municipality
Installation
...
GET /leads
React
Django
REST
GET /addresses
POST /leads
GET /counties
GET /installations
React Native
GraphQL?
POST /graphql
graphene
GraphQL
GraphQL?
Specification for data queries.
Publicly released by Facebook in 2015.
Alternative to regular REST APIs.
Why GraphQL
Fewer roundtrips
POST /graphql
/* Payload */
query {
// Let me get all addresses
addresses {
id
streetAddress
}
// Also, I need a county list
counties {
id
code
name
country
}
}
GET /location/addresses
GET /location/counties
POST /graphql
Why GraphQL
Fewer roundtrips
Smaller payloads
{
"id": "12345678-1234-4789-ba21-dbe243ab8e8a",
"url": "http://local-api.otovo.com:8000/web/installations/12345678-1234-4789-ba21-dbe243ab8e8a/",
"address": {
"buildings": [],
"municipality_code": "0701",
"municipality_name": "Horten",
"county_code": "07",
"county_name": "Vestfold",
"cadastral_unit_id": "07010013103050000123",
"cadastral_unit_number": 131,
"property_unit_number": 305,
"leasehold_number": null,
"country": "no",
"address_lines": ["Sesame Stasjon 11", "3189 Horten"],
"street_address": "Sesame Stasjon 11",
"zip_code_code": "3189",
"zip_code_name": "Horten",
"longitude": 10.1234512345123,
"latitude": 59.1234512345123,
"sunrise": "2018-11-21T07:25:51Z",
"sunset": "2018-11-21T14:41:57Z",
},
"customer": {
"customer_number": 12053,
"name": "Tomas Albertsen Fagerbekk",
"legal_contact": {
"id": 3281,
"username": "tomas@otovo.com",
"email": "tomas@otovo.com",
"first_name": "Tomas Albertsen",
"last_name": "Fagerbekk",
},
"users": [
{
"id": 2487,
"username": "tomfa@otovo.com",
"email": "tomfa@otovo.com",
"first_name": "Tomas",
"last_name": "Fagerbekk",
},
],
},
"contract": {
"id": "12345123-1234-45cf-9940-be4bd6b51ac6",
"interest_id": "12345678-1234-4c61-bc4e-321803a265b7",
"is_signed": true,
"customer": {
"customer_number": 12053,
"name": "Tomas Albertsen Fagerbekk",
"legal_contact": {
"id": 13,
"username": "tomas@otovo.com",
"email": "tomas@otovo.com",
"first_name": "Tomas Albertsen",
"last_name": "Fagerbekk",
},
},
"address": {
"buildings": [],
"municipality_code": "0701",
"municipality_name": "Horten",
"county_code": "07",
"county_name": "Vestfold",
"cadastral_unit_id": "07010013103050012345",
"cadastral_unit_number": 123,
"property_unit_number": 321,
"leasehold_number": null,
"country": "no",
"address_lines": ["Sesame Stasjon 11", "3189 Horten"],
"street_address": "Sesame Stasjon 11",
"zip_code_code": "3189",
"zip_code_name": "Horten",
"longitude": 10.1234512345123,
"latitude": 59.1234512345123,
"sunrise": "2018-11-21T07:25:51Z",
"sunset": "2018-11-21T14:41:57Z",
},
"contract_date": "2017-05-05",
"signed_at": "2017-05-05T03:04:34Z",
"purchase_model": "LOAN",
"loan_start_balance": 218577.0,
"loan_start_balance_currency": "NOK",
"gross_price": 218577,
"gross_price_currency": "NOK",
"num_panels": 86,
"premium_panels": 0,
"standard_panels": 56,
"optimizers": 0,
"nominal_system_power": 14840,
"community_product": "2017",
"public_subsidy_type": "enova",
"public_subsidy": 28550,
"public_subsidy_currency": "NOK",
"subsidies": [
{"slug": "none", "amount": 0.0},
{"slug": "enova", "amount": 28550.0},
],
"discount": null,
"electronic_signing_available": true,
"loan_application_status": "loan_signed",
"loan_application_url": "http://local-www.otovo.no:5000/solar/loan/12345123-1234-4439-81c5-3b0d66192665",
"enova_pdf_url": "http://local-api.otovo.com:8000/web/contracts/12345123-1234-45cf-9940-be4bd6b51ac6/enova_pdf/",
"pdf_url": "http://local-api.otovo.com:8000/web/contracts/12345123-1234-45cf-9940-be4bd6b51ac6/pdf/",
"surfaces": null,
"project_id": "c4e005c3-0651-4726-8108-b056dd6cf132",
"ssn_required": true,
"is_premium": false,
"tilings": null,
},
"monthly_payment": null,
"monthly_payment_currency": null,
"repayment_plan": null,
"repayment_plan_urls": [],
"enova_pdf_url": "http://local-api.otovo.com:8000/web/installations/12345678-1234-4789-ba21-dbe243ab8e8a/enova_pdf/",
"installation_date": "2018-06-05",
"nominal_system_power_in_kwp": 14.84,
"nominal_system_power": 26500,
"nominal_panel_power": 265,
"handover_kit_url": "http://local-api.otovo.com:8000/web/installations/12345678-1234-4789-ba21-dbe243ab8e8a/handover_document/",
"num_panels": 100,
"devices": [],
}
{
"data": {
"installation": {
"id": "12345678-1234-4789-ba21-dbe243ab8e8a",
}
}
}
Why GraphQL
Fewer roundtrips
Smaller payloads
- Easier to expose new data/fields without adding payload
Better support for different API clients
API
"installations": {
"id": 1,
}
"installations": {
"id": 1,
"address": {
"streetAddress": "..."
}
}
"installations": {
"id",
"address": {
"streetAddress": "..."
},
"customer": {
"firstName": "...",
"lastName": "...",
"username": "..."
}
}
"installations": {
"id"
"address": {
"streetAddress": "...",
"zipCode": {
"code": "...",
"name": "...",
"county": {
"code": "...",
"name": "...",
}
}
},
"customer": {
"firstName": "...",
"lastName": "...",
"username": "..."
}
}
"installations": {
"id",
"address": {
"streetAddress": "...",
"zipCode": {
"code": "...",
"name": "...",
"county": {
"code": "...",
"name": "...",
}
}
},
"customer": {
"firstName": "...",
"lastName": "...",
"username": "..."
},
"contract": {
"signedDate": "...",
"price": "..."
}
}
v1.0
v1.1
v2.0
Web
Web
Why GraphQL
Fewer roundtrips
Smaller payloads
- Easier to expose new data/fields without adding payload
Better support for different API clients
- Easier to deprecate fields without breaking API clients
- Avoid adding own endpoints to support different needs
Howto: Django REST
( POST )
Terminology
GET 👉 Query
POST 👉 Mutation
# my_graphql_types.py
class CountyType(DjangoObjectType):
"""
Geographical area within country,
containing municipalities.
"""
country = graphene.String()
class Meta:
model = County
only_fields = (
'id',
'code',
'name',
'country',
)
# my_serializers.py
class CountySerializer(ModelSerializer):
"""
Geographical area within country,
containing municipalities.
"""
country = serializers.CharField()
class Meta:
model = County
fields = (
'id',
'code',
'name',
'country',
)
# my_graphql_mutations.py
class LeadCreateMutation(SerializerMutation):
lead = graphene.Field(LeadType)
class Meta:
# I'll just take this, thanks!
serializer_class = LeadCreateSerializer
# my_serializers.py
class LeadCreateSerializer(serializers.Serializer):
address = serializers.CharField(
min_length=8, max_length=64,
)
email = serializers.EmailField(
max_length=254,
)
utm_campaign = serializers.CharField(
required=False, max_length=64)
utm_medium = serializers.CharField(
required=False, max_length=64)
utm_source = serializers.CharField(
required=False, max_length=64)
def validate_email(self, email):
return email.lower()
def validate_address(self, address):
if ',' not in address:
raise ValidationError(
'Any address with respect for '
'itself has atl east one comma.'
)
return address.lower()
def create(self, validated_data):
uncleaned_address = validated_data.pop(
'address',
)
return Lead.objects.create(
uncleaned_address=uncleaned_address,
**validated_data
)
# my_graphql_actions.py
class CountyQuery:
county = graphene.Field(
CountyType,
id=graphene.UUID(),
)
counties = graphene.List(CountyType)
def resolve_county(self, info, **kwargs):
return (
County.objects
.filter(id=kwargs.get('id'))
.first()
)
def resolve_counties(self, info, **kwargs):
return County.objects.all()
class LeadMutation:
create_lead = LeadCreateMutation.Field()
# views.py
class CountyAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
mixins.ListModelMixin
):
serializer_class = CountySerializer
permission_classes = (permissions.AllowAny,)
def get_queryset(self):
return County.objects.all()
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.CreateModelMixin,
):
permission_classes = (permissions.AllowAny,)
def get_serializer_class(self):
return LeadCreateSerializer
# my_graphql_schema.py
class Query(
graphene.ObjectType,
actions.CountyQuery,
):
pass
class Mutation(
graphene.ObjectType,
actions.LeadMutation,
):
pass
schema = graphene.Schema(
query=Query,
mutation=Mutation)
# urls.py
router = DefaultRouter()
router.register(
r'counties',
CountyAPIViewSet,
basename='counties',
)
router.register(
r'leads',
LeadAPIViewSet,
basename='leads',
)
urlpatterns = router.urls
Surprise benefits
"Keep views slim"
"That was my intention"
# models.py
class Lead(models.model):
...
@classmethod
def all_for_user(cls, user):
# PUT LOGIC HERE
...
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def get_queryset(self):
return Lead.all_for_user(self.request.user)
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def get_queryset(self):
user = self.request.user
# LOTS OF LOGIC
...
...
...
...
...
...
...
...
...
...
return (
Lead.objects
.filter(...)
.exclude(...)
)
# models.py
class Lead(models.model):
...
@classmethod
def all_for_user(cls, user):
# PUT LOGIC HERE
...
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def get_queryset(self):
return Lead.all_for_user(self.request.user)
# my_graphql_actions.py
class LeadQuery:
...
def resolve_leads(self, info, **kwargs):
return Lead.all_for_user(info.context.user)
# interface.py
class LeadInterface:
...
@classmethod
def accept_lead(cls, lead, *, data):
address = data['address']
# Add REST OF LOGIC ON LEFT SIDE
...
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def accept(self, request, pk=None):
lead = self.get_object()
serializer = self.get_serializer(
data=request.data)
serializer.is_valid(raise_exception=True)
LeadInterface.accept(
lead,
data=serializer.validated_data,
)
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def accept(self, request, pk=None):
lead = self.get_object()
serializer = self.get_serializer(
data=request.data)
serializer.is_valid(
raise_exception=True)
address = (
serializer
.validated_data['address']
)
# LOTS OF LOGIC
...
return response.Response(
status=status.HTTP_201_CREATED,
)
# interface.py
class LeadInterface:
...
@classmethod
def accept_lead(cls, lead, *, data):
address = data['address']
# Add REST OF LOGIC ON LEFT SIDE
...
# views.py
class LeadAPIViewSet(
viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
):
...
def accept(self, request, pk=None):
lead = self.get_object()
serializer = self.get_serializer(
data=request.data)
serializer.is_valid(raise_exception=True)
LeadInterface.accept(
lead,
data=serializer.validated_data,
)
# my_graphql_mutations.py
class LeadCreateMutatation:
...
def perform_mutate(cls, serializer, info):
LeadInterface.accept(
cls,
data=serializer.validated_data,
)
Authentication
GraphQL spec does not include auth.
Common behaviour:
- Query: Return null / no result.
- Mutation: error object.
Alternative behaviour:
- Raise HTTP status 40x.
Implement it on your own
# Custom exception
class GraphqlAuthenticationError(GraphQLError):
pass
# Wrapper
def auth_required(fn):
def wrapper(*args, **kwargs):
*_, info = args
if not (
info.context.
user.is_authenticated
):
raise GraphqlAuthenticationError(
'Authentication required.'
)
return fn(*args, **kwargs)
return wrapper
class CountyQuery:
county = graphene.Field(
CountyType, id=graphene.UUID())
@auth_required
def resolve_county(self, info, **kwargs):
return County.objects.filter(
id=kwargs.get('id')).first()
# Custom exception
class GraphqlAuthenticationError(GraphQLError):
pass
# Wrapper
def auth_required(fn):
def wrapper(*args, **kwargs):
*_, info = args
if not (
info.context.
user.is_authenticated
):
raise GraphqlAuthenticationError(
'Authentication required.'
)
return fn(*args, **kwargs)
return wrapper
https://github.com/tomfa/graphy/blob/master/graphy/utils/graphql.py
Performance
Immature GraphQL?
Nimble GraphQL?
{
"id": "c9bae15d-2096-434d-9cfe-9d3c0e8105d2",
"street_address": "cf7b23f8 Avenue",
"zip_code": {
"id": "3d7d6d96-6933-4f9c-98fe-ea570fd9801a",
"code": "0350",
"name": "Oslo",
"municipality": {
"id": "2a770a20-8f64-4f2b-a494-a2df8a45e955",
"name": "Oslo",
"county": {
"id": "5b20d462-095a-4341-9924-54d8039bccf0",
"code": "03",
"name": "Oslo",
"country": "NO"
}
},
"county": {
"id": "5b20d462-095a-4341-9924-54d8039bccf0",
"code": "03",
"name": "Oslo",
"country": "NO"
}
},
"country": "NO",
"latitude": "39.7468079011114",
"longitude": "25.1128034550868"
}
Performance
500 x curl on Postgres – avg. time
Array<1> (ms)
Array<100> (ms)
28.1
27.9
441.6
422.1
DRF
DRF
select_related
GraphQL
24.5
50.0
19.2
29.8
Preventing DoS attacks
allInstallations {
address {
allInstallations {
address {
allInstallations {
address {
allInstallations {
etc...
}
}
}
}
}
}
}
DepthAnalyzer
Extends GraphQLBackend
- Naively sets a limit of query depth
Disallows any deeper query
Limits "badness" of client querying
https://github.com/tomfa/graphy/blob/master/graphy/utils/graphql.py
allInstallations {
address {
allInstallations {
address {
allInstallations {
address {
allInstallations {
etc...
}
}
}
}
}
}
}
{
"errors": [{
"message": "Query is too complex"
}]
}
Move to GraphQL?
Got performance issues?
Got API versioning issues?
Got bloated endpoints?
Got messy Django views?
How many considers using GraphQL now?
FAQ
Relay support?
100%, but we haven't tried it.
https://docs.graphene-python.org/en/latest/relay/
GraphQL subscriptions?
Yes'ish, but we haven't tried it
https://github.com/graphql-python/graphql-ws
Code examples?
Everything in these slides is
demonstrated at tomfa/graphy
https://github.com/tomfa/graphy
Need a crafty pythonist?
Always. See otovo.no/careers
or find @tomfa on github
Other questions?
GraphQL w/graphene
By Tomas Fagerbekk
GraphQL w/graphene
- 1,223