I recently broke a mobile application by changing the timezone1 of the backend application. This came as a surprise because we were using ISO8601 timestamps with UTC offset in the API, so every client should still have been able to decode the timestamp correctly even though the offset had changed.

It turned out that the mobile application was using a parser which only supports ISO timestamps with the Z suffix (i.e UTC)2 and the backend was now serving them in local time.

Since one can never be sure that an update to a mobile application reaches all users, we had to adapt the backend to continue serving the timestamps in UTC, but now on purpose rather than by accident.

I couldn’t quite follow. Could you please give some examples?

Sure. Let’s see look at it in IRB.

irb> require 'time'
 => false
irb> my_time = Time.now
 => 2023-05-12 16:43:14.080126602 +0200 

An ISO timestamp looks this:

irb> my_time.iso8601
 => "2023-05-12T16:43:14+02:00" 

Note the +02:00 indicating that my machine is currently running on CEST. And this is also the format which the mobile application was not able to parse. It was expecting:

irb> my_time.utc.iso8601
 => "2023-05-12T14:43:14Z" 

which denotes the exact same moment in time, just in a slightly different format.

So what does the GraphQL specification say?

Nothing. Except for this example maybe3

"timestamp": "Fri Feb 9 14:33:09 UTC 2018"

The GraphQL specification makes sure that all basic scalar types work the same way in any implementation, e.g. an Int must always be understood as a 32-bit signed integer. But whether you communicate a timestamp as an Int or use any kind of String representation, that is not specified and totally up to you4.

In my case, the API builds on top of the Ruby GraphQL library which offers a custom ISO8061DateTime type right out of the box. Let’s look at its implementation:

# Abridged version of https://github.com/rmosolgo/graphql-ruby/blob/a048682e6d8468d16947ee1c80946dd23c5d91f9/lib/graphql/types/iso_8601_date_time.rb

require 'time'

module GraphQL
  module Types
    class ISO8601DateTime < GraphQL::Schema::Scalar
      # ...

      def self.coerce_result(value, _ctx)
        case value
        when Date
          return value.to_time.iso8601(time_precision)
        when ::String
          return Time.parse(value).iso8601(time_precision)
        else
          # Time, DateTime or compatible is given:
          return value.iso8601(time_precision)
        end
      rescue StandardError => error
        raise GraphQL::Error, "An incompatible object (#{value.class}) was given to #{self}. Make sure that only Times, Dates, DateTimes, and well-formatted Strings are used with this type. (#{error.message})"
      end

      # ...

The class method .coerce_result takes the value we provided to the GraphQL field and (depending on its type) tries to convert it to an instance of Time in order to apply Time#iso8601 just like we did above. Let’s try it out:

irb> require 'graphql'
 => true
irb> GraphQL::Types::ISO8601DateTime.coerce_result(my_time, nil)
 => "2023-05-12T16:43:14+02:00" 

If we could only tweak that method to somehow enforce UTC, then we would be done …

Scope reopening to the rescue

We’re trying to solve a seemingly simple problem: Just convert Time values to UTC before generating the ISO format. It should be solved in one place and automatically apply everywhere an ISO timestamp is generated in an API response. We don’t want to fork the graphql-ruby gem for this. And luckily we don’t need to.

One of the most polarizing features of the Ruby programming language is scope reopening5. This means that in Ruby you add, change, and remove methods from objects and classes form any place at any time (even dynamically at runtime6). And that’s exactly what we’re about to do …

The basic idea

The ISO8601DateTime type already takes care of generating a valid ISO timestamp for us. To arrive at the UTC version of it, we just need to apply some “post-processing”:

irb> iso_timestamp = GraphQL::Types::ISO8601DateTime.coerce_result(my_time, nil)
 => "2023-05-12T16:43:14+02:00" 
irb> Time.parse(iso_timestamp).utc.iso8601
 => "2023-05-12T14:43:14Z"

Let’s try to redefine the .coerce_result to do just that.

Redefining methods: A common pitfall

Once graphql-ruby is loaded, we can start to redefine its classes. And it might seem plausible to do it like this:

module GraphQL
  module Types
    class ISO8601DateTime < GraphQL::Schema::Scalar
      def self.coerce_result(value, _ctx)
        iso_timestamp = super

        Time.parse(iso_timestamp).utc.iso8601
      end
    end
  end
end

But this just results in a type error

irb> iso_timestamp = GraphQL::Types::ISO8601DateTime.coerce_result(my_time, nil)
/.../lib/ruby/3.2.0/time.rb:380:in `_parse': no implicit conversion of Time into String (TypeError)

The gotcha here is that super is not pointing to the previous implementation in ISO8601DateTime but rather the implementation of .coerce_result in its superclass GraphQL::Types::Scalar:

irb> GraphQL::Types::ISO8601DateTime.method(:coerce_result).super_method.source_location
=> ["/.../graphql-2.0.21/lib/graphql/schema/scalar.rb", 12]

That’s where the switcheroo comes into play …

Let’s do a switcheroo

Instead of redefining the original .coerce_result, we’re just gonna “swap it out”:

  1. Alias the original implementation of .coerce_result to another method name called .old_coerce_result
  2. Add our custom implementation called .new_coerce_result which uses .old_coerce_result internally
  3. Alias .new_coerce_result to be the new implementation of .coerce_result

Here’s the piece of cargo cult you were looking for:

# Load this at the start of your application
# e.g. as a Rails initializer in `config/initializers/graphql_types_iso_8601_date_time.rb`

# Make sure original implementation is already loaded
require 'graphql'

module GraphQL
  module Types
    class ISO8601DateTime < GraphQL::Schema::Scalar
      class << self # Redefine class methods
        
        # New implementation, built around original implementation
        def new_coerce_result(value, ctx)
          iso_timestamp = old_coerce_result(value, ctx)

          Time.parse(iso_timestamp).utc.iso8601(time_precision)
        end
        
        # Switcheroo
        alias old_coerce_result coerce_result
        alias coerce_result new_coerce_result
      end
    end
  end
end

… and it “just works” as if that was how graphql-ruby always worked:

irb> GraphQL::Types::ISO8601DateTime.coerce_result(my_time, nil)
 => "2023-05-12T14:43:14Z" 

Footnotes

  1. You can spell it timezone, time-zone, or time zone according to StackExchange

  2. I blame the OmegaStar team for this. 

  3. I’m sure you’re itching to implement a parser for this if you’re a Golang programmer. 

  4. If you plan to use your system in the year 2038 or beyond, please don’t use Int for Unix timestamps. 

  5. I’m not sure whether scope reopening is the correct computer science term for this. I got it from Obie’s Introduction to Rails

  6. You just had to come here after trying to debate me on timezone, didn’t you? Here, help yourself to some more StackExchange