wcc-contentful 0.2.2 → 0.3.0.pre.rc

Sign up to get free protection for your applications and to get access to all the features.
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