wcc-contentful 0.4.0.pre.rc → 1.0.0.pre.rc1

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 +5 -5
  2. data/Guardfile +43 -0
  3. data/README.md +101 -12
  4. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  5. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  6. data/config/routes.rb +1 -1
  7. data/doc/wcc-contentful.png +0 -0
  8. data/lib/tasks/download_schema.rake +12 -0
  9. data/lib/wcc/contentful.rb +70 -16
  10. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  11. data/lib/wcc/contentful/configuration.rb +177 -46
  12. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  13. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  14. data/lib/wcc/contentful/engine.rb +33 -14
  15. data/lib/wcc/contentful/event.rb +171 -0
  16. data/lib/wcc/contentful/events.rb +41 -0
  17. data/lib/wcc/contentful/exceptions.rb +3 -0
  18. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  19. data/lib/wcc/contentful/instrumentation.rb +31 -0
  20. data/lib/wcc/contentful/link.rb +28 -0
  21. data/lib/wcc/contentful/link_visitor.rb +122 -0
  22. data/lib/wcc/contentful/middleware.rb +7 -0
  23. data/lib/wcc/contentful/middleware/store.rb +158 -0
  24. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  25. data/lib/wcc/contentful/model.rb +37 -3
  26. data/lib/wcc/contentful/model_builder.rb +1 -0
  27. data/lib/wcc/contentful/model_methods.rb +40 -15
  28. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  29. data/lib/wcc/contentful/rake.rb +3 -0
  30. data/lib/wcc/contentful/rspec.rb +13 -8
  31. data/lib/wcc/contentful/services.rb +61 -27
  32. data/lib/wcc/contentful/simple_client.rb +81 -25
  33. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  34. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  35. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  36. data/lib/wcc/contentful/store.rb +7 -66
  37. data/lib/wcc/contentful/store/README.md +85 -0
  38. data/lib/wcc/contentful/store/base.rb +34 -119
  39. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  40. data/lib/wcc/contentful/store/factory.rb +186 -0
  41. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  42. data/lib/wcc/contentful/store/interface.rb +82 -0
  43. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  44. data/lib/wcc/contentful/store/postgres_store.rb +253 -107
  45. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  46. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  47. data/lib/wcc/contentful/store/query.rb +246 -0
  48. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  49. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  50. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  51. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  52. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  53. data/lib/wcc/contentful/sync_engine.rb +181 -0
  54. data/lib/wcc/contentful/test/attributes.rb +17 -5
  55. data/lib/wcc/contentful/test/factory.rb +22 -46
  56. data/lib/wcc/contentful/version.rb +1 -1
  57. data/wcc-contentful.gemspec +14 -11
  58. metadata +201 -146
  59. data/Gemfile +0 -6
  60. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  61. data/lib/wcc/contentful/client_ext.rb +0 -28
  62. data/lib/wcc/contentful/graphql.rb +0 -14
  63. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  64. data/lib/wcc/contentful/graphql/types.rb +0 -54
  65. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  66. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -1,20 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './interface'
4
+
3
5
  # @api Store
4
6
  module WCC::Contentful::Store
5
7
  # This is the base class for stores which implement #index, and therefore
6
8
  # must be kept up-to-date via the Sync API.
7
- # @abstract At a minimum subclasses should override {#find}, {#find_all}, {#set},
9
+ # @abstract At a minimum subclasses should override {#find}, {#execute}, {#set},
8
10
  # and #{delete}. As an alternative to overriding set and delete, the subclass
9
11
  # can override {#index}. Index is called when a webhook triggers a sync, to
10
12
  # update the store.
13
+ #
14
+ # To implement a new store, you should include the rspec_examples in your rspec
15
+ # tests for the store. See spec/wcc/contentful/store/memory_store_spec.rb for
16
+ # an example.
11
17
  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.
15
- def find(_id)
16
- raise NotImplementedError, "#{self.class} does not implement #find"
17
- end
18
+ include WCC::Contentful::Store::Interface
18
19
 
19
20
  # Sets the value of the entry with the given ID in the store.
20
21
  # @abstract
@@ -28,6 +29,22 @@ module WCC::Contentful::Store
28
29
  raise NotImplementedError, "#{self.class} does not implement #delete"
29
30
  end
30
31
 
32
+ # Executes a WCC::Contentful::Store::Query object created by {#find_all} or
33
+ # {#find_by}. Implementations should override this to translate the query's
34
+ # conditions into a query against the datastore.
35
+ #
36
+ # For a very naiive implementation see WCC::Contentful::Store::MemoryStore#execute
37
+ # @abstract
38
+ def execute(_query)
39
+ raise NotImplementedError, "#{self.class} does not implement #execute"
40
+ end
41
+
42
+ # Returns true if this store can persist entries and assets which are
43
+ # retrieved from the sync API.
44
+ def index?
45
+ true
46
+ end
47
+
31
48
  # Processes a data point received via the Sync API. This can be a published
32
49
  # entry or asset, or a 'DeletedEntry' or 'DeletedAsset'. The default
33
50
  # implementation calls into #set and #delete to perform the appropriate
@@ -76,17 +93,19 @@ module WCC::Contentful::Store
76
93
 
77
94
  # Finds all entries of the given content type. A content type is required.
78
95
  #
79
- # @abstract Subclasses should implement this at a minimum to provide data
80
- # to the {WCC::Contentful::Model} API.
96
+ # Subclasses may override this to provide their own query implementation,
97
+ # or else override #execute to run the query after it has been parsed.
81
98
  # @param [String] content_type The ID of the content type to search for.
82
99
  # @param [Hash] options An optional set of additional parameters to the query
83
100
  # defining for example include depth. Not all store implementations respect all options.
84
101
  # @return [Query] A query object that exposes methods to apply filters
85
- # rubocop:disable Lint/UnusedMethodArgument
86
102
  def find_all(content_type:, options: nil)
87
- raise NotImplementedError, "#{self.class} does not implement find_all"
103
+ Query.new(
104
+ self,
105
+ content_type: content_type,
106
+ options: options
107
+ )
88
108
  end
89
- # rubocop:enable Lint/UnusedMethodArgument
90
109
 
91
110
  def initialize
92
111
  @mutex = Concurrent::ReentrantReadWriteLock.new
@@ -96,114 +115,10 @@ module WCC::Contentful::Store
96
115
  raise ArgumentError, 'Value must be a Hash' unless val.is_a?(Hash)
97
116
  end
98
117
 
99
- protected
118
+ private
100
119
 
101
120
  attr_reader :mutex
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.
106
- class Query
107
- delegate :first, to: :result
108
- delegate :map, to: :result
109
- delegate :count, to: :result
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.
128
- def result
129
- raise NotImplementedError
130
- end
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.
147
- def apply(filter, context = nil)
148
- filter.reduce(self) do |query, (field, value)|
149
- if value.is_a?(Hash)
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
155
- else
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
181
- end
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
206
- end
207
- end
208
121
  end
209
122
  end
123
+
124
+ require_relative './query'
@@ -2,13 +2,30 @@
2
2
 
3
3
  module WCC::Contentful::Store
4
4
  class CDNAdapter
5
- attr_reader :client
5
+ include WCC::Contentful::Store::Interface
6
+ # Note: CDNAdapter should not instrument store events cause it's not a store.
7
+
8
+ attr_writer :client, :preview_client
9
+
10
+ def client
11
+ @preview ? @preview_client : @client
12
+ end
13
+
14
+ # The CDNAdapter cannot index data coming back from the Sync API.
15
+ def index?
16
+ false
17
+ end
18
+
19
+ def index
20
+ raise NotImplementedError, 'Cannot put data to the CDN!'
21
+ end
6
22
 
7
23
  # Intentionally not implementing write methods
8
24
 
9
- def initialize(client)
25
+ def initialize(client = nil, preview: false)
10
26
  super()
11
27
  @client = client
28
+ @preview = preview
12
29
  end
13
30
 
14
31
  def find(key, hint: nil, **options)
@@ -37,39 +54,61 @@ module WCC::Contentful::Store
37
54
 
38
55
  def find_all(content_type:, options: nil)
39
56
  Query.new(
40
- store: self,
41
- client: @client,
57
+ self,
58
+ client: client,
42
59
  relation: { content_type: content_type },
43
60
  options: options
44
61
  )
45
62
  end
46
63
 
47
- class Query < Base::Query
64
+ class Query
65
+ include WCC::Contentful::Store::Query::Interface
66
+ include Enumerable
67
+
68
+ # by default all enumerable methods delegated to the lazy enumerable
69
+ delegate(*(Enumerable.instance_methods - Module.instance_methods), to: :to_enum)
70
+
71
+ # response.count gets the number of items
48
72
  delegate :count, to: :response
49
73
 
50
- def result
74
+ def to_enum
51
75
  return response.items unless @options[:include]
52
76
 
53
77
  response.items.map { |e| resolve_includes(e, @options[:include]) }
54
78
  end
55
79
 
56
- def initialize(store:, client:, relation:, options: nil, **extra)
80
+ def initialize(store, client:, relation:, options: nil, **extra)
57
81
  raise ArgumentError, 'Client cannot be nil' unless client.present?
58
82
  raise ArgumentError, 'content_type must be provided' unless relation[:content_type].present?
59
83
 
60
- super(store)
84
+ @store = store
61
85
  @client = client
62
86
  @relation = relation
63
87
  @options = options || {}
64
88
  @extra = extra || {}
65
89
  end
66
90
 
91
+ # Called with a filter object by {Base#find_by} in order to apply the filter.
92
+ def apply(filter, context = nil)
93
+ filter.reduce(self) do |query, (field, value)|
94
+ if value.is_a?(Hash)
95
+ if op?(k = value.keys.first)
96
+ query.apply_operator(k.to_sym, field.to_s, value[k], context)
97
+ else
98
+ query.nested_conditions(field, value, context)
99
+ end
100
+ else
101
+ query.apply_operator(:eq, field.to_s, value)
102
+ end
103
+ end
104
+ end
105
+
67
106
  def apply_operator(operator, field, expected, context = nil)
68
107
  op = operator == :eq ? nil : operator
69
108
  param = parameter(field, operator: op, context: context, locale: true)
70
109
 
71
110
  self.class.new(
72
- store: @store,
111
+ @store,
73
112
  client: @client,
74
113
  relation: @relation.merge(param => expected),
75
114
  options: @options,
@@ -85,7 +124,7 @@ module WCC::Contentful::Store
85
124
  end
86
125
  end
87
126
 
88
- Base::Query::OPERATORS.each do |op|
127
+ WCC::Contentful::Store::Query::Interface::OPERATORS.each do |op|
89
128
  define_method(op) do |field, expected, context = nil|
90
129
  apply_operator(op, field, expected, context)
91
130
  end
@@ -93,6 +132,18 @@ module WCC::Contentful::Store
93
132
 
94
133
  private
95
134
 
135
+ def op?(key)
136
+ WCC::Contentful::Store::Query::Interface::OPERATORS.include?(key.to_sym)
137
+ end
138
+
139
+ def sys?(field)
140
+ field.to_s =~ /sys\./
141
+ end
142
+
143
+ def id?(field)
144
+ field.to_sym == :id
145
+ end
146
+
96
147
  def response
97
148
  @response ||=
98
149
  if @relation[:content_type] == 'Asset'
@@ -104,11 +155,19 @@ module WCC::Contentful::Store
104
155
  end
105
156
  end
106
157
 
107
- def resolve_link(val, depth)
158
+ def resolve_includes(entry, depth)
159
+ return entry unless entry && depth && depth > 0
160
+
161
+ WCC::Contentful::LinkVisitor.new(entry, :Link, :Asset, depth: depth - 1).map! do |val|
162
+ resolve_link(val)
163
+ end
164
+ end
165
+
166
+ def resolve_link(val)
108
167
  return val unless val.is_a?(Hash) && val.dig('sys', 'type') == 'Link'
109
168
  return val unless included = response.includes[val.dig('sys', 'id')]
110
169
 
111
- resolve_includes(included, depth - 1)
170
+ included
112
171
  end
113
172
 
114
173
  def parameter(field, operator: nil, context: nil, locale: false)
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'memory_store'
5
+ require_relative 'cdn_adapter'
6
+ require_relative '../middleware/store'
7
+ require_relative '../middleware/store/caching_middleware'
8
+
9
+ module WCC::Contentful::Store
10
+ # This factory presents a DSL for configuring the store stack. The store stack
11
+ # sits in between the Model layer and the datastore, which can be Contentful
12
+ # or something else like Postgres.
13
+ #
14
+ # A set of "presets" are available to get pre-configured stacks based on what
15
+ # we've found most useful.
16
+ class Factory
17
+ attr_reader :preset, :options, :config
18
+
19
+ # Set the base store instance.
20
+ attr_accessor :store
21
+
22
+ # An array of tuples that set up and configure a Store middleware.
23
+ def middleware
24
+ @middleware ||= self.class.default_middleware.dup
25
+ end
26
+
27
+ def initialize(config = WCC::Contentful.configuration, preset = :direct, options = nil)
28
+ @config = config
29
+ @preset = preset || :custom
30
+ @options = [*options] || []
31
+
32
+ # Infer whether they passed in a store implementation object or class
33
+ if class_implements_store_interface?(@preset) ||
34
+ object_implements_store_interface?(@preset)
35
+ @options.unshift(@preset)
36
+ @preset = :custom
37
+ end
38
+
39
+ configure_preset(@preset)
40
+ end
41
+
42
+ # Adds a middleware to the chain. Use a block here to configure the middleware
43
+ # after it has been created.
44
+ def use(middleware, *middleware_params, &block)
45
+ configure_proc = block_given? ? Proc.new(&block) : nil
46
+ self.middleware << [middleware, middleware_params, configure_proc]
47
+ end
48
+
49
+ def replace(middleware, *middleware_params, &block)
50
+ idx = self.middleware.find_index { |m| m[0] == middleware }
51
+ raise ArgumentError, "Middleware #{middleware} not present" if idx.nil?
52
+
53
+ configure_proc = block_given? ? Proc.new(&block) : nil
54
+ self.middleware[idx] = [middleware, middleware_params, configure_proc]
55
+ end
56
+
57
+ def unuse(middleware)
58
+ idx = self.middleware.find_index { |m| m[0] == middleware }
59
+ return if idx.nil?
60
+
61
+ self.middleware.delete_at idx
62
+ end
63
+
64
+ def build(services = WCC::Contentful::Services.instance)
65
+ store_instance = build_store(services)
66
+ options = {
67
+ config: config,
68
+ services: services
69
+ }
70
+ middleware.reverse
71
+ .reduce(store_instance) do |memo, middleware_config|
72
+ # May have added a middleware with `middleware << MyMiddleware.new`
73
+ middleware_config = [middleware_config] unless middleware_config.is_a? Array
74
+
75
+ middleware, params, configure_proc = middleware_config
76
+ middleware_options = options.merge((params || []).extract_options!)
77
+ middleware = middleware.call(memo, *params, **middleware_options)
78
+ middleware&.instance_exec(&configure_proc) if configure_proc
79
+ middleware || memo
80
+ end
81
+ end
82
+
83
+ def validate!
84
+ unless preset.nil? || PRESETS.include?(preset)
85
+ raise ArgumentError, "Please use one of #{PRESETS} instead of #{preset}"
86
+ end
87
+
88
+ middleware.each do |m|
89
+ next if m[0].respond_to?(:call)
90
+
91
+ raise ArgumentError, "The middleware '#{m[0]&.try(:name) || m[0]}' cannot be applied! " \
92
+ 'It must respond to :call'
93
+ end
94
+
95
+ validate_store!(store)
96
+ end
97
+
98
+ # Sets the "eager sync" preset using one of the preregistered stores like :postgres
99
+ def preset_eager_sync
100
+ store = options.shift || :memory
101
+ store = SYNC_STORES[store]&.call(config, *options) if store.is_a?(Symbol)
102
+ self.store = store
103
+ end
104
+
105
+ # Configures a "lazy sync" preset which caches direct lookups but hits Contentful
106
+ # for any missing information. The cache is kept up to date by the sync engine.
107
+ def preset_lazy_sync
108
+ preset_direct
109
+ use(WCC::Contentful::Middleware::Store::CachingMiddleware,
110
+ ActiveSupport::Cache.lookup_store(*options))
111
+ end
112
+
113
+ # Configures the default "direct" preset which passes everything through to
114
+ # Contentful CDN
115
+ def preset_direct
116
+ self.store = CDNAdapter.new(preview: options.include?(:preview))
117
+ end
118
+
119
+ def preset_custom
120
+ self.store = options.shift
121
+ end
122
+
123
+ private
124
+
125
+ def validate_store!(store)
126
+ raise ArgumentError, 'No store provided' unless store
127
+
128
+ return true if class_implements_store_interface?(store) ||
129
+ object_implements_store_interface?(store)
130
+
131
+ methods = [*store.try(:instance_methods), *store.try(:methods)]
132
+ WCC::Contentful::Store::Interface::INTERFACE_METHODS.each do |method|
133
+ next if methods.include?(method)
134
+
135
+ raise ArgumentError, "Custom store '#{store}' must respond to the #{method} method"
136
+ end
137
+ end
138
+
139
+ def configure_preset(preset)
140
+ unless respond_to?("preset_#{preset}")
141
+ raise ArgumentError, "Don't know how to build content delivery method '#{preset}'"
142
+ end
143
+
144
+ public_send("preset_#{preset}")
145
+ end
146
+
147
+ def build_store(services)
148
+ store_class = store
149
+ store =
150
+ if object_implements_store_interface?(store_class)
151
+ store_class
152
+ else
153
+ store_class.new(config, *options - [store_class])
154
+ end
155
+
156
+ # Inject services into the custom store class
157
+ (WCC::Contentful::SERVICES - %i[store preview_store]).each do |s|
158
+ next unless store.respond_to?("#{s}=")
159
+
160
+ store.public_send("#{s}=",
161
+ services.public_send(s))
162
+ end
163
+
164
+ store
165
+ end
166
+
167
+ def class_implements_store_interface?(klass)
168
+ (WCC::Contentful::Store::Interface::INTERFACE_METHODS -
169
+ (klass.try(:instance_methods) || [])).empty?
170
+ end
171
+
172
+ def object_implements_store_interface?(object)
173
+ (WCC::Contentful::Store::Interface::INTERFACE_METHODS -
174
+ (object.try(:methods) || [])).empty?
175
+ end
176
+
177
+ class << self
178
+ # The middleware that by default lives at the top of the middleware stack.
179
+ def default_middleware
180
+ [
181
+ [WCC::Contentful::Store::InstrumentationMiddleware]
182
+ ].freeze
183
+ end
184
+ end
185
+ end
186
+ end