wcc-contentful 0.2.2 → 0.3.0.pre.rc

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/README.md +181 -8
  4. data/app/controllers/wcc/contentful/webhook_controller.rb +42 -2
  5. data/app/jobs/wcc/contentful/delayed_sync_job.rb +52 -3
  6. data/app/jobs/wcc/contentful/webhook_enable_job.rb +43 -0
  7. data/bin/console +4 -3
  8. data/bin/rails +2 -0
  9. data/config/initializers/mime_types.rb +10 -1
  10. data/lib/wcc/contentful.rb +14 -142
  11. data/lib/wcc/contentful/client_ext.rb +17 -4
  12. data/lib/wcc/contentful/configuration.rb +25 -84
  13. data/lib/wcc/contentful/engine.rb +19 -0
  14. data/lib/wcc/contentful/exceptions.rb +25 -28
  15. data/lib/wcc/contentful/graphql.rb +0 -1
  16. data/lib/wcc/contentful/graphql/types.rb +1 -1
  17. data/lib/wcc/contentful/helpers.rb +3 -2
  18. data/lib/wcc/contentful/indexed_representation.rb +6 -0
  19. data/lib/wcc/contentful/model.rb +68 -34
  20. data/lib/wcc/contentful/model_builder.rb +65 -67
  21. data/lib/wcc/contentful/model_methods.rb +189 -0
  22. data/lib/wcc/contentful/model_singleton_methods.rb +83 -0
  23. data/lib/wcc/contentful/services.rb +146 -0
  24. data/lib/wcc/contentful/simple_client.rb +35 -33
  25. data/lib/wcc/contentful/simple_client/http_adapter.rb +9 -0
  26. data/lib/wcc/contentful/simple_client/management.rb +81 -0
  27. data/lib/wcc/contentful/simple_client/response.rb +61 -37
  28. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +12 -0
  29. data/lib/wcc/contentful/store.rb +45 -18
  30. data/lib/wcc/contentful/store/base.rb +128 -8
  31. data/lib/wcc/contentful/store/cdn_adapter.rb +92 -22
  32. data/lib/wcc/contentful/store/lazy_cache_store.rb +94 -9
  33. data/lib/wcc/contentful/store/memory_store.rb +13 -8
  34. data/lib/wcc/contentful/store/postgres_store.rb +44 -11
  35. data/lib/wcc/contentful/sys.rb +28 -0
  36. data/lib/wcc/contentful/version.rb +1 -1
  37. data/wcc-contentful.gemspec +3 -9
  38. metadata +87 -107
  39. data/.circleci/config.yml +0 -51
  40. data/.gitignore +0 -26
  41. data/.rubocop.yml +0 -243
  42. data/.rubocop_todo.yml +0 -13
  43. data/.travis.yml +0 -5
  44. data/CHANGELOG.md +0 -45
  45. data/CODE_OF_CONDUCT.md +0 -74
  46. data/Guardfile +0 -58
  47. data/LICENSE.txt +0 -21
  48. data/Rakefile +0 -8
  49. data/lib/generators/wcc/USAGE +0 -24
  50. data/lib/generators/wcc/model_generator.rb +0 -90
  51. data/lib/generators/wcc/templates/.keep +0 -0
  52. data/lib/generators/wcc/templates/Procfile +0 -3
  53. data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
  54. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -90
  55. data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
  56. data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
  57. data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
  58. data/lib/generators/wcc/templates/page/models/page.rb +0 -23
  59. data/lib/generators/wcc/templates/release +0 -9
  60. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
  61. data/lib/wcc/contentful/model/menu.rb +0 -7
  62. data/lib/wcc/contentful/model/menu_button.rb +0 -15
  63. data/lib/wcc/contentful/model/page.rb +0 -8
  64. data/lib/wcc/contentful/model/redirect.rb +0 -19
  65. data/lib/wcc/contentful/model_validators.rb +0 -115
  66. data/lib/wcc/contentful/model_validators/dsl.rb +0 -165
@@ -22,6 +22,24 @@ class WCC::Contentful::SimpleClient
22
22
  raw.dig('message') || "#{code}: #{raw_response.message}"
23
23
  end
24
24
 
25
+ def next_page?
26
+ return unless raw.key? 'items'
27
+
28
+ raw['items'].length + raw['skip'] < raw['total']
29
+ end
30
+
31
+ def next_page
32
+ return unless next_page?
33
+
34
+ @next_page ||= @client.get(
35
+ @request[:url],
36
+ (@request[:query] || {}).merge({
37
+ skip: raw['items'].length + raw['skip']
38
+ })
39
+ )
40
+ @next_page.assert_ok!
41
+ end
42
+
25
43
  def initialize(client, request, raw_response)
26
44
  @client = client
27
45
  @request = request
@@ -31,33 +49,20 @@ class WCC::Contentful::SimpleClient
31
49
 
32
50
  def assert_ok!
33
51
  return self if code >= 200 && code < 300
52
+
34
53
  raise ApiError[code], self
35
54
  end
36
55
 
37
56
  def each_page(&block)
38
57
  raise ArgumentError, 'Not a collection response' unless raw['items']
39
58
 
40
- memoized_pages = (@memoized_pages ||= [self])
41
59
  ret =
42
60
  Enumerator.new do |y|
43
- page_index = 0
44
- current_page = self
45
- loop do
46
- y << current_page
47
-
48
- skip_amt = current_page.raw['items'].length + current_page.raw['skip']
49
- break if current_page.raw['items'].empty? || skip_amt >= current_page.raw['total']
50
-
51
- page_index += 1
52
- if page_index < memoized_pages.length
53
- current_page = memoized_pages[page_index]
54
- else
55
- current_page = @client.get(
56
- @request[:url],
57
- (@request[:query] || {}).merge({ skip: skip_amt })
58
- )
59
- current_page.assert_ok!
60
- memoized_pages.push(current_page)
61
+ y << self
62
+
63
+ if next_page?
64
+ next_page.each_page.each do |page|
65
+ y << page
61
66
  end
62
67
  end
63
68
  end
@@ -81,8 +86,21 @@ class WCC::Contentful::SimpleClient
81
86
 
82
87
  def first
83
88
  raise ArgumentError, 'Not a collection response' unless raw['items']
89
+
84
90
  raw['items'].first
85
91
  end
92
+
93
+ def includes
94
+ @includes ||=
95
+ raw.dig('includes')&.each_with_object({}) do |(_t, entries), h|
96
+ entries.each { |e| h[e.dig('sys', 'id')] = e }
97
+ end || {}
98
+
99
+ return @includes unless @next_page
100
+
101
+ # This could be more efficient - maybe not worth worrying about
102
+ @includes.merge(@next_page.includes)
103
+ end
86
104
  end
87
105
 
88
106
  class SyncResponse < Response
@@ -90,31 +108,36 @@ class WCC::Contentful::SimpleClient
90
108
  super(response.client, response.request, response.raw_response)
91
109
  end
92
110
 
111
+ def next_page?
112
+ raw['nextPageUrl'].present?
113
+ end
114
+
115
+ def next_page
116
+ return unless next_page?
117
+
118
+ @next_page ||= SyncResponse.new(@client.get(raw['nextPageUrl']))
119
+ @next_page.assert_ok!
120
+ end
121
+
93
122
  def next_sync_token
94
- @next_sync_token ||= SyncResponse.parse_sync_token(raw['nextSyncUrl'])
123
+ # If we haven't grabbed the next page yet, then our next "sync" will be getting
124
+ # the next page. We could just as easily call sync again with that token.
125
+ @next_page&.next_sync_token ||
126
+ @next_sync_token ||= SyncResponse.parse_sync_token(
127
+ raw['nextPageUrl'] || raw['nextSyncUrl']
128
+ )
95
129
  end
96
130
 
97
131
  def each_page
98
132
  raise ArgumentError, 'Not a collection response' unless raw['items']
99
133
 
100
- memoized_pages = (@memoized_pages ||= [self])
101
134
  ret =
102
135
  Enumerator.new do |y|
103
- page_index = 0
104
- current_page = self
105
- loop do
106
- y << current_page
107
-
108
- break if current_page.raw['items'].empty?
109
-
110
- page_index += 1
111
- if page_index < memoized_pages.length
112
- current_page = memoized_pages[page_index]
113
- else
114
- current_page = @client.get(raw['nextSyncUrl'])
115
- current_page.assert_ok!
116
- @next_sync_token = SyncResponse.parse_sync_token(current_page.raw['nextSyncUrl'])
117
- memoized_pages.push(current_page)
136
+ y << self
137
+
138
+ if next_page?
139
+ next_page.each_page.each do |page|
140
+ y << page
118
141
  end
119
142
  end
120
143
  end
@@ -127,7 +150,8 @@ class WCC::Contentful::SimpleClient
127
150
  end
128
151
 
129
152
  def count
130
- raw['items'].length
153
+ raise NotImplementedError,
154
+ 'Sync does not return an accurate total. Use #items.count instead.'
131
155
  end
132
156
 
133
157
  def self.parse_sync_token(url)
@@ -16,6 +16,18 @@ class TyphoeusAdapter
16
16
  )
17
17
  end
18
18
 
19
+ def post(url, body, headers = {}, proxy = {})
20
+ raise NotImplementedError, 'Proxying Not Yet Implemented' if proxy[:host]
21
+
22
+ TyphoeusAdapter::Response.new(
23
+ Typhoeus.post(
24
+ url,
25
+ body: body.to_json,
26
+ headers: headers
27
+ )
28
+ )
29
+ end
30
+
19
31
  Response =
20
32
  Struct.new(:raw) do
21
33
  delegate :body, to: :raw
@@ -1,20 +1,38 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require_relative 'store/base'
5
4
  require_relative 'store/memory_store'
6
- require_relative 'store/lazy_cache_store'
7
5
  require_relative 'store/cdn_adapter'
6
+ require_relative 'store/lazy_cache_store'
8
7
 
9
- # required dynamically if they select the 'postgres' store option
10
- # require_relative 'store/postgres_store'
11
-
8
+ # The "Store" is the middle layer in the WCC::Contentful gem. It exposes an API
9
+ # that implements the configured content delivery strategy.
10
+ #
11
+ # The different content delivery strategies require different store implementations.
12
+ #
13
+ # direct:: Uses the WCC::Contentful::Store::CDNAdapter to wrap the Contentful CDN,
14
+ # providing an API consistent with the other stores. Any query made to
15
+ # the CDNAdapter will be immediately passed through to the API.
16
+ # The CDNAdapter does not implement #index because it does not care about
17
+ # updates coming from the Sync API.
18
+ #
19
+ # lazy_sync:: Uses the Contentful CDN in combination with an ActiveSupport::Cache
20
+ # implementation in order to respond with the cached data where possible,
21
+ # saving your CDN quota. The cache is kept up-to-date via the Sync API
22
+ # and the WCC::Contentful::DelayedSyncJob. It is correct, but not complete.
23
+ #
24
+ # eager_sync:: Uses one of the full store implementations to store the entirety
25
+ # of the Contentful space locally. All queries are run against this
26
+ # local copy, which is kept up to date via the Sync API and the
27
+ # WCC::Contentful::DelayedSyncJob. The local store is correct and complete.
28
+ #
29
+ # The currently configured store is available on WCC::Contentful::Services.instance.store
12
30
  module WCC::Contentful::Store
13
31
  SYNC_STORES = {
14
32
  memory: ->(_config) { WCC::Contentful::Store::MemoryStore.new },
15
- postgres: ->(_config) {
33
+ postgres: ->(config, *options) {
16
34
  require_relative 'store/postgres_store'
17
- WCC::Contentful::Store::PostgresStore.new(ENV['POSTGRES_CONNECTION'])
35
+ WCC::Contentful::Store::PostgresStore.new(config, *options)
18
36
  }
19
37
  }.freeze
20
38
 
@@ -22,10 +40,11 @@ module WCC::Contentful::Store
22
40
  eager_sync
23
41
  lazy_sync
24
42
  direct
43
+ custom
25
44
  ].freeze
26
45
 
27
46
  Factory =
28
- Struct.new(:config, :cdn_method, :content_delivery_params) do
47
+ Struct.new(:config, :services, :cdn_method, :content_delivery_params) do
29
48
  def build_sync_store
30
49
  unless respond_to?("build_#{cdn_method}")
31
50
  raise ArgumentError, "Don't know how to build content delivery method #{cdn_method}"
@@ -36,38 +55,46 @@ module WCC::Contentful::Store
36
55
 
37
56
  def validate!
38
57
  unless CDN_METHODS.include?(cdn_method)
39
- raise ArgumentError, "Please use one of #{CDN_METHODS} for 'content_delivery'"
58
+ raise ArgumentError, "Please use one of #{CDN_METHODS} instead of #{cdn_method}"
40
59
  end
41
60
 
42
61
  return unless respond_to?("validate_#{cdn_method}")
62
+
43
63
  public_send("validate_#{cdn_method}", config, *content_delivery_params)
44
64
  end
45
65
 
46
- def build_eager_sync(config, store = nil, *_options)
47
- puts "store: #{store}"
48
- store = SYNC_STORES[store].call(config) if store.is_a?(Symbol)
66
+ def build_eager_sync(config, store = nil, *options)
67
+ store = SYNC_STORES[store].call(config, *options) if store.is_a?(Symbol)
49
68
  store || MemoryStore.new
50
69
  end
51
70
 
52
- def build_lazy_sync(config, *options)
71
+ def build_lazy_sync(_config, *options)
53
72
  WCC::Contentful::Store::LazyCacheStore.new(
54
- config.client,
73
+ services.client,
55
74
  cache: ActiveSupport::Cache.lookup_store(*options)
56
75
  )
57
76
  end
58
77
 
59
- def build_direct(config, *options)
78
+ def build_direct(_config, *options)
60
79
  if options.find { |array| array[:preview] == true }
61
- CDNAdapter.new(config.preview_client)
80
+ CDNAdapter.new(services.preview_client)
62
81
  else
63
- CDNAdapter.new(config.client)
82
+ CDNAdapter.new(services.client)
64
83
  end
65
84
  end
66
85
 
86
+ def build_custom(config, *options)
87
+ store = config.store
88
+ return store unless store&.respond_to?(:new)
89
+
90
+ store.new(config, options)
91
+ end
92
+
67
93
  def validate_eager_sync(_config, store = nil, *_options)
68
94
  return unless store.is_a?(Symbol)
69
95
 
70
- return if SYNC_STORES.keys.include?(store)
96
+ return if SYNC_STORES.key?(store)
97
+
71
98
  raise ArgumentError, "Please use one of #{SYNC_STORES.keys}"
72
99
  end
73
100
  end
@@ -1,20 +1,37 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
3
+ # @api Store
4
4
  module WCC::Contentful::Store
5
+ # This is the base class for stores which implement #index, and therefore
6
+ # must be kept up-to-date via the Sync API.
7
+ # @abstract At a minimum subclasses should override {#find}, {#find_all}, {#set},
8
+ # and #{delete}. As an alternative to overriding set and delete, the subclass
9
+ # can override {#index}. Index is called when a webhook triggers a sync, to
10
+ # update the store.
5
11
  class Base
12
+ # Finds an entry by it's ID. The returned entry is a JSON hash
13
+ # @abstract Subclasses should implement this at a minimum to provide data
14
+ # to the WCC::Contentful::Model API.
6
15
  def find(_id)
7
16
  raise NotImplementedError, "#{self.class} does not implement #find"
8
17
  end
9
18
 
19
+ # Sets the value of the entry with the given ID in the store.
20
+ # @abstract
10
21
  def set(_id, _value)
11
22
  raise NotImplementedError, "#{self.class} does not implement #set"
12
23
  end
13
24
 
25
+ # Removes the entry by ID from the store.
26
+ # @abstract
14
27
  def delete(_id)
15
28
  raise NotImplementedError, "#{self.class} does not implement #delete"
16
29
  end
17
30
 
31
+ # Processes a data point received via the Sync API. This can be a published
32
+ # entry or asset, or a 'DeletedEntry' or 'DeletedAsset'. The default
33
+ # implementation calls into #set and #delete to perform the appropriate
34
+ # operations in the store.
18
35
  def index(json)
19
36
  # Subclasses can override to do this in a more performant thread-safe way.
20
37
  # Example: postgres_store could do this in a stored procedure for speed
@@ -43,15 +60,30 @@ module WCC::Contentful::Store
43
60
  end
44
61
  end
45
62
 
46
- def find_by(content_type:, filter: nil)
63
+ # Finds the first entry matching the given filter. A content type is required.
64
+ #
65
+ # @param [String] content_type The ID of the content type to search for.
66
+ # @param [Hash] filter A set of key-value pairs defining filter operations.
67
+ # See WCC::Contentful::Store::Base::Query
68
+ # @param [Hash] options An optional set of additional parameters to the query
69
+ # defining for example include depth. Not all store implementations respect all options.
70
+ def find_by(content_type:, filter: nil, options: nil)
47
71
  # default implementation - can be overridden
48
- q = find_all(content_type: content_type)
72
+ q = find_all(content_type: content_type, options: { limit: 1 }.merge!(options || {}))
49
73
  q = q.apply(filter) if filter
50
74
  q.first
51
75
  end
52
76
 
77
+ # Finds all entries of the given content type. A content type is required.
78
+ #
79
+ # @abstract Subclasses should implement this at a minimum to provide data
80
+ # to the {WCC::Contentful::Model} API.
81
+ # @param [String] content_type The ID of the content type to search for.
82
+ # @param [Hash] options An optional set of additional parameters to the query
83
+ # defining for example include depth. Not all store implementations respect all options.
84
+ # @return [Query] A query object that exposes methods to apply filters
53
85
  # rubocop:disable Lint/UnusedMethodArgument
54
- def find_all(content_type:)
86
+ def find_all(content_type:, options: nil)
55
87
  raise NotImplementedError, "#{self.class} does not implement find_all"
56
88
  end
57
89
  # rubocop:enable Lint/UnusedMethodArgument
@@ -60,29 +92,117 @@ module WCC::Contentful::Store
60
92
  @mutex = Concurrent::ReentrantReadWriteLock.new
61
93
  end
62
94
 
95
+ def ensure_hash(val)
96
+ raise ArgumentError, 'Value must be a Hash' unless val.is_a?(Hash)
97
+ end
98
+
63
99
  protected
64
100
 
65
101
  attr_reader :mutex
66
102
 
103
+ # The base class for query objects returned by find_all. Subclasses should
104
+ # override the #result method to return an array-like containing the query
105
+ # results.
67
106
  class Query
68
107
  delegate :first, to: :result
69
108
  delegate :map, to: :result
70
109
  delegate :count, to: :result
71
110
 
111
+ OPERATORS = %i[
112
+ eq
113
+ ne
114
+ all
115
+ in
116
+ nin
117
+ exists
118
+ lt
119
+ lte
120
+ gt
121
+ gte
122
+ query
123
+ match
124
+ ].freeze
125
+
126
+ # @abstract Subclasses should provide this in order to fetch the results
127
+ # of the query.
72
128
  def result
73
129
  raise NotImplementedError
74
130
  end
75
131
 
132
+ def initialize(store)
133
+ @store = store
134
+ end
135
+
136
+ # @abstract Subclasses can either override this method to properly respond
137
+ # to find_by query objects, or they can define a method for each supported
138
+ # operator. Ex. `#eq`, `#ne`, `#gt`.
139
+ def apply_operator(operator, field, expected, context = nil)
140
+ respond_to?(operator) ||
141
+ raise(ArgumentError, "Operator not implemented: #{operator}")
142
+
143
+ public_send(operator, field, expected, context)
144
+ end
145
+
146
+ # Called with a filter object by {Base#find_by} in order to apply the filter.
76
147
  def apply(filter, context = nil)
77
148
  filter.reduce(self) do |query, (field, value)|
78
149
  if value.is_a?(Hash)
79
- k = value.keys.first
80
- raise ArgumentError, "Filter not implemented: #{value}" unless query.respond_to?(k)
81
- query.public_send(k, field, value[k], context)
150
+ if op?(k = value.keys.first)
151
+ query.apply_operator(k.to_sym, field.to_s, value[k], context)
152
+ else
153
+ query.nested_conditions(field, value, context)
154
+ end
82
155
  else
83
- query.eq(field.to_s, value)
156
+ query.apply_operator(:eq, field.to_s, value)
157
+ end
158
+ end
159
+ end
160
+
161
+ protected
162
+
163
+ # naive implementation recursively descends the graph to turns links into
164
+ # the actual entry data. This calls {Base#find} for each link and so it is
165
+ # very inefficient.
166
+ #
167
+ # @abstract Override this to provide a more efficient implementation for
168
+ # a given store.
169
+ def resolve_includes(entry, depth)
170
+ return entry unless entry && depth && depth > 0 && fields = entry['fields']
171
+
172
+ fields.each do |(_name, locales)|
173
+ # TODO: handle non-* locale
174
+ locales.each do |(locale, val)|
175
+ locales[locale] =
176
+ if val.is_a? Array
177
+ val.map { |e| resolve_link(e, depth) }
178
+ else
179
+ resolve_link(val, depth)
180
+ end
84
181
  end
85
182
  end
183
+
184
+ entry
185
+ end
186
+
187
+ def resolve_link(val, depth)
188
+ return val unless val.is_a?(Hash) && val.dig('sys', 'type') == 'Link'
189
+ return val unless included = @store.find(val.dig('sys', 'id'))
190
+
191
+ resolve_includes(included, depth - 1)
192
+ end
193
+
194
+ private
195
+
196
+ def op?(key)
197
+ OPERATORS.include?(key.to_sym)
198
+ end
199
+
200
+ def sys?(field)
201
+ field.to_s =~ /sys\./
202
+ end
203
+
204
+ def id?(field)
205
+ field.to_sym == :id
86
206
  end
87
207
  end
88
208
  end