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

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