Skip to content

Commit

Permalink
Merge pull request #4688 from rubyroobs/rubyroobs/add-iso8601-duration
Browse files Browse the repository at this point in the history
Add ISO8601Duration scalar type
  • Loading branch information
rmosolgo authored Oct 26, 2023
2 parents 4c6e804 + 3fdcf19 commit 1d7d5e0
Show file tree
Hide file tree
Showing 6 changed files with 211 additions and 0 deletions.
3 changes: 3 additions & 0 deletions guides/type_definitions/scalars.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Scalars are "leaf" values in GraphQL. There are several built-in scalars, and yo
- `ID`, which a specialized `String` for representing unique object identifiers
- `ISO8601DateTime`, an ISO 8601-encoded datetime
- `ISO8601Date`, an ISO 8601-encoded date
- `ISO8601Duration`, an ISO 8601-encoded duration. ⚠ This requires `ActiveSupport::Duration` to be loaded and will raise {{ "GraphQL::Error" | api_doc }} if it's `.coerce_*` methods are called when it is not defined.
- `JSON`, ⚠ This returns arbitrary JSON (Ruby hashes, arrays, strings, integers, floats, booleans and nils). Take care: by using this type, you completely lose all GraphQL type safety. Consider building object types for your data instead.
- `BigInt`, a numeric value which may exceed the size of a 32-bit integer

Expand All @@ -39,6 +40,8 @@ field :id, ID, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
# ISO8601Date field
field :birthday, GraphQL::Types::ISO8601Date, null: false
# ISO8601Duration field
field :age, GraphQL::Types::ISO8601Duration, null: false
# JSON field ⚠
field :parameters, GraphQL::Types::JSON, null: false
# BigInt field
Expand Down
1 change: 1 addition & 0 deletions lib/graphql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ module EmptyObjects
require "graphql/integer_encoding_error"
require "graphql/string_encoding_error"
require "graphql/date_encoding_error"
require "graphql/duration_encoding_error"
require "graphql/type_kinds"
require "graphql/name_validator"
require "graphql/language"
Expand Down
16 changes: 16 additions & 0 deletions lib/graphql/duration_encoding_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module GraphQL
# This error is raised when `Types::ISO8601Duration` is asked to return a value
# that cannot be parsed as an ISO8601-formatted duration by ActiveSupport::Duration.
#
# @see GraphQL::Types::ISO8601Duration which raises this error
class DurationEncodingError < GraphQL::RuntimeTypeError
# The value which couldn't be encoded
attr_reader :duration_value

def initialize(value)
@duration_value = value
super("Duration cannot be parsed: #{value}. \nDuration must be an ISO8601-formatted duration.")
end
end
end
1 change: 1 addition & 0 deletions lib/graphql/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "graphql/types/int"
require "graphql/types/iso_8601_date"
require "graphql/types/iso_8601_date_time"
require "graphql/types/iso_8601_duration"
require "graphql/types/json"
require "graphql/types/string"
require "graphql/types/relay"
77 changes: 77 additions & 0 deletions lib/graphql/types/iso_8601_duration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true
module GraphQL
module Types
# This scalar takes `Duration`s and transmits them as strings,
# using ISO 8601 format. ActiveSupport >= 5.0 must be loaded to use
# this scalar.
#
# Use it for fields or arguments as follows:
#
# field :age, GraphQL::Types::ISO8601Duration, null: false
#
# argument :interval, GraphQL::Types::ISO8601Duration, null: false
#
# Alternatively, use this built-in scalar as inspiration for your
# own Duration type.
class ISO8601Duration < GraphQL::Schema::Scalar
description "An ISO 8601-encoded duration"

# @return [Integer, nil]
def self.seconds_precision
# ActiveSupport::Duration precision defaults to whatever input was given
@seconds_precision
end

# @param [Integer, nil] value
def self.seconds_precision=(value)
@seconds_precision = value
end

# @param value [ActiveSupport::Duration, String]
# @return [String]
# @raise [GraphQL::Error] if ActiveSupport::Duration is not defined or if an incompatible object is passed
def self.coerce_result(value, _ctx)
unless defined?(ActiveSupport::Duration)
raise GraphQL::Error, "ActiveSupport >= 5.0 must be loaded to use the built-in ISO8601Duration type."
end

begin
case value
when ActiveSupport::Duration
value.iso8601(precision: seconds_precision)
when ::String
ActiveSupport::Duration.parse(value).iso8601(precision: seconds_precision)
else
# Try calling as ActiveSupport::Duration compatible as a fallback
value.iso8601(precision: seconds_precision)
end
rescue StandardError => error
raise GraphQL::Error, "An incompatible object (#{value.class}) was given to #{self}. Make sure that only ActiveSupport::Durations and well-formatted Strings are used with this type. (#{error.message})"
end
end

# @param value [String, ActiveSupport::Duration]
# @return [ActiveSupport::Duration, nil]
# @raise [GraphQL::Error] if ActiveSupport::Duration is not defined
# @raise [GraphQL::DurationEncodingError] if duration cannot be parsed
def self.coerce_input(value, ctx)
unless defined?(ActiveSupport::Duration)
raise GraphQL::Error, "ActiveSupport >= 5.0 must be loaded to use the built-in ISO8601Duration type."
end

begin
if value.is_a?(ActiveSupport::Duration)
value
elsif value.nil?
nil
else
ActiveSupport::Duration.parse(value)
end
rescue ArgumentError, TypeError
err = GraphQL::DurationEncodingError.new(value)
ctx.schema.type_error(err, ctx)
end
end
end
end
end
113 changes: 113 additions & 0 deletions spec/integration/rails/graphql/types/iso_8601_duration_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true
require "spec_helper"

describe GraphQL::Types::ISO8601Duration do
module DurationTest
class Schema < GraphQL::Schema
def self.type_error(err, ctx)
raise err
end
end
end

let(:context) { GraphQL::Query.new(DurationTest::Schema, "{ __typename }").context }

# 3 years, 6 months, 4 days, 12 hours, 30 minutes, and 5.12345 seconds
let (:duration_str) { "P3Y6M4DT12H30M5.12345S" }
let (:duration) { ActiveSupport::Duration.parse(duration_str) }

describe "coerce_result" do
describe "coercing ActiveSupport::Duration" do
it "coerces defaulting to same precision as input precision" do
assert_equal duration_str, GraphQL::Types::ISO8601Duration.coerce_result(duration, context)
end

it "coerces with seconds_precision when set" do
initial_precision = GraphQL::Types::ISO8601Duration.seconds_precision

GraphQL::Types::ISO8601Duration.seconds_precision = 2

assert_equal duration.iso8601(precision: 2), GraphQL::Types::ISO8601Duration.coerce_result(duration, context)

GraphQL::Types::ISO8601Duration.seconds_precision = initial_precision
end
end

describe "coercing String" do
it "defaults to same precision as input precision" do
assert_equal duration_str, GraphQL::Types::ISO8601Duration.coerce_result(duration_str, context)
end

it "coerces with seconds_precision when set" do
initial_precision = GraphQL::Types::ISO8601Duration.seconds_precision

GraphQL::Types::ISO8601Duration.seconds_precision = 2

assert_equal duration.iso8601(precision: 2), GraphQL::Types::ISO8601Duration.coerce_result(duration_str, context)

GraphQL::Types::ISO8601Duration.seconds_precision = initial_precision
end
end

describe "coercing incompatible objects" do
it "raises GraphQL::Error" do
assert_raises GraphQL::Error do
GraphQL::Types::ISO8601Duration.coerce_result(Object.new, context)
end
end
end
end

describe "coerce_input" do
describe "coercing ActiveSupport::Duration" do
it "returns itself" do
assert_equal duration, GraphQL::Types::ISO8601Duration.coerce_input(duration, context)
end
end

describe "coercing nil" do
it "returns nil" do
assert_equal nil, GraphQL::Types::ISO8601Duration.coerce_input(nil, context)
end
end

describe "coercing String" do
it "returns a ActiveSupport::Duration for ISO8601-formatted durations" do
assert_equal duration, GraphQL::Types::ISO8601Duration.coerce_input(duration_str, context)
end

it "raises GraphQL::DurationEncodingError for incorrectly formatted strings" do
assert_raises GraphQL::DurationEncodingError do
# ISO8601 dates are not durations
GraphQL::Types::ISO8601Duration.coerce_input("2007-03-01T13:00:00Z", context)
end
end
end

describe "coercing other objects" do
it "raises GraphQL::DurationEncodingError" do
assert_raises GraphQL::DurationEncodingError do
# ISO8601 dates are not durations
GraphQL::Types::ISO8601Duration.coerce_input(Object.new, context)
end
end
end
end

describe "when ActiveSupport is not defined" do
it "coerce_result and coerce_input raise GraphQL::Error" do
old_active_support = defined?(ActiveSupport) ? ActiveSupport : nil
Object.send(:remove_const, :ActiveSupport) if defined?(ActiveSupport)

assert_raises GraphQL::Error do
GraphQL::Types::ISO8601Duration.coerce_result("", context)
end

assert_raises GraphQL::Error do
GraphQL::Types::ISO8601Duration.coerce_input("", context)
end

ActiveSupport = old_active_support unless old_active_support.nil?
end
end
end

0 comments on commit 1d7d5e0

Please sign in to comment.