wcc-contentful 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +1 -1
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +3 -0
  5. data/CHANGELOG.md +8 -1
  6. data/Guardfile +23 -1
  7. data/app/controllers/wcc/contentful/application_controller.rb +7 -0
  8. data/app/controllers/wcc/contentful/webhook_controller.rb +30 -0
  9. data/app/jobs/wcc/contentful/delayed_sync_job.rb +14 -0
  10. data/bin/rails +14 -0
  11. data/config/initializers/mime_types.rb +3 -0
  12. data/config/routes.rb +5 -0
  13. data/lib/generators/wcc/menu_generator.rb +4 -4
  14. data/lib/generators/wcc/templates/contentful_shell_wrapper +109 -68
  15. data/lib/generators/wcc/templates/menu/menu.rb +4 -6
  16. data/lib/generators/wcc/templates/menu/menu_button.rb +4 -6
  17. data/lib/generators/wcc/templates/release +2 -2
  18. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -1
  19. data/lib/wcc/contentful/client_ext.rb +1 -1
  20. data/lib/wcc/contentful/configuration.rb +76 -35
  21. data/lib/wcc/contentful/engine.rb +13 -0
  22. data/lib/wcc/contentful/exceptions.rb +6 -0
  23. data/lib/wcc/contentful/graphql/builder.rb +8 -3
  24. data/lib/wcc/contentful/helpers.rb +6 -0
  25. data/lib/wcc/contentful/indexed_representation.rb +31 -0
  26. data/lib/wcc/contentful/model.rb +82 -1
  27. data/lib/wcc/contentful/model_builder.rb +18 -8
  28. data/lib/wcc/contentful/model_validators.rb +69 -18
  29. data/lib/wcc/contentful/simple_client/http_adapter.rb +15 -0
  30. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +30 -0
  31. data/lib/wcc/contentful/simple_client.rb +67 -18
  32. data/lib/wcc/contentful/store/base.rb +89 -0
  33. data/lib/wcc/contentful/store/cdn_adapter.rb +13 -19
  34. data/lib/wcc/contentful/store/lazy_cache_store.rb +76 -0
  35. data/lib/wcc/contentful/store/memory_store.rb +17 -23
  36. data/lib/wcc/contentful/store/postgres_store.rb +32 -19
  37. data/lib/wcc/contentful/store.rb +62 -0
  38. data/lib/wcc/contentful/version.rb +1 -1
  39. data/lib/wcc/contentful.rb +113 -24
  40. data/wcc-contentful.gemspec +4 -0
  41. metadata +75 -2
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'http'
4
-
5
3
  class WCC::Contentful::Configuration
6
4
  ATTRIBUTES = %i[
7
5
  access_token
@@ -9,47 +7,79 @@ class WCC::Contentful::Configuration
9
7
  space
10
8
  default_locale
11
9
  content_delivery
12
- override_get_http
10
+ http_adapter
11
+ sync_cache_store
12
+ webhook_username
13
+ webhook_password
13
14
  ].freeze
14
15
  attr_accessor(*ATTRIBUTES)
15
16
 
16
- CDN_METHODS = [
17
- :eager_sync,
18
- # TODO: :lazy_sync
19
- :direct
20
- ].freeze
17
+ ##
18
+ # Defines the method by which content is downloaded from the Contentful CDN.
19
+ #
20
+ # [:direct] `config.content_delivery = :direct`
21
+ # with the `:direct` method, all queries result in web requests to
22
+ # 'https://cdn.contentful.com' via the
23
+ # {SimpleClient}[rdoc-ref:WCC::Contentful::SimpleClient::Cdn]
24
+ #
25
+ # [:eager_sync] `config.content_delivery = :eager_sync, [sync_store], [options]`
26
+ # with the `:eager_sync` method, the entire content of the Contentful
27
+ # space is downloaded locally and stored in the
28
+ # {Sync Store}[rdoc-ref:WCC::Contentful.store]. The application is responsible
29
+ # to periodically call `WCC::Contentful.sync!` to keep the store updated.
30
+ # Alternatively, the provided {Engine}[WCC::Contentful::Engine]
31
+ # can be mounted to receive a webhook from the Contentful space
32
+ # on publish events:
33
+ # mount WCC::Contentful::Engine, at: '/wcc/contentful'
34
+ #
35
+ # [:lazy_sync] `config.content_delivery = :lazy_sync, [cache]`
36
+ # The `:lazy_sync` method is a hybrid between the other two methods.
37
+ # Frequently accessed data is stored in an ActiveSupport::Cache implementation
38
+ # and is kept up-to-date via the Sync API. Any data that is not present
39
+ # in the cache is fetched from the CDN like in the `:direct` method.
40
+ # The application is still responsible to periodically call `sync!`
41
+ # or to mount the provided Engine.
42
+ #
43
+ def content_delivery=(params)
44
+ cd, *cd_params = params
45
+ unless cd.is_a? Symbol
46
+ raise ArgumentError, 'content_delivery must be a symbol, use store= to '\
47
+ 'directly set contentful CDN access adapter'
48
+ end
21
49
 
22
- SYNC_STORES = {
23
- memory: ->(_config) { WCC::Contentful::Store::MemoryStore.new },
24
- postgres: ->(_config) {
25
- require_relative 'store/postgres_store'
26
- WCC::Contentful::Store::PostgresStore.new(ENV['POSTGRES_CONNECTION'])
27
- }
28
- }.freeze
50
+ WCC::Contentful::Store::Factory.new(
51
+ self,
52
+ cd,
53
+ cd_params
54
+ ).validate!
29
55
 
30
- def content_delivery=(symbol)
31
- raise ArgumentError, "Please set one of #{CDN_METHODS}" unless CDN_METHODS.include?(symbol)
32
- @content_delivery = symbol
56
+ @content_delivery = cd
57
+ @content_delivery_params = cd_params
33
58
  end
34
59
 
35
- def sync_store=(symbol)
36
- if symbol.is_a? Symbol
37
- unless SYNC_STORES.keys.include?(symbol)
38
- raise ArgumentError, "Please use one of #{SYNC_STORES.keys}"
39
- end
40
- end
41
- @sync_store = symbol
60
+ ##
61
+ # Initializes the configured Sync Store.
62
+ def store
63
+ @store ||= WCC::Contentful::Store::Factory.new(
64
+ self,
65
+ @content_delivery,
66
+ @content_delivery_params
67
+ ).build_sync_store
42
68
  end
43
69
 
44
- def sync_store
45
- @sync_store = SYNC_STORES[@sync_store].call(self) if @sync_store.is_a? Symbol
46
- @sync_store ||= Store::MemoryStore.new
70
+ ##
71
+ # Directly sets the adapter layer for communicating with Contentful
72
+ def store=(value)
73
+ @content_delivery = :custom
74
+ @store = value
47
75
  end
48
76
 
49
- # A proc which overrides the "get_http" function in Contentful::Client.
50
- # All interaction with Contentful will go through this function.
51
- # Should be a lambda like: ->(url, query, headers = {}, proxy = {}) { ... }
52
- attr_writer :override_get_http
77
+ # Sets the adapter which is used to make HTTP requests.
78
+ # If left unset, the gem attempts to load either 'http' or 'typhoeus'.
79
+ # You can pass your own adapter which responds to 'call', or even a lambda
80
+ # that accepts the following parameters:
81
+ # ->(url, query, headers = {}, proxy = {}) { ... }
82
+ attr_writer :http_adapter
53
83
 
54
84
  def initialize
55
85
  @access_token = ''
@@ -57,12 +87,21 @@ class WCC::Contentful::Configuration
57
87
  @space = ''
58
88
  @default_locale = nil
59
89
  @content_delivery = :direct
60
- @sync_store = :memory
61
90
  end
62
91
 
92
+ ##
93
+ # Gets a {CDN Client}[rdoc-ref:WCC::Contentful::SimpleClient::Cdn] which provides
94
+ # methods for getting and paging raw JSON data from the Contentful CDN.
63
95
  attr_reader :client
64
96
  attr_reader :management_client
65
97
 
98
+ ##
99
+ # Called by WCC::Contentful.init! to configure the
100
+ # Contentful clients. This method can be called independently of `init!` if
101
+ # the application would prefer not to generate all the models.
102
+ #
103
+ # If the {contentful.rb}[https://github.com/contentful/contentful.rb] gem is
104
+ # loaded, it is extended to make use of the `http_adapter` lambda.
66
105
  def configure_contentful
67
106
  @client = nil
68
107
  @management_client = nil
@@ -81,13 +120,15 @@ class WCC::Contentful::Configuration
81
120
  @client = WCC::Contentful::SimpleClient::Cdn.new(
82
121
  access_token: access_token,
83
122
  space: space,
84
- default_locale: default_locale
123
+ default_locale: default_locale,
124
+ adapter: http_adapter
85
125
  )
86
126
  return unless management_token.present?
87
127
  @management_client = WCC::Contentful::SimpleClient::Management.new(
88
128
  management_token: management_token,
89
129
  space: space,
90
- default_locale: default_locale
130
+ default_locale: default_locale,
131
+ adapter: http_adapter
91
132
  )
92
133
  end
93
134
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wcc/rails'
4
+
5
+ module WCC::Contentful
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace WCC::Contentful
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec, fixture: false
11
+ end
12
+ end
13
+ end
@@ -31,4 +31,10 @@ module WCC::Contentful
31
31
  ret.flatten(1)
32
32
  end
33
33
  end
34
+
35
+ class SyncError < StandardError
36
+ end
37
+
38
+ class ContentTypeNotFoundError < NameError
39
+ end
34
40
  end
@@ -46,7 +46,7 @@ module WCC::Contentful::Graphql
46
46
 
47
47
  resolve ->(_obj, args, _ctx) {
48
48
  if args['id'].nil?
49
- store.find_by(content_type: content_type).first
49
+ store.find_by(content_type: content_type)
50
50
  else
51
51
  store.find(args['id'])
52
52
  end
@@ -58,8 +58,13 @@ module WCC::Contentful::Graphql
58
58
  argument :filter, Types::FilterType
59
59
 
60
60
  resolve ->(_obj, args, ctx) {
61
- relation = store.find_by(content_type: content_type)
62
- relation = relation.apply(args[:filter], ctx) if args[:filter]
61
+ relation = store.find_all(content_type: content_type)
62
+ # TODO: improve this POC
63
+ if args[:filter]
64
+ filter = {}
65
+ filter[args[:filter]['field']] = { eq: args[:filter][:eq] }
66
+ relation = relation.apply(filter, ctx)
67
+ end
63
68
  relation.result
64
69
  }
65
70
  end
@@ -25,4 +25,10 @@ module WCC::Contentful::Helpers
25
25
  l
26
26
  end
27
27
  end
28
+
29
+ def content_type_from_constant(const)
30
+ return const.content_type if const.respond_to?(:content_type)
31
+ name = const.try(:name) || const.to_s
32
+ name.demodulize.camelize(:lower)
33
+ end
28
34
  end
@@ -32,6 +32,16 @@ module WCC::Contentful
32
32
  @types.to_json
33
33
  end
34
34
 
35
+ def deep_dup
36
+ self.class.new(@types.deep_dup)
37
+ end
38
+
39
+ def ==(other)
40
+ my_keys = keys
41
+ return false unless my_keys == other.keys
42
+ my_keys.all? { |k| self[k] == other[k] }
43
+ end
44
+
35
45
  class ContentType
36
46
  ATTRIBUTES = %i[
37
47
  name
@@ -57,6 +67,18 @@ module WCC::Contentful
57
67
 
58
68
  hash_or_id.each { |k, v| public_send("#{k}=", v) }
59
69
  end
70
+
71
+ def deep_dup
72
+ dup_hash =
73
+ ATTRIBUTES.each_with_object({}) do |att, h|
74
+ h[att] = public_send(att)
75
+ end
76
+ self.class.new(dup_hash)
77
+ end
78
+
79
+ def ==(other)
80
+ ATTRIBUTES.all? { |att| public_send(att) == other.public_send(att) }
81
+ end
60
82
  end
61
83
 
62
84
  class Field
@@ -96,6 +118,11 @@ module WCC::Contentful
96
118
  return
97
119
  end
98
120
 
121
+ unless hash_or_id.is_a?(Hash)
122
+ ATTRIBUTES.each { |att| public_send("#{att}=", hash_or_id.public_send(att)) }
123
+ return
124
+ end
125
+
99
126
  if raw_type = hash_or_id.delete('type')
100
127
  raw_type = raw_type.to_sym
101
128
  unless TYPES.include?(raw_type)
@@ -106,6 +133,10 @@ module WCC::Contentful
106
133
 
107
134
  hash_or_id.each { |k, v| public_send("#{k}=", v) }
108
135
  end
136
+
137
+ def ==(other)
138
+ ATTRIBUTES.all? { |att| public_send(att) == other.public_send(att) }
139
+ end
109
140
  end
110
141
  end
111
142
  end
@@ -5,20 +5,101 @@ class WCC::Contentful::Model
5
5
  extend WCC::Contentful::Helpers
6
6
  extend WCC::Contentful::ModelValidators
7
7
 
8
+ # The Model base class maintains a registry which is best expressed as a
9
+ # class var.
10
+ # rubocop:disable Style/ClassVars
11
+
8
12
  class << self
13
+ ##
14
+ # The configured store which executes all model queries against either the
15
+ # Contentful CDN or a locally-downloaded copy.
16
+ #
17
+ # See the {sync_store}[rdoc-ref:WCC::Contentful::Configuration.sync_store] parameter
18
+ # on the WCC::Contentful::Configuration class.
9
19
  attr_accessor :store
20
+
21
+ def const_missing(name)
22
+ raise WCC::Contentful::ContentTypeNotFoundError,
23
+ "Content type '#{content_type_from_constant(name)}' does not exist in the space"
24
+ end
10
25
  end
11
26
 
27
+ @@registry = {}
28
+
12
29
  def self.all_models
30
+ # TODO: this needs to use the registry but it's OK for now cause we only
31
+ # use it in specs
13
32
  WCC::Contentful::Model.constants(false).map { |k| WCC::Contentful::Model.const_get(k) }
14
33
  end
15
34
 
35
+ ##
36
+ # Finds an Entry or Asset by ID in the configured contentful space
37
+ # and returns an initialized instance of the appropriate model type.
38
+ #
39
+ # Makes use of the configured {store}[rdoc-ref:WCC::Contentful::Model.store]
40
+ # to access the Contentful CDN.
16
41
  def self.find(id, context = nil)
17
42
  return unless raw = store.find(id)
18
43
 
19
44
  content_type = content_type_from_raw(raw)
20
45
 
21
- const = WCC::Contentful::Model.const_get(constant_from_content_type(content_type))
46
+ unless const = @@registry[content_type]
47
+ begin
48
+ # The app may have defined a model and we haven't loaded it yet
49
+ const = Object.const_missing(constant_from_content_type(content_type).to_s)
50
+ rescue NameError
51
+ nil
52
+ end
53
+ end
54
+ unless const
55
+ # Autoloading couldn't find their model - we'll register our own.
56
+ const = WCC::Contentful::Model.const_get(constant_from_content_type(content_type))
57
+ register_for_content_type(content_type, klass: const)
58
+ end
59
+
22
60
  const.new(raw, context)
23
61
  end
62
+
63
+ ##
64
+ # Registers a class constant to be instantiated when resolving an instance
65
+ # of the given content type. This automatically happens for the first subclass
66
+ # of a generated model type, example:
67
+ #
68
+ # class MyMenu < WCC::Contentful::Model::Menu
69
+ # end
70
+ #
71
+ # In the above case, instances of MyMenu will be instantiated whenever a 'menu'
72
+ # content type is resolved.
73
+ # The mapping can be made explicit with the optional parameters. Example:
74
+ #
75
+ # class MyFoo < WCC::Contentful::Model::Foo
76
+ # register_for_content_type 'bar' # MyFoo is assumed
77
+ # end
78
+ #
79
+ # # in initializers/wcc_contentful.rb
80
+ # WCC::Contentful::Model.register_for_content_type('bar', klass: MyFoo)
81
+ def self.register_for_content_type(content_type = nil, klass: nil)
82
+ klass ||= self
83
+ raise ArgumentError, "#{klass} must be a class constant!" unless klass.respond_to?(:new)
84
+ content_type ||= content_type_from_constant(klass)
85
+
86
+ @@registry[content_type] = klass
87
+ end
88
+
89
+ ##
90
+ # Returns the current registry of content type names to constants.
91
+ def self.registry
92
+ return {} unless @@registry
93
+ @@registry.dup.freeze
94
+ end
95
+
96
+ ##
97
+ # Checks if a content type has already been registered to a class and returns
98
+ # that class. If nil, the generated WCC::Contentful::Model::{content_type} class
99
+ # will be resolved for this content type.
100
+ def self.registered?(content_type)
101
+ @@registry[content_type]
102
+ end
24
103
  end
104
+
105
+ # rubocop:enable Style/ClassVars
@@ -40,9 +40,16 @@ module WCC::Contentful
40
40
  new(raw, context) if raw.present?
41
41
  end
42
42
 
43
- define_singleton_method(:find_all) do |context = nil|
44
- raw = WCC::Contentful::Model.store.find_by(content_type: content_type)
45
- raw.map { |r| new(r, context) }
43
+ define_singleton_method(:find_all) do |filter = nil, context = nil|
44
+ if filter
45
+ filter.transform_keys! { |k| k.to_s.camelize(:lower) }
46
+ bad_fields = filter.keys.reject { |k| fields.include?(k) }
47
+ raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
48
+ end
49
+
50
+ query = WCC::Contentful::Model.store.find_all(content_type: content_type)
51
+ query = query.apply(filter) if filter
52
+ query.map { |r| new(r, context) }
46
53
  end
47
54
 
48
55
  define_singleton_method(:find_by) do |filter, context = nil|
@@ -50,11 +57,14 @@ module WCC::Contentful
50
57
  bad_fields = filter.keys.reject { |k| fields.include?(k) }
51
58
  raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
52
59
 
53
- query = WCC::Contentful::Model.store.find_by(content_type: content_type)
54
- filter.each do |field, v|
55
- query = query.eq(field, v, context)
56
- end
57
- query.map { |r| new(r, context) }
60
+ result = WCC::Contentful::Model.store.find_by(content_type: content_type, filter: filter)
61
+ new(result, context)
62
+ end
63
+
64
+ define_singleton_method(:inherited) do |subclass|
65
+ # only register if it's not already registered
66
+ return if WCC::Contentful::Model.registered?(typedef.content_type)
67
+ WCC::Contentful::Model.register_for_content_type(typedef.content_type, klass: subclass)
58
68
  end
59
69
 
60
70
  define_method(:initialize) do |raw, context = nil|
@@ -6,43 +6,94 @@ require_relative 'model_validators/dsl'
6
6
 
7
7
  module WCC::Contentful::ModelValidators
8
8
  def schema
9
- return if @field_validations.nil? || @field_validations.empty?
10
- field_validations = @field_validations
9
+ return if validations.nil? || validations.empty?
11
10
 
12
- # "page": {
13
- # "sys": { ... }
14
- # "fields": {
15
- # "title": { ... },
16
- # "sections": { ... },
17
- # ...
18
- # }
19
- # }
11
+ all_field_validations =
12
+ validations.each_with_object({}) do |(content_type, procs), h|
13
+ next if procs.empty?
20
14
 
21
- fields_schema =
22
- Dry::Validation.Schema do
23
- # Had to dig through the internals of Dry::Validation to find
24
- # this magic incantation
25
- field_validations.each { |dsl| instance_eval(&dsl.to_proc) }
15
+ # "page": {
16
+ # "sys": { ... }
17
+ # "fields": {
18
+ # "title": { ... },
19
+ # "sections": { ... },
20
+ # ...
21
+ # }
22
+ # }
23
+ h[content_type] =
24
+ Dry::Validation.Schema do
25
+ # Had to dig through the internals of Dry::Validation to find
26
+ # this magic incantation
27
+ procs.each { |dsl| instance_eval(&dsl.to_proc) }
28
+ end
26
29
  end
27
30
 
28
31
  Dry::Validation.Schema do
29
- required('fields').schema(fields_schema)
32
+ all_field_validations.each do |content_type, fields_schema|
33
+ required(content_type).schema do
34
+ required('fields').schema(fields_schema)
35
+ end
36
+ end
30
37
  end
31
38
  end
32
39
 
40
+ def validations
41
+ # This needs to be a class variable so that subclasses defined in application
42
+ # code can add to the total package of model validations
43
+ # rubocop:disable Style/ClassVars
44
+ @@validations ||= {}
45
+ # rubocop:enable Style/ClassVars
46
+ end
47
+
48
+ ##
49
+ # Accepts a block which uses the {dry-validation DSL}[http://dry-rb.org/gems/dry-validation/]
50
+ # to validate the 'fields' object of a content type.
33
51
  def validate_fields(&block)
34
52
  raise ArgumentError, 'validate_fields requires a block' unless block_given?
35
53
  dsl = ProcDsl.new(Proc.new(&block))
36
54
 
37
- (@field_validations ||= []) << dsl
55
+ ct = try(:content_type) || name.demodulize.camelize(:lower)
56
+ (validations[ct] ||= []) << dsl
38
57
  end
39
58
 
59
+ ##
60
+ # Validates a single field is of the expected type.
61
+ # Type expectations are one of:
62
+ #
63
+ # [:String] the field type must be `Symbol` or `Text`
64
+ # [:Int] the field type must be `Integer`
65
+ # [:Float] the field type must be `Number`
66
+ # [:DateTime] the field type must be 'Date'
67
+ # [:Asset] the field must be a link and the `linkType` must be `Asset`
68
+ # [:Link] the field must be a link and the `linkType` must be `Entry`.
69
+ # [:Location] the field type must be `Location`
70
+ # [:Boolean] the field type must be `Boolean`
71
+ # [:Json] the field type must be `Json` - a json blob.
72
+ # [:Array] the field must be a List.
73
+ #
74
+ # Additional validation options can be enforced:
75
+ #
76
+ # [:required] the 'Required Field' checkbox must be checked
77
+ # [:optional] the 'Required Field' checkbox must not be checked
78
+ # [:link_to] (only `:Link` or `:Array` type) the given content type(s) must be
79
+ # checked in the 'Accept only specified entry type' validations
80
+ # Example:
81
+ # validate_field :button, :Link, link_to: ['button', 'altButton']
82
+ #
83
+ # [:items] (only `:Array` type) the items of the list must be of the given type.
84
+ # Example:
85
+ # validate_field :my_strings, :Array, items: :String
86
+ #
87
+ # Examples:
88
+ # see WCC::Contentful::Model::Menu and WCC::Contentful::Model::MenuButton
40
89
  def validate_field(field, type, *options)
41
90
  dsl = FieldDsl.new(field, type, options)
42
91
 
43
- (@field_validations ||= []) << dsl
92
+ ct = try(:content_type) || name.demodulize.camelize(:lower)
93
+ (validations[ct] ||= []) << dsl
44
94
  end
45
95
 
96
+ ##
46
97
  # Accepts a content types response from the API and transforms it
47
98
  # to be acceptible for the validator.
48
99
  def self.transform_content_types_for_validation(content_types)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem 'http'
4
+ require 'http'
5
+
6
+ class HttpAdapter
7
+ def call(url, query, headers = {}, proxy = {})
8
+ if proxy[:host]
9
+ HTTP[headers].via(proxy[:host], proxy[:port], proxy[:username], proxy[:password])
10
+ .get(url, params: query)
11
+ else
12
+ HTTP[headers].get(url, params: query)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem 'typhoeus'
4
+ require 'typhoeus'
5
+
6
+ class TyphoeusAdapter
7
+ def call(url, query, headers = {}, proxy = {})
8
+ raise NotImplementedError, 'Proxying Not Yet Implemented' if proxy[:host]
9
+
10
+ TyphoeusAdapter::Response.new(
11
+ Typhoeus.get(
12
+ url,
13
+ params: query,
14
+ headers: headers
15
+ )
16
+ )
17
+ end
18
+
19
+ Response =
20
+ Struct.new(:raw) do
21
+ delegate :body, to: :raw
22
+ delegate :to_s, to: :body
23
+ delegate :code, to: :raw
24
+ delegate :headers, to: :raw
25
+
26
+ def status
27
+ raw.code
28
+ end
29
+ end
30
+ end