Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove Her dependency #142

Open
wants to merge 45 commits into
base: master
Choose a base branch
from
Open

Remove Her dependency #142

wants to merge 45 commits into from

Conversation

rzane
Copy link
Contributor

@rzane rzane commented Jan 22, 2025

Summary

This PR removes vendored gems. We do this by replacing all usages of Her with Faraday.

This PR does contain breaking changes:

  • use_api is no longer available. If you need to customize the HTTP connection, you can assign TestTrack::Client.connection directly. Consider using TestTrack::Client.build_connection.
  • Her provides a bunch of methods for sending HTTP requests that are not implemented. This PR only implements HTTP requests that are necessary for test_track_rails_client to function. So, if consumers of this gem were invoking methods on TestTrack::Remote::* models directly, compatibility is not guaranteed.

None of these functionalities were documented, so the risk is pretty low.

Other Information

The test suite for this gem makes extensive use of stubbing. It's hard to be completely confident that this change represents parity.

/domain @Betterment/test_track_core
/platform @Betterment/test_track_core

@rzane rzane marked this pull request as ready for review January 24, 2025 16:46
@rzane rzane requested a review from smudge January 24, 2025 16:47
@rzane rzane requested review from a team and removed request for smudge January 24, 2025 16:54
Comment on lines +10 to +18
def save
return false unless valid?

persist!
true
rescue Faraday::UnprocessableEntityError
errors.add(:base, 'The HTTP request failed with a 422 status code')
false
end
Copy link
Contributor Author

@rzane rzane Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: The 422 response contains the errors that occurred, and Her would normally assign that to the record. I don't think we're relying on that functionality, so I did the bare minimum here.

Comment on lines +12 to +15
def initialize(...)
super
clear_changes_information
end
Copy link
Contributor Author

@rzane rzane Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resource uses ActiveModel::Dirty to implement variant_changed?. It'd probably be better to not rely on AM::Dirty, but I wanted to maintain the current behavior as much as possible.

end

def save!
save or raise(ActiveModel::ValidationError, self)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rare or spotted in the wild! but is it worth using it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, this is one of those rare cases where you're supposed to use or. I restrict my usage to or raise and or return.

def _assign_attribute(name, value)
super
rescue ActiveModel::UnknownAttributeError
# Don't raise when we encounter an unknown attribute.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there was an existing test for this behavior, but also because it makes sense with the way APIs are versioned.

Also, if we don't allow unknown fields, any non-breaking change (e.g. new field is added) to the API will break the client.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do have an API versioning strategy, so the addition of a new field could (and probably should) fall under a new version release. But I can understand not introducing additional strictness as part of this PR.

Comment on lines -6 to -13
def assigned_at
original = super
if original.blank? || !original.respond_to?(:in_time_zone)
nil
else
original.in_time_zone rescue nil # rubocop:disable Style/RescueModifier
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is more or less what :datetime buys us here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, exactly. There's a test that covers this, too.


def request(method:, path:, fake:, body: nil, headers: nil)
# Ensure that fake responses are consistent with real responses
return JSON.parse(JSON.generate(fake)) if fake?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 so this is essentially the fakeable_her replacement API -- just pass in fake: with every request, which will be ignored if we're not in fake mode.

Comment on lines 15 to 17
def visitor=(value)
@visitor = TestTrack::Remote::Visitor.new(value).to_visitor
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the assignment here depends on assign_attributes (below) which is why this method needs to be public (?) even if it's not intended for anything external to be able to assign it.

Also value here is really hash with :id and :assignments -- wondering if there's a code/name change that would help communicate that. (It's basically private though, so it's more that it jumped out me as a public API and I was trying to figure out what uses it.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to visitor_attributes

Comment on lines +10 to +18
def self.destroy_existing(id)
TestTrack::Client.request(
method: :delete,
path: "api/v1/split_configs/#{id}",
fake: nil
)

nil
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What calls this method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def drop_split(name)
TestTrack::Remote::SplitConfig.destroy_existing(name)
splits.except!(name.to_s)
persist_schema!
end

@@ -39,4 +45,8 @@ def self.fake_variant_details
}
]
end

def variant_details=(values)
super(values.map(&:symbolize_keys))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should a symbolize_keys or deep_symbolize_keys be baked into the way that TestTrack::Client.request returns results?

Copy link
Contributor Author

@rzane rzane Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into that. The Faraday::Response::Json middleware does allow you to specify parser_options: { symbolize_names: true }.

This project is been pretty inconsistent with usage of symbol versus string keys. Fakes mostly have symbol keys. Some of our stubs mix string/symbol keys. Splits have string keys (deep). Variant details use symbol keys (shallow).

Using symbolize_names actually caused more problems than it solved, for whatever reason.

Comment on lines +25 to +31
def assignment_details=(values)
assignment_details = values.map do |value|
TestTrack::Remote::AssignmentDetail.new(value)
end

super(assignment_details)
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, so just handling has_many mappings on assignment. 👍

weights: { variant.to_s => 100 },
feature_gate: split_name.end_with?('_enabled')
'weights' => { variant.to_s => 100 },
'feature_gate' => split_name.end_with?('_enabled')
}
assignments << { split_name:, variant: variant.to_s, unsynced: false }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still symbols here but needed strings above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this change might not be necessary now that I'm doing JSON.parse(JSON.generate(data)). Let me take a peek.

Copy link
Contributor Author

@rzane rzane Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, this change isn't actually necessary, so I could revert it, but I'd prefer to keep it.

This helper is building stubs. The stubbed return values should be consistent with the real return values.

  • TestTrack::Remote::Visitor.fake_instance_attributes returns a hash with deeply symbolized keys.
  • TestTrack::Remote::SplitRegistry.fake_instance_attributes returns a hash with deeply stringified keys.

Our usages of string vs symbol keys are really inconsistent.

$ spec/dummy/bin/rails c      
Loading development environment (Rails 8.0.1)
[1] pry(main)> TestTrack::Remote::Visitor.fake_instance_attributes(nil)
=> {:id=>"fake_visitor_id", :assignments=>[{:split_name=>"split_1", :variant=>"true", :context=>"context", :unsynced=>false}, {:split_name=>"split_2", :variant=>"true", :context=>"context", :unsynced=>false}]}
[2] pry(main)> TestTrack::Remote::SplitRegistry.fake_instance_attributes(nil)
=> {"splits"=>{"banner_color"=>{"weights"=>{"blue"=>100, "red"=>0, "white"=>0}, "feature_gate"=>false}, "buy_one_get_one_promotion_enabled"=>{"weights"=>{"false"=>100, "true"=>0}, "feature_gate"=>true}, "decided_split"=>{"weights"=>{"treatment"=>100, "control"=>0}, "feature_gate"=>false}}, "experience_sampling_weight"=>1}

def build_connection(url:, options: {})
Faraday.new(url) do |conn|
conn.use Faraday::Request::Json
conn.use Faraday::Response::Json, content_type: []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remind me what content_type: [] does here -- it makes it so the server doesn't strictly need to return with a JSON content type for the response to be cast to JSON?

Copy link
Contributor Author

@rzane rzane Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right content_type: [] means "just assume it's JSON, even if the Content-Type header doesn't specify". It's more consistent with the old behavior from FaradayMiddleware::ParseJson.

I realize that the test track server is always going to specify the correct content type. The problem is that our tests using stub_request won't, and there's a lot of them.

attr_writer :connection

def fake?
!TestTrack.enabled?
Copy link
Member

@smudge smudge Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior definition of faked? was:

      def faked?
        !ENV["#{service_name.to_s.upcase}_ENABLED"] && (Rails.env.development? || Rails.env.test?)
      end

Unless we're changing the definition of enabled? somewhere, seems like we'd be changing the definition of fake?/faked? to ignore Rails.env when computing a default, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RemoteModel was overriding the definition from FakeableHer here:

def faked?
!TestTrack.enabled?
end

@rzane rzane requested a review from smudge February 6, 2025 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants