wcc-contentful 1.2.0 → 1.3.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -1
  3. data/app/controllers/wcc/contentful/webhook_controller.rb +2 -0
  4. data/lib/tasks/download_schema.rake +1 -1
  5. data/lib/wcc/contentful/configuration.rb +37 -18
  6. data/lib/wcc/contentful/downloads_schema.rb +5 -4
  7. data/lib/wcc/contentful/engine.rb +2 -4
  8. data/lib/wcc/contentful/event.rb +2 -3
  9. data/lib/wcc/contentful/exceptions.rb +2 -3
  10. data/lib/wcc/contentful/indexed_representation.rb +2 -6
  11. data/lib/wcc/contentful/instrumentation.rb +2 -1
  12. data/lib/wcc/contentful/link.rb +1 -3
  13. data/lib/wcc/contentful/link_visitor.rb +2 -4
  14. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +4 -8
  15. data/lib/wcc/contentful/middleware/store.rb +4 -6
  16. data/lib/wcc/contentful/model_api.rb +3 -5
  17. data/lib/wcc/contentful/model_builder.rb +5 -4
  18. data/lib/wcc/contentful/model_methods.rb +10 -12
  19. data/lib/wcc/contentful/model_singleton_methods.rb +1 -1
  20. data/lib/wcc/contentful/rich_text/node.rb +1 -3
  21. data/lib/wcc/contentful/rich_text.rb +1 -1
  22. data/lib/wcc/contentful/rspec.rb +1 -3
  23. data/lib/wcc/contentful/services.rb +9 -0
  24. data/lib/wcc/contentful/simple_client/cdn.rb +126 -0
  25. data/lib/wcc/contentful/simple_client/preview.rb +17 -0
  26. data/lib/wcc/contentful/simple_client/response.rb +24 -19
  27. data/lib/wcc/contentful/simple_client.rb +13 -118
  28. data/lib/wcc/contentful/store/base.rb +19 -27
  29. data/lib/wcc/contentful/store/cdn_adapter.rb +1 -1
  30. data/lib/wcc/contentful/store/factory.rb +1 -1
  31. data/lib/wcc/contentful/store/memory_store.rb +10 -7
  32. data/lib/wcc/contentful/store/postgres_store.rb +52 -42
  33. data/lib/wcc/contentful/store/query.rb +2 -2
  34. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +6 -4
  35. data/lib/wcc/contentful/store/rspec_examples/operators.rb +1 -1
  36. data/lib/wcc/contentful/sync_engine.rb +58 -34
  37. data/lib/wcc/contentful/sys.rb +2 -1
  38. data/lib/wcc/contentful/test/double.rb +3 -5
  39. data/lib/wcc/contentful/test/factory.rb +4 -6
  40. data/lib/wcc/contentful/version.rb +1 -1
  41. data/lib/wcc/contentful.rb +9 -12
  42. data/wcc-contentful.gemspec +5 -5
  43. metadata +37 -30
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The CDN SimpleClient accesses 'https://cdn.contentful.com' to get raw
4
+ # JSON responses. It exposes methods to query entries, assets, and content_types.
5
+ # The responses are instances of WCC::Contentful::SimpleClient::Response
6
+ # which handles paging automatically.
7
+ #
8
+ # @api Client
9
+ class WCC::Contentful::SimpleClient::Cdn < WCC::Contentful::SimpleClient
10
+ def initialize(space:, access_token:, **options)
11
+ super(
12
+ api_url: options[:api_url] || 'https://cdn.contentful.com/',
13
+ space: space,
14
+ access_token: access_token,
15
+ **options
16
+ )
17
+ end
18
+
19
+ def client_type
20
+ 'cdn'
21
+ end
22
+
23
+ # Gets an entry by ID
24
+ def entry(key, query = {})
25
+ resp =
26
+ _instrument 'entries', id: key, type: 'Entry', query: query do
27
+ get("entries/#{key}", query)
28
+ end
29
+ resp.assert_ok!
30
+ end
31
+
32
+ # Queries entries with optional query parameters
33
+ def entries(query = {})
34
+ resp =
35
+ _instrument 'entries', type: 'Entry', query: query do
36
+ get('entries', query)
37
+ end
38
+ resp.assert_ok!
39
+ end
40
+
41
+ # Gets an asset by ID
42
+ def asset(key, query = {})
43
+ resp =
44
+ _instrument 'entries', type: 'Asset', id: key, query: query do
45
+ get("assets/#{key}", query)
46
+ end
47
+ resp.assert_ok!
48
+ end
49
+
50
+ # Queries assets with optional query parameters
51
+ def assets(query = {})
52
+ resp =
53
+ _instrument 'entries', type: 'Asset', query: query do
54
+ get('assets', query)
55
+ end
56
+ resp.assert_ok!
57
+ end
58
+
59
+ # Queries content types with optional query parameters
60
+ def content_types(query = {})
61
+ resp =
62
+ _instrument 'content_types', query: query do
63
+ get('content_types', query)
64
+ end
65
+ resp.assert_ok!
66
+ end
67
+
68
+ # Accesses the Sync API to get a list of items that have changed since
69
+ # the last sync. Accepts a block that receives each changed item, and returns
70
+ # the next sync token.
71
+ #
72
+ # If `sync_token` is nil, an initial sync is performed.
73
+ #
74
+ # @return String the next sync token parsed from nextSyncUrl
75
+ # @example
76
+ # my_sync_token = storage.get('sync_token')
77
+ # my_sync_token = client.sync(sync_token: my_sync_token) do |item|
78
+ # storage.put(item.dig('sys', 'id'), item) }
79
+ # end
80
+ # storage.put('sync_token', my_sync_token)
81
+ def sync(sync_token: nil, **query, &block)
82
+ return sync_old(sync_token: sync_token, **query) unless block_given?
83
+
84
+ sync_token =
85
+ if sync_token
86
+ { sync_token: sync_token }
87
+ else
88
+ { initial: true }
89
+ end
90
+ query = query.merge(sync_token)
91
+
92
+ _instrument 'sync', sync_token: sync_token, query: query do
93
+ resp = get('sync', query)
94
+ resp = SyncResponse.new(resp)
95
+ resp.assert_ok!
96
+
97
+ resp.each_page do |page|
98
+ page.page_items.each(&block)
99
+ sync_token = resp.next_sync_token
100
+ end
101
+ end
102
+
103
+ sync_token
104
+ end
105
+
106
+ private
107
+
108
+ def sync_old(sync_token: nil, **query)
109
+ ActiveSupport::Deprecation.warn('Sync without a block is deprecated, please use new block syntax instead')
110
+
111
+ sync_token =
112
+ if sync_token
113
+ { sync_token: sync_token }
114
+ else
115
+ { initial: true }
116
+ end
117
+ query = query.merge(sync_token)
118
+
119
+ resp =
120
+ _instrument 'sync', sync_token: sync_token, query: query do
121
+ get('sync', query)
122
+ end
123
+ resp = SyncResponse.new(resp, memoize: true)
124
+ resp.assert_ok!
125
+ end
126
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api Client
4
+ class WCC::Contentful::SimpleClient::Preview < WCC::Contentful::SimpleClient::Cdn
5
+ def initialize(space:, preview_token:, **options)
6
+ super(
7
+ **options,
8
+ api_url: options[:preview_api_url] || 'https://preview.contentful.com/',
9
+ space: space,
10
+ access_token: preview_token
11
+ )
12
+ end
13
+
14
+ def client_type
15
+ 'preview'
16
+ end
17
+ end
@@ -6,9 +6,7 @@ class WCC::Contentful::SimpleClient
6
6
  class Response
7
7
  include ::WCC::Contentful::Instrumentation
8
8
 
9
- attr_reader :raw_response
10
- attr_reader :client
11
- attr_reader :request
9
+ attr_reader :raw_response, :client, :request
12
10
 
13
11
  delegate :status, to: :raw_response
14
12
  alias_method :code, :status
@@ -26,7 +24,7 @@ class WCC::Contentful::SimpleClient
26
24
  def error_message
27
25
  parsed_message =
28
26
  begin
29
- raw.dig('message')
27
+ raw['message']
30
28
  rescue JSON::ParserError
31
29
  nil
32
30
  end
@@ -105,15 +103,16 @@ class WCC::Contentful::SimpleClient
105
103
 
106
104
  def includes
107
105
  @includes ||=
108
- raw.dig('includes')&.each_with_object({}) do |(_t, entries), h|
106
+ raw['includes']&.each_with_object({}) do |(_t, entries), h|
109
107
  entries.each { |e| h[e.dig('sys', 'id')] = e }
110
108
  end || {}
111
109
  end
112
110
  end
113
111
 
114
112
  class SyncResponse < Response
115
- def initialize(response)
113
+ def initialize(response, memoize: false)
116
114
  super(response.client, response.request, response.raw_response)
115
+ @memoize = memoize
117
116
  end
118
117
 
119
118
  def next_page?
@@ -122,6 +121,7 @@ class WCC::Contentful::SimpleClient
122
121
 
123
122
  def next_page
124
123
  return unless next_page?
124
+ return @next_page if @next_page
125
125
 
126
126
  url = raw['nextPageUrl']
127
127
  next_page =
@@ -131,26 +131,31 @@ class WCC::Contentful::SimpleClient
131
131
 
132
132
  next_page = SyncResponse.new(next_page)
133
133
  next_page.assert_ok!
134
+ @next_page = next_page if @memoize
135
+ next_page
134
136
  end
135
137
 
136
138
  def next_sync_token
137
- # If we haven't grabbed the next page yet, then our next "sync" will be getting
138
- # the next page. We could just as easily call sync again with that token.
139
- @next_page&.next_sync_token ||
140
- @next_sync_token ||= SyncResponse.parse_sync_token(
141
- raw['nextPageUrl'] || raw['nextSyncUrl']
142
- )
143
- end
144
-
145
- def each_page
146
- raise ArgumentError, 'Not a collection response' unless page_items
139
+ # If we have iterated some pages, return the sync token of the final
140
+ # page that was iterated. Do this without maintaining a reference to
141
+ # all the pages.
142
+ return @last_sync_token if @last_sync_token
147
143
 
148
- ret = PaginatingEnumerable.new(self)
144
+ SyncResponse.parse_sync_token(raw['nextPageUrl'] || raw['nextSyncUrl'])
145
+ end
149
146
 
147
+ def each_page(&block)
150
148
  if block_given?
151
- ret.map(&block)
149
+ super do |page|
150
+ @last_sync_token = page.next_sync_token
151
+
152
+ yield page
153
+ end
152
154
  else
153
- ret.lazy
155
+ super.map do |page|
156
+ @last_sync_token = page.next_sync_token
157
+ page
158
+ end
154
159
  end
155
160
  end
156
161
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative 'simple_client/response'
4
4
  require_relative 'simple_client/management'
5
+ require_relative 'simple_client/cdn'
6
+ require_relative 'simple_client/preview'
5
7
  require_relative 'instrumentation'
6
8
 
7
9
  module WCC::Contentful
@@ -24,8 +26,7 @@ module WCC::Contentful
24
26
  class SimpleClient
25
27
  include WCC::Contentful::Instrumentation
26
28
 
27
- attr_reader :api_url
28
- attr_reader :space
29
+ attr_reader :api_url, :space
29
30
 
30
31
  # Creates a new SimpleClient with the given configuration.
31
32
  #
@@ -41,7 +42,7 @@ module WCC::Contentful
41
42
  # @option options [Number] rate_limit_wait_timeout The maximum time to block the thread waiting
42
43
  # on a rate limit response. By default will wait for one 429 and then fail on the second 429.
43
44
  def initialize(api_url:, space:, access_token:, **options)
44
- @api_url = URI.join(api_url, '/spaces/', space + '/')
45
+ @api_url = URI.join(api_url, '/spaces/', "#{space}/")
45
46
  @space = space
46
47
  @access_token = access_token
47
48
 
@@ -57,7 +58,7 @@ module WCC::Contentful
57
58
 
58
59
  return unless options[:environment].present?
59
60
 
60
- @api_url = URI.join(@api_url, 'environments/', options[:environment] + '/')
61
+ @api_url = URI.join(@api_url, 'environments/', "#{options[:environment]}/")
61
62
  end
62
63
 
63
64
  # performs an HTTP GET request to the specified path within the configured
@@ -84,15 +85,13 @@ module WCC::Contentful
84
85
  case adapter
85
86
  when nil
86
87
  ADAPTERS.each do |a, spec|
87
- begin
88
- gem(*spec)
89
- return load_adapter(a)
90
- rescue Gem::LoadError
91
- next
92
- end
88
+ gem(*spec)
89
+ return load_adapter(a)
90
+ rescue Gem::LoadError
91
+ next
93
92
  end
94
- raise ArgumentError, 'Unable to load adapter! Please install one of '\
95
- "#{ADAPTERS.values.map(&:join).join(',')}"
93
+ raise ArgumentError, 'Unable to load adapter! Please install one of ' \
94
+ "#{ADAPTERS.values.map(&:join).join(',')}"
96
95
  when :faraday
97
96
  require 'faraday'
98
97
  ::Faraday.new do |faraday|
@@ -104,8 +103,8 @@ module WCC::Contentful
104
103
  TyphoeusAdapter.new
105
104
  else
106
105
  unless adapter.respond_to?(:get)
107
- raise ArgumentError, "Adapter #{adapter} is not invokeable! Please "\
108
- "pass use one of #{ADAPTERS.keys} or create a Faraday-compatible adapter"
106
+ raise ArgumentError, "Adapter #{adapter} is not invokeable! Please " \
107
+ "pass use one of #{ADAPTERS.keys} or create a Faraday-compatible adapter"
109
108
  end
110
109
  adapter
111
110
  end
@@ -149,109 +148,5 @@ module WCC::Contentful
149
148
  return resp
150
149
  end
151
150
  end
152
-
153
- # The CDN SimpleClient accesses 'https://cdn.contentful.com' to get raw
154
- # JSON responses. It exposes methods to query entries, assets, and content_types.
155
- # The responses are instances of WCC::Contentful::SimpleClient::Response
156
- # which handles paging automatically.
157
- #
158
- # @api Client
159
- class Cdn < SimpleClient
160
- def initialize(space:, access_token:, **options)
161
- super(
162
- api_url: options[:api_url] || 'https://cdn.contentful.com/',
163
- space: space,
164
- access_token: access_token,
165
- **options
166
- )
167
- end
168
-
169
- def client_type
170
- 'cdn'
171
- end
172
-
173
- # Gets an entry by ID
174
- def entry(key, query = {})
175
- resp =
176
- _instrument 'entries', id: key, type: 'Entry', query: query do
177
- get("entries/#{key}", query)
178
- end
179
- resp.assert_ok!
180
- end
181
-
182
- # Queries entries with optional query parameters
183
- def entries(query = {})
184
- resp =
185
- _instrument 'entries', type: 'Entry', query: query do
186
- get('entries', query)
187
- end
188
- resp.assert_ok!
189
- end
190
-
191
- # Gets an asset by ID
192
- def asset(key, query = {})
193
- resp =
194
- _instrument 'entries', type: 'Asset', id: key, query: query do
195
- get("assets/#{key}", query)
196
- end
197
- resp.assert_ok!
198
- end
199
-
200
- # Queries assets with optional query parameters
201
- def assets(query = {})
202
- resp =
203
- _instrument 'entries', type: 'Asset', query: query do
204
- get('assets', query)
205
- end
206
- resp.assert_ok!
207
- end
208
-
209
- # Queries content types with optional query parameters
210
- def content_types(query = {})
211
- resp =
212
- _instrument 'content_types', query: query do
213
- get('content_types', query)
214
- end
215
- resp.assert_ok!
216
- end
217
-
218
- # Accesses the Sync API to get a list of items that have changed since
219
- # the last sync.
220
- #
221
- # If `sync_token` is nil, an initial sync is performed.
222
- # Returns a WCC::Contentful::SimpleClient::SyncResponse
223
- # which handles paging automatically.
224
- def sync(sync_token: nil, **query)
225
- sync_token =
226
- if sync_token
227
- { sync_token: sync_token }
228
- else
229
- { initial: true }
230
- end
231
- query = query.merge(sync_token)
232
- resp =
233
- _instrument 'sync', sync_token: sync_token, query: query do
234
- get('sync', query)
235
- end
236
- resp = SyncResponse.new(resp)
237
- resp.assert_ok!
238
- end
239
- end
240
-
241
- # @api Client
242
- class Preview < Cdn
243
- def initialize(space:, preview_token:, **options)
244
- super(
245
- **options,
246
- api_url: options[:preview_api_url] || 'https://preview.contentful.com/',
247
- space: space,
248
- access_token: preview_token
249
- )
250
- end
251
-
252
- def client_type
253
- 'preview'
254
- end
255
- end
256
151
  end
257
152
  end
@@ -50,30 +50,30 @@ module WCC::Contentful::Store
50
50
  # implementation calls into #set and #delete to perform the appropriate
51
51
  # operations in the store.
52
52
  def index(json)
53
+ # This implementation assumes that #delete and #set are individually thread-safe.
54
+ # No mutex is needed so long as the revisions are accurate.
53
55
  # Subclasses can override to do this in a more performant thread-safe way.
54
56
  # Example: postgres_store could do this in a stored procedure for speed
55
- mutex.with_write_lock do
56
- prev =
57
- case type = json.dig('sys', 'type')
58
- when 'DeletedEntry', 'DeletedAsset'
59
- delete(json.dig('sys', 'id'))
60
- else
61
- set(json.dig('sys', 'id'), json)
62
- end
63
-
64
- if (prev_rev = prev&.dig('sys', 'revision')) && (next_rev = json.dig('sys', 'revision'))
65
- if next_rev < prev_rev
66
- # Uh oh! we overwrote an entry with a prior revision. Put the previous back.
67
- return index(prev)
68
- end
69
- end
70
-
71
- case type
57
+ prev =
58
+ case type = json.dig('sys', 'type')
72
59
  when 'DeletedEntry', 'DeletedAsset'
73
- nil
60
+ delete(json.dig('sys', 'id'))
74
61
  else
75
- json
62
+ set(json.dig('sys', 'id'), json)
76
63
  end
64
+
65
+ if (prev_rev = prev&.dig('sys', 'revision')) &&
66
+ (next_rev = json.dig('sys', 'revision')) &&
67
+ (next_rev < prev_rev)
68
+ # Uh oh! we overwrote an entry with a prior revision. Put the previous back.
69
+ return index(prev)
70
+ end
71
+
72
+ case type
73
+ when 'DeletedEntry', 'DeletedAsset'
74
+ nil
75
+ else
76
+ json
77
77
  end
78
78
  end
79
79
 
@@ -107,17 +107,9 @@ module WCC::Contentful::Store
107
107
  )
108
108
  end
109
109
 
110
- def initialize
111
- @mutex = Concurrent::ReentrantReadWriteLock.new
112
- end
113
-
114
110
  def ensure_hash(val)
115
111
  raise ArgumentError, 'Value must be a Hash' unless val.is_a?(Hash)
116
112
  end
117
-
118
- private
119
-
120
- attr_reader :mutex
121
113
  end
122
114
  end
123
115
 
@@ -3,7 +3,7 @@
3
3
  module WCC::Contentful::Store
4
4
  class CDNAdapter
5
5
  include WCC::Contentful::Store::Interface
6
- # Note: CDNAdapter should not instrument store events cause it's not a store.
6
+ # NOTE: CDNAdapter should not instrument store events cause it's not a store.
7
7
 
8
8
  attr_writer :client, :preview_client
9
9
 
@@ -90,7 +90,7 @@ module WCC::Contentful::Store
90
90
  next if m[0].respond_to?(:call)
91
91
 
92
92
  raise ArgumentError, "The middleware '#{m[0]&.try(:name) || m[0]}' cannot be applied! " \
93
- 'It must respond to :call'
93
+ 'It must respond to :call'
94
94
  end
95
95
 
96
96
  validate_store!(store)
@@ -9,13 +9,15 @@ module WCC::Contentful::Store
9
9
  class MemoryStore < Base
10
10
  def initialize
11
11
  super
12
+
13
+ @mutex = Concurrent::ReentrantReadWriteLock.new
12
14
  @hash = {}
13
15
  end
14
16
 
15
17
  def set(key, value)
16
18
  value = value.deep_dup.freeze
17
19
  ensure_hash value
18
- mutex.with_write_lock do
20
+ @mutex.with_write_lock do
19
21
  old = @hash[key]
20
22
  @hash[key] = value
21
23
  old
@@ -23,17 +25,17 @@ module WCC::Contentful::Store
23
25
  end
24
26
 
25
27
  def delete(key)
26
- mutex.with_write_lock do
28
+ @mutex.with_write_lock do
27
29
  @hash.delete(key)
28
30
  end
29
31
  end
30
32
 
31
33
  def keys
32
- mutex.with_read_lock { @hash.keys }
34
+ @mutex.with_read_lock { @hash.keys }
33
35
  end
34
36
 
35
37
  def find(key, **_options)
36
- mutex.with_read_lock do
38
+ @mutex.with_read_lock do
37
39
  @hash[key]
38
40
  end
39
41
  end
@@ -41,11 +43,12 @@ module WCC::Contentful::Store
41
43
  SUPPORTED_OPS = %i[eq ne in nin].freeze
42
44
 
43
45
  def execute(query)
44
- (query.conditions.map(&:op) - SUPPORTED_OPS).each do |op|
45
- raise ArgumentError, "Operator :#{op} not supported"
46
+ if bad_op = (query.conditions.map(&:op) - SUPPORTED_OPS).first
47
+ raise ArgumentError, "Operator :#{bad_op} not supported"
46
48
  end
47
49
 
48
- relation = mutex.with_read_lock { @hash.values }
50
+ # Since @hash.values returns a new array, we only need to lock here
51
+ relation = @mutex.with_read_lock { @hash.values }
49
52
 
50
53
  # relation is an enumerable that we apply conditions to in the form of
51
54
  # Enumerable#select and Enumerable#reject.