Experimenting with GraphQL

Strava has had a public REST API since 2011. I won’t enumerate all of the classic REST shortcomings, but here are three Strava-specific highlights:

  1. Our mobile apps make a lot of requests when they boot and our REST APIs are difficult to optimize for the exact data any one version of our mobile app might need.
  2. Our mobile apps (and external clients) over-fetch data. We might hit the athlete profile endpoint when all we need is an athlete’s name.
  3. We have one set of controllers for our web app and another set for our public/partner/internal API. Maintaining parity between them is a constant source of overhead.

Three times a year, Strava has a Guild Week. During guild week, engineers put regular product development work on hold and come together by platform to experiment with technology, learn new skills, and work on interesting projects that don’t fit into the normal product development cycle. In previous Guild Weeks, the Web Guild has transitioned web frontends to React and also migrated our QA suite from Selenium to Cypress. This time a group of us decided to experiment with GraphQL.

Backing REST endpoints with GraphQL

Our end goal is to open up a GraphQL API to our mobile clients and the public, but we are taking an approach of first backing existing REST endpoints with GraphQL to experiment with the technology and build out our schema so that it can eventually support clients directly. Before discussing how GraphQL fits into the picture, I think it’s useful to start with a description of the components in our current stack.

Components in our stack.

For a given client request, we might need to load data from a variety of services. Our data objects are smart and execute queries in bulk, fetching objects from models that are either defined in our Rails app or in external services. The data object is then used by the view object to expose data as an HTML or JSON representation. View objects only contain display logic. Depending on the complexity of the controller action, the controller either returns the view object or passes it to a template. For simpler actions, the view and data objects are oftentimes combined into a single view. Now, let’s take a look at how GraphQL fits into things.

Our stack with GraphQL replacing the data layer.

GraphQL replaces our data objects. Our views are still simple, but now they’re backed by GraphQL instead of a custom data object. Each REST endpoint has its own API view, and it’s this view object which executes a GraphQL query and formats the data to match the existing REST response.

module ApiViews
module Kudos
class Index
def to_h
result = StravaSchema.execute(
query,
context: { request: @request },
variables: {
id: @activity_id,
first: @first,
cursor: @cursor
}
)
        result['data']['activity']['kudos']['athletes']['edges'].map{ |edge| edge['node'] }.map do |athlete|
{
id: athlete['id'],
username: athlete['username'],
resource_state: Api::StatefulResourceState::SUMMARY,
firstname: athlete['firstname'],
lastname: athlete['lastname'],
...
}
end
end
      def query
<<-QUERY
query($id: Int!) {
activity(id: $id) {
kudos {
athletes {
edges {
node {
id
username
firstname
lastname
...
}
}
}
}
}
}
QUERY
end
end
end
end

Permissions

GraphQL provides consistent permissions and access to all of our data, instead of that logic being implemented across separate controller actions. If we write permissions logic once in a Type class then that permissions logic applies to all possible future queries of our underlying data sources. The AthleteType ensures that last names are only shown in full if the requesting athlete follows the requested athlete, i.e. Peter Sagan for followers and Peter S for everyone else.

Lazy Loading isn’t Free

We want clients to be able to construct complex queries while being efficient in our backend about how we retrieve data. You could imagine a single query that requests athletes associated with a certain club and also athletes who gave you kudos on your most recent activities. While executing that query we want to wait as long as possible to query the database for athlete details so that we can retrieve both sets of athletes (the club members and the friends that kudoed your activity) in a single database round-trip.

GraphQL doesn’t give you lazy loading for free, but the graphql-batch gem makes it pretty easy to set up. In this next example, we want to use GraphQL to back the GET /activities/:id/kudos endpoint, which returns a paginated list of athletes who have given an activity kudos.

We start with an ActivityType which has a kudos field. Whenever a GraphQL query asks for a list of kudos on a particular object, we add that object’s identifier to a lazy loader for future retrieval. In this case we are asking for kudos on an activity, so we pass the Activity class into our lazy loader, followed by the activity’s id.

module Types  
class ActivityType < ::Types::BasicType
field :kudos, [::Types::KudosType], null: falseK
def kudos
::Loaders::KudosLoader.for(Activity).load(object.id)
end
end
end

Now we can take a look at the KudosLoader. Loaders are instantiated with the entity type which is being given kudos, which in this case is an Activity. We define a perform method, which takes as an argument all of the activity IDs that we’ve passed to the load method throughout our query evaluation (see above). In one go, we retrieve the Kudos for all of the activities we passed to the KudosLoader.

module Loaders
class KudosLoader < ::GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
::Strava::Kudos
.where(kudo_type: @model)
.(kudo_id: ids)
.each { |id, kudos| fulfill(id, kudos) }
ids.each { |id| fulfill(id, []) unless fulfilled?(id) }
end
end
end

Note that our existing REST endpoint delivers the kudos for a single activity, but this implementation automatically supports more complex queries.

Pagination isn’t Free

We provide pagination for many of our REST endpoints. Clients specify per_page and page params and we slice the results appropriately. We want to use cursor-based pagination going forward, so we need a translation layer to back REST endpoints by GraphQL. Cursor-based pagination takes a cursor, which is a unique identifier for the first element you want, and then a first param, which is the number of total elements you want the query to return. The first param is identical to our old per_page param. But we need to find the first element so that we can generate a cursor for it. We multiply the requested page by first, then base 64-encode the result and return that value as the cursor. This allows us to translate incoming page-based pagination requests into cursor-based parameters that GraphQL understands.

Tying it Together

Our original REST endpoint GET /activities/:id/kudos is now backed by GraphQL. We can hit the REST endpoint just as we regularly would before. API view objects translate GraphQL query results into the backwards-compatible REST format.

curl -X GET \
'https://strava.com/api/v3/activities/1709795198/kudos?page=1&per_page=2' \
-H 'Authorization: Bearer xxxxx'

Our page and per_page params are respected and we bulk load the Kudos objects on our backend.

Kudos Load (0.5ms)  SELECT `kudos`.* FROM `kudos` WHERE `kudos`.`model` = 'Activity' and `kudos`.`model_id` IN (134815, 5693843)

And there you have it: during Guild Week we were able to establish a pattern for backing existing REST endpoints with GraphQL with pagination and lazy loading. Next we need to build out instrumentation and monitoring. Since GraphQL queries vary in complexity and can span multiple services, it’s not enough to simply monitor the latency of our GraphQL endpoint. We need to account for breadth, depth, and complexity of a query in order to gauge the performance of our application.

If you want to work on projects like this, check out our Careers page. We’re always hiring!