How to convert all ISO timestamps to UTC in Ruby GraphQL
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”:
- Alias the original implementation of
.coerce_result
to another method name called.old_coerce_result
- Add our custom implementation called
.new_coerce_result
which uses.old_coerce_result
internally - 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
-
You can spell it timezone, time-zone, or time zone according to StackExchange. ↩
-
I’m sure you’re itching to implement a parser for this if you’re a Golang programmer. ↩
-
If you plan to use your system in the year 2038 or beyond, please don’t use
Int
for Unix timestamps. ↩ -
I’m not sure whether scope reopening is the correct computer science term for this. I got it from Obie’s Introduction to Rails. ↩
-
You just had to come here after trying to debate me on timezone, didn’t you? Here, help yourself to some more StackExchange. ↩