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.
- checksums.yaml +5 -5
- data/Guardfile +43 -0
- data/README.md +101 -12
- data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
- data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
- data/config/routes.rb +1 -1
- data/doc/wcc-contentful.png +0 -0
- data/lib/tasks/download_schema.rake +12 -0
- data/lib/wcc/contentful.rb +70 -16
- data/lib/wcc/contentful/active_record_shim.rb +72 -0
- data/lib/wcc/contentful/configuration.rb +177 -46
- data/lib/wcc/contentful/content_type_indexer.rb +14 -0
- data/lib/wcc/contentful/downloads_schema.rb +112 -0
- data/lib/wcc/contentful/engine.rb +33 -14
- data/lib/wcc/contentful/event.rb +171 -0
- data/lib/wcc/contentful/events.rb +41 -0
- data/lib/wcc/contentful/exceptions.rb +3 -0
- data/lib/wcc/contentful/indexed_representation.rb +2 -2
- data/lib/wcc/contentful/instrumentation.rb +31 -0
- data/lib/wcc/contentful/link.rb +28 -0
- data/lib/wcc/contentful/link_visitor.rb +122 -0
- data/lib/wcc/contentful/middleware.rb +7 -0
- data/lib/wcc/contentful/middleware/store.rb +158 -0
- data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
- data/lib/wcc/contentful/model.rb +37 -3
- data/lib/wcc/contentful/model_builder.rb +1 -0
- data/lib/wcc/contentful/model_methods.rb +40 -15
- data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
- data/lib/wcc/contentful/rake.rb +3 -0
- data/lib/wcc/contentful/rspec.rb +13 -8
- data/lib/wcc/contentful/services.rb +61 -27
- data/lib/wcc/contentful/simple_client.rb +81 -25
- data/lib/wcc/contentful/simple_client/management.rb +43 -10
- data/lib/wcc/contentful/simple_client/response.rb +61 -22
- data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
- data/lib/wcc/contentful/store.rb +7 -66
- data/lib/wcc/contentful/store/README.md +85 -0
- data/lib/wcc/contentful/store/base.rb +34 -119
- data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
- data/lib/wcc/contentful/store/factory.rb +186 -0
- data/lib/wcc/contentful/store/instrumentation.rb +55 -0
- data/lib/wcc/contentful/store/interface.rb +82 -0
- data/lib/wcc/contentful/store/memory_store.rb +27 -24
- data/lib/wcc/contentful/store/postgres_store.rb +253 -107
- data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
- data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
- data/lib/wcc/contentful/store/query.rb +246 -0
- data/lib/wcc/contentful/store/query/interface.rb +63 -0
- data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
- data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
- data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
- data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
- data/lib/wcc/contentful/sync_engine.rb +181 -0
- data/lib/wcc/contentful/test/attributes.rb +17 -5
- data/lib/wcc/contentful/test/factory.rb +22 -46
- data/lib/wcc/contentful/version.rb +1 -1
- data/wcc-contentful.gemspec +14 -11
- metadata +201 -146
- data/Gemfile +0 -6
- data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
- data/lib/wcc/contentful/client_ext.rb +0 -28
- data/lib/wcc/contentful/graphql.rb +0 -14
- data/lib/wcc/contentful/graphql/builder.rb +0 -177
- data/lib/wcc/contentful/graphql/types.rb +0 -54
- data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
- data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -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
|
data/lib/wcc/contentful/model.rb
CHANGED
@@ -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,
|
63
|
-
|
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,
|
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.
|
@@ -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:
|
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
|
-
|
36
|
-
links.
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
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
|