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