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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful::Middleware
4
+ end
5
+
6
+ require_relative './middleware/store'
7
+ # someday: ./middleware/client & ./middleware/model ?
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../middleware'
4
+
5
+ # A Store middleware wraps the Store interface to perform any desired transformations
6
+ # on the Contentful entries coming back from the store. A Store middleware must
7
+ # implement the Store interface as well as a `store=` attribute writer, which is
8
+ # used to inject the next store or middleware in the chain.
9
+ #
10
+ # The Store interface can be seen on the WCC::Contentful::Store::Base class. It
11
+ # consists of the `#find, #find_by, #find_all, #set, #delete,` and `#index` methods.
12
+ #
13
+ # Including this concern will define those methods to pass through to the next store.
14
+ # Any of those methods can be overridden on the implementing middleware.
15
+ # It will also expose two overridable methods, `#select?` and `#transform`. These
16
+ # methods are applied when reading values out of the store, and can be used to
17
+ # apply a filter or transformation to each entry in the store.
18
+ module WCC::Contentful::Middleware::Store
19
+ extend ActiveSupport::Concern
20
+ include WCC::Contentful::Store::Interface
21
+
22
+ attr_accessor :store
23
+
24
+ delegate :index, :index?, to: :store
25
+
26
+ class_methods do
27
+ def call(store, *content_delivery_params, **_)
28
+ instance = new(*content_delivery_params)
29
+ instance.store = store
30
+ instance
31
+ end
32
+ end
33
+
34
+ def find(id, **options)
35
+ found = store.find(id, **options)
36
+ return transform(found) if found && (!has_select? || select?(found))
37
+ end
38
+
39
+ def find_by(options: nil, **args)
40
+ result = store.find_by(**args.merge(options: options))
41
+ return unless result && (!has_select? || select?(result))
42
+
43
+ result = resolve_includes(result, options[:include]) if options && options[:include]
44
+ transform(result)
45
+ end
46
+
47
+ def find_all(options: nil, **args)
48
+ DelegatingQuery.new(
49
+ store.find_all(**args.merge(options: options)),
50
+ middleware: self,
51
+ options: options
52
+ )
53
+ end
54
+
55
+ def resolve_includes(entry, depth)
56
+ return entry unless entry && depth && depth > 0
57
+
58
+ WCC::Contentful::LinkVisitor.new(entry, :Link, depth: depth).map! do |val|
59
+ resolve_link(val)
60
+ end
61
+ end
62
+
63
+ def resolve_link(val)
64
+ return val unless resolved_link?(val)
65
+
66
+ if !has_select? || select?(val)
67
+ transform(val)
68
+ else
69
+ # Pretend it's an unresolved link -
70
+ # matches the behavior of a store when the link cannot be retrieved
71
+ WCC::Contentful::Link.new(val.dig('sys', 'id'), val.dig('sys', 'type')).to_h
72
+ end
73
+ end
74
+
75
+ def resolved_link?(value)
76
+ value.is_a?(Hash) && value.dig('sys', 'type') == 'Entry'
77
+ end
78
+
79
+ def has_select? # rubocop:disable Naming/PredicateName
80
+ respond_to?(:select?)
81
+ end
82
+
83
+ # The default version of `#transform` just returns the entry.
84
+ # Override this with your own implementation.
85
+ def transform(entry)
86
+ entry
87
+ end
88
+
89
+ class DelegatingQuery
90
+ include WCC::Contentful::Store::Query::Interface
91
+ include Enumerable
92
+
93
+ # by default all enumerable methods delegated to the to_enum method
94
+ delegate(*(Enumerable.instance_methods - Module.instance_methods), to: :to_enum)
95
+
96
+ def count
97
+ if middleware.has_select?
98
+ raise NameError, "Count cannot be determined because the middleware '#{middleware}'" \
99
+ " implements the #select? method. Please use '.to_a.count' to count entries that" \
100
+ ' pass the #select? method.'
101
+ end
102
+
103
+ # The wrapped query may get count from the "Total" field in the response,
104
+ # or apply a "COUNT(*)" to the query.
105
+ wrapped_query.count
106
+ end
107
+
108
+ attr_reader :wrapped_query, :middleware, :options
109
+
110
+ def to_enum
111
+ result = wrapped_query.to_enum
112
+ result = result.select { |x| middleware.select?(x) } if middleware.has_select?
113
+
114
+ if options && options[:include]
115
+ result = result.map { |x| middleware.resolve_includes(x, options[:include]) }
116
+ end
117
+
118
+ result.map { |x| middleware.transform(x) }
119
+ end
120
+
121
+ def apply(filter, context = nil)
122
+ self.class.new(
123
+ wrapped_query.apply(filter, context),
124
+ middleware: middleware,
125
+ options: options,
126
+ **@extra
127
+ )
128
+ end
129
+
130
+ def apply_operator(operator, field, expected, context = nil)
131
+ self.class.new(
132
+ wrapped_query.apply_operator(operator, field, expected, context),
133
+ middleware: middleware,
134
+ options: options,
135
+ **@extra
136
+ )
137
+ end
138
+
139
+ WCC::Contentful::Store::Query::Interface::OPERATORS.each do |op|
140
+ # @see #apply_operator
141
+ define_method(op) do |field, expected, context = nil|
142
+ self.class.new(
143
+ wrapped_query.public_send(op, field, expected, context),
144
+ middleware: middleware,
145
+ options: options,
146
+ **@extra
147
+ )
148
+ end
149
+ end
150
+
151
+ def initialize(wrapped_query, middleware:, options: nil, **extra)
152
+ @wrapped_query = wrapped_query
153
+ @middleware = middleware
154
+ @options = options
155
+ @extra = extra
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful::Middleware::Store
4
+ class CachingMiddleware
5
+ include WCC::Contentful::Middleware::Store
6
+ # include instrumentation, but not specifically store stack instrumentation
7
+ include WCC::Contentful::Instrumentation
8
+
9
+ attr_accessor :expires_in
10
+
11
+ def initialize(cache = nil)
12
+ @cache = cache || ActiveSupport::Cache::MemoryStore.new
13
+ @expires_in = nil
14
+ end
15
+
16
+ def find(key, **options)
17
+ event = 'fresh'
18
+ found =
19
+ @cache.fetch(key, expires_in: expires_in) do
20
+ event = 'miss'
21
+ # if it's not a contentful ID don't hit the API.
22
+ # Store a nil object if we can't find the object on the CDN.
23
+ (store.find(key, options) || nil_obj(key)) if key =~ /^\w+$/
24
+ end
25
+ _instrument(event, key: key, options: options)
26
+
27
+ case found.try(:dig, 'sys', 'type')
28
+ when 'Nil', 'DeletedEntry', 'DeletedAsset'
29
+ nil
30
+ else
31
+ found
32
+ end
33
+ end
34
+
35
+ # TODO: https://github.com/watermarkchurch/wcc-contentful/issues/18
36
+ # figure out how to cache the results of a find_by query, ex:
37
+ # `find_by('slug' => '/about')`
38
+ def find_by(content_type:, filter: nil, options: nil)
39
+ if filter&.keys == ['sys.id']
40
+ # Direct ID lookup, like what we do in `WCC::Contentful::ModelMethods.resolve`
41
+ # We can return just this item. Stores are not required to implement :include option.
42
+ if found = @cache.read(filter['sys.id'])
43
+ return found
44
+ end
45
+ end
46
+
47
+ store.find_by(content_type: content_type, filter: filter, options: options)
48
+ end
49
+
50
+ delegate :find_all, to: :store
51
+
52
+ # #index is called whenever the sync API comes back with more data.
53
+ def index(json)
54
+ delegated_result = store.index(json) if store.index?
55
+ caching_result = _index(json)
56
+ # _index returns nil if we don't already have it cached - so use the store result.
57
+ # store result is nil if it doesn't index, so use the caching result if we have it.
58
+ # They ought to be the same thing if it's cached and the store also indexes.
59
+ caching_result || delegated_result
60
+ end
61
+
62
+ def index?
63
+ true
64
+ end
65
+
66
+ private
67
+
68
+ LAZILY_CACHEABLE_TYPES = %w[
69
+ Entry
70
+ Asset
71
+ DeletedEntry
72
+ DeletedAsset
73
+ ].freeze
74
+
75
+ def _index(json)
76
+ ensure_hash(json)
77
+ id = json.dig('sys', 'id')
78
+ type = json.dig('sys', 'type')
79
+ prev = @cache.read(id)
80
+ return if prev.nil? && LAZILY_CACHEABLE_TYPES.include?(type)
81
+
82
+ if (prev_rev = prev&.dig('sys', 'revision')) && (next_rev = json.dig('sys', 'revision'))
83
+ return prev if next_rev < prev_rev
84
+ end
85
+
86
+ # we also set DeletedEntry objects in the cache - no need to go hit the API when we know
87
+ # this is a nil object
88
+ @cache.write(id, json, expires_in: expires_in)
89
+
90
+ case type
91
+ when 'DeletedEntry', 'DeletedAsset'
92
+ _instrument 'delete', id: id
93
+ nil
94
+ else
95
+ _instrument 'set', id: id
96
+ json
97
+ end
98
+ end
99
+
100
+ def nil_obj(id)
101
+ {
102
+ 'sys' => {
103
+ 'id' => id,
104
+ 'type' => 'Nil',
105
+ 'revision' => 1
106
+ }
107
+ }
108
+ end
109
+
110
+ def ensure_hash(val)
111
+ raise ArgumentError, 'Value must be a Hash' unless val.is_a?(Hash)
112
+ end
113
+ end
114
+ end
@@ -54,15 +54,29 @@ class WCC::Contentful::Model
54
54
 
55
55
  @@registry = {}
56
56
 
57
+ def self.store(preview = false)
58
+ if preview
59
+ if preview_store.nil?
60
+ raise ArgumentError,
61
+ 'You must include a contentful preview token in your WCC::Contentful.configure block'
62
+ end
63
+ preview_store
64
+ else
65
+ super()
66
+ end
67
+ end
68
+
57
69
  # Finds an Entry or Asset by ID in the configured contentful space
58
70
  # and returns an initialized instance of the appropriate model type.
59
71
  #
60
72
  # Makes use of the {WCC::Contentful::Services#store configured store}
61
73
  # to access the Contentful CDN.
62
- def self.find(id, context = nil)
63
- return unless raw = store.find(id)
74
+ def self.find(id, options: nil)
75
+ options ||= {}
76
+ raw = store(options[:preview])
77
+ .find(id, options.except(*WCC::Contentful::ModelMethods::MODEL_LAYER_CONTEXT_KEYS))
64
78
 
65
- new_from_raw(raw, context)
79
+ new_from_raw(raw, options) if raw.present?
66
80
  end
67
81
 
68
82
  # Creates a new initialized instance of the appropriate model type for the
@@ -77,6 +91,8 @@ class WCC::Contentful::Model
77
91
  # Accepts a content type ID as a string and returns the Ruby constant
78
92
  # stored in the registry that represents this content type.
79
93
  def self.resolve_constant(content_type)
94
+ raise ArgumentError, 'content_type cannot be nil' unless content_type
95
+
80
96
  const = @@registry[content_type]
81
97
  return const if const
82
98
 
@@ -129,6 +145,24 @@ class WCC::Contentful::Model
129
145
  @@registry.dup.freeze
130
146
  end
131
147
 
148
+ def self.reload!
149
+ registry = self.registry
150
+ registry.each do |(content_type, klass)|
151
+ const_name = klass.name
152
+ begin
153
+ const = Object.const_missing(const_name)
154
+ register_for_content_type(content_type, klass: const) if const
155
+ rescue NameError => e
156
+ msg = "Error when reloading constant #{const_name} - #{e}"
157
+ if defined?(Rails) && Rails.logger
158
+ Rails.logger.error msg
159
+ else
160
+ puts msg
161
+ end
162
+ end
163
+ end
164
+ end
165
+
132
166
  # Checks if a content type has already been registered to a class and returns
133
167
  # that class. If nil, the generated WCC::Contentful::Model::{content_type} class
134
168
  # will be resolved for this content type.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './link'
3
4
  require_relative './sys'
4
5
 
5
6
  module WCC::Contentful
@@ -5,6 +5,15 @@
5
5
  #
6
6
  # @api Model
7
7
  module WCC::Contentful::ModelMethods
8
+ include WCC::Contentful::Instrumentation
9
+
10
+ # The set of options keys that are specific to the Model layer and shouldn't
11
+ # be passed down to the Store layer.
12
+ MODEL_LAYER_CONTEXT_KEYS = %i[
13
+ preview
14
+ backlinks
15
+ ].freeze
16
+
8
17
  # Resolves all links in an entry to the specified depth.
9
18
  #
10
19
  # Each link in the entry is recursively retrieved from the store until the given
@@ -22,7 +31,7 @@ module WCC::Contentful::ModelMethods
22
31
  # handled. `:raise` causes a {WCC::Contentful::CircularReferenceError} to be raised,
23
32
  # `:ignore` will cause the field to remain unresolved, and any other value (or nil)
24
33
  # will cause the field to point to the previously resolved ruby object for that ID.
25
- def resolve(depth: 1, fields: nil, context: {}, **options)
34
+ def resolve(depth: 1, fields: nil, context: sys.context.to_h, **options)
26
35
  raise ArgumentError, "Depth must be > 0 (was #{depth})" unless depth && depth > 0
27
36
  return self if resolved?(depth: depth, fields: fields)
28
37
 
@@ -32,20 +41,26 @@ module WCC::Contentful::ModelMethods
32
41
  typedef = self.class.content_type_definition
33
42
  links = fields.select { |f| %i[Asset Link].include?(typedef.fields[f].type) }
34
43
 
35
- raw_links =
36
- links.any? do |field_name|
37
- raw_value = raw.dig('fields', field_name, sys.locale)
38
- if raw_value&.is_a? Array
39
- raw_value.any? { |v| v&.dig('sys', 'type') == 'Link' }
40
- elsif raw_value
41
- raw_value.dig('sys', 'type') == 'Link'
44
+ raw_link_ids =
45
+ links.map { |field_name| raw.dig('fields', field_name, sys.locale) }
46
+ .flat_map do |raw_value|
47
+ _try_map(raw_value) { |v| v.dig('sys', 'id') if v.dig('sys', 'type') == 'Link' }
48
+ end
49
+ raw_link_ids = raw_link_ids.compact
50
+ backlinked_ids = (context[:backlinks]&.map { |m| m.id } || [])
51
+
52
+ has_unresolved_raw_links = (raw_link_ids - backlinked_ids).any?
53
+ if has_unresolved_raw_links
54
+ raw =
55
+ _instrument 'resolve', id: id, depth: depth, backlinks: backlinked_ids do
56
+ # use include param to do resolution
57
+ self.class.store(context[:preview])
58
+ .find_by(content_type: self.class.content_type,
59
+ filter: { 'sys.id' => id },
60
+ options: context.except(*MODEL_LAYER_CONTEXT_KEYS).merge!({
61
+ include: [depth, 10].min
62
+ }))
42
63
  end
43
- end
44
- if raw_links
45
- # use include param to do resolution
46
- raw = self.class.store.find_by(content_type: self.class.content_type,
47
- filter: { 'sys.id' => id },
48
- options: { include: [depth, 10].min })
49
64
  unless raw
50
65
  raise WCC::Contentful::ResolveError, "Cannot find #{self.class.content_type} with ID #{id}"
51
66
  end
@@ -118,6 +133,12 @@ module WCC::Contentful::ModelMethods
118
133
 
119
134
  delegate :to_json, to: :to_h
120
135
 
136
+ protected
137
+
138
+ def _instrumentation_event_prefix
139
+ '.model.contentful.wcc'
140
+ end
141
+
121
142
  private
122
143
 
123
144
  def _resolve_field(field_name, depth = 1, context = {}, options = {})
@@ -147,7 +168,10 @@ module WCC::Contentful::ModelMethods
147
168
  # instantiate from already resolved raw entry data.
148
169
  m = already_resolved ||
149
170
  if raw.dig('sys', 'type') == 'Link'
150
- WCC::Contentful::Model.find(id, new_context)
171
+ _instrument 'resolve',
172
+ id: self.id, depth: depth, backlinks: context[:backlinks]&.map(&:id) do
173
+ WCC::Contentful::Model.find(id, options: new_context)
174
+ end
151
175
  else
152
176
  WCC::Contentful::Model.new_from_raw(raw, new_context)
153
177
  end
@@ -158,6 +182,7 @@ module WCC::Contentful::ModelMethods
158
182
 
159
183
  begin
160
184
  val = _try_map(val) { |v| load.call(v) }
185
+ val = val.compact if val.is_a? Array
161
186
 
162
187
  instance_variable_set(var_name + '_resolved', val)
163
188
  rescue WCC::Contentful::CircularReferenceError