Skip to content

Remove manual steps to configure WebMock (and VCR)#251

Merged
3v0k4 merged 1 commit intomasterfrom
webmock
May 9, 2024
Merged

Remove manual steps to configure WebMock (and VCR)#251
3v0k4 merged 1 commit intomasterfrom
webmock

Conversation

@3v0k4
Copy link
Copy Markdown
Contributor

@3v0k4 3v0k4 commented May 7, 2024

Story

Ticket https://trello.com/c/nInb3gxY/359-knapsackpro-gem-dx-remove-manual-steps-for-webmock-vcr

Currently, setting up Knapsack Pro with WebMock/VCR requires additional steps because WebMock replaces Net::HTTP with its own client that (also) blocks requests to the Knapsack Pro API.

Some codebases configure WM in a way that doesn't play well with Knapsack Pro, even when the manual configuration steps are followed.

For example, each call to WebMock.disable_net_connect! without passing allow: ['api.knapsackpro.com'] overrides the previous WebMock.disable_net_connect!(allow: ['api.knapsackpro.com']) call preventing requests to api.knapsackpro.com.

Users could have multiple calls to WebMock that "reset" the configuration including in before/after hooks. So it's difficult to solve the issue by using hooks to undo the reset.

(Aside: WebMock.reset! does not touch WebMock::Config.instance.allow so that does not pose a problem, only disable_net_connection! does.)

This PR, introduces a mechanism to always call the original Net::HTTP for requests to the Knapsack Pro API preventing WM from interfering.

In other words, users won't have to follow any additional steps to configure Knapsack Pro and WebMock/VCR. This will also eliminate all related support requests and people getting stuck.

What follows are the internal details on why this PR works. It may be overwhelming to understand it, so feel free to ignore. But I'm leaving notes for reference.

Test

I created a webmock branch (same name as the branch for this PR) in rails-app-with-knapsack-pro with:

diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb
index 72e2dbd..89dce79 100644
--- a/spec/support/vcr.rb
+++ b/spec/support/vcr.rb
@@ -2,26 +2,9 @@ require 'vcr'
 
 VCR.configure do |config|
   config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
-  config.hook_into :webmock # or :fakeweb
-
-  config.ignore_hosts(
-    'localhost',
-    '127.0.0.1',
-    '0.0.0.0',
-    'api.knapsackpro.com',
-    'api-fake.knapsackpro.com',
-    'api-staging.knapsackpro.com',
-    'api.knapsackpro.test',
-    'api-disabled-for-fork.knapsackpro.com',
-  )
+  config.hook_into :webmock
+  config.ignore_localhost = true
 end
 
 require 'webmock/rspec'
-
-WebMock.disable_net_connect!(allow: [
-  'api.knapsackpro.com',
-  'api-fake.knapsackpro.com',
-  'api-staging.knapsackpro.com',
-  'api.knapsackpro.test',
-  'api-disabled-for-fork.knapsackpro.com',
-]) if defined?(WebMock)
+WebMock.disable_net_connect!

Since the branch names are the same, CI will use that for the E2E tests of this PR.

Here are:

I also built the API with this PR but without removing the custom WM/VCR config:

gem 'knapsack_pro', github: 'KnapsackPro/knapsack_pro-ruby', branch: 'webmock'

And built without the custom WM/VCR config:

diff --git a/spec/support/config/vcr.rb b/spec/support/config/vcr.rb
index 66b6b75a..9de2e61a 100644
--- a/spec/support/config/vcr.rb
+++ b/spec/support/config/vcr.rb
@@ -2,20 +2,9 @@ require 'vcr'
 
 VCR.configure do |config|
   config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
-  config.hook_into :webmock # or :fakeweb
+  config.hook_into :webmock
   config.allow_http_connections_when_no_cassette = true
-  config.ignore_hosts(
-    'localhost',
-    '127.0.0.1',
-    '0.0.0.0',
-    'api.knapsackpro.com',
-    'api-staging.knapsackpro.com',
-    'api.knapsackpro.dev',
-  )
+  config.ignore_localhost = true
 end
 
-WebMock.disable_net_connect!(allow: [
-  'api.knapsackpro.com',
-  'api-staging.knapsackpro.com',
-  'api.knapsackpro.dev',
-])
+WebMock.disable_net_connect!

Details for WebMock

WebMock enables itself at the beginning of a test run. Here's how it's done for RSpec:

  config.before(:suite) do
    WebMock.enable!
  end

https://github.com/bblimke/webmock/blob/fc6a2ab897a069d861adbc1c88e51b2cf8aa88ac/lib/webmock/rspec.rb#L30-L32

enable! ends up calling WebMock::HttpLibAdapters::NetHttpAdapter.enable!:

      def self.enable!
        Net.send(:remove_const, :HTTP)
        Net.send(:remove_const, :HTTPSession)
        Net.send(:const_set, :HTTP, @webMockNetHTTP)
        Net.send(:const_set, :HTTPSession, @webMockNetHTTP)
      end

https://github.com/bblimke/webmock/blob/fc6a2ab897a069d861adbc1c88e51b2cf8aa88ac/lib/webmock/http_lib_adapters/net_http.rb#L16-L21

which replaces Net::HTTP with @webMockNetHTTP:

@webMockNetHTTP = Class.new(Net::HTTP) do

https://github.com/bblimke/webmock/blob/fc6a2ab897a069d861adbc1c88e51b2cf8aa88ac/lib/webmock/http_lib_adapters/net_http.rb#L40

Notice it also saves the original Net::HTTP in WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP:

OriginalNetHTTP = Net::HTTP unless const_defined?(:OriginalNetHTTP)

https://github.com/bblimke/webmock/blob/fc6a2ab897a069d861adbc1c88e51b2cf8aa88ac/lib/webmock/http_lib_adapters/net_http.rb#L14

Details for WebMock and VCR

When WM is used in tandem with VCR, the situation gets a bit more complicated.

When VCR starts it enables WebMock and monkey patches WM's net_connect_allowed? to:

VCR.turned_on? ? true : net_connect_allowed_without_vcr?(*args)

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/library_hooks/webmock.rb#L160-L171

which means if VCR is on it does not allow WM to block anything, otherwise it delegates to WM because net_connect_allowed_without_vcr? is the original WM’s net_connect_allowed?.

If VCR is off, we are in the WM-only situation explained above.

Otherwise, VCR interacts with WM’s @webMockNetHTTP as follows.

VCR hooks into WM's global stubs:

::WebMock.globally_stub_request do |req|
  global_hook_disabled?(req) ? nil : RequestHandler.new(req).handle
end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/library_hooks/webmock.rb#L134-L136

In turn, WM in @webMockNetHTTP:

        def request(request, body = nil, &block)
          # ...
          if webmock_response = WebMock::StubRegistry.instance.response_for_request(request_signature)
            # use stub
          elsif WebMock.net_connect_allowed?(request_signature.uri)
            # perform request
          else
            raise WebMock::NetConnectNotAllowedError.new(request_signature)
          end
        end

https://github.com/bblimke/webmock/blob/fc6a2ab897a069d861adbc1c88e51b2cf8aa88ac/lib/webmock/http_lib_adapters/net_http.rb#L71-L108

So request invokes WebMock::StubRegistry.instance.response_for_request(request_signature), which ends up calling VCR's RequestHandler.new(req).handle.

WebMock::StubRegistry.instance.response_for_request(request_signature) may return a stubbed response when:

  • WebMock stubbed the request
  • VCR is using a cassette

or it may return a fals-y value (nil) and let WM perform the request. Remember that net_connect_allowed? is always true when using VCR.

Here's how handle does it:

    def handle
      # ...
      req_type = request_type(:consume_stub)
      # ...
      send "on_#{req_type}_request"
    end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/request_handler.rb#L6-L25

Where

    def request_type(consume_stub = false)
      case
        when externally_stubbed?                then :externally_stubbed # if stubbed by WM, let WM return the stubbed response
        when should_ignore?                     then :ignored # if ignored by VCR, let WM perform the request and record the cassette
        when has_response_stub?(consume_stub)   then :stubbed_by_vcr # let WM use the VCR cassette
        when VCR.real_http_connections_allowed? then :recordable # let WM perform the request and record the cassette
        else                                         :unhandled # raise
      end
    end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/request_handler.rb#L33-L41

        def externally_stubbed? # WM stubbed the request?
          # prevent infinite recursion...
          VCR::LibraryHooks::WebMock.with_global_hook_disabled(request) do
            ::WebMock.registered_request?(request)
          end
        end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/library_hooks/webmock.rb#L97-L102

    def should_ignore?
      disabled? || VCR.request_ignorer.ignore?(vcr_request)
    end

    def disabled?
      VCR.library_hooks.disabled?(library_name) # always false when using WM
    end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/request_handler.rb#L58-L64

        def on_externally_stubbed_request
          # nil allows WebMock to handle the request
          nil
        end

        def on_unhandled_request
          invoke_after_request_hook(nil)
          super # raise unhandled
        end

        def on_stubbed_by_vcr_request
          {
            :body    => stubbed_response.body.dup,
            :status  => [stubbed_response.status.code.to_i, stubbed_response.status.message],
            :headers => stubbed_response.headers
          }
        end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/library_hooks/webmock.rb#L113-L129

    def on_ignored_request
      # nil allows WebMock to handle the request
      nil
    end

    def on_recordable_request
      # nil allows WebMock to handle the request
      nil
    end

https://github.com/vcr/vcr/blob/cde89346aebcbe5280299ba10aedb3d97860c557/lib/vcr/request_handler.rb#L87-L94

Checklist reminder

  • You follow the architecture outlined below for RSpec in Queue Mode, which is a work in progress (feel free to propose changes):
    • Pure: lib/knapsack_pro/pure/queue/rspec_pure.rb contains pure functions that are unit tested.
    • Extension: lib/knapsack_pro/extensions/rspec_extension.rb encapsulates calls to RSpec internals and is integration and e2e tested.
    • Runner: lib/knapsack_pro/runners/queue/rspec_runner.rb invokes the pure code and the extension to produce side effects, which are integration and e2e tested.

@3v0k4 3v0k4 self-assigned this May 7, 2024
@3v0k4 3v0k4 force-pushed the webmock branch 2 times, most recently from 327dd45 to a1429fc Compare May 8, 2024 13:16
@3v0k4 3v0k4 marked this pull request as ready for review May 8, 2024 13:35
@3v0k4 3v0k4 force-pushed the webmock branch 4 times, most recently from 456a71a to c421642 Compare May 8, 2024 13:49
Copy link
Copy Markdown
Member

@ArturT ArturT left a comment

Choose a reason for hiding this comment

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

Great work and awesome PR description with testing CI builds.

Comment thread CHANGELOG.md
Comment thread lib/knapsack_pro/client/connection.rb Outdated
@3v0k4 3v0k4 merged commit 36e9e27 into master May 9, 2024
@3v0k4 3v0k4 deleted the webmock branch May 9, 2024 10:52
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.

3 participants