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,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