From 5b79b2c834848a5e4413f52e9e3383205f51b5d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 16:59:03 +0000 Subject: [PATCH 1/6] chore(internal): version bump (#74) --- sig/finch-api/version.rbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sig/finch-api/version.rbs b/sig/finch-api/version.rbs index cf337ea0..09587fe7 100644 --- a/sig/finch-api/version.rbs +++ b/sig/finch-api/version.rbs @@ -1,3 +1,3 @@ module FinchAPI - VERSION: "0.1.0-alpha.2" + VERSION: "0.1.0-alpha.3" end From 9b88b83a6775fb547dceb5ed3c54c67f47587609 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:53:41 +0000 Subject: [PATCH 2/6] chore(internal): minor refactoring of utils (#76) --- Rakefile | 2 +- lib/finch-api/individuals_page.rb | 2 +- lib/finch-api/page.rb | 2 +- lib/finch-api/single_page.rb | 2 +- lib/finch-api/util.rb | 8 +++----- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Rakefile b/Rakefile index d1a3eb10..9dd35599 100644 --- a/Rakefile +++ b/Rakefile @@ -11,7 +11,7 @@ CLEAN.push(*%w[.idea/ .ruby-lsp/ .yardoc/]) xargs = %w[xargs --no-run-if-empty --null --max-procs=0 --max-args=300 --] -task(default: [:test]) +multitask(default: [:test]) multitask(:test) do rb = diff --git a/lib/finch-api/individuals_page.rb b/lib/finch-api/individuals_page.rb index f14c9aeb..09b1d549 100644 --- a/lib/finch-api/individuals_page.rb +++ b/lib/finch-api/individuals_page.rb @@ -49,7 +49,7 @@ def initialize(client:, req:, headers:, page_data:) case page_data in {paging: Hash | nil => paging} - @paging = FinchAPI::Models::Paging.coerce(paging) + @paging = FinchAPI::Converter.coerce(FinchAPI::Models::Paging, paging) else end end diff --git a/lib/finch-api/page.rb b/lib/finch-api/page.rb index 117cd89d..fde0c8cf 100644 --- a/lib/finch-api/page.rb +++ b/lib/finch-api/page.rb @@ -49,7 +49,7 @@ def initialize(client:, req:, headers:, page_data:) case page_data in {paging: Hash | nil => paging} - @paging = FinchAPI::Models::Paging.coerce(paging) + @paging = FinchAPI::Converter.coerce(FinchAPI::Models::Paging, paging) else end end diff --git a/lib/finch-api/single_page.rb b/lib/finch-api/single_page.rb index ea744fec..e05e9e68 100644 --- a/lib/finch-api/single_page.rb +++ b/lib/finch-api/single_page.rb @@ -37,7 +37,7 @@ def initialize(client:, req:, headers:, page_data:) case page_data in Array - replace(page_data.map { model.coerce(_1) }) + replace(page_data.map { FinchAPI::Converter.coerce(model, _1) }) else end end diff --git a/lib/finch-api/util.rb b/lib/finch-api/util.rb index ae602709..92ab9d3d 100644 --- a/lib/finch-api/util.rb +++ b/lib/finch-api/util.rb @@ -75,7 +75,7 @@ def primitive?(input) def coerce_boolean(input) case input.is_a?(String) ? input.downcase : input in Numeric - !input.zero? + input.nonzero? in "true" true in "false" @@ -165,14 +165,12 @@ class << self private def deep_merge_lr(lhs, rhs, concat: false) case [lhs, rhs, concat] in [Hash, Hash, _] - # rubocop:disable Style/YodaCondition - rhs_cleaned = rhs.reject { |_, val| OMIT == val } + rhs_cleaned = rhs.reject { _2 == FinchAPI::Util::OMIT } lhs - .reject { |key, _| OMIT == rhs[key] } + .reject { |key, _| rhs[key] == FinchAPI::Util::OMIT } .merge(rhs_cleaned) do |_, old_val, new_val| deep_merge_lr(old_val, new_val, concat: concat) end - # rubocop:enable Style/YodaCondition in [Array, Array, true] lhs.concat(rhs) else From 54c0c146c432894dafd73fbe17ad2fe7a931a2d5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 19:57:41 +0000 Subject: [PATCH 3/6] feat(internal): converter interface should recurse without schema (#77) --- lib/finch-api/base_model.rb | 891 ++++++++++++++---------------- rbi/lib/finch-api/base_model.rbi | 196 +++---- sig/finch-api/base_model.rbs | 92 ++- test/finch-api/base_model_test.rb | 666 ++++++++++++++-------- 4 files changed, 976 insertions(+), 869 deletions(-) diff --git a/lib/finch-api/base_model.rb b/lib/finch-api/base_model.rb index 925e7ee1..4518e86f 100644 --- a/lib/finch-api/base_model.rb +++ b/lib/finch-api/base_model.rb @@ -9,22 +9,34 @@ module Converter # # @param value [Object] # - # @return [Object] - def coerce(value) = value - - # @api private + # @param state [Hash{Symbol=>Object}] . # - # @param value [Object] + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched # # @return [Object] - def dump(value) = value + def coerce(value, state:) = (raise NotImplementedError) # @api private # # @param value [Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) = (raise NotImplementedError) + # @return [Object] + def dump(value) + case value + in Array + value.map { FinchAPI::Unknown.dump(_1) } + in Hash + value.transform_values { FinchAPI::Unknown.dump(_1) } + in FinchAPI::BaseModel + value.class.dump(value) + else + value + end + end # rubocop:enable Lint/UnusedMethodArgument @@ -44,14 +56,14 @@ class << self # @return [Proc] def type_info(spec) case spec - in Hash - type_info(spec.slice(:const, :enum, :union).first&.last) in Proc spec - in FinchAPI::Converter | Module | Symbol - -> { spec } + in Hash + type_info(spec.slice(:const, :enum, :union).first&.last) in true | false -> { FinchAPI::BooleanModel } + in FinchAPI::Converter | Class | Symbol + -> { spec } in NilClass | Integer | Float -> { spec.class } end @@ -66,108 +78,127 @@ def type_info(spec) # converted value # 3. otherwise, the given `value` unaltered # + # The coercion process is subject to improvement between minor release versions. + # See https://docs.pydantic.dev/latest/concepts/unions/#smart-mode + # # @param target [FinchAPI::Converter, Class] + # # @param value [Object] # + # @param state [Hash{Symbol=>Object}] The `strictness` is one of `true`, `false`, or `:strong`. This informs the + # coercion strategy when we have to decide between multiple possible conversion + # targets: + # + # - `true`: the conversion must be exact, with minimum coercion. + # - `false`: the conversion can be approximate, with some coercion. + # - `:strong`: the conversion must be exact, with no coercion, and raise an error + # if not possible. + # + # The `exactness` is `Hash` with keys being one of `yes`, `no`, or `maybe`. For + # any given conversion attempt, the exactness will be updated based on how closely + # the value recursively matches the target type: + # + # - `yes`: the value can be converted to the target type with minimum coercion. + # - `maybe`: the value can be converted to the target type with some reasonable + # coercion. + # - `no`: the value cannot be converted to the target type. + # + # See implementation below for more details. + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Object] - def coerce(target, value) + def coerce(target, value, state: {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0}) + strictness, exactness = state.fetch_values(:strictness, :exactness) + case target in FinchAPI::Converter - target.coerce(value) - in Symbol - case value - in Symbol | String if (val = value.to_sym) == target - val - else - value + return target.coerce(value, state: state) + in Class + if value.is_a?(target) + exactness[:yes] += 1 + return value end - in Module + case target in -> { _1 <= NilClass } - nil + exactness[value.nil? ? :yes : :maybe] += 1 + return nil in -> { _1 <= Integer } - value.is_a?(Numeric) ? Integer(value) : value + if value.is_a?(Integer) + exactness[:yes] += 1 + return value + elsif strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + else + Kernel.then do + return Integer(value).tap { exactness[:maybe] += 1 } + rescue ArgumentError, TypeError + end + end in -> { _1 <= Float } - value.is_a?(Numeric) ? Float(value) : value - in -> { _1 <= Symbol } - value.is_a?(String) ? value.to_sym : value + if value.is_a?(Numeric) + exactness[:yes] += 1 + return Float(value) + elsif strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + else + Kernel.then do + return Float(value).tap { exactness[:maybe] += 1 } + rescue ArgumentError, TypeError + end + end in -> { _1 <= String } - value.is_a?(Symbol) ? value.to_s : value + case value + in String | Symbol | Numeric + exactness[value.is_a?(Numeric) ? :maybe : :yes] += 1 + return value.to_s + else + if strictness == :strong + message = "no implicit conversion of #{value.class} into #{target.inspect}" + raise TypeError.new(message) + end + end in -> { _1 <= Date || _1 <= Time } - value.is_a?(String) ? target.parse(value) : value - in -> { _1 <= IO } - value.is_a?(String) ? StringIO.new(value) : value + Kernel.then do + return target.parse(value).tap { exactness[:yes] += 1 } + rescue ArgumentError, TypeError => e + raise e if strictness == :strong + end + in -> { _1 <= IO } if value.is_a?(String) + exactness[:yes] += 1 + return StringIO.new(value.b) else - value end - end - end - - # @api private - # - # @param target [FinchAPI::Converter, Class] - # @param value [Object] - # - # @return [Object] - def dump(target, value) - case target - in FinchAPI::Converter - target.dump(value) + in Symbol + if (value.is_a?(Symbol) || value.is_a?(String)) && value.to_sym == target + exactness[:yes] += 1 + return target + elsif strictness == :strong + message = "cannot convert non-matching #{value.class} into #{target.inspect}" + raise ArgumentError.new(message) + end else - value end + + exactness[:no] += 1 + value end # @api private # - # The underlying algorithm for computing maximal compatibility is subject to - # future improvements. - # - # Similar to `#.coerce`, used to determine the best union variant to decode into. - # - # 1. determine if strict-ish coercion is possible - # 2. return either result of successful coercion or if loose coercion is possible - # 3. return a score for recursively tallied count for fields that can be coerced - # # @param target [FinchAPI::Converter, Class] # @param value [Object] # # @return [Object] - def try_strict_coerce(target, value) - case target - in FinchAPI::Converter - target.try_strict_coerce(value) - in Symbol - case value - in Symbol | String if (val = value.to_sym) == target - [true, val, 1] - else - [false, false, 0] - end - in Module - case [target, value] - in [-> { _1 <= NilClass }, _] - [true, nil, value.nil? ? 1 : 0] - in [-> { _1 <= Integer }, Numeric] - [true, Integer(value), 1] - in [-> { _1 <= Float }, Numeric] - [true, Float(value), 1] - in [-> { _1 <= Symbol }, String] - [true, value.to_sym, 1] - in [-> { _1 <= String }, Symbol] - [true, value.to_s, 1] - in [-> { _1 <= Date || _1 <= Time }, String] - Kernel.then do - [true, target.parse(value), 1] - rescue ArgumentError - [false, false, 0] - end - in [_, ^target] - [true, value, 1] - else - [false, false, 0] - end - end + def dump(target, value) + target.is_a?(FinchAPI::Converter) ? target.dump(value) : FinchAPI::Unknown.dump(value) end end end @@ -193,13 +224,23 @@ def self.===(other) = true def self.==(other) = other.is_a?(Class) && other <= FinchAPI::Unknown class << self - # @!parse - # # @api private - # # - # # @param value [Object] - # # - # # @return [Object] - # def coerce(value) = super + # @api private + # + # @param value [Object] + # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # + # @return [Object] + def coerce(value, state:) + state.fetch(:exactness)[:yes] += 1 + value + end # @!parse # # @api private @@ -208,16 +249,6 @@ class << self # # # # @return [Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - # prevent unknown variant from being chosen during the first coercion pass - [false, true, 0] - end end # rubocop:enable Lint/UnusedMethodArgument @@ -242,13 +273,23 @@ def self.===(other) = other == true || other == false def self.==(other) = other.is_a?(Class) && other <= FinchAPI::BooleanModel class << self - # @!parse - # # @api private - # # - # # @param value [Boolean, Object] - # # - # # @return [Boolean, Object] - # def coerce(value) = super + # @api private + # + # @param value [Boolean, Object] + # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # + # @return [Boolean, Object] + def coerce(value, state:) + state.fetch(:exactness)[value == true || value == false ? :yes : :no] += 1 + value + end # @!parse # # @api private @@ -257,20 +298,6 @@ class << self # # # # @return [Boolean, Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in true | false - [true, value, 1] - else - [false, false, 0] - end - end end end @@ -333,19 +360,34 @@ def ===(other) = values.include?(other) # # @return [Boolean] def ==(other) - other.is_a?(Module) && other.singleton_class.ancestors.include?(FinchAPI::Enum) && other.values.to_set == values.to_set + other.is_a?(Module) && other.singleton_class <= FinchAPI::Enum && other.values.to_set == values.to_set end # @api private # + # Unlike with primitives, `Enum` additionally validates that the value is a member + # of the enum. + # # @param value [String, Symbol, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Symbol, Object] - def coerce(value) - case value - in Symbol | String if values.include?(val = value.to_sym) + def coerce(value, state:) + exactness = state.fetch(:exactness) + val = value.is_a?(String) ? value.to_sym : value + + if values.include?(val) + exactness[:yes] += 1 val else + exactness[values.first&.class == val.class ? :maybe : :no] += 1 value end end @@ -357,27 +399,6 @@ def coerce(value) # # # # @return [Symbol, Object] # def dump(value) = super - - # @api private - # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - return [true, value, 1] if values.include?(value) - - case value - in Symbol | String if values.include?(val = value.to_sym) - [true, val, 1] - else - case [value, values.first] - in [true | false, true | false] | [Integer, Integer] | [Symbol | String, Symbol] - [false, true, 0] - else - [false, false, 0] - end - end - end end # @api private @@ -422,9 +443,7 @@ module Union # All of the specified variants for this union. # # @return [Array] - def variants - derefed_variants.map(&:last) - end + def variants = derefed_variants.map(&:last) # @api private # @@ -454,7 +473,7 @@ def variants case key in Symbol [key, FinchAPI::Converter.type_info(spec)] - in Proc | FinchAPI::Converter | Module | Hash + in Proc | FinchAPI::Converter | Class | Hash [nil, FinchAPI::Converter.type_info(key)] end @@ -471,16 +490,14 @@ def variants in [_, FinchAPI::BaseModel] value.class in [Symbol, Hash] - key = - if value.key?(@discriminator) - value.fetch(@discriminator) - elsif value.key?((discriminator = @discriminator.to_s)) - value.fetch(discriminator) - end + key = value.fetch(@discriminator) do + value.fetch(@discriminator.to_s, FinchAPI::Util::OMIT) + end + + return nil if key == FinchAPI::Util::OMIT key = key.to_sym if key.is_a?(String) - _, resolved = known_variants.find { |k,| k == key } - resolved.nil? ? FinchAPI::Unknown : resolved.call + known_variants.find { |k,| k == key }&.last&.call else nil end @@ -502,87 +519,81 @@ def ===(other) # # @return [Boolean] def ==(other) - other.is_a?(Module) && other.singleton_class.ancestors.include?(FinchAPI::Union) && other.derefed_variants == derefed_variants + other.is_a?(Module) && other.singleton_class <= FinchAPI::Union && other.derefed_variants == derefed_variants end # @api private # # @param value [Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Object] - def coerce(value) - if (variant = resolve_variant(value)) - return FinchAPI::Converter.coerce(variant, value) + def coerce(value, state:) + if (target = resolve_variant(value)) + return FinchAPI::Converter.coerce(target, value, state: state) end - matches = [] + strictness = state.fetch(:strictness) + exactness = state.fetch(:exactness) + state[:strictness] = strictness == :strong ? true : strictness + alternatives = [] known_variants.each do |_, variant_fn| - variant = variant_fn.call - - case FinchAPI::Converter.try_strict_coerce(variant, value) - in [true, coerced, _] + target = variant_fn.call + exact = state[:exactness] = {yes: 0, no: 0, maybe: 0} + state[:branched] += 1 + + coerced = FinchAPI::Converter.coerce(target, value, state: state) + yes, no, maybe = exact.values + if (no + maybe).zero? || (!strictness && yes.positive?) + exact.each { exactness[_1] += _2 } + state[:exactness] = exactness return coerced - in [false, true, score] - matches << [score, variant] - in [false, false, _] - nil + elsif maybe.positive? + alternatives << [[-yes, -maybe, no], exact, coerced] end end - _, variant = matches.sort! { _2.first <=> _1.first }.find { |score,| !score.zero? } - variant.nil? ? value : FinchAPI::Converter.coerce(variant, value) - end - - # @api private - # - # @param value [Object] - # - # @return [Object] - def dump(value) - if (variant = resolve_variant(value)) - return FinchAPI::Converter.dump(variant, value) - end - - known_variants.each do |_, variant_fn| - variant = variant_fn.call - if variant === value - return FinchAPI::Converter.dump(variant, value) + case alternatives.sort_by(&:first) + in [] + exactness[:no] += 1 + if strictness == :strong + message = "no possible conversion of #{value.class} into a variant of #{target.inspect}" + raise ArgumentError.new(message) end + value + in [[_, exact, coerced], *] + exact.each { exactness[_1] += _2 } + coerced end - value + .tap { state[:exactness] = exactness } + ensure + state[:strictness] = strictness end # @api private # # @param value [Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - # TODO(ruby) this will result in super linear decoding behaviour for nested unions - # follow up with a decoding context that captures current strictness levels - if (variant = resolve_variant(value)) - return Converter.try_strict_coerce(variant, value) + # @return [Object] + def dump(value) + if (target = resolve_variant(value)) + return FinchAPI::Converter.dump(target, value) end - coercible = false - max_score = 0 - - known_variants.each do |_, variant_fn| - variant = variant_fn.call - - case FinchAPI::Converter.try_strict_coerce(variant, value) - in [true, coerced, score] - return [true, coerced, score] - in [false, true, score] - coercible = true - max_score = [max_score, score].max - in [false, false, _] - nil - end + known_variants.each do + target = _2.call + return FinchAPI::Converter.dump(target, value) if target === value end - [false, coercible, max_score] + super end # rubocop:enable Style/CaseEquality @@ -613,36 +624,46 @@ def self.[](type_info, spec = {}) = new(type_info, spec) # @param other [Object] # # @return [Boolean] - def ===(other) - type = item_type - case other - in Array - # rubocop:disable Style/CaseEquality - other.all? { type === _1 } - # rubocop:enable Style/CaseEquality - else - false - end - end + def ===(other) = other.is_a?(Array) && other.all?(item_type) # @param other [Object] # # @return [Boolean] - def ==(other) = other.is_a?(FinchAPI::ArrayOf) && other.item_type == item_type + def ==(other) = other.is_a?(FinchAPI::ArrayOf) && other.nilable? == nilable? && other.item_type == item_type # @api private # # @param value [Enumerable, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Array, Object] - def coerce(value) - type = item_type - case value - in Enumerable unless value.is_a?(Hash) - value.map { FinchAPI::Converter.coerce(type, _1) } - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + unless value.is_a?(Array) + exactness[:no] += 1 + return value end + + target = item_type + exactness[:yes] += 1 + value + .map do |item| + case [nilable?, item] + in [true, nil] + exactness[:yes] += 1 + nil + else + FinchAPI::Converter.coerce(target, item, state: state) + end + end end # @api private @@ -651,57 +672,19 @@ def coerce(value) # # @return [Array, Object] def dump(value) - type = item_type - case value - in Enumerable unless value.is_a?(Hash) - value.map { FinchAPI::Converter.dump(type, _1) }.to_a - else - value - end + target = item_type + value.is_a?(Array) ? value.map { FinchAPI::Converter.dump(target, _1) } : super end # @api private # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Array - type = item_type - great_success = true - tally = 0 - - mapped = - value.map do |item| - case FinchAPI::Converter.try_strict_coerce(type, item) - in [true, coerced, score] - tally += score - coerced - in [false, true, score] - great_success = false - tally += score - item - in [false, false, _] - great_success &&= item.nil? - item - end - end - - if great_success - [true, mapped, tally] - else - [false, true, tally] - end - else - [false, false, 0] - end - end + # @return [FinchAPI::Converter, Class] + protected def item_type = @item_type_fn.call # @api private # - # @return [FinchAPI::Converter, Class] - protected def item_type = @item_type_fn.call + # @return [Boolean] + protected def nilable? = @nilable # @api private # @@ -718,6 +701,7 @@ def try_strict_coerce(value) # @option spec [Boolean] :"nil?" def initialize(type_info, spec = {}) @item_type_fn = FinchAPI::Converter.type_info(type_info || spec) + @nilable = spec[:nil?] end end @@ -765,24 +749,46 @@ def ===(other) # @param other [Object] # # @return [Boolean] - def ==(other) = other.is_a?(FinchAPI::HashOf) && other.item_type == item_type + def ==(other) = other.is_a?(FinchAPI::HashOf) && other.nilable? == nilable? && other.item_type == item_type # @api private # # @param value [Hash{Object=>Object}, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [Hash{Symbol=>Object}, Object] - def coerce(value) - type = item_type - case value - in Hash - value.to_h do |key, val| - coerced = FinchAPI::Converter.coerce(type, val) - [key.is_a?(String) ? key.to_sym : key, coerced] - end - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + unless value.is_a?(Hash) + exactness[:no] += 1 + return value end + + target = item_type + exactness[:yes] += 1 + value + .to_h do |key, val| + k = key.is_a?(String) ? key.to_sym : key + v = + case [nilable?, val] + in [true, nil] + exactness[:yes] += 1 + nil + else + FinchAPI::Converter.coerce(target, val, state: state) + end + + exactness[:no] += 1 unless k.is_a?(Symbol) + [k, v] + end end # @api private @@ -791,59 +797,19 @@ def coerce(value) # # @return [Hash{Symbol=>Object}, Object] def dump(value) - type = item_type - case value - in Hash - value.transform_values do |val| - FinchAPI::Converter.dump(type, val) - end - else - value - end + target = item_type + value.is_a?(Hash) ? value.transform_values { FinchAPI::Converter.dump(target, _1) } : super end # @api private # - # @param value [Object] - # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Hash - type = item_type - great_success = true - tally = 0 - - mapped = - value.transform_values do |val| - case FinchAPI::Converter.try_strict_coerce(type, val) - in [true, coerced, score] - tally += score - coerced - in [false, true, score] - great_success = false - tally += score - val - in [false, false, _] - great_success &&= val.nil? - val - end - end - - if great_success - [true, mapped, tally] - else - [false, true, tally] - end - else - [false, false, 0] - end - end + # @return [FinchAPI::Converter, Class] + protected def item_type = @item_type_fn.call # @api private # - # @return [FinchAPI::Converter, Class] - protected def item_type = @item_type_fn.call + # @return [Boolean] + protected def nilable? = @nilable # @api private # @@ -860,6 +826,7 @@ def try_strict_coerce(value) # @option spec [Boolean] :"nil?" def initialize(type_info, spec = {}) @item_type_fn = FinchAPI::Converter.type_info(type_info || spec) + @nilable = spec[:nil?] end end @@ -886,13 +853,6 @@ def known_fields @known_fields ||= (self < FinchAPI::BaseModel ? superclass.known_fields.dup : {}) end - # @api private - # - # @return [Hash{Symbol=>Symbol}] - def reverse_map - @reverse_map ||= (self < FinchAPI::BaseModel ? superclass.reverse_map.dup : {}) - end - # @api private # # @return [Hash{Symbol=>Hash{Symbol=>Object}}] @@ -902,11 +862,6 @@ def fields end end - # @api private - # - # @return [Hash{Symbol=>Proc}] - def defaults = (@defaults ||= {}) - # @api private # # @param name_sym [Symbol] @@ -927,38 +882,40 @@ def defaults = (@defaults ||= {}) private def add_field(name_sym, required:, type_info:, spec:) type_fn, info = case type_info - in Proc | Module | FinchAPI::Converter + in Proc | FinchAPI::Converter | Class [FinchAPI::Converter.type_info({**spec, union: type_info}), spec] in Hash [FinchAPI::Converter.type_info(type_info), type_info] end - fallback = info[:const] - defaults[name_sym] = fallback if required && !info[:nil?] && info.key?(:const) - - key = info[:api_name]&.tap { reverse_map[_1] = name_sym } || name_sym setter = "#{name_sym}=" + api_name = info.fetch(:api_name, name_sym) + nilable = info[:nil?] + const = required && !nilable ? info.fetch(:const, FinchAPI::Util::OMIT) : FinchAPI::Util::OMIT - if known_fields.key?(name_sym) - [name_sym, setter].each { undef_method(_1) } - end + [name_sym, setter].each { undef_method(_1) } if known_fields.key?(name_sym) - known_fields[name_sym] = {mode: @mode, key: key, required: required, type_fn: type_fn} + known_fields[name_sym] = + { + mode: @mode, + api_name: api_name, + required: required, + nilable: nilable, + const: const, + type_fn: type_fn + } - define_method(setter) do |val| - @data[key] = val - end + define_method(setter) { @data.store(name_sym, _1) } define_method(name_sym) do - field_type = type_fn.call - value = @data.fetch(key) { self.class.defaults[key] } - FinchAPI::Converter.coerce(field_type, value) + target = type_fn.call + value = @data.fetch(name_sym) { const == FinchAPI::Util::OMIT ? nil : const } + state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + (nilable || !required) && value.nil? ? nil : FinchAPI::Converter.coerce(target, value, state: state) rescue StandardError - name = self.class.name.split("::").last - raise FinchAPI::ConversionError.new( - "Failed to parse #{name}.#{name_sym} as #{field_type.inspect}. " \ - "To get the unparsed API response, use #{name}[:#{key}]." - ) + cls = self.class.name.split("::").last + message = "Failed to parse #{cls}.#{__method__} from #{value.class} to #{target.inspect}. To get the unparsed API response, use #{cls}[:#{__method__}]." + raise FinchAPI::ConversionError.new(message) end end @@ -1024,120 +981,124 @@ def optional(name_sym, type_info, spec = {}) ensure @mode = nil end + + # @param other [Object] + # + # @return [Boolean] + def ==(other) = other.is_a?(Class) && other <= FinchAPI::BaseModel && other.fields == fields end # @param other [Object] # # @return [Boolean] - def ==(other) - case other - in FinchAPI::BaseModel - self.class.fields == other.class.fields && @data == other.to_h - else - false - end - end + def ==(other) = self.class == other.class && @data == other.to_h class << self # @api private # # @param value [FinchAPI::BaseModel, Hash{Object=>Object}, Object] # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean, :strong] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Integer] :branched + # # @return [FinchAPI::BaseModel, Object] - def coerce(value) - case FinchAPI::Util.coerce_hash(value) - in Hash => coerced - new(coerced) - else - value + def coerce(value, state:) + exactness = state.fetch(:exactness) + + if value.is_a?(self.class) + exactness[:yes] += 1 + return value end - end - # @api private - # - # @param value [FinchAPI::BaseModel, Object] - # - # @return [Hash{Object=>Object}, Object] - def dump(value) - unless (coerced = FinchAPI::Util.coerce_hash(value)).is_a?(Hash) + unless (val = FinchAPI::Util.coerce_hash(value)).is_a?(Hash) + exactness[:no] += 1 return value end + exactness[:yes] += 1 - values = coerced.filter_map do |key, val| - name = key.to_sym - case (field = known_fields[name]) - in nil - [name, val] - else - mode, type_fn, api_name = field.fetch_values(:mode, :type_fn, :key) - case mode - in :coerce - next + keys = val.keys.to_set + instance = new + data = instance.to_h + + fields.each do |name, field| + mode, required, target = field.fetch_values(:mode, :required, :type) + api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const) + + unless val.key?(api_name) + if const != FinchAPI::Util::OMIT + exactness[:yes] += 1 + elsif required && mode != :dump + exactness[nilable ? :maybe : :no] += 1 else - target = type_fn.call - [api_name, FinchAPI::Converter.dump(target, val)] + exactness[:yes] += 1 end + next end - end.to_h - defaults.each do |key, val| - next if values.key?(key) + item = val.fetch(api_name) + keys.delete(api_name) - values[key] = val + converted = + if item.nil? && (nilable || !required) + exactness[nilable ? :yes : :maybe] += 1 + nil + else + coerced = FinchAPI::Converter.coerce(target, item, state: state) + case target + in FinchAPI::Converter | Symbol + coerced + else + item + end + end + data.store(name, converted) end - values + keys.each { data.store(_1, val.fetch(_1)) } + instance end # @api private # - # @param value [Object] + # @param value [FinchAPI::BaseModel, Object] # - # @return [Array(true, Object, nil), Array(false, Boolean, Integer)] - def try_strict_coerce(value) - case value - in Hash | FinchAPI::BaseModel - value = value.to_h - else - return [false, false, 0] + # @return [Hash{Object=>Object}, Object] + def dump(value) + unless (coerced = FinchAPI::Util.coerce_hash(value)).is_a?(Hash) + return super end - keys = value.keys.to_set - great_success = true - tally = 0 acc = {} - known_fields.each_value do |field| - mode, required, type_fn, api_name = field.fetch_values(:mode, :required, :type_fn, :key) - keys.delete(api_name) - - case [required && mode != :dump, value.key?(api_name)] - in [_, true] - target = type_fn.call - item = value.fetch(api_name) - case FinchAPI::Converter.try_strict_coerce(target, item) - in [true, coerced, score] - tally += score - acc[api_name] = coerced - in [false, true, score] - great_success = false - tally += score - acc[api_name] = item - in [false, false, _] - great_success &&= item.nil? + coerced.each do |key, val| + name = key.is_a?(String) ? key.to_sym : key + case (field = known_fields[name]) + in nil + acc.store(name, super(val)) + else + mode, api_name, type_fn = field.fetch_values(:mode, :api_name, :type_fn) + case mode + in :coerce + next + else + target = type_fn.call + acc.store(api_name, FinchAPI::Converter.dump(target, val)) end - in [true, false] - great_success = false - in [false, false] - nil end end - keys.each do |key| - acc[key] = value.fetch(key) + known_fields.each_value do |field| + mode, api_name, const = field.fetch_values(:mode, :api_name, :const) + next if mode == :coerce || acc.key?(api_name) || const == FinchAPI::Util::OMIT + acc.store(api_name, const) end - great_success ? [true, new(acc), tally] : [false, true, tally] + acc end end @@ -1177,14 +1138,15 @@ def to_h = @data # # @return [Hash{Symbol=>Object}] def deconstruct_keys(keys) - (keys || self.class.known_fields.keys).filter_map do |k| - unless self.class.known_fields.key?(k) - next - end + (keys || self.class.known_fields.keys) + .filter_map do |k| + unless self.class.known_fields.key?(k) + next + end - [k, method(k).call] - end - .to_h + [k, public_send(k)] + end + .to_h end # Create a new instance of a model. @@ -1193,21 +1155,7 @@ def deconstruct_keys(keys) def initialize(data = {}) case FinchAPI::Util.coerce_hash(data) in Hash => coerced - @data = coerced.to_h do |key, value| - name = key.to_sym - mapped = self.class.reverse_map.fetch(name, name) - type = self.class.fields[mapped]&.fetch(:type) - stored = - case [type, value] - in [Module, Hash] if type <= FinchAPI::BaseModel - type.new(value) - in [FinchAPI::ArrayOf, Array] | [FinchAPI::HashOf, Hash] - type.coerce(value) - else - value - end - [name, stored] - end + @data = coerced else raise ArgumentError.new("Expected a #{Hash} or #{FinchAPI::BaseModel}, got #{data.inspect}") end @@ -1218,9 +1166,12 @@ def to_s = @data.to_s # @return [String] def inspect - "#<#{self.class.name}:0x#{object_id.to_s(16)} #{deconstruct_keys(nil).map do |k, v| - "#{k}=#{v.inspect}" - end.join(' ')}>" + rows = self.class.known_fields.keys.map do + "#{_1}=#{@data.key?(_1) ? public_send(_1) : ''}" + rescue FinchAPI::ConversionError + "#{_1}=#{@data.fetch(_1)}" + end + "#<#{self.class.name}:0x#{object_id.to_s(16)} #{rows.join(' ')}>" end end end diff --git a/rbi/lib/finch-api/base_model.rbi b/rbi/lib/finch-api/base_model.rbi index d5219284..bb62383d 100644 --- a/rbi/lib/finch-api/base_model.rbi +++ b/rbi/lib/finch-api/base_model.rbi @@ -5,9 +5,18 @@ module FinchAPI module Converter Input = T.type_alias { T.any(FinchAPI::Converter, T::Class[T.anything]) } + State = + T.type_alias do + { + strictness: T.any(T::Boolean, Symbol), + exactness: {yes: Integer, no: Integer, maybe: Integer}, + branched: Integer + } + end + # @api private - sig { overridable.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig { overridable.params(value: T.anything, state: FinchAPI::Converter::State).returns(T.anything) } + def coerce(value, state:) end # @api private @@ -15,15 +24,6 @@ module FinchAPI def dump(value) end - # @api private - sig do - overridable - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end - class << self # @api private sig do @@ -51,28 +51,43 @@ module FinchAPI # 2. if it's possible and safe to convert the given `value` to `target`, then the # converted value # 3. otherwise, the given `value` unaltered - sig { params(target: FinchAPI::Converter::Input, value: T.anything).returns(T.anything) } - def self.coerce(target, value) + # + # The coercion process is subject to improvement between minor release versions. + # See https://docs.pydantic.dev/latest/concepts/unions/#smart-mode + sig do + params(target: FinchAPI::Converter::Input, value: T.anything, state: FinchAPI::Converter::State) + .returns(T.anything) + end + def self.coerce( + target, + value, + # The `strictness` is one of `true`, `false`, or `:strong`. This informs the + # coercion strategy when we have to decide between multiple possible conversion + # targets: + # + # - `true`: the conversion must be exact, with minimum coercion. + # - `false`: the conversion can be approximate, with some coercion. + # - `:strong`: the conversion must be exact, with no coercion, and raise an error + # if not possible. + # + # The `exactness` is `Hash` with keys being one of `yes`, `no`, or `maybe`. For + # any given conversion attempt, the exactness will be updated based on how closely + # the value recursively matches the target type: + # + # - `yes`: the value can be converted to the target type with minimum coercion. + # - `maybe`: the value can be converted to the target type with some reasonable + # coercion. + # - `no`: the value cannot be converted to the target type. + # + # See implementation below for more details. + state: {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + ) end # @api private sig { params(target: FinchAPI::Converter::Input, value: T.anything).returns(T.anything) } def self.dump(target, value) end - - # @api private - # - # The underlying algorithm for computing maximal compatibility is subject to - # future improvements. - # - # Similar to `#.coerce`, used to determine the best union variant to decode into. - # - # 1. determine if strict-ish coercion is possible - # 2. return either result of successful coercion or if loose coercion is possible - # 3. return a score for recursively tallied count for fields that can be coerced - sig { params(target: FinchAPI::Converter::Input, value: T.anything).returns(T.anything) } - def self.try_strict_coerce(target, value) - end end end @@ -95,23 +110,16 @@ module FinchAPI class << self # @api private - sig(:final) { override.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig(:final) do + override.params(value: T.anything, state: FinchAPI::Converter::State).returns(T.anything) + end + def coerce(value, state:) end # @api private sig(:final) { override.params(value: T.anything).returns(T.anything) } def dump(value) end - - # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end end @@ -135,9 +143,11 @@ module FinchAPI class << self # @api private sig(:final) do - override.params(value: T.any(T::Boolean, T.anything)).returns(T.any(T::Boolean, T.anything)) + override + .params(value: T.any(T::Boolean, T.anything), state: FinchAPI::Converter::State) + .returns(T.any(T::Boolean, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -146,15 +156,6 @@ module FinchAPI end def dump(value) end - - # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end end @@ -194,23 +195,21 @@ module FinchAPI end # @api private - sig { override.params(value: T.any(String, Symbol, T.anything)).returns(T.any(Symbol, T.anything)) } - def coerce(value) + # + # Unlike with primitives, `Enum` additionally validates that the value is a member + # of the enum. + sig do + override + .params(value: T.any(String, Symbol, T.anything), state: FinchAPI::Converter::State) + .returns(T.any(Symbol, T.anything)) + end + def coerce(value, state:) end # @api private sig { override.params(value: T.any(Symbol, T.anything)).returns(T.any(Symbol, T.anything)) } def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # @api private @@ -264,23 +263,14 @@ module FinchAPI end # @api private - sig { override.params(value: T.anything).returns(T.anything) } - def coerce(value) + sig { override.params(value: T.anything, state: FinchAPI::Converter::State).returns(T.anything) } + def coerce(value, state:) end # @api private sig { override.params(value: T.anything).returns(T.anything) } def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # @api private @@ -317,10 +307,10 @@ module FinchAPI # @api private sig(:final) do override - .params(value: T.any(T::Enumerable[T.anything], T.anything)) + .params(value: T.any(T::Enumerable[T.anything], T.anything), state: FinchAPI::Converter::State) .returns(T.any(T::Array[T.anything], T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -333,17 +323,13 @@ module FinchAPI end # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) + sig(:final) { returns(T.anything) } + protected def item_type end # @api private - sig(:final) { returns(T.anything) } - protected def item_type + sig(:final) { returns(T::Boolean) } + protected def nilable? end # @api private @@ -396,10 +382,10 @@ module FinchAPI # @api private sig(:final) do override - .params(value: T.any(T::Hash[T.anything, T.anything], T.anything)) + .params(value: T.any(T::Hash[T.anything, T.anything], T.anything), state: FinchAPI::Converter::State) .returns(T.any(FinchAPI::Util::AnyHash, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -412,17 +398,13 @@ module FinchAPI end # @api private - sig(:final) do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) + sig(:final) { returns(T.anything) } + protected def item_type end # @api private - sig(:final) { returns(T.anything) } - protected def item_type + sig(:final) { returns(T::Boolean) } + protected def nilable? end # @api private @@ -446,7 +428,7 @@ module FinchAPI abstract! - KnownFieldShape = T.type_alias { {mode: T.nilable(Symbol), required: T::Boolean} } + KnownFieldShape = T.type_alias { {mode: T.nilable(Symbol), required: T::Boolean, nilable: T::Boolean} } class << self # @api private @@ -465,11 +447,6 @@ module FinchAPI def known_fields end - # @api private - sig { returns(T::Hash[Symbol, Symbol]) } - def reverse_map - end - # @api private sig do returns( @@ -480,11 +457,6 @@ module FinchAPI def fields end - # @api private - sig { returns(T::Hash[Symbol, T.proc.returns(T::Class[T.anything])]) } - def defaults - end - # @api private sig do params( @@ -554,6 +526,10 @@ module FinchAPI sig { params(blk: T.proc.void).void } private def response_only(&blk) end + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + end end sig { params(other: T.anything).returns(T::Boolean) } @@ -564,10 +540,13 @@ module FinchAPI # @api private sig do override - .params(value: T.any(FinchAPI::BaseModel, T::Hash[T.anything, T.anything], T.anything)) + .params( + value: T.any(FinchAPI::BaseModel, T::Hash[T.anything, T.anything], T.anything), + state: FinchAPI::Converter::State + ) .returns(T.any(T.attached_class, T.anything)) end - def coerce(value) + def coerce(value, state:) end # @api private @@ -578,15 +557,6 @@ module FinchAPI end def dump(value) end - - # @api private - sig do - override - .params(value: T.anything) - .returns(T.any([T::Boolean, T.anything, NilClass], [T::Boolean, T::Boolean, Integer])) - end - def try_strict_coerce(value) - end end # Returns the raw value associated with the given key, if found. Otherwise, nil is diff --git a/sig/finch-api/base_model.rbs b/sig/finch-api/base_model.rbs index 1844dafe..30380bd9 100644 --- a/sig/finch-api/base_model.rbs +++ b/sig/finch-api/base_model.rbs @@ -2,13 +2,16 @@ module FinchAPI module Converter type input = FinchAPI::Converter | Class - def coerce: (top value) -> top + type state = + { + strictness: bool | :strong, + exactness: { yes: Integer, no: Integer, maybe: Integer }, + branched: Integer + } - def dump: (top value) -> top + def coerce: (top value, state: FinchAPI::Converter::state) -> top - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) + def dump: (top value) -> top def self.type_info: ( { @@ -20,14 +23,13 @@ module FinchAPI | FinchAPI::Converter::input spec ) -> (^-> top) - def self.coerce: (FinchAPI::Converter::input target, top value) -> top - - def self.dump: (FinchAPI::Converter::input target, top value) -> top - - def self.try_strict_coerce: ( + def self.coerce: ( FinchAPI::Converter::input target, - top value + top value, + ?state: FinchAPI::Converter::state ) -> top + + def self.dump: (FinchAPI::Converter::input target, top value) -> top end class Unknown @@ -37,13 +39,9 @@ module FinchAPI def self.==: (top other) -> bool - def self.coerce: (top value) -> top + def self.coerce: (top value, state: FinchAPI::Converter::state) -> top def self.dump: (top value) -> top - - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end class BooleanModel @@ -53,13 +51,12 @@ module FinchAPI def self.==: (top other) -> bool - def self.coerce: (bool | top value) -> (bool | top) + def self.coerce: ( + bool | top value, + state: FinchAPI::Converter::state + ) -> (bool | top) def self.dump: (bool | top value) -> (bool | top) - - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end module Enum @@ -73,13 +70,12 @@ module FinchAPI def ==: (top other) -> bool - def coerce: (String | Symbol | top value) -> (Symbol | top) + def coerce: ( + String | Symbol | top value, + state: FinchAPI::Converter::state + ) -> (Symbol | top) def dump: (Symbol | top value) -> (Symbol | top) - - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end module Union @@ -109,13 +105,9 @@ module FinchAPI def ==: (top other) -> bool - def coerce: (top value) -> top + def coerce: (top value, state: FinchAPI::Converter::state) -> top def dump: (top value) -> top - - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) end class ArrayOf @@ -132,16 +124,17 @@ module FinchAPI def ==: (top other) -> bool - def coerce: (Enumerable[top] | top value) -> (::Array[top] | top) + def coerce: ( + Enumerable[top] | top value, + state: FinchAPI::Converter::state + ) -> (::Array[top] | top) def dump: (Enumerable[top] | top value) -> (::Array[top] | top) - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def item_type: -> top + def nilable?: -> bool + def initialize: ( ::Hash[Symbol, top] | ^-> FinchAPI::Converter::input @@ -164,16 +157,17 @@ module FinchAPI def ==: (top other) -> bool - def coerce: (::Hash[top, top] | top value) -> (::Hash[Symbol, top] | top) + def coerce: ( + ::Hash[top, top] | top value, + state: FinchAPI::Converter::state + ) -> (::Hash[Symbol, top] | top) def dump: (::Hash[top, top] | top value) -> (::Hash[Symbol, top] | top) - def try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def item_type: -> top + def nilable?: -> bool + def initialize: ( ::Hash[Symbol, top] | ^-> FinchAPI::Converter::input @@ -185,18 +179,15 @@ module FinchAPI class BaseModel extend FinchAPI::Converter - type known_field = { mode: (:coerce | :dump)?, required: bool } + type known_field = + { mode: (:coerce | :dump)?, required: bool, nilable: bool } def self.known_fields: -> ::Hash[Symbol, (FinchAPI::BaseModel::known_field & { type_fn: (^-> FinchAPI::Converter::input) })] - def self.reverse_map: -> ::Hash[Symbol, Symbol] - def self.fields: -> ::Hash[Symbol, (FinchAPI::BaseModel::known_field & { type: FinchAPI::Converter::input })] - def self.defaults: -> ::Hash[Symbol, (^-> Class)] - private def self.add_field: ( Symbol name_sym, required: bool, @@ -231,18 +222,17 @@ module FinchAPI private def self.response_only: { -> void } -> void + def self.==: (top other) -> bool + def ==: (top other) -> bool def self.coerce: ( - FinchAPI::BaseModel | ::Hash[top, top] | top value + FinchAPI::BaseModel | ::Hash[top, top] | top value, + state: FinchAPI::Converter::state ) -> (instance | top) def self.dump: (instance | top value) -> (::Hash[top, top] | top) - def self.try_strict_coerce: ( - top value - ) -> ([true, top, nil] | [false, bool, Integer]) - def []: (Symbol key) -> top? def to_h: -> ::Hash[Symbol, top] diff --git a/test/finch-api/base_model_test.rb b/test/finch-api/base_model_test.rb index 53ea4094..cb88957b 100644 --- a/test/finch-api/base_model_test.rb +++ b/test/finch-api/base_model_test.rb @@ -2,380 +2,576 @@ require_relative "test_helper" -class FinchAPI::Test::BaseModelTest < Minitest::Test - module E1 - extend FinchAPI::Enum +class FinchAPI::Test::PrimitiveModelTest < Minitest::Test + A = FinchAPI::ArrayOf[-> { Integer }] + H = FinchAPI::HashOf[-> { Integer }, nil?: true] - A = :a - B = :b + module E + extend FinchAPI::Enum end - A1 = FinchAPI::ArrayOf[-> { Integer }] - A2 = FinchAPI::ArrayOf[enum: -> { E1 }] + module U + extend FinchAPI::Union + end - def test_basic - assert(E1.is_a?(FinchAPI::Converter)) - assert(A1.is_a?(FinchAPI::Converter)) + class B < FinchAPI::BaseModel + optional :a, Integer + optional :b, B end - def test_basic_coerce - assert_pattern do - FinchAPI::Converter.coerce(A1, [1.0, 2.0, 3.0]) => [1, 2, 3] - end + def test_typing + converters = [ + FinchAPI::Unknown, + FinchAPI::BooleanModel, + A, + H, + E, + U, + B + ] - assert_pattern do - FinchAPI::Converter.coerce(A2, %w[a b c]) => [:a, :b, "c"] + converters.each do |conv| + assert_pattern do + conv => FinchAPI::Converter + end end end - def test_basic_dump - assert_pattern do - FinchAPI::Converter.dump(A1, [1.0, 2.0, 3.0]) => [1, 2, 3] - end + def test_coerce + cases = { + [FinchAPI::Unknown, :a] => [{yes: 1}, :a], + [NilClass, :a] => [{maybe: 1}, nil], + [NilClass, nil] => [{yes: 1}, nil], + [FinchAPI::BooleanModel, true] => [{yes: 1}, true], + [FinchAPI::BooleanModel, "true"] => [{no: 1}, "true"], + [Integer, 1] => [{yes: 1}, 1], + [Integer, 1.0] => [{maybe: 1}, 1], + [Integer, "1"] => [{maybe: 1}, 1], + [Integer, "one"] => [{no: 1}, "one"], + [Float, 1] => [{yes: 1}, 1.0], + [Float, "1"] => [{maybe: 1}, 1.0], + [Float, :one] => [{no: 1}, :one], + [String, :str] => [{yes: 1}, "str"], + [String, "str"] => [{yes: 1}, "str"], + [String, 1] => [{maybe: 1}, "1"], + [:a, "a"] => [{yes: 1}, :a], + [Date, "1990-09-19"] => [{yes: 1}, Date.new(1990, 9, 19)], + [Date, Date.new(1990, 9, 19)] => [{yes: 1}, Date.new(1990, 9, 19)], + [Date, "one"] => [{no: 1}, "one"], + [Time, "1990-09-19"] => [{yes: 1}, Time.new(1990, 9, 19)], + [Time, Time.new(1990, 9, 19)] => [{yes: 1}, Time.new(1990, 9, 19)], + [Time, "one"] => [{no: 1}, "one"] + } - assert_pattern do - FinchAPI::Converter.dump(A2, %w[a b c]) => %w[a b c] + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_pattern do + FinchAPI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + end end end - def test_primitive_try_strict_coerce - d_now = Date.today - t_now = Time.now - + def test_dump cases = { - [NilClass, :a] => [true, nil, 0], - [NilClass, nil] => [true, nil, 1], - [Integer, 1.0] => [true, 1, 1], - [Float, 1] => [true, 1.0, 1], - [Date, d_now] => [true, d_now, 1], - [Time, t_now] => [true, t_now, 1] + [FinchAPI::Unknown, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [A, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [H, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [E, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [U, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [B, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [String, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [:b, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [nil, B.new(a: "one", b: B.new(a: 1.0))] => {a: "one", b: {a: 1}}, + [FinchAPI::BooleanModel, true] => true, + [FinchAPI::BooleanModel, "true"] => "true", + [Integer, "1"] => "1", + [Float, 1] => 1, + [String, "one"] => "one", + [String, :one] => :one, + [:a, :b] => :b, + [:a, "a"] => "a" } - cases.each do |test, expect| - type, input = test + cases.each do + target, input = _1 + expect = _2 assert_pattern do - FinchAPI::Converter.try_strict_coerce(type, input) => ^expect + FinchAPI::Converter.dump(target, input) => ^expect end end end - def test_basic_enum_try_strict_coerce + def test_coerce_errors cases = { - :a => [true, :a, 1], - "a" => [true, :a, 1], - :c => [false, true, 0], - 1 => [false, false, 0] + [Integer, "one"] => TypeError, + [Float, "one"] => TypeError, + [String, Time] => TypeError, + [:a, "one"] => ArgumentError, + [Date, "one"] => ArgumentError, + [Time, "one"] => ArgumentError } - cases.each do |input, expect| - assert_pattern do - FinchAPI::Converter.try_strict_coerce(E1, input) => ^expect + cases.each do + target, input = _1 + state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_raises(_2) do + FinchAPI::Converter.coerce(target, input, state: state) end end end +end + +class FinchAPI::Test::EnumModelTest < Minitest::Test + module E1 + extend FinchAPI::Enum + + TRUE = true + end + + module E2 + extend FinchAPI::Enum + + ONE = 1 + TWO = 2 + end - def test_basic_array_try_strict_coerce + module E3 + extend FinchAPI::Enum + + ONE = 1.0 + TWO = 2.0 + end + + module E4 + extend FinchAPI::Enum + + ONE = :one + TWO = :two + end + + def test_coerce cases = { - [] => [true, [], 0], - nil => [false, false, 0], - [1, 2, 3] => [true, [1, 2, 3], 3], - [1.0, 2.0, 3.0] => [true, [1, 2, 3], 3], - [1, nil, 3] => [true, [1, nil, 3], 2], - [1, nil, nil] => [true, [1, nil, nil], 1], - [1, "two", 3] => [false, true, 2] + [E1, true] => [{yes: 1}, true], + [E1, false] => [{no: 1}, false], + [E1, :true] => [{no: 1}, :true], + + [E2, 1] => [{yes: 1}, 1], + [E2, 1.0] => [{yes: 1}, 1], + [E2, 1.2] => [{no: 1}, 1.2], + [E2, "1"] => [{no: 1}, "1"], + + [E3, 1.0] => [{yes: 1}, 1.0], + [E3, 1] => [{yes: 1}, 1.0], + [E3, "one"] => [{no: 1}, "one"], + + [E4, :one] => [{yes: 1}, :one], + [E4, "one"] => [{yes: 1}, :one], + [E4, "1"] => [{maybe: 1}, "1"], + [E4, :"1"] => [{maybe: 1}, :"1"], + [E4, 1] => [{no: 1}, 1] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - FinchAPI::Converter.try_strict_coerce(A1, input) => ^expect + FinchAPI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness end end end - def test_nested_array_try_strict_coerce + def test_dump cases = { - %w[a b] => [true, [:a, :b], 2], - %w[a b c] => [false, true, 2] + [E1, true] => true, + [E1, "true"] => "true", + + [E2, 1.0] => 1.0, + [E2, 3] => 3, + [E2, "1.0"] => "1.0", + + [E3, 1.0] => 1.0, + [E3, 3] => 3, + [E3, "1.0"] => "1.0", + + [E4, :one] => :one, + [E4, "one"] => "one", + [E4, "1.0"] => "1.0" } - cases.each do |input, expect| + cases.each do + target, input = _1 + expect = _2 assert_pattern do - FinchAPI::Converter.try_strict_coerce(A2, input) => ^expect + FinchAPI::Converter.dump(target, input) => ^expect end end end +end - class M1 < FinchAPI::BaseModel - required :a, Time - optional :b, E1, api_name: :renamed - required :c, A1 +class FinchAPI::Test::CollectionModelTest < Minitest::Test + A1 = FinchAPI::ArrayOf[-> { Integer }] + H1 = FinchAPI::HashOf[Integer] - request_only do - required :w, Integer - optional :x, String - end + A2 = FinchAPI::ArrayOf[H1] + H2 = FinchAPI::HashOf[-> { A1 }] - response_only do - required :y, Integer - optional :z, String + A3 = FinchAPI::ArrayOf[Integer, nil?: true] + H3 = FinchAPI::HashOf[Integer, nil?: true] + + def test_coerce + cases = { + [A1, []] => [{yes: 1}, []], + [A1, {}] => [{no: 1}, {}], + [A1, [1, 2.0]] => [{yes: 2, maybe: 1}, [1, 2]], + [A1, ["1", 2.0]] => [{yes: 1, maybe: 2}, [1, 2]], + [H1, {}] => [{yes: 1}, {}], + [H1, []] => [{no: 1}, []], + [H1, {a: 1, b: 2}] => [{yes: 3}, {a: 1, b: 2}], + [H1, {"a" => 1, "b" => 2}] => [{yes: 3}, {a: 1, b: 2}], + [H1, {[] => 1}] => [{yes: 2, no: 1}, {[] => 1}], + [H1, {a: 1.5}] => [{yes: 1, maybe: 1}, {a: 1}], + + [A2, [{}, {"a" => 1}]] => [{yes: 4}, [{}, {a: 1}]], + [A2, [{"a" => "1"}]] => [{yes: 2, maybe: 1}, [{a: 1}]], + [H2, {a: [1, 2]}] => [{yes: 4}, {a: [1, 2]}], + [H2, {"a" => ["1", 2]}] => [{yes: 3, maybe: 1}, {a: [1, 2]}], + [H2, {"a" => ["one", 2]}] => [{yes: 3, no: 1}, {a: ["one", 2]}], + + [A3, [nil, 1]] => [{yes: 3}, [nil, 1]], + [A3, [nil, "1"]] => [{yes: 2, maybe: 1}, [nil, 1]], + [H3, {a: nil, b: "1"}] => [{yes: 2, maybe: 1}, {a: nil, b: 1}], + [H3, {a: nil}] => [{yes: 2}, {a: nil}] + } + + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} + assert_pattern do + FinchAPI::Converter.coerce(target, input, state: state) => ^expect + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + end end end +end - class M2 < M1 - required :c, M1 +class FinchAPI::Test::BaseModelTest < Minitest::Test + class M1 < FinchAPI::BaseModel + required :a, Integer end - def test_model_accessors - now = Time.now.round(0) - model = M2.new(a: now.to_s, b: "b", renamed: "a", c: [1.0, 2.0, 3.0], w: 1, y: 1) + class M2 < M1 + required :a, Time + required :b, Integer, nil?: true + optional :c, String + end - cases = [ - [model.a, now], - [model.b, :a], - [model.c, [1, 2, 3]], - [model.w, 1], - [model.y, 1] - ] + class M3 < FinchAPI::BaseModel + optional :c, const: :c + required :d, const: :d + end - cases.each do |input, expect| - assert_pattern do - input => ^expect - end + class M4 < M1 + request_only do + required :a, Integer + optional :b, String end - end - def test_model_conversion_accessor - model = M2.new(c: {}) - assert_pattern do - model.c => M1 + response_only do + required :c, Integer + optional :d, String end end - def test_model_equality - now = Time.now - model1 = M2.new(a: now, b: "b", renamed: "a", c: M1.new, w: 1, y: 1) - model2 = M2.new(a: now, b: "b", renamed: "a", c: M1.new, w: 1, y: 1) + class M5 < FinchAPI::BaseModel + request_only do + required :c, const: :c + end - assert_pattern do - model2 => ^model1 + response_only do + required :d, const: :d end end - def test_basic_model_coerce + class M6 < M1 + required :a, FinchAPI::ArrayOf[M6] + end + + def test_coerce cases = { - {} => M2.new, - {a: nil, b: :a, c: [1.0, 2.0, 3.0], w: 1} => M2.new(a: nil, b: :a, c: [1.0, 2.0, 3.0], w: 1) + [M1, {}] => [{yes: 1, no: 1}, {}], + [M1, :m1] => [{no: 1}, :m1], + + [M2, {}] => [{yes: 2, no: 1, maybe: 1}, {}], + [M2, {a: "1990-09-19", b: nil}] => [{yes: 4}, {a: "1990-09-19", b: nil}], + [M2, {a: "1990-09-19", b: "1"}] => [{yes: 3, maybe: 1}, {a: "1990-09-19", b: "1"}], + [M2, {a: "1990-09-19"}] => [{yes: 3, maybe: 1}, {a: "1990-09-19"}], + [M2, {a: "1990-09-19", c: nil}] => [{yes: 2, maybe: 2}, {a: "1990-09-19", c: nil}], + + [M3, {c: "c", d: "d"}] => [{yes: 3}, {c: :c, d: :d}], + [M3, {c: "d", d: "c"}] => [{yes: 1, no: 2}, {c: "d", d: "c"}], + + [M4, {c: 2}] => [{yes: 5}, {c: 2}], + [M4, {a: "1", c: 2}] => [{yes: 4, maybe: 1}, {a: "1", c: 2}], + [M4, {b: nil, c: 2}] => [{yes: 4, maybe: 1}, {b: nil, c: 2}], + + [M5, {}] => [{yes: 3}, {}], + [M5, {c: "c"}] => [{yes: 3}, {c: :c}], + [M5, {d: "d"}] => [{yes: 3}, {d: :d}], + [M5, {d: nil}] => [{yes: 2, no: 1}, {d: nil}], + + [M6, {a: [{a: []}]}] => [{yes: 4}, -> { _1 in {a: [M6]} }] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - FinchAPI::Converter.coerce(M2, input) => ^expect + coerced = FinchAPI::Converter.coerce(target, input, state: state) + assert_equal(coerced, coerced) + if coerced.is_a?(FinchAPI::BaseModel) + coerced.to_h => ^expect + else + coerced => ^expect + end + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness end end end - def test_basic_model_dump + def test_dump cases = { - nil => nil, - {} => {}, - {w: 1, x: "x", y: 1, z: "z"} => {w: 1, x: "x"}, - [1, 2, 3] => [1, 2, 3] + [M3, M3.new] => {d: :d}, + [M3, {}] => {d: :d}, + [M3, {d: 1}] => {d: 1}, + + [M4, M4.new(a: 1, b: "b", c: 2, d: "d")] => {a: 1, b: "b"}, + [M4, {a: 1, b: "b", c: 2, d: "d"}] => {a: 1, b: "b"}, + + [M5, M5.new] => {c: :c}, + [M5, {}] => {c: :c}, + [M5, {c: 1}] => {c: 1} } - cases.each do |input, expect| + cases.each do + target, input = _1 + expect = _2 assert_pattern do - FinchAPI::Converter.dump(M2, input) => ^expect + FinchAPI::Converter.dump(target, input) => ^expect end end end - def test_basic_model_try_strict_coerce - raw = {a: Time.now, c: [2], y: 1} - addn = {x: "x", n: "n"} - expect_exact = M1.new(raw) - expect_addn = M1.new(**raw, **addn) - + def test_accessors cases = { - {} => [false, true, 0], - raw => [true, expect_exact, 3], - {**raw, **addn} => [true, expect_addn, 4] + M2.new({a: "1990-09-19", b: "1"}) => {a: Time.new(1990, 9, 19), b: TypeError}, + M2.new(a: "one", b: "one") => {a: ArgumentError, b: TypeError}, + M2.new(a: nil, b: 2.0) => {a: TypeError, b: TypeError}, + + M3.new => {d: :d}, + M3.new(d: 1) => {d: ArgumentError}, + + M5.new => {c: :c, d: :d} } - cases.each do |input, expect| - assert_pattern do - FinchAPI::Converter.try_strict_coerce(M1, input) => ^expect + cases.each do + target = _1 + _2.each do |accessor, expect| + case expect + in Class if expect <= StandardError + tap do + target.public_send(accessor) + flunk + rescue FinchAPI::ConversionError => e + assert_kind_of(expect, e.cause) + end + else + assert_pattern { target.public_send(accessor) => ^expect } + end end end end +end - def test_nested_model_dump - now = Time.now - models = [M1, M2] - inputs = [ - M1.new(a: now, b: "a", c: [1.0, 2.0, 3.0], y: 1), - {a: now, b: "a", c: [1.0, 2.0, 3.0], y: 1}, - {"a" => now, b: "", "b" => "a", "c" => [], :c => [1.0, 2.0, 3.0], "y" => 1} - ] +class FinchAPI::Test::UnionTest < Minitest::Test + module U0 + extend FinchAPI::Union + end - models.product(inputs).each do |model, input| - assert_pattern do - FinchAPI::Converter.dump(model, input) => {a: now, renamed: "a", c: [1, 2, 3]} - end - end + module U1 + extend FinchAPI::Union + variant const: :a + variant const: 2 end - class M4 < M2 - required :c, M1 - required :d, FinchAPI::ArrayOf[M4] - required :e, M2, api_name: :f + class M1 < FinchAPI::BaseModel + required :t, const: :a, api_name: :type + optional :c, String end - def test_model_to_h - model = M4.new(a: "wow", c: {}, d: [{}, 2, {c: {}}], f: {}) - assert_pattern do - model.to_h => {a: "wow", c: M1, d: [M4, 2, M4 => child], f: M2} - assert_equal({c: M1.new}, child.to_h) - end + class M2 < FinchAPI::BaseModel + required :type, const: :b + optional :c, String end - A3 = FinchAPI::ArrayOf[A1] + module U2 + extend FinchAPI::Union + discriminator :type - class M3 < M1 - optional :b, E1, api_name: :renamed_again + variant :a, M1 + variant :b, M2 end - module U1 + module U3 extend FinchAPI::Union - discriminator :type + variant :a, M1 - variant :b, M3 + variant String end - module U2 + module U4 extend FinchAPI::Union + discriminator :type - variant A1 - variant A3 + variant String + variant :a, M1 end - def test_basic_union - assert(U1.is_a?(FinchAPI::Converter)) + class M3 < FinchAPI::BaseModel + optional :recur, -> { U5 } + required :a, Integer + end - assert_pattern do - M1.new => U1 - M3.new => U1 - end + class M4 < FinchAPI::BaseModel + optional :recur, -> { U5 } + required :a, FinchAPI::ArrayOf[-> { U5 }] end - def test_basic_discriminated_union_coerce - common = {a: Time.now, c: [], w: 1} - cases = { - nil => nil, - {type: "a", **common} => M1.new(type: "a", **common), - {type: :b, **common} => M3.new(type: :b, **common), - {type: :c, xyz: 1} => {type: :c, xyz: 1} - } + class M5 < FinchAPI::BaseModel + optional :recur, -> { U5 } + required :b, FinchAPI::ArrayOf[-> { U5 }] + end - cases.each do |input, expect| - assert_pattern do - FinchAPI::Converter.coerce(U1, input) => ^expect - end - end + module U5 + extend FinchAPI::Union + + variant -> { M3 } + variant -> { M4 } end - def test_basic_discriminated_union_dump - now = Time.now - cases = { - nil => nil, - M1.new(a: now, b: :a, c: [1.0, 2.0, 3.0], y: 1) => {a: now, renamed: :a, c: [1, 2, 3]}, - M3.new(b: "a", y: 1) => {renamed_again: "a"}, - {type: :a, b: "a", y: 1} => {type: :a, renamed: "a"}, - {type: "b", b: "a", y: 1} => {type: "b", renamed_again: "a"}, - {type: :c, xyz: 1} => {type: :c, xyz: 1} - } + module U6 + extend FinchAPI::Union - cases.each do |input, expect| - assert_pattern do - FinchAPI::Converter.dump(U1, input) => ^expect - end + variant -> { M3 } + variant -> { M5 } + end + + def test_accessors + model = M3.new(recur: []) + tap do + model.recur + flunk + rescue FinchAPI::ConversionError => e + assert_kind_of(ArgumentError, e.cause) end end - def test_basic_undifferentiated_union_try_strict_coerce + def test_coerce cases = { - [] => [true, [], 0], - [[]] => [true, [[]], 0], - # [nil] => [false, true, 0], - [1, 2, 3] => [true, [1, 2, 3], 3], - [[1, 2, 3], [4, 5, 6]] => [true, [[1, 2, 3], [4, 5, 6]], 6] + [U0, :""] => [{no: 1}, 0, :""], + + [U1, "a"] => [{yes: 1}, 1, :a], + [U1, "2"] => [{maybe: 1}, 2, 2], + [U1, :b] => [{no: 1}, 2, :b], + + [U2, {type: :a}] => [{yes: 3}, 0, {t: :a}], + [U2, {type: "b"}] => [{yes: 3}, 0, {type: :b}], + + [U3, "one"] => [{yes: 1}, 2, "one"], + [U4, "one"] => [{yes: 1}, 1, "one"], + + [U5, {a: []}] => [{yes: 3}, 2, {a: []}], + [U6, {b: []}] => [{yes: 3}, 2, {b: []}], + + [U5, {a: [{a: []}]}] => [{yes: 6}, 4, {a: [M4.new(a: [])]}], + [U5, {a: [{a: [{a: []}]}]}] => [{yes: 9}, 6, {a: [M4.new(a: [M4.new(a: [])])]}] } - cases.each do |input, expect| + cases.each do |lhs, rhs| + target, input = lhs + exactness, branched, expect = rhs + state = {strictness: true, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0} assert_pattern do - FinchAPI::Converter.try_strict_coerce(U2, input) => ^expect + coerced = FinchAPI::Converter.coerce(target, input, state: state) + assert_equal(coerced, coerced) + if coerced.is_a?(FinchAPI::BaseModel) + coerced.to_h => ^expect + else + coerced => ^expect + end + state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness + state => {branched: ^branched} end end end +end - class C1 < FinchAPI::BaseModel - required :a, const: :a - required :b, const: :b, nil?: true - optional :c, const: :c - end +class FinchAPI::Test::BaseModelQoLTest < Minitest::Test + module E1 + extend FinchAPI::Enum - def test_basic_const - assert_pattern do - C1.dump(C1.new) => {a: :a} - C1.new => {a: :a} - C1.new(a: "a") => {a: :a} - C1.new(b: 2) => {b: 2} - C1.new.a => :a - C1.new.b => nil - C1.new.c => nil - end + A = 1 end module E2 extend FinchAPI::Enum - A = :a - B = :b + A = 1 end - module U3 - extend FinchAPI::Union + module E3 + extend FinchAPI::Enum - discriminator :type - variant :a, M1 - variant :b, M3 + A = 2 + B = 3 end - def test_basic_eql - assert_equal(FinchAPI::Unknown, FinchAPI::Unknown) - refute_equal(FinchAPI::Unknown, FinchAPI::BooleanModel) - assert_equal(FinchAPI::BooleanModel, FinchAPI::BooleanModel) - - assert_equal(E1, E2) - assert_equal(E1, E2) - - refute_equal(U1, U2) - assert_equal(U1, U3) + class M1 < FinchAPI::BaseModel + required :a, Integer end - module U4 - extend FinchAPI::Union + class M2 < FinchAPI::BaseModel + required :a, Integer, nil?: true + end - variant :a, const: :a - variant :b, const: :b + class M3 < M2 + required :a, Integer end - def test_basic_const_union - assert_pattern do - U4.coerce(nil) => nil - U4.coerce("") => "" - U4.coerce(:a) => :a - U4.coerce("a") => :a + def test_equality + cases = { + [FinchAPI::Unknown, FinchAPI::Unknown] => true, + [FinchAPI::BooleanModel, FinchAPI::BooleanModel] => true, + [FinchAPI::Unknown, FinchAPI::BooleanModel] => false, + [E1, E2] => true, + [E1, E3] => false, + [M1, M2] => false, + [M1, M3] => true + } + + cases.each do + if _2 + assert_equal(*_1) + else + refute_equal(*_1) + end end end end From c8fe96f6528184a2d15385e2607be024329c5c6d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:34:19 +0000 Subject: [PATCH 4/6] chore: remove unnecessary & confusing module (#78) --- lib/finch-api.rb | 1 - lib/finch-api/base_model.rb | 3 --- lib/finch-api/extern.rb | 7 ------- rbi/lib/finch-api/base_client.rbi | 2 +- rbi/lib/finch-api/base_model.rbi | 6 +----- rbi/lib/finch-api/extern.rbi | 7 ------- sig/finch-api/base_client.rbs | 2 +- sig/finch-api/base_model.rbs | 2 -- sig/finch-api/extern.rbs | 4 ---- 9 files changed, 3 insertions(+), 31 deletions(-) delete mode 100644 lib/finch-api/extern.rb delete mode 100644 rbi/lib/finch-api/extern.rbi delete mode 100644 sig/finch-api/extern.rbs diff --git a/lib/finch-api.rb b/lib/finch-api.rb index 53f49010..575c5069 100644 --- a/lib/finch-api.rb +++ b/lib/finch-api.rb @@ -36,7 +36,6 @@ # Package files. require_relative "finch-api/version" require_relative "finch-api/util" -require_relative "finch-api/extern" require_relative "finch-api/base_model" require_relative "finch-api/base_page" require_relative "finch-api/request_options" diff --git a/lib/finch-api/base_model.rb b/lib/finch-api/base_model.rb index 4518e86f..e95b064c 100644 --- a/lib/finch-api/base_model.rb +++ b/lib/finch-api/base_model.rb @@ -1161,9 +1161,6 @@ def initialize(data = {}) end end - # @return [String] - def to_s = @data.to_s - # @return [String] def inspect rows = self.class.known_fields.keys.map do diff --git a/lib/finch-api/extern.rb b/lib/finch-api/extern.rb deleted file mode 100644 index ec4b0ef7..00000000 --- a/lib/finch-api/extern.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module FinchAPI - # @api private - module Extern - end -end diff --git a/rbi/lib/finch-api/base_client.rbi b/rbi/lib/finch-api/base_client.rbi index 5ee8e3ce..356ef4ca 100644 --- a/rbi/lib/finch-api/base_client.rbi +++ b/rbi/lib/finch-api/base_client.rbi @@ -84,7 +84,7 @@ module FinchAPI end # @api private - sig { returns(T.anything) } + sig { returns(FinchAPI::PooledNetRequester) } attr_accessor :requester # @api private diff --git a/rbi/lib/finch-api/base_model.rbi b/rbi/lib/finch-api/base_model.rbi index bb62383d..c1651638 100644 --- a/rbi/lib/finch-api/base_model.rbi +++ b/rbi/lib/finch-api/base_model.rbi @@ -38,7 +38,7 @@ module FinchAPI FinchAPI::Converter::Input ) ) - .returns(T.proc.returns(T.anything).void) + .returns(T.proc.returns(T.anything)) end def self.type_info(spec) end @@ -602,10 +602,6 @@ module FinchAPI def self.new(data = {}) end - sig { returns(String) } - def to_s - end - sig { returns(String) } def inspect end diff --git a/rbi/lib/finch-api/extern.rbi b/rbi/lib/finch-api/extern.rbi deleted file mode 100644 index f57af343..00000000 --- a/rbi/lib/finch-api/extern.rbi +++ /dev/null @@ -1,7 +0,0 @@ -# typed: strong - -module FinchAPI - # @api private - module Extern - end -end diff --git a/sig/finch-api/base_client.rbs b/sig/finch-api/base_client.rbs index d63a83c0..2ce0a80a 100644 --- a/sig/finch-api/base_client.rbs +++ b/sig/finch-api/base_client.rbs @@ -49,7 +49,7 @@ module FinchAPI ) -> void # @api private - attr_accessor requester: top + attr_accessor requester: FinchAPI::PooledNetRequester def initialize: ( base_url: String, diff --git a/sig/finch-api/base_model.rbs b/sig/finch-api/base_model.rbs index 30380bd9..2553080d 100644 --- a/sig/finch-api/base_model.rbs +++ b/sig/finch-api/base_model.rbs @@ -243,8 +243,6 @@ module FinchAPI def initialize: (?::Hash[Symbol, top] | self data) -> void - def to_s: -> String - def inspect: -> String end end diff --git a/sig/finch-api/extern.rbs b/sig/finch-api/extern.rbs deleted file mode 100644 index 3122cf04..00000000 --- a/sig/finch-api/extern.rbs +++ /dev/null @@ -1,4 +0,0 @@ -module FinchAPI - module Extern - end -end From 382667da8c8b9fd9dca9c8be1e903a26f8c39d3a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:53:36 +0000 Subject: [PATCH 5/6] feat(api): api update (#79) --- .stats.yml | 4 ++-- lib/finch-api/models/introspection.rb | 13 +++++++++++- rbi/lib/finch-api/models/introspection.rbi | 23 +++++++++++++++++++--- sig/finch-api/models/introspection.rbs | 11 ++++++++++- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9be07e66..66cac5a2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 41 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/finch%2Ffinch-63d6857158c2634529b468b53df0b9c02f42d6f9783399939a38986e3137a86f.yml -openapi_spec_hash: 2c3aea6ae3e0a3dd7ac65c25b8fdc24d +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/finch%2Ffinch-3224f142ed1542ed2535c155e8eb23b7ea10470c12448d60c3ce584126073a93.yml +openapi_spec_hash: 6c4b213ed5e4f7d1b369e04e4bfb5164 config_hash: 8303e755d3e16cf28542d5f0aec83851 diff --git a/lib/finch-api/models/introspection.rb b/lib/finch-api/models/introspection.rb index 3029b2b5..053ec767 100644 --- a/lib/finch-api/models/introspection.rb +++ b/lib/finch-api/models/introspection.rb @@ -253,6 +253,16 @@ module ClientType end class ConnectionStatus < FinchAPI::BaseModel + # @!attribute [r] last_successful_sync + # The datetime when the connection was last successfully synced. + # + # @return [Time, nil] + optional :last_successful_sync, Time + + # @!parse + # # @return [Time] + # attr_writer :last_successful_sync + # @!attribute [r] message # # @return [String, nil] @@ -272,10 +282,11 @@ class ConnectionStatus < FinchAPI::BaseModel # attr_writer :status # @!parse + # # @param last_successful_sync [Time] # # @param message [String] # # @param status [Symbol, FinchAPI::Models::ConnectionStatusType] # # - # def initialize(message: nil, status: nil, **) = super + # def initialize(last_successful_sync: nil, message: nil, status: nil, **) = super # def initialize: (Hash | FinchAPI::BaseModel) -> void end diff --git a/rbi/lib/finch-api/models/introspection.rbi b/rbi/lib/finch-api/models/introspection.rbi index dd5311c6..cf0f037f 100644 --- a/rbi/lib/finch-api/models/introspection.rbi +++ b/rbi/lib/finch-api/models/introspection.rbi @@ -265,6 +265,13 @@ module FinchAPI end class ConnectionStatus < FinchAPI::BaseModel + # The datetime when the connection was last successfully synced. + sig { returns(T.nilable(Time)) } + attr_reader :last_successful_sync + + sig { params(last_successful_sync: Time).void } + attr_writer :last_successful_sync + sig { returns(T.nilable(String)) } attr_reader :message @@ -278,12 +285,22 @@ module FinchAPI attr_writer :status sig do - params(message: String, status: FinchAPI::Models::ConnectionStatusType::OrSymbol).returns(T.attached_class) + params( + last_successful_sync: Time, + message: String, + status: FinchAPI::Models::ConnectionStatusType::OrSymbol + ) + .returns(T.attached_class) end - def self.new(message: nil, status: nil) + def self.new(last_successful_sync: nil, message: nil, status: nil) end - sig { override.returns({message: String, status: FinchAPI::Models::ConnectionStatusType::TaggedSymbol}) } + sig do + override + .returns( + {last_successful_sync: Time, message: String, status: FinchAPI::Models::ConnectionStatusType::TaggedSymbol} + ) + end def to_hash end end diff --git a/sig/finch-api/models/introspection.rbs b/sig/finch-api/models/introspection.rbs index 257a8217..c79ea354 100644 --- a/sig/finch-api/models/introspection.rbs +++ b/sig/finch-api/models/introspection.rbs @@ -157,9 +157,17 @@ module FinchAPI end type connection_status = - { message: String, status: FinchAPI::Models::connection_status_type } + { + last_successful_sync: Time, + message: String, + status: FinchAPI::Models::connection_status_type + } class ConnectionStatus < FinchAPI::BaseModel + attr_reader last_successful_sync: Time? + + def last_successful_sync=: (Time) -> Time + attr_reader message: String? def message=: (String) -> String @@ -171,6 +179,7 @@ module FinchAPI ) -> FinchAPI::Models::connection_status_type def initialize: ( + ?last_successful_sync: Time, ?message: String, ?status: FinchAPI::Models::connection_status_type ) -> void From 95271fdbce77563c2518199b996a0315805e4d39 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:53:54 +0000 Subject: [PATCH 6/6] release: 0.1.0-alpha.4 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ Gemfile.lock | 2 +- lib/finch-api/version.rb | 2 +- rbi/lib/finch-api/version.rbi | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index aaf968a1..b56c3d0b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.3" + ".": "0.1.0-alpha.4" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 079005f5..ca808762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.0-alpha.4 (2025-03-31) + +Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/Finch-API/finch-api-ruby/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) + +### Features + +* **api:** api update ([#79](https://github.com/Finch-API/finch-api-ruby/issues/79)) ([382667d](https://github.com/Finch-API/finch-api-ruby/commit/382667da8c8b9fd9dca9c8be1e903a26f8c39d3a)) +* **internal:** converter interface should recurse without schema ([#77](https://github.com/Finch-API/finch-api-ruby/issues/77)) ([54c0c14](https://github.com/Finch-API/finch-api-ruby/commit/54c0c146c432894dafd73fbe17ad2fe7a931a2d5)) + + +### Chores + +* **internal:** minor refactoring of utils ([#76](https://github.com/Finch-API/finch-api-ruby/issues/76)) ([9b88b83](https://github.com/Finch-API/finch-api-ruby/commit/9b88b83a6775fb547dceb5ed3c54c67f47587609)) +* **internal:** version bump ([#74](https://github.com/Finch-API/finch-api-ruby/issues/74)) ([5b79b2c](https://github.com/Finch-API/finch-api-ruby/commit/5b79b2c834848a5e4413f52e9e3383205f51b5d7)) +* remove unnecessary & confusing module ([#78](https://github.com/Finch-API/finch-api-ruby/issues/78)) ([c8fe96f](https://github.com/Finch-API/finch-api-ruby/commit/c8fe96f6528184a2d15385e2607be024329c5c6d)) + ## 0.1.0-alpha.3 (2025-03-27) Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/Finch-API/finch-api-ruby/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) diff --git a/Gemfile.lock b/Gemfile.lock index 768a9f07..7b314324 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT PATH remote: . specs: - finch-api (0.1.0.pre.alpha.3) + finch-api (0.1.0.pre.alpha.4) connection_pool GEM diff --git a/lib/finch-api/version.rb b/lib/finch-api/version.rb index bc1ed6f2..53914ff7 100644 --- a/lib/finch-api/version.rb +++ b/lib/finch-api/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FinchAPI - VERSION = "0.1.0-alpha.3" + VERSION = "0.1.0-alpha.4" end diff --git a/rbi/lib/finch-api/version.rbi b/rbi/lib/finch-api/version.rbi index c1396f8c..1b646ed6 100644 --- a/rbi/lib/finch-api/version.rbi +++ b/rbi/lib/finch-api/version.rbi @@ -1,5 +1,5 @@ # typed: strong module FinchAPI - VERSION = "0.1.0-alpha.3" + VERSION = "0.1.0-alpha.4" end