Daniel Doubrovkine bio photo

Daniel Doubrovkine

aka dB., @awscloud, former CTO @artsy, +@vestris, NYC

Email Twitter LinkedIn Github Strava
Creative Commons License

How does one return and accept a geolocation type in GraphQL? Something that has a latitude and longitude?

Naive Implementation

The naive approach is to create a location type with a latitude and longitude.

Types::LocationType = GraphQL::ObjectType.define do
  name 'Location'
  description 'A geo location.'

  field :latitude, !types.Float, 'Lat.'
  field :longitude, !types.Float, 'Lon.'
end

This can be returned as field :location, Types::LocationType in any GraphQL ObjectType.

To accept a location in a mutation we create an input type with the same properties.

Types::InputLocationType = GraphQL::InputObjectType.define do
  name 'InputLocation'
  description 'A geo location.'

  argument :latitude, !types.Float, 'Lat.'
  argument :longitude, !types.Float, 'Lon.'
end

A query can ask for a location.

location {
  latitude
  longitude
}

This needs to be wired to our MongoDB storage. I’m using mongoid-geospatial and saving locations as a Point. The latter can accept latitude and logitude but then stores these as x and y. The location type specifies property: :x and property :y where appropriate, which is used when converting a Point into a LocationType, while the mutation converts input to a hash that is fed into Point.new automatically.

You can see this code in 33-minutes-server@c1fb00, in which I confused :x and :y coordinates of Point. This was fixed in 33-minutes-server@9fc79d.

Custom GraphQL Scalar Type

The naive approach requires separate input and output types and the knowledge of a location internals (latitude, longitude). Lets elevate a location to a first class scalar type, similar to how dates and times are implemented and call this geo coordinates. I’m using geo_coord that can parse and format geo coordinates according to multiple existing conventions, such as pairs of latitude and longitude or a combination of degrees, minutes and seconds.

Types::GeoCoordinates = GraphQL::ScalarType.define do
  name 'GeoCoordinates'
  description 'Geo coordinate, latitude followed by longitude.'

  coerce_input ->(value, _ctx) { Geo::Coord.parse(value) }
  coerce_result ->(value, _ctx) { value.to_s }
end

We need to implement Point#to_s used above and convert a Geo::Coord to a Hash with latitude and longitude in our mutations to wire this up with mongoid-geospatial.

module Mongoid
  module Geospatial
    class Point
      def to_s
        Geo::Coord.new(y, x).to_s
      end
    end
  end
end

Note that Point seems to implement support for latitude and longitude backwards. This is mongoid-geospatial#61.

This is implemented in 33-minutes-server@28f309.

Location Formats

The values passed back-and-forth are much more readable now and our API accepts standard notations (eg. 50.004444, 36.231389 or 50° 0′ 16″ N, 36° 13′ 53″ E) and returns a location as a string (eg. 50° 0′ 16″ N, 36° 13′ 53″ E). This allows for seamless future improvements in the location formats, but burdens the client to parse a complex response.

The first attempt at easing this is to return a location in the simpler lat,lon format.

Types::GeoCoordinates = GraphQL::ScalarType.define do
  name 'GeoCoordinates'

  coerce_input ->(value, _ctx) { Geo::Coord.parse(value) }
  coerce_result ->(value, _ctx) { [value.y, value.x].map(&:to_s).join(',') }
end

We can do better and just return an array, as well as allow multiple kinds of location inputs.

Types::GeoCoordinates = GraphQL::ScalarType.define do
  name 'GeoCoordinates'

  coerce_input ->(value, _ctx) {
    case value
    when Array
      Geo::Coord.new(value[0], value[1])
    else
      Geo::Coord.parse(value)
    end
  }

  coerce_result ->(value, _ctx) { [value.y, value.x] }
end

This is implemented in 33-minutes-server@098ff1 and 33-minutes-server@cc159b.