diff --git a/guides/type_definitions/scalars.md b/guides/type_definitions/scalars.md index b713c03542..cada689ef3 100644 --- a/guides/type_definitions/scalars.md +++ b/guides/type_definitions/scalars.md @@ -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 @@ -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 diff --git a/lib/graphql.rb b/lib/graphql.rb index eadefeeac9..c63e3dc9aa 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -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" diff --git a/lib/graphql/duration_encoding_error.rb b/lib/graphql/duration_encoding_error.rb new file mode 100644 index 0000000000..9611bb7795 --- /dev/null +++ b/lib/graphql/duration_encoding_error.rb @@ -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 diff --git a/lib/graphql/types.rb b/lib/graphql/types.rb index 4cf1c56949..5d288c96da 100644 --- a/lib/graphql/types.rb +++ b/lib/graphql/types.rb @@ -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" diff --git a/lib/graphql/types/iso_8601_duration.rb b/lib/graphql/types/iso_8601_duration.rb new file mode 100644 index 0000000000..9c128900d0 --- /dev/null +++ b/lib/graphql/types/iso_8601_duration.rb @@ -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 diff --git a/spec/integration/rails/graphql/types/iso_8601_duration_spec.rb b/spec/integration/rails/graphql/types/iso_8601_duration_spec.rb new file mode 100644 index 0000000000..f1c9eaa38f --- /dev/null +++ b/spec/integration/rails/graphql/types/iso_8601_duration_spec.rb @@ -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