wcc-contentful 0.4.0.pre.alpha → 1.0.0.pre.rc3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +5 -5
  2. data/Guardfile +43 -0
  3. data/README.md +246 -11
  4. data/Rakefile +5 -0
  5. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  6. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  7. data/config/routes.rb +1 -1
  8. data/doc +1 -0
  9. data/lib/tasks/download_schema.rake +12 -0
  10. data/lib/wcc/contentful.rb +70 -16
  11. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  12. data/lib/wcc/contentful/configuration.rb +177 -46
  13. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  14. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  15. data/lib/wcc/contentful/engine.rb +33 -14
  16. data/lib/wcc/contentful/event.rb +171 -0
  17. data/lib/wcc/contentful/events.rb +41 -0
  18. data/lib/wcc/contentful/exceptions.rb +3 -0
  19. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  20. data/lib/wcc/contentful/instrumentation.rb +31 -0
  21. data/lib/wcc/contentful/link.rb +28 -0
  22. data/lib/wcc/contentful/link_visitor.rb +122 -0
  23. data/lib/wcc/contentful/middleware.rb +7 -0
  24. data/lib/wcc/contentful/middleware/store.rb +158 -0
  25. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  26. data/lib/wcc/contentful/model.rb +37 -3
  27. data/lib/wcc/contentful/model_builder.rb +1 -0
  28. data/lib/wcc/contentful/model_methods.rb +40 -15
  29. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  30. data/lib/wcc/contentful/rake.rb +4 -0
  31. data/lib/wcc/contentful/rspec.rb +46 -0
  32. data/lib/wcc/contentful/services.rb +61 -27
  33. data/lib/wcc/contentful/simple_client.rb +81 -25
  34. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  35. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  36. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  37. data/lib/wcc/contentful/store.rb +7 -66
  38. data/lib/wcc/contentful/store/README.md +85 -0
  39. data/lib/wcc/contentful/store/base.rb +34 -119
  40. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  41. data/lib/wcc/contentful/store/factory.rb +186 -0
  42. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  43. data/lib/wcc/contentful/store/interface.rb +82 -0
  44. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  45. data/lib/wcc/contentful/store/postgres_store.rb +268 -101
  46. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  47. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  48. data/lib/wcc/contentful/store/query.rb +246 -0
  49. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  50. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  51. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  52. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  53. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  54. data/lib/wcc/contentful/sync_engine.rb +181 -0
  55. data/lib/wcc/contentful/test.rb +7 -0
  56. data/lib/wcc/contentful/test/attributes.rb +56 -0
  57. data/lib/wcc/contentful/test/double.rb +76 -0
  58. data/lib/wcc/contentful/test/factory.rb +101 -0
  59. data/lib/wcc/contentful/version.rb +1 -1
  60. data/wcc-contentful.gemspec +23 -11
  61. metadata +299 -116
  62. data/Gemfile +0 -6
  63. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  64. data/lib/wcc/contentful/client_ext.rb +0 -28
  65. data/lib/wcc/contentful/graphql.rb +0 -14
  66. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  67. data/lib/wcc/contentful/graphql/types.rb +0 -54
  68. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  69. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './link'
3
4
  require_relative './sys'
4
5
 
5
6
  module WCC::Contentful
@@ -5,6 +5,15 @@
5
5
  #
6
6
  # @api Model
7
7
  module WCC::Contentful::ModelMethods
8
+ include WCC::Contentful::Instrumentation
9
+
10
+ # The set of options keys that are specific to the Model layer and shouldn't
11
+ # be passed down to the Store layer.
12
+ MODEL_LAYER_CONTEXT_KEYS = %i[
13
+ preview
14
+ backlinks
15
+ ].freeze
16
+
8
17
  # Resolves all links in an entry to the specified depth.
9
18
  #
10
19
  # Each link in the entry is recursively retrieved from the store until the given
@@ -22,7 +31,7 @@ module WCC::Contentful::ModelMethods
22
31
  # handled. `:raise` causes a {WCC::Contentful::CircularReferenceError} to be raised,
23
32
  # `:ignore` will cause the field to remain unresolved, and any other value (or nil)
24
33
  # will cause the field to point to the previously resolved ruby object for that ID.
25
- def resolve(depth: 1, fields: nil, context: {}, **options)
34
+ def resolve(depth: 1, fields: nil, context: sys.context.to_h, **options)
26
35
  raise ArgumentError, "Depth must be > 0 (was #{depth})" unless depth && depth > 0
27
36
  return self if resolved?(depth: depth, fields: fields)
28
37
 
@@ -32,20 +41,26 @@ module WCC::Contentful::ModelMethods
32
41
  typedef = self.class.content_type_definition
33
42
  links = fields.select { |f| %i[Asset Link].include?(typedef.fields[f].type) }
34
43
 
35
- raw_links =
36
- links.any? do |field_name|
37
- raw_value = raw.dig('fields', field_name, sys.locale)
38
- if raw_value&.is_a? Array
39
- raw_value.any? { |v| v&.dig('sys', 'type') == 'Link' }
40
- elsif raw_value
41
- raw_value.dig('sys', 'type') == 'Link'
44
+ raw_link_ids =
45
+ links.map { |field_name| raw.dig('fields', field_name, sys.locale) }
46
+ .flat_map do |raw_value|
47
+ _try_map(raw_value) { |v| v.dig('sys', 'id') if v.dig('sys', 'type') == 'Link' }
48
+ end
49
+ raw_link_ids = raw_link_ids.compact
50
+ backlinked_ids = (context[:backlinks]&.map { |m| m.id } || [])
51
+
52
+ has_unresolved_raw_links = (raw_link_ids - backlinked_ids).any?
53
+ if has_unresolved_raw_links
54
+ raw =
55
+ _instrument 'resolve', id: id, depth: depth, backlinks: backlinked_ids do
56
+ # use include param to do resolution
57
+ self.class.store(context[:preview])
58
+ .find_by(content_type: self.class.content_type,
59
+ filter: { 'sys.id' => id },
60
+ options: context.except(*MODEL_LAYER_CONTEXT_KEYS).merge!({
61
+ include: [depth, 10].min
62
+ }))
42
63
  end
43
- end
44
- if raw_links
45
- # use include param to do resolution
46
- raw = self.class.store.find_by(content_type: self.class.content_type,
47
- filter: { 'sys.id' => id },
48
- options: { include: [depth, 10].min })
49
64
  unless raw
50
65
  raise WCC::Contentful::ResolveError, "Cannot find #{self.class.content_type} with ID #{id}"
51
66
  end
@@ -118,6 +133,12 @@ module WCC::Contentful::ModelMethods
118
133
 
119
134
  delegate :to_json, to: :to_h
120
135
 
136
+ protected
137
+
138
+ def _instrumentation_event_prefix
139
+ '.model.contentful.wcc'
140
+ end
141
+
121
142
  private
122
143
 
123
144
  def _resolve_field(field_name, depth = 1, context = {}, options = {})
@@ -147,7 +168,10 @@ module WCC::Contentful::ModelMethods
147
168
  # instantiate from already resolved raw entry data.
148
169
  m = already_resolved ||
149
170
  if raw.dig('sys', 'type') == 'Link'
150
- WCC::Contentful::Model.find(id, new_context)
171
+ _instrument 'resolve',
172
+ id: self.id, depth: depth, backlinks: context[:backlinks]&.map(&:id) do
173
+ WCC::Contentful::Model.find(id, options: new_context)
174
+ end
151
175
  else
152
176
  WCC::Contentful::Model.new_from_raw(raw, new_context)
153
177
  end
@@ -158,6 +182,7 @@ module WCC::Contentful::ModelMethods
158
182
 
159
183
  begin
160
184
  val = _try_map(val) { |v| load.call(v) }
185
+ val = val.compact if val.is_a? Array
161
186
 
162
187
  instance_variable_set(var_name + '_resolved', val)
163
188
  rescue WCC::Contentful::CircularReferenceError
@@ -4,18 +4,6 @@
4
4
  # methods that are not dynamically generated.
5
5
  # @api Model
6
6
  module WCC::Contentful::ModelSingletonMethods
7
- def store(preview = false)
8
- if preview
9
- if WCC::Contentful::Model.preview_store.nil?
10
- raise ArgumentError,
11
- 'You must include a contentful preview token in your WCC::Contentful.configure block'
12
- end
13
- WCC::Contentful::Model.preview_store
14
- else
15
- WCC::Contentful::Model.store
16
- end
17
- end
18
-
19
7
  # Finds an instance of this content type.
20
8
  #
21
9
  # @return [nil, WCC::Contentful::Model] An instance of the appropriate model class
@@ -24,8 +12,12 @@ module WCC::Contentful::ModelSingletonMethods
24
12
  # WCC::Contentful::Model::Page.find(id)
25
13
  def find(id, options: nil)
26
14
  options ||= {}
27
- raw = store(options[:preview])
28
- .find(id, { hint: type }.merge!(options.except(:preview)))
15
+ store = store(options[:preview])
16
+ raw =
17
+ WCC::Contentful::Instrumentation.instrument 'find.model.contentful.wcc',
18
+ content_type: content_type, id: id, options: options do
19
+ store.find(id, { hint: type }.merge!(options.except(:preview)))
20
+ end
29
21
  new(raw, options) if raw.present?
30
22
  end
31
23
 
@@ -40,16 +32,16 @@ module WCC::Contentful::ModelSingletonMethods
40
32
  filter = filter&.dup
41
33
  options = filter&.delete(:options) || {}
42
34
 
43
- if filter.present?
44
- filter.transform_keys! { |k| k.to_s.camelize(:lower) }
45
- bad_fields = filter.keys.reject { |k| self::FIELDS.include?(k) }
46
- raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
47
- end
35
+ filter.transform_keys! { |k| k.to_s.camelize(:lower) } if filter.present?
48
36
 
49
- query = store(options[:preview])
50
- .find_all(content_type: content_type, options: options.except(:preview))
37
+ store = store(options[:preview])
38
+ query =
39
+ WCC::Contentful::Instrumentation.instrument 'find_all.model.contentful.wcc',
40
+ content_type: content_type, filter: filter, options: options do
41
+ store.find_all(content_type: content_type, options: options.except(:preview))
42
+ end
51
43
  query = query.apply(filter) if filter.present?
52
- query.map { |r| new(r, options) }
44
+ ModelQuery.new(query, options, self)
53
45
  end
54
46
 
55
47
  # Finds the first instance of this content type matching the given query.
@@ -62,22 +54,47 @@ module WCC::Contentful::ModelSingletonMethods
62
54
  filter = filter&.dup
63
55
  options = filter&.delete(:options) || {}
64
56
 
65
- if filter.present?
66
- filter.transform_keys! { |k| k.to_s.camelize(:lower) }
67
- bad_fields = filter.keys.reject { |k| self::FIELDS.include?(k) }
68
- raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
69
- end
57
+ filter.transform_keys! { |k| k.to_s.camelize(:lower) } if filter.present?
70
58
 
71
- result = store(options[:preview])
72
- .find_by(content_type: content_type, filter: filter, options: options.except(:preview))
59
+ store = store(options[:preview])
60
+ result =
61
+ WCC::Contentful::Instrumentation.instrument 'find_by.model.contentful.wcc',
62
+ content_type: content_type, filter: filter, options: options do
63
+ store.find_by(content_type: content_type, filter: filter, options: options.except(:preview))
64
+ end
73
65
 
74
66
  new(result, options) if result
75
67
  end
76
68
 
77
69
  def inherited(subclass)
78
- # only register if it's not already registered
70
+ # If another different class is already registered for this content type,
71
+ # don't auto-register this one.
79
72
  return if WCC::Contentful::Model.registered?(content_type)
80
73
 
81
74
  WCC::Contentful::Model.register_for_content_type(content_type, klass: subclass)
82
75
  end
76
+
77
+ class ModelQuery
78
+ include Enumerable
79
+
80
+ # by default all enumerable methods delegated to the to_enum method
81
+ delegate(*(Enumerable.instance_methods - Module.instance_methods), to: :to_enum)
82
+ delegate :each, to: :to_enum
83
+
84
+ # except count - because that needs to pull data off the final query obj
85
+ delegate :count, to: :wrapped_query
86
+
87
+ attr_reader :wrapped_query, :options, :klass
88
+
89
+ def initialize(wrapped_query, options, klass)
90
+ @wrapped_query = wrapped_query
91
+ @options = options
92
+ @klass = klass
93
+ end
94
+
95
+ def to_enum
96
+ wrapped_query.to_enum
97
+ .map { |r| klass.new(r, options) }
98
+ end
99
+ end
83
100
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(File.dirname(__FILE__), '../../tasks/**/*.rake')]
4
+ .each { |f| load f }
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wcc/contentful'
4
+
5
+ require_relative './test'
6
+
7
+ module WCC::Contentful::RSpec
8
+ include WCC::Contentful::Test::Double
9
+ include WCC::Contentful::Test::Factory
10
+
11
+ ##
12
+ # Builds out a fake Contentful entry for the given content type, and then
13
+ # stubs the Model API to return that content type for `.find` and `.find_by`
14
+ # query methods.
15
+ def contentful_stub(content_type, **attrs)
16
+ const = WCC::Contentful::Model.resolve_constant(content_type.to_s)
17
+ instance = contentful_create(content_type, **attrs)
18
+
19
+ # mimic what's going on inside model_singleton_methods.rb
20
+ # find, find_by, etc always return a new instance from the same raw
21
+ allow(WCC::Contentful::Model).to receive(:find)
22
+ .with(instance.id, any_args) do |_id, keyword_params|
23
+ options = keyword_params && keyword_params[:options]
24
+ contentful_create(content_type, options, raw: instance.raw, **attrs)
25
+ end
26
+ allow(const).to receive(:find) { |id, options| WCC::Contentful::Model.find(id, **(options || {})) }
27
+
28
+ attrs.each do |k, v|
29
+ allow(const).to receive(:find_by)
30
+ .with(hash_including(k => v)) do |filter|
31
+ filter = filter&.dup
32
+ options = filter&.delete(:options) || {}
33
+
34
+ contentful_create(content_type, options, raw: instance.raw, **attrs)
35
+ end
36
+ end
37
+
38
+ instance
39
+ end
40
+ end
41
+
42
+ if defined?(RSpec)
43
+ RSpec.configure do |config|
44
+ config.include WCC::Contentful::RSpec
45
+ end
46
+ end
@@ -1,21 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'singleton'
4
-
5
3
  module WCC::Contentful
6
4
  class Services
7
- include Singleton
5
+ class << self
6
+ def instance
7
+ @singleton__instance__ ||= new # rubocop:disable Naming/MemoizedInstanceVariableName
8
+ end
9
+ end
10
+
11
+ def configuration
12
+ @configuration ||= WCC::Contentful.configuration
13
+ end
14
+
15
+ def initialize(configuration = nil)
16
+ @configuration = configuration
17
+ end
8
18
 
9
19
  # Gets the data-store which executes the queries run against the dynamic
10
20
  # models in the WCC::Contentful::Model namespace.
11
- # This is one of the following based on the configured content_delivery method:
21
+ # This is one of the following based on the configured store method:
12
22
  #
13
23
  # [:direct] an instance of {WCC::Contentful::Store::CDNAdapter} with a
14
24
  # {WCC::Contentful::SimpleClient::Cdn CDN Client} to access the CDN.
15
25
  #
16
- # [:lazy_sync] an instance of {WCC::Contentful::Store::LazyCacheStore}
17
- # with the configured ActiveSupport::Cache implementation and a
18
- # {WCC::Contentful::SimpleClient::Cdn CDN Client} for when data
26
+ # [:lazy_sync] an instance of {WCC::Contentful::Middleware::Store::CachingMiddleware}
27
+ # with the configured ActiveSupport::Cache implementation around a
28
+ # {WCC::Contentful::Store::CDNAdapter} for when data
19
29
  # cannot be found in the cache.
20
30
  #
21
31
  # [:eager_sync] an instance of the configured Store type, defined by
@@ -25,12 +35,7 @@ module WCC::Contentful
25
35
  def store
26
36
  @store ||=
27
37
  ensure_configured do |config|
28
- WCC::Contentful::Store::Factory.new(
29
- config,
30
- self,
31
- config.content_delivery,
32
- config.content_delivery_params
33
- ).build_sync_store
38
+ config.store.build(self)
34
39
  end
35
40
  end
36
41
 
@@ -43,10 +48,9 @@ module WCC::Contentful
43
48
  ensure_configured do |config|
44
49
  WCC::Contentful::Store::Factory.new(
45
50
  config,
46
- self,
47
51
  :direct,
48
- [{ preview: true }]
49
- ).build_sync_store
52
+ :preview
53
+ ).build(self)
50
54
  end
51
55
  end
52
56
 
@@ -58,10 +62,11 @@ module WCC::Contentful
58
62
  @client ||=
59
63
  ensure_configured do |config|
60
64
  WCC::Contentful::SimpleClient::Cdn.new(
65
+ **config.connection_options,
61
66
  access_token: config.access_token,
62
67
  space: config.space,
63
68
  default_locale: config.default_locale,
64
- adapter: config.http_adapter,
69
+ connection: config.connection,
65
70
  environment: config.environment
66
71
  )
67
72
  end
@@ -76,10 +81,11 @@ module WCC::Contentful
76
81
  ensure_configured do |config|
77
82
  if config.preview_token.present?
78
83
  WCC::Contentful::SimpleClient::Preview.new(
84
+ **config.connection_options,
79
85
  preview_token: config.preview_token,
80
86
  space: config.space,
81
87
  default_locale: config.default_locale,
82
- adapter: config.http_adapter,
88
+ connection: config.connection,
83
89
  environment: config.environment
84
90
  )
85
91
  end
@@ -95,27 +101,57 @@ module WCC::Contentful
95
101
  ensure_configured do |config|
96
102
  if config.management_token.present?
97
103
  WCC::Contentful::SimpleClient::Management.new(
104
+ **config.connection_options,
98
105
  management_token: config.management_token,
99
106
  space: config.space,
100
107
  default_locale: config.default_locale,
101
- adapter: config.http_adapter,
108
+ connection: config.connection,
102
109
  environment: config.environment
103
110
  )
104
111
  end
105
112
  end
106
113
  end
107
114
 
115
+ # Gets the configured WCC::Contentful::SyncEngine which is responsible for
116
+ # updating the currently configured store. The application must periodically
117
+ # call #next on this instance. Alternately, the application can mount the
118
+ # WCC::Contentful::Engine, which will call #next anytime a webhook is received.
119
+ #
120
+ # This returns `nil` if the currently configured store does not respond to sync
121
+ # events.
122
+ def sync_engine
123
+ @sync_engine ||=
124
+ if store.index?
125
+ SyncEngine.new(
126
+ store: store,
127
+ client: client,
128
+ key: 'sync:token'
129
+ )
130
+ end
131
+ end
132
+
133
+ # Gets the configured instrumentation adapter, defaulting to ActiveSupport::Notifications
134
+ def instrumentation
135
+ return @instrumentation if @instrumentation
136
+ return ActiveSupport::Notifications if WCC::Contentful.configuration.nil?
137
+
138
+ @instrumentation ||=
139
+ WCC::Contentful.configuration.instrumentation_adapter ||
140
+ ActiveSupport::Notifications
141
+ end
142
+
108
143
  private
109
144
 
110
145
  def ensure_configured
111
- if WCC::Contentful.configuration.nil?
112
- raise StandardError, 'WCC::Contentful has not yet been configured!'
113
- end
146
+ raise StandardError, 'WCC::Contentful has not yet been configured!' if configuration.nil?
114
147
 
115
- yield WCC::Contentful.configuration
148
+ yield configuration
116
149
  end
117
150
  end
118
151
 
152
+ SERVICES = (WCC::Contentful::Services.instance_methods -
153
+ Object.instance_methods)
154
+
119
155
  # Include this module to define accessors for every method defined on the
120
156
  # {Services} singleton.
121
157
  #
@@ -129,14 +165,12 @@ module WCC::Contentful
129
165
  # store.find(...)
130
166
  #
131
167
  # client.entries(...)
168
+ #
169
+ # sync_engine.next
132
170
  # end
133
171
  # end
134
172
  # @see Services
135
173
  module ServiceAccessors
136
- SERVICES = (WCC::Contentful::Services.instance_methods -
137
- Object.instance_methods -
138
- Singleton.instance_methods)
139
-
140
174
  SERVICES.each do |m|
141
175
  define_method m do
142
176
  Services.instance.public_send(m)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'simple_client/response'
4
4
  require_relative 'simple_client/management'
5
+ require_relative 'instrumentation'
5
6
 
6
7
  module WCC::Contentful
7
8
  # The SimpleClient accesses the Contentful CDN to get JSON responses,
@@ -16,11 +17,13 @@ module WCC::Contentful
16
17
  # `get`. This method returns a WCC::Contentful::SimpleClient::Response
17
18
  # that handles paging automatically.
18
19
  #
19
- # The SimpleClient by default uses 'http' to perform the gets, but any HTTP
20
- # client can be injected by passing a proc as the `adapter:` option.
20
+ # The SimpleClient by default uses 'faraday' to perform the gets, but any HTTP
21
+ # client adapter be injected by passing the `connection:` option.
21
22
  #
22
23
  # @api Client
23
24
  class SimpleClient
25
+ include WCC::Contentful::Instrumentation
26
+
24
27
  attr_reader :api_url
25
28
  attr_reader :space
26
29
 
@@ -30,21 +33,27 @@ module WCC::Contentful
30
33
  # @param [String] space The Space ID to access
31
34
  # @param [String] access_token A Contentful Access Token to be sent in the Authorization header
32
35
  # @param [Hash] options The remaining optional parameters, defined below
33
- # @option options [Symbol, Object] adapter The Adapter to use to make requests.
36
+ # @option options [Symbol, Object] connection The Faraday connection to use to make requests.
34
37
  # Auto-discovered based on what gems are installed if this is not provided.
35
38
  # @option options [String] default_locale The locale query param to set by default.
36
39
  # @option options [String] environment The contentful environment to access. Defaults to 'master'.
37
40
  # @option options [Boolean] no_follow_redirects If true, do not follow 300 level redirects.
41
+ # @option options [Number] rate_limit_wait_timeout The maximum time to block the thread waiting
42
+ # on a rate limit response. By default will wait for one 429 and then fail on the second 429.
38
43
  def initialize(api_url:, space:, access_token:, **options)
39
44
  @api_url = URI.join(api_url, '/spaces/', space + '/')
40
45
  @space = space
41
46
  @access_token = access_token
42
47
 
43
- @adapter = SimpleClient.load_adapter(options[:adapter])
48
+ @adapter = SimpleClient.load_adapter(options[:connection])
44
49
 
45
50
  @options = options
51
+ @_instrumentation = @options[:instrumentation]
46
52
  @query_defaults = {}
47
53
  @query_defaults[:locale] = @options[:default_locale] if @options[:default_locale]
54
+ # default 1.5 so that we retry one time then fail if still rate limited
55
+ # https://www.contentful.com/developers/docs/references/content-preview-api/#/introduction/api-rate-limits
56
+ @rate_limit_wait_timeout = @options[:rate_limit_wait_timeout] || 1.5
48
57
 
49
58
  return unless options[:environment].present?
50
59
 
@@ -57,13 +66,17 @@ module WCC::Contentful
57
66
  def get(path, query = {})
58
67
  url = URI.join(@api_url, path)
59
68
 
69
+ resp =
70
+ _instrument 'get_http', url: url, query: query do
71
+ get_http(url, query)
72
+ end
60
73
  Response.new(self,
61
74
  { url: url, query: query },
62
- get_http(url, query))
75
+ resp)
63
76
  end
64
77
 
65
78
  ADAPTERS = {
66
- http: ['http', '> 1.0', '< 3.0'],
79
+ faraday: ['faraday', '>= 0.9'],
67
80
  typhoeus: ['typhoeus', '~> 1.0']
68
81
  }.freeze
69
82
 
@@ -80,16 +93,19 @@ module WCC::Contentful
80
93
  end
81
94
  raise ArgumentError, 'Unable to load adapter! Please install one of '\
82
95
  "#{ADAPTERS.values.map(&:join).join(',')}"
83
- when :http
84
- require_relative 'simple_client/http_adapter'
85
- HttpAdapter.new
96
+ when :faraday
97
+ require 'faraday'
98
+ ::Faraday.new do |faraday|
99
+ faraday.response :logger, (Rails.logger if defined?(Rails)), { headers: false, bodies: false }
100
+ faraday.adapter :net_http
101
+ end
86
102
  when :typhoeus
87
103
  require_relative 'simple_client/typhoeus_adapter'
88
104
  TyphoeusAdapter.new
89
105
  else
90
- unless adapter.respond_to?(:call)
106
+ unless adapter.respond_to?(:get)
91
107
  raise ArgumentError, "Adapter #{adapter} is not invokeable! Please "\
92
- "pass a proc or use one of #{ADAPTERS.keys}"
108
+ "pass use one of #{ADAPTERS.keys} or create a Faraday-compatible adapter"
93
109
  end
94
110
  adapter
95
111
  end
@@ -97,7 +113,12 @@ module WCC::Contentful
97
113
 
98
114
  private
99
115
 
100
- def get_http(url, query, headers = {}, proxy = {})
116
+ def _instrumentation_event_prefix
117
+ # Unify all CDN, Management, Preview notifications under same namespace
118
+ '.simpleclient.contentful.wcc'
119
+ end
120
+
121
+ def get_http(url, query, headers = {})
101
122
  headers = {
102
123
  Authorization: "Bearer #{@access_token}"
103
124
  }.merge(headers || {})
@@ -105,12 +126,28 @@ module WCC::Contentful
105
126
  q = @query_defaults.dup
106
127
  q = q.merge(query) if query
107
128
 
108
- resp = @adapter.call(url, q, headers, proxy)
129
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+ loop do
131
+ resp = @adapter.get(url, q, headers)
132
+
133
+ if [301, 302, 307].include?(resp.status) && !@options[:no_follow_redirects]
134
+ url = resp.headers['Location']
135
+ next
136
+ end
137
+
138
+ if resp.status == 429 &&
139
+ reset = resp.headers['X-Contentful-RateLimit-Reset'].presence
140
+ reset = reset.to_f
141
+ _instrument 'rate_limit', start: start, reset: reset, timeout: @rate_limit_wait_timeout
142
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ if (now - start) + reset < @rate_limit_wait_timeout
144
+ sleep(reset)
145
+ next
146
+ end
147
+ end
109
148
 
110
- if [301, 302, 307].include?(resp.code) && !@options[:no_follow_redirects]
111
- resp = get_http(resp.headers['location'], nil, headers, proxy)
149
+ return resp
112
150
  end
113
- resp
114
151
  end
115
152
 
116
153
  # The CDN SimpleClient accesses 'https://cdn.contentful.com' to get raw
@@ -135,31 +172,46 @@ module WCC::Contentful
135
172
 
136
173
  # Gets an entry by ID
137
174
  def entry(key, query = {})
138
- resp = get("entries/#{key}", query)
175
+ resp =
176
+ _instrument 'entries', id: key, type: 'Entry', query: query do
177
+ get("entries/#{key}", query)
178
+ end
139
179
  resp.assert_ok!
140
180
  end
141
181
 
142
182
  # Queries entries with optional query parameters
143
183
  def entries(query = {})
144
- resp = get('entries', query)
184
+ resp =
185
+ _instrument 'entries', type: 'Entry', query: query do
186
+ get('entries', query)
187
+ end
145
188
  resp.assert_ok!
146
189
  end
147
190
 
148
191
  # Gets an asset by ID
149
192
  def asset(key, query = {})
150
- resp = get("assets/#{key}", query)
193
+ resp =
194
+ _instrument 'entries', type: 'Asset', id: key, query: query do
195
+ get("assets/#{key}", query)
196
+ end
151
197
  resp.assert_ok!
152
198
  end
153
199
 
154
200
  # Queries assets with optional query parameters
155
201
  def assets(query = {})
156
- resp = get('assets', query)
202
+ resp =
203
+ _instrument 'entries', type: 'Asset', query: query do
204
+ get('assets', query)
205
+ end
157
206
  resp.assert_ok!
158
207
  end
159
208
 
160
209
  # Queries content types with optional query parameters
161
210
  def content_types(query = {})
162
- resp = get('content_types', query)
211
+ resp =
212
+ _instrument 'content_types', query: query do
213
+ get('content_types', query)
214
+ end
163
215
  resp.assert_ok!
164
216
  end
165
217
 
@@ -177,7 +229,11 @@ module WCC::Contentful
177
229
  { initial: true }
178
230
  end
179
231
  query = query.merge(sync_token)
180
- resp = SyncResponse.new(get('sync', query))
232
+ resp =
233
+ _instrument 'sync', sync_token: sync_token, query: query do
234
+ get('sync', query)
235
+ end
236
+ resp = SyncResponse.new(resp)
181
237
  resp.assert_ok!
182
238
  end
183
239
  end
@@ -186,10 +242,10 @@ module WCC::Contentful
186
242
  class Preview < Cdn
187
243
  def initialize(space:, preview_token:, **options)
188
244
  super(
189
- api_url: options[:api_url] || 'https://preview.contentful.com/',
245
+ **options,
246
+ api_url: options[:preview_api_url] || 'https://preview.contentful.com/',
190
247
  space: space,
191
- access_token: preview_token,
192
- **options
248
+ access_token: preview_token
193
249
  )
194
250
  end
195
251