wcc-contentful 0.3.0.pre.rc3 → 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 (69) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -0
  3. data/Guardfile +101 -0
  4. data/README.md +161 -11
  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/wcc-contentful.png +0 -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 +3 -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 +22 -10
  61. metadata +282 -103
  62. data/Gemfile +0 -8
  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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WCC::Contentful::Link
4
+ attr_reader :id
5
+ attr_reader :link_type
6
+ attr_reader :raw
7
+
8
+ LINK_TYPES = {
9
+ Asset: 'Asset',
10
+ Link: 'Entry'
11
+ }.freeze
12
+
13
+ def initialize(model, link_type = nil)
14
+ @id = model.try(:id) || model
15
+ @link_type = link_type
16
+ @link_type ||= model.is_a?(WCC::Contentful::Model::Asset) ? :Asset : :Link
17
+ @raw =
18
+ {
19
+ 'sys' => {
20
+ 'type' => 'Link',
21
+ 'linkType' => LINK_TYPES[@link_type] || link_type,
22
+ 'id' => @id
23
+ }
24
+ }
25
+ end
26
+
27
+ alias_method :to_h, :raw
28
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The LinkVisitor is a utility class for walking trees of linked entries.
4
+ # It is used internally by the Store layer to compose the resulting resolved hashes.
5
+ # But you can use it too!
6
+ class WCC::Contentful::LinkVisitor
7
+ attr_reader :entry
8
+ attr_reader :type
9
+ attr_reader :fields
10
+ attr_reader :depth
11
+
12
+ # @param [Hash] entry The entry hash (resolved or unresolved) to walk
13
+ # @param [Array<String, Symbol>] The fields to select from the entry tree.
14
+ # Use `:Link` to select only links, or `'slug'` to select all slugs in the tree.
15
+ # @param [Fixnum] depth (optional) How far to walk down the tree of links. Be careful of
16
+ # recursive trees!
17
+ # @example
18
+ # entry = store.find_by(id: id, include: 3)
19
+ # WCC::Contentful::LinkVisitor.new(entry, 'slug', depth: 3)
20
+ # .map { |slug| 'https://mirror-site' + slug }
21
+ def initialize(entry, *fields, depth: 0)
22
+ unless entry.is_a?(Hash) && entry.dig('sys', 'id')
23
+ raise ArgumentError, "Please provide an entry as a hash value (got #{entry})"
24
+ end
25
+ unless ct = entry.dig('sys', 'contentType', 'sys', 'id')
26
+ raise ArgumentError, 'Entry has no content type!'
27
+ end
28
+
29
+ @type = WCC::Contentful.types[ct]
30
+ raise ArgumentError, "Unknown content type '#{ct}'" unless @type
31
+
32
+ @entry = entry
33
+ @fields = fields
34
+ @depth = depth
35
+ end
36
+
37
+ # Walks an entry and its resolved links, without transforming the entry.
38
+ # @yield [value, field, locale]
39
+ # @yieldparam [Object] value The value of the selected field.
40
+ # @yieldparam [WCC::Contentful::IndexedRepresentation::Field] field The type of the selected field
41
+ # @yieldparam [String] locale The locale of the current field value
42
+ # @returns nil
43
+ def each(&block)
44
+ _each do |val, field, locale, index|
45
+ yield(val, field, locale, index) if should_yield_field?(field)
46
+
47
+ next unless should_walk_link?(field, val)
48
+
49
+ self.class.new(val, *fields, depth: depth - 1).each(&block)
50
+ end
51
+
52
+ nil
53
+ end
54
+
55
+ def map!(&block)
56
+ _each do |val, field, locale, index|
57
+ if should_yield_field?(field)
58
+ val = yield(val, field, locale, index)
59
+ set_field(field, locale, index, val)
60
+ end
61
+
62
+ next unless should_walk_link?(field, val)
63
+
64
+ self.class.new(val, *fields, depth: depth - 1).map!(&block)
65
+ end
66
+
67
+ entry
68
+ end
69
+
70
+ private
71
+
72
+ def _each(&block)
73
+ type.fields.each_value do |f|
74
+ each_field(f, &block)
75
+ end
76
+ end
77
+
78
+ def each_field(field)
79
+ each_locale(field) do |val, locale|
80
+ if field.array
81
+ val&.each_with_index do |v, index|
82
+ yield(v, field, locale, index) unless v.nil?
83
+ end
84
+ else
85
+ yield(val, field, locale) unless val.nil?
86
+ end
87
+ end
88
+ end
89
+
90
+ def each_locale(field)
91
+ raw_value = entry.dig('fields', field.name)
92
+ if locale = entry.dig('sys', 'locale')
93
+ if raw_value.is_a?(Hash) && raw_value[locale]
94
+ # it's a locale=* entry, but they've added sys.locale to those now
95
+ raw_value = raw_value[locale]
96
+ end
97
+ yield(raw_value, locale)
98
+ else
99
+ raw_value&.each_with_object({}) do |(l, val), h|
100
+ h[l] = yield(val, l)
101
+ end
102
+ end
103
+ end
104
+
105
+ def should_yield_field?(field)
106
+ fields.empty? || fields.include?(field.type) || fields.include?(field.name)
107
+ end
108
+
109
+ def should_walk_link?(field, val, dep = depth)
110
+ dep > 0 && field.type == :Link && val.dig('sys', 'type') == 'Entry'
111
+ end
112
+
113
+ def set_field(field, locale, index, val)
114
+ current_field = (entry['fields'][field.name] ||= {})
115
+
116
+ if field.array
117
+ (current_field[locale] ||= [])[index] = val
118
+ else
119
+ current_field[locale] = val
120
+ end
121
+ end
122
+ end
@@ -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.