Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
- 'integrated/**'
- 'stl-preview-head/**'
- 'stl-preview-base/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
- 'stl-preview-base/**'

jobs:
lint:
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.8.0"
".": "1.8.1"
}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 1.8.1 (2025-06-18)

Full Changelog: [v1.8.0...v1.8.1](https://github.com/knocklabs/knock-ruby/compare/v1.8.0...v1.8.1)

### Bug Fixes

* issue where we cannot mutate arrays on base model derivatives ([2c56679](https://github.com/knocklabs/knock-ruby/commit/2c56679d7f62da36dcae21fe5944d24211afe854))


### Chores

* **ci:** enable for pull requests ([985de4e](https://github.com/knocklabs/knock-ruby/commit/985de4e91baf5684d7cf308f6e3de661fbb8163d))

## 1.8.0 (2025-06-13)

Full Changelog: [v1.7.0...v1.8.0](https://github.com/knocklabs/knock-ruby/compare/v1.7.0...v1.8.0)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ GIT
PATH
remote: .
specs:
knockapi (1.8.0)
knockapi (1.8.1)
connection_pool

GEM
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ To use this gem, install via Bundler by adding the following to your application
<!-- x-release-please-start-version -->

```ruby
gem "knockapi", "~> 1.8.0"
gem "knockapi", "~> 1.8.1"
```

<!-- x-release-please-end -->
Expand Down
22 changes: 22 additions & 0 deletions lib/knockapi/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ class Error < StandardError
end

class ConversionError < Knockapi::Errors::Error
# @return [StandardError, nil]
def cause = @cause.nil? ? super : @cause

# @api private
#
# @param on [Class<StandardError>]
# @param method [Symbol]
# @param target [Object]
# @param value [Object]
# @param cause [StandardError, nil]
def initialize(on:, method:, target:, value:, cause: nil)
cls = on.name.split("::").last

message = [
"Failed to parse #{cls}.#{method} from #{value.class} to #{target.inspect}.",
"To get the unparsed API response, use #{cls}[#{method.inspect}].",
cause && "Cause: #{cause.message}"
].filter(&:itself).join(" ")

@cause = cause
super(message)
end
end

class APIError < Knockapi::Errors::Error
Expand Down
7 changes: 6 additions & 1 deletion lib/knockapi/internal/type/array_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ def hash = [self.class, item_type].hash
#
# @param state [Hash{Symbol=>Object}] .
#
# @option state [Boolean, :strong] :strictness
# @option state [Boolean] :translate_names
#
# @option state [Boolean] :strictness
#
# @option state [Hash{Symbol=>Object}] :exactness
#
# @option state [Class<StandardError>] :error
#
# @option state [Integer] :branched
#
# @return [Array<Object>, Object]
Expand All @@ -74,6 +78,7 @@ def coerce(value, state:)

unless value.is_a?(Array)
exactness[:no] += 1
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Array}")
return value
end

Expand Down
102 changes: 77 additions & 25 deletions lib/knockapi/internal/type/base_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def fields
[Knockapi::Internal::Type::Converter.type_info(type_info), type_info]
end

setter = "#{name_sym}="
setter = :"#{name_sym}="
api_name = info.fetch(:api_name, name_sym)
nilable = info.fetch(:nil?, false)
const = if required && !nilable
Expand All @@ -84,30 +84,61 @@ def fields
type_fn: type_fn
}

define_method(setter) { @data.store(name_sym, _1) }
define_method(setter) do |value|
target = type_fn.call
state = Knockapi::Internal::Type::Converter.new_coerce_state(translate_names: false)
coerced = Knockapi::Internal::Type::Converter.coerce(target, value, state: state)
status = @coerced.store(name_sym, state.fetch(:error) || true)
stored =
case [target, status]
in [Knockapi::Internal::Type::Converter | Symbol, true]
coerced
else
value
end
@data.store(name_sym, stored)
end

# rubocop:disable Style/CaseEquality
# rubocop:disable Metrics/BlockLength
define_method(name_sym) do
target = type_fn.call
value = @data.fetch(name_sym) { const == Knockapi::Internal::OMIT ? nil : const }
state = {strictness: :strong, exactness: {yes: 0, no: 0, maybe: 0}, branched: 0}
if (nilable || !required) && value.nil?
nil
else
Knockapi::Internal::Type::Converter.coerce(
target,
value,
state: state

case @coerced[name_sym]
in true | false if Knockapi::Internal::Type::Converter === target
@data.fetch(name_sym)
in ::StandardError => e
raise Knockapi::Errors::ConversionError.new(
on: self.class,
method: __method__,
target: target,
value: @data.fetch(name_sym),
cause: e
)
else
Kernel.then do
value = @data.fetch(name_sym) { const == Knockapi::Internal::OMIT ? nil : const }
state = Knockapi::Internal::Type::Converter.new_coerce_state(translate_names: false)
if (nilable || !required) && value.nil?
nil
else
Knockapi::Internal::Type::Converter.coerce(
target, value, state: state
)
end
rescue StandardError => e
raise Knockapi::Errors::ConversionError.new(
on: self.class,
method: __method__,
target: target,
value: value,
cause: e
)
end
end
rescue StandardError => e
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__.inspect}].",
"Cause: #{e.message}"
].join(" ")
raise Knockapi::Errors::ConversionError.new(message)
end
# rubocop:enable Metrics/BlockLength
# rubocop:enable Style/CaseEquality
end

# @api private
Expand Down Expand Up @@ -207,37 +238,44 @@ class << self
#
# @param state [Hash{Symbol=>Object}] .
#
# @option state [Boolean, :strong] :strictness
# @option state [Boolean] :translate_names
#
# @option state [Boolean] :strictness
#
# @option state [Hash{Symbol=>Object}] :exactness
#
# @option state [Class<StandardError>] :error
#
# @option state [Integer] :branched
#
# @return [self, Object]
def coerce(value, state:)
exactness = state.fetch(:exactness)

if value.is_a?(self.class)
if value.is_a?(self)
exactness[:yes] += 1
return value
end

unless (val = Knockapi::Internal::Util.coerce_hash(value)).is_a?(Hash)
exactness[:no] += 1
state[:error] = TypeError.new("#{value.class} can't be coerced into #{Hash}")
return value
end
exactness[:yes] += 1

keys = val.keys.to_set
instance = new
data = instance.to_h
status = instance.instance_variable_get(:@coerced)

# rubocop:disable Metrics/BlockLength
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)
src_name = state.fetch(:translate_names) ? api_name : name

unless val.key?(api_name)
unless val.key?(src_name)
if required && mode != :dump && const == Knockapi::Internal::OMIT
exactness[nilable ? :maybe : :no] += 1
else
Expand All @@ -246,9 +284,10 @@ def coerce(value, state:)
next
end

item = val.fetch(api_name)
keys.delete(api_name)
item = val.fetch(src_name)
keys.delete(src_name)

state[:error] = nil
converted =
if item.nil? && (nilable || !required)
exactness[nilable ? :yes : :maybe] += 1
Expand All @@ -262,6 +301,8 @@ def coerce(value, state:)
item
end
end

status.store(name, state.fetch(:error) || true)
data.store(name, converted)
end
# rubocop:enable Metrics/BlockLength
Expand Down Expand Up @@ -437,7 +478,18 @@ def to_yaml(*a) = Knockapi::Internal::Type::Converter.dump(self.class, self).to_
# Create a new instance of a model.
#
# @param data [Hash{Symbol=>Object}, self]
def initialize(data = {}) = (@data = Knockapi::Internal::Util.coerce_hash!(data).to_h)
def initialize(data = {})
@data = {}
@coerced = {}
Knockapi::Internal::Util.coerce_hash!(data).each do
if self.class.known_fields.key?(_1)
public_send(:"#{_1}=", _2)
else
@data.store(_1, _2)
@coerced.store(_1, false)
end
end
end

class << self
# @api private
Expand Down
8 changes: 7 additions & 1 deletion lib/knockapi/internal/type/boolean.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ def self.==(other) = other.is_a?(Class) && other <= Knockapi::Internal::Type::Bo
class << self
# @api private
#
# Coerce value to Boolean if possible, otherwise return the original value.
#
# @param value [Boolean, Object]
#
# @param state [Hash{Symbol=>Object}] .
#
# @option state [Boolean, :strong] :strictness
# @option state [Boolean] :translate_names
#
# @option state [Boolean] :strictness
#
# @option state [Hash{Symbol=>Object}] :exactness
#
# @option state [Class<StandardError>] :error
#
# @option state [Integer] :branched
#
# @return [Boolean, Object]
Expand Down
Loading