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?
About Otovo.
Why GraphQL.
How GraphQL.
Surprise benefits.
Authentication.
Performance.
Protecting against DoS.
FAQ.
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
Specification for data queries.
Publicly released by Facebook in 2015.
Alternative to regular REST APIs.
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
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",
}
}
}
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
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
( POST )
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
"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,
)
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
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"
}
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
allInstallations {
address {
allInstallations {
address {
allInstallations {
address {
allInstallations {
etc...
}
}
}
}
}
}
}
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"
}]
}
Got performance issues?
Got API versioning issues?
Got bloated endpoints?
Got messy Django views?
How many considers using GraphQL now?
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?