wcc-contentful 0.2.2 → 0.3.0.pre.rc

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 +4 -4
  2. data/.rspec +0 -1
  3. data/README.md +181 -8
  4. data/app/controllers/wcc/contentful/webhook_controller.rb +42 -2
  5. data/app/jobs/wcc/contentful/delayed_sync_job.rb +52 -3
  6. data/app/jobs/wcc/contentful/webhook_enable_job.rb +43 -0
  7. data/bin/console +4 -3
  8. data/bin/rails +2 -0
  9. data/config/initializers/mime_types.rb +10 -1
  10. data/lib/wcc/contentful.rb +14 -142
  11. data/lib/wcc/contentful/client_ext.rb +17 -4
  12. data/lib/wcc/contentful/configuration.rb +25 -84
  13. data/lib/wcc/contentful/engine.rb +19 -0
  14. data/lib/wcc/contentful/exceptions.rb +25 -28
  15. data/lib/wcc/contentful/graphql.rb +0 -1
  16. data/lib/wcc/contentful/graphql/types.rb +1 -1
  17. data/lib/wcc/contentful/helpers.rb +3 -2
  18. data/lib/wcc/contentful/indexed_representation.rb +6 -0
  19. data/lib/wcc/contentful/model.rb +68 -34
  20. data/lib/wcc/contentful/model_builder.rb +65 -67
  21. data/lib/wcc/contentful/model_methods.rb +189 -0
  22. data/lib/wcc/contentful/model_singleton_methods.rb +83 -0
  23. data/lib/wcc/contentful/services.rb +146 -0
  24. data/lib/wcc/contentful/simple_client.rb +35 -33
  25. data/lib/wcc/contentful/simple_client/http_adapter.rb +9 -0
  26. data/lib/wcc/contentful/simple_client/management.rb +81 -0
  27. data/lib/wcc/contentful/simple_client/response.rb +61 -37
  28. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +12 -0
  29. data/lib/wcc/contentful/store.rb +45 -18
  30. data/lib/wcc/contentful/store/base.rb +128 -8
  31. data/lib/wcc/contentful/store/cdn_adapter.rb +92 -22
  32. data/lib/wcc/contentful/store/lazy_cache_store.rb +94 -9
  33. data/lib/wcc/contentful/store/memory_store.rb +13 -8
  34. data/lib/wcc/contentful/store/postgres_store.rb +44 -11
  35. data/lib/wcc/contentful/sys.rb +28 -0
  36. data/lib/wcc/contentful/version.rb +1 -1
  37. data/wcc-contentful.gemspec +3 -9
  38. metadata +87 -107
  39. data/.circleci/config.yml +0 -51
  40. data/.gitignore +0 -26
  41. data/.rubocop.yml +0 -243
  42. data/.rubocop_todo.yml +0 -13
  43. data/.travis.yml +0 -5
  44. data/CHANGELOG.md +0 -45
  45. data/CODE_OF_CONDUCT.md +0 -74
  46. data/Guardfile +0 -58
  47. data/LICENSE.txt +0 -21
  48. data/Rakefile +0 -8
  49. data/lib/generators/wcc/USAGE +0 -24
  50. data/lib/generators/wcc/model_generator.rb +0 -90
  51. data/lib/generators/wcc/templates/.keep +0 -0
  52. data/lib/generators/wcc/templates/Procfile +0 -3
  53. data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
  54. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -90
  55. data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
  56. data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
  57. data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
  58. data/lib/generators/wcc/templates/page/models/page.rb +0 -23
  59. data/lib/generators/wcc/templates/release +0 -9
  60. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
  61. data/lib/wcc/contentful/model/menu.rb +0 -7
  62. data/lib/wcc/contentful/model/menu_button.rb +0 -15
  63. data/lib/wcc/contentful/model/page.rb +0 -8
  64. data/lib/wcc/contentful/model/redirect.rb +0 -19
  65. data/lib/wcc/contentful/model_validators.rb +0 -115
  66. data/lib/wcc/contentful/model_validators/dsl.rb +0 -165
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  gem 'graphql', '~> 1.7'
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module WCC::Contentful::Graphql::Types
@@ -17,6 +16,7 @@ module WCC::Contentful::Graphql::Types
17
16
  return value if value.is_a? Array
18
17
  return value.to_h if value.respond_to?(:to_h)
19
18
  return JSON.parse(value) if value.is_a? String
19
+
20
20
  raise ArgumentError, "Cannot coerce value '#{value}' to a hash"
21
21
  }
22
22
  end
@@ -5,9 +5,9 @@ module WCC::Contentful::Helpers
5
5
 
6
6
  def content_type_from_raw(value)
7
7
  case value.dig('sys', 'type')
8
- when 'Entry'
8
+ when 'Entry', 'DeletedEntry'
9
9
  value.dig('sys', 'contentType', 'sys', 'id')
10
- when 'Asset'
10
+ when 'Asset', 'DeletedAsset'
11
11
  'Asset'
12
12
  else
13
13
  raise ArgumentError, "Unknown content type '#{value.dig('sys', 'type') || 'null'}'"
@@ -27,6 +27,7 @@ module WCC::Contentful::Helpers
27
27
 
28
28
  def content_type_from_constant(const)
29
29
  return const.content_type if const.respond_to?(:content_type)
30
+
30
31
  name = const.try(:name) || const.to_s
31
32
  name.demodulize.camelize(:lower)
32
33
  end
@@ -15,6 +15,7 @@ module WCC::Contentful
15
15
 
16
16
  def []=(id, value)
17
17
  raise ArgumentError unless value.is_a?(ContentType)
18
+
18
19
  @types[id] = value
19
20
  end
20
21
 
@@ -39,6 +40,7 @@ module WCC::Contentful
39
40
  def ==(other)
40
41
  my_keys = keys
41
42
  return false unless my_keys == other.keys
43
+
42
44
  my_keys.all? { |k| self[k] == other[k] }
43
45
  end
44
46
 
@@ -54,6 +56,7 @@ module WCC::Contentful
54
56
  def initialize(hash_or_id = nil)
55
57
  @fields = {}
56
58
  return unless hash_or_id
59
+
57
60
  if hash_or_id.is_a?(String)
58
61
  @name = hash_or_id
59
62
  return
@@ -108,11 +111,13 @@ module WCC::Contentful
108
111
  unless TYPES.include?(raw_type)
109
112
  raise ArgumentError, "Unknown type #{raw_type}, expected one of: #{TYPES}"
110
113
  end
114
+
111
115
  @type = raw_type
112
116
  end
113
117
 
114
118
  def initialize(hash_or_id = nil)
115
119
  return unless hash_or_id
120
+
116
121
  if hash_or_id.is_a?(String)
117
122
  @name = hash_or_id
118
123
  return
@@ -128,6 +133,7 @@ module WCC::Contentful
128
133
  unless TYPES.include?(raw_type)
129
134
  raise ArgumentError, "Unknown type #{raw_type}, expected one of: #{TYPES}"
130
135
  end
136
+
131
137
  @type = raw_type
132
138
  end
133
139
 
@@ -1,23 +1,50 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
3
+ # This is the top layer of the WCC::Contentful gem. It exposes an API by which
4
+ # you can query for data from Contentful. The API is only accessible after calling
5
+ # WCC::Contentful.init!
6
+ #
7
+ # The WCC::Contentful::Model class is the base class for all auto-generated model
8
+ # classes. A model class represents a content type inside Contentful. For example,
9
+ # the "page" content type is represented by a class named WCC::Contentful::Model::Page
10
+ #
11
+ # This WCC::Contentful::Model::Page class exposes the following API methods:
12
+ # * {WCC::Contentful::ModelSingletonMethods#find Page.find(id)}
13
+ # finds a single Page by it's ID
14
+ # * {WCC::Contentful::ModelSingletonMethods#find_by Page.find_by(field: <value>)}
15
+ # finds a single Page with the matching value for the specified field
16
+ # * {WCC::Contentful::ModelSingletonMethods#find_all Page.find_all(field: <value>)}
17
+ # finds all instances of Page with the matching value for the specified field.
18
+ # It returns a lazy iterator of Page objects.
19
+ #
20
+ # The returned objects are instances of WCC::Contentful::Model::Page, or whatever
21
+ # constant exists in the registry for the page content type. You can register
22
+ # custom types to be instantiated for each content type. If a Model is subclassed,
23
+ # the subclass is automatically registered. This allows you to put models in your
24
+ # app's `app/models` directory:
25
+ #
26
+ # class Page < WCC::Contentful::Model::Page; end
27
+ #
28
+ # and then use the API via those models:
29
+ #
30
+ # # this returns a ::Page, not a WCC::Contentful::Model::Page
31
+ # Page.find_by(slug: 'foo')
32
+ #
33
+ # Furthermore, anytime links are automatically resolved, the registered classes will
34
+ # be used:
35
+ #
36
+ # Menu.find_by(name: 'home').buttons.first.linked_page # is a ::Page
37
+ #
38
+ # @api Model
4
39
  class WCC::Contentful::Model
5
40
  extend WCC::Contentful::Helpers
6
- extend WCC::Contentful::ModelValidators
7
41
 
8
42
  # The Model base class maintains a registry which is best expressed as a
9
43
  # class var.
10
44
  # rubocop:disable Style/ClassVars
11
45
 
12
46
  class << self
13
- ##
14
- # The configured store which executes all model queries against either the
15
- # Contentful CDN or a locally-downloaded copy.
16
- #
17
- # See the {sync_store}[rdoc-ref:WCC::Contentful::Configuration.sync_store] parameter
18
- # on the WCC::Contentful::Configuration class.
19
- attr_accessor :store
20
- attr_accessor :preview_store
47
+ include WCC::Contentful::ServiceAccessors
21
48
 
22
49
  def const_missing(name)
23
50
  raise WCC::Contentful::ContentTypeNotFoundError,
@@ -27,41 +54,48 @@ class WCC::Contentful::Model
27
54
 
28
55
  @@registry = {}
29
56
 
30
- def self.all_models
31
- # TODO: this needs to use the registry but it's OK for now cause we only
32
- # use it in specs
33
- WCC::Contentful::Model.constants(false).map { |k| WCC::Contentful::Model.const_get(k) }
34
- end
35
-
36
- ##
37
57
  # Finds an Entry or Asset by ID in the configured contentful space
38
58
  # and returns an initialized instance of the appropriate model type.
39
59
  #
40
- # Makes use of the configured {store}[rdoc-ref:WCC::Contentful::Model.store]
60
+ # Makes use of the {WCC::Contentful::Services#store configured store}
41
61
  # to access the Contentful CDN.
42
62
  def self.find(id, context = nil)
43
63
  return unless raw = store.find(id)
44
64
 
65
+ new_from_raw(raw, context)
66
+ end
67
+
68
+ # Creates a new initialized instance of the appropriate model type for the
69
+ # given raw value. The raw value must be the same format as returned from one
70
+ # of the stores for a given object.
71
+ def self.new_from_raw(raw, context = nil)
45
72
  content_type = content_type_from_raw(raw)
73
+ const = resolve_constant(content_type)
74
+ const.new(raw, context)
75
+ end
46
76
 
47
- unless const = @@registry[content_type]
48
- begin
49
- # The app may have defined a model and we haven't loaded it yet
50
- const = Object.const_missing(constant_from_content_type(content_type).to_s)
51
- rescue NameError
52
- nil
53
- end
54
- end
55
- unless const
56
- # Autoloading couldn't find their model - we'll register our own.
57
- const = WCC::Contentful::Model.const_get(constant_from_content_type(content_type))
58
- register_for_content_type(content_type, klass: const)
77
+ # Accepts a content type ID as a string and returns the Ruby constant
78
+ # stored in the registry that represents this content type.
79
+ def self.resolve_constant(content_type)
80
+ const = @@registry[content_type]
81
+ return const if const
82
+
83
+ const_name = constant_from_content_type(content_type).to_s
84
+ begin
85
+ # The app may have defined a model and we haven't loaded it yet
86
+ const = Object.const_missing(const_name)
87
+ return const if const && const < WCC::Contentful::Model
88
+ rescue NameError => e
89
+ raise e unless e.message =~ /uninitialized constant #{const_name}/
90
+
91
+ nil
59
92
  end
60
93
 
61
- const.new(raw, context)
94
+ # Autoloading couldn't find their model - we'll register our own.
95
+ const = WCC::Contentful::Model.const_get(constant_from_content_type(content_type))
96
+ register_for_content_type(content_type, klass: const)
62
97
  end
63
98
 
64
- ##
65
99
  # Registers a class constant to be instantiated when resolving an instance
66
100
  # of the given content type. This automatically happens for the first subclass
67
101
  # of a generated model type, example:
@@ -82,19 +116,19 @@ class WCC::Contentful::Model
82
116
  def self.register_for_content_type(content_type = nil, klass: nil)
83
117
  klass ||= self
84
118
  raise ArgumentError, "#{klass} must be a class constant!" unless klass.respond_to?(:new)
119
+
85
120
  content_type ||= content_type_from_constant(klass)
86
121
 
87
122
  @@registry[content_type] = klass
88
123
  end
89
124
 
90
- ##
91
125
  # Returns the current registry of content type names to constants.
92
126
  def self.registry
93
127
  return {} unless @@registry
128
+
94
129
  @@registry.dup.freeze
95
130
  end
96
131
 
97
- ##
98
132
  # Checks if a content type has already been registered to a class and returns
99
133
  # that class. If nil, the generated WCC::Contentful::Model::{content_type} class
100
134
  # will be resolved for this content type.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './sys'
4
+
3
5
  module WCC::Contentful
4
6
  class ModelBuilder
5
7
  include Helpers
@@ -22,11 +24,23 @@ module WCC::Contentful
22
24
 
23
25
  # TODO: https://github.com/dkubb/ice_nine ?
24
26
  typedef = typedef.deep_dup.freeze
25
- fields = typedef.fields.keys
26
27
  WCC::Contentful::Model.const_set(const,
27
28
  Class.new(WCC::Contentful::Model) do
29
+ extend ModelSingletonMethods
30
+ include ModelMethods
28
31
  include Helpers
29
32
 
33
+ const_set('ATTRIBUTES', typedef.fields.keys.map(&:to_sym).freeze)
34
+ const_set('FIELDS', typedef.fields.keys.freeze)
35
+
36
+ # Magic type in their system which has a separate endpoint
37
+ # but we represent in the same model space
38
+ if const == 'Asset'
39
+ define_singleton_method(:type) { 'Asset' }
40
+ else
41
+ define_singleton_method(:type) { 'Entry' }
42
+ end
43
+
30
44
  define_singleton_method(:content_type) do
31
45
  typedef.content_type
32
46
  end
@@ -35,67 +49,45 @@ module WCC::Contentful
35
49
  typedef
36
50
  end
37
51
 
38
- define_singleton_method(:find) do |id, context = nil|
39
- raw = WCC::Contentful::Model.store.find(id)
40
- new(raw, context) if raw.present?
41
- end
42
-
43
- define_singleton_method(:find_all) do |filter = nil, context = nil|
44
- if filter
45
- filter.transform_keys! { |k| k.to_s.camelize(:lower) }
46
- bad_fields = filter.keys.reject { |k| fields.include?(k) }
47
- raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
48
- end
49
-
50
- query = WCC::Contentful::Model.store.find_all(content_type: content_type)
51
- query = query.apply(filter) if filter
52
- query.map { |r| new(r, context) }
53
- end
54
-
55
- define_singleton_method(:find_by) do |filter, context = nil|
56
- filter.transform_keys! { |k| k.to_s.camelize(:lower) }
57
- bad_fields = filter.keys.reject { |k| fields.include?(k) }
58
- raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
59
-
60
- result =
61
- if defined?(context[:preview]) && context[:preview] == true
62
- WCC::Contentful::Model.preview_store.find_by(content_type: content_type, filter: filter)
63
- else
64
- WCC::Contentful::Model.store.find_by(content_type: content_type, filter: filter)
65
- end
66
-
67
- new(result, context) if result
68
- end
69
-
70
- define_singleton_method(:inherited) do |subclass|
71
- # only register if it's not already registered
72
- return if WCC::Contentful::Model.registered?(typedef.content_type)
73
- WCC::Contentful::Model.register_for_content_type(typedef.content_type, klass: subclass)
74
- end
75
-
76
52
  define_method(:initialize) do |raw, context = nil|
77
53
  ct = content_type_from_raw(raw)
78
54
  if ct != typedef.content_type
79
55
  raise ArgumentError, 'Wrong Content Type - ' \
80
56
  "'#{raw.dig('sys', 'id')}' is a #{ct}, expected #{typedef.content_type}"
81
57
  end
82
-
83
- @locale = context[:locale] if context.present?
84
- @locale ||= 'en-US'
85
- @id = raw.dig('sys', 'id')
86
- @space = raw.dig('sys', 'space', 'sys', 'id')
87
- @created_at = raw.dig('sys', 'createdAt')
88
- @created_at = Time.parse(@created_at) if @created_at.present?
89
- @updated_at = raw.dig('sys', 'updatedAt')
90
- @updated_at = Time.parse(@updated_at) if @updated_at.present?
91
- @revision = raw.dig('sys', 'revision')
58
+ @raw = raw.freeze
59
+
60
+ created_at = raw.dig('sys', 'createdAt')
61
+ created_at = Time.parse(created_at) if created_at.present?
62
+ updated_at = raw.dig('sys', 'updatedAt')
63
+ updated_at = Time.parse(updated_at) if updated_at.present?
64
+ @sys = WCC::Contentful::Sys.new(
65
+ raw.dig('sys', 'id'),
66
+ raw.dig('sys', 'type'),
67
+ raw.dig('sys', 'locale') || context.try(:[], :locale) || 'en-US',
68
+ raw.dig('sys', 'space', 'sys', 'id'),
69
+ created_at,
70
+ updated_at,
71
+ raw.dig('sys', 'revision'),
72
+ OpenStruct.new(context).freeze
73
+ )
92
74
 
93
75
  typedef.fields.each_value do |f|
94
- raw_value = raw.dig('fields', f.name, @locale)
76
+ raw_value = raw.dig('fields', f.name, @sys.locale)
95
77
  if raw_value.present?
96
78
  case f.type
97
- when :DateTime
98
- raw_value = Time.parse(raw_value).localtime
79
+ # DateTime is intentionally not parsed!
80
+ # a DateTime can be '2018-09-28', '2018-09-28T17:00:00', or '2018-09-28T17:00:00Z'
81
+ # depending entirely on the editor interface in Contentful. Trying to parse this
82
+ # requires an assumption of the correct time zone to place them in. At this point
83
+ # in the code we don't have that knowledge, so we're punting to app-defined models.
84
+ #
85
+ # As an example, a user enters '2018-09-28' into Contentful. That date is parsed as
86
+ # '2018-09-28T00:00:00Z' when system time is UTC (ex. on Heroku), but translating that
87
+ # date to US Central results in '2018-09-27' which is not what the user intentded.
88
+ #
89
+ # when :DateTime
90
+ # raw_value = Time.parse(raw_value).localtime
99
91
  when :Int
100
92
  raw_value = Integer(raw_value)
101
93
  when :Float
@@ -109,11 +101,13 @@ module WCC::Contentful
109
101
  end
110
102
  end
111
103
 
112
- attr_reader :id
113
- attr_reader :space
114
- attr_reader :created_at
115
- attr_reader :updated_at
116
- attr_reader :revision
104
+ attr_reader :sys
105
+ attr_reader :raw
106
+ delegate :id, to: :sys
107
+ delegate :created_at, to: :sys
108
+ delegate :updated_at, to: :sys
109
+ delegate :revision, to: :sys
110
+ delegate :space, to: :sys
117
111
 
118
112
  # Make a field for each column:
119
113
  typedef.fields.each_value do |f|
@@ -125,18 +119,21 @@ module WCC::Contentful
125
119
  val = instance_variable_get(var_name + '_resolved')
126
120
  return val if val.present?
127
121
 
128
- return unless val = instance_variable_get(var_name)
129
-
130
- val =
131
- if val.is_a? Array
132
- val.map { |v| WCC::Contentful::Model.find(v.dig('sys', 'id')) }
133
- else
134
- WCC::Contentful::Model.find(val.dig('sys', 'id'))
135
- end
122
+ _resolve_field(name)
123
+ end
136
124
 
137
- instance_variable_set(var_name + '_resolved', val)
138
- val
125
+ id_method_name = "#{name}_id"
126
+ if f.array
127
+ id_method_name = "#{name}_ids"
128
+ define_method(id_method_name) do
129
+ instance_variable_get(var_name)&.map { |link| link.dig('sys', 'id') }
130
+ end
131
+ else
132
+ define_method(id_method_name) do
133
+ instance_variable_get(var_name)&.dig('sys', 'id')
134
+ end
139
135
  end
136
+ alias_method id_method_name.underscore, id_method_name
140
137
  when :Coordinates
141
138
  define_method(name) do
142
139
  val = instance_variable_get(var_name)
@@ -162,6 +159,7 @@ module WCC::Contentful
162
159
  instance_variable_get(var_name)
163
160
  end
164
161
  end
162
+
165
163
  alias_method name.underscore, name
166
164
  end
167
165
  end)
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is included by all {WCC::Contentful::Model models} and defines instance
4
+ # methods that are not dynamically generated.
5
+ #
6
+ # @api Model
7
+ module WCC::Contentful::ModelMethods
8
+ # Resolves all links in an entry to the specified depth.
9
+ #
10
+ # Each link in the entry is recursively retrieved from the store until the given
11
+ # depth is satisfied. Depth resolution is unlimited, circular references will
12
+ # be resolved to the same object.
13
+ #
14
+ # @param [Fixnum] depth how far to recursively resolve. Must be >= 1
15
+ # @param [Array<String, Symbol>] fields (optional) A subset of fields whose
16
+ # links should be resolved. Defaults to all fields.
17
+ # @param [Hash] context passed to the resolved model's `new` function to provide
18
+ # contextual information ex. current locale.
19
+ # See {WCC::Contentful::ModelSingletonMethods#find Model#find}, {WCC::Contentful::Sys#context}
20
+ # @param [Hash] options The remaining optional parameters, defined below
21
+ # @option options [Symbol] circular_reference Determines how circular references are
22
+ # handled. `:raise` causes a {WCC::Contentful::CircularReferenceError} to be raised,
23
+ # `:ignore` will cause the field to remain unresolved, and any other value (or nil)
24
+ # will cause the field to point to the previously resolved ruby object for that ID.
25
+ def resolve(depth: 1, fields: nil, context: {}, **options)
26
+ raise ArgumentError, "Depth must be > 0 (was #{depth})" unless depth && depth > 0
27
+ return self if resolved?(depth: depth, fields: fields)
28
+
29
+ fields = fields.map { |f| f.to_s.camelize(:lower) } if fields.present?
30
+ fields ||= self.class::FIELDS
31
+
32
+ typedef = self.class.content_type_definition
33
+ links = fields.select { |f| %i[Asset Link].include?(typedef.fields[f].type) }
34
+
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'
42
+ 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
+ unless raw
50
+ raise WCC::Contentful::ResolveError, "Cannot find #{self.class.content_type} with ID #{id}"
51
+ end
52
+
53
+ @raw = raw.freeze
54
+ links.each { |f| instance_variable_set('@' + f, raw.dig('fields', f, sys.locale)) }
55
+ end
56
+
57
+ links.each { |f| _resolve_field(f, depth, context, options) }
58
+ self
59
+ end
60
+
61
+ # Determines whether the object has been resolved up to the prescribed depth.
62
+ def resolved?(depth: 1, fields: nil)
63
+ raise ArgumentError, "Depth must be > 0 (was #{depth})" unless depth && depth > 0
64
+
65
+ fields = fields.map { |f| f.to_s.camelize(:lower) } if fields.present?
66
+ fields ||= self.class::FIELDS
67
+
68
+ typedef = self.class.content_type_definition
69
+ links = fields.select { |f| %i[Asset Link].include?(typedef.fields[f].type) }
70
+ links.all? { |f| _resolved_field?(f, depth) }
71
+ end
72
+
73
+ # Turns the current model into a hash representation as though it had been retrieved from
74
+ # the Contentful API.
75
+ #
76
+ # This differs from `#raw` in that it recursively includes the `#to_h`
77
+ # of resolved links. It also sets the fields to the value for the entry's `#sys.locale`,
78
+ # as though the entry had been retrieved from the API with `locale={#sys.locale}` rather
79
+ # than `locale=*`.
80
+ def to_h(stack = nil)
81
+ raise WCC::Contentful::CircularReferenceError.new(stack, id) if stack&.include?(id)
82
+
83
+ stack = [*stack, id]
84
+ typedef = self.class.content_type_definition
85
+ fields =
86
+ typedef.fields.each_with_object({}) do |(name, field_def), h|
87
+ if field_def.type == :Link || field_def.type == :Asset
88
+ if _resolved_field?(name, 0)
89
+ val = public_send(name)
90
+ val =
91
+ _try_map(val) { |v| v.to_h(stack) }
92
+ else
93
+ ids = field_def.array ? public_send("#{name}_ids") : public_send("#{name}_id")
94
+ val =
95
+ _try_map(ids) do |id|
96
+ {
97
+ 'sys' => {
98
+ 'type' => 'Link',
99
+ 'linkType' => field_def.type == :Asset ? 'Asset' : 'Entry',
100
+ 'id' => id
101
+ }
102
+ }
103
+ end
104
+ end
105
+ else
106
+ val = public_send(name)
107
+ val = _try_map(val) { |v| v.respond_to?(:to_h) ? v.to_h.stringify_keys! : v }
108
+ end
109
+
110
+ h[name] = val
111
+ end
112
+
113
+ {
114
+ 'sys' => { 'locale' => @sys.locale }.merge!(@raw['sys']),
115
+ 'fields' => fields
116
+ }
117
+ end
118
+
119
+ delegate :to_json, to: :to_h
120
+
121
+ private
122
+
123
+ def _resolve_field(field_name, depth = 1, context = {}, options = {})
124
+ return if depth <= 0
125
+
126
+ var_name = '@' + field_name
127
+ return unless val = instance_variable_get(var_name)
128
+
129
+ context = sys.context.to_h.merge(context)
130
+ # load a single link from a raw link or entry, by either finding it via the API
131
+ # or instantiating it directly from a raw entry
132
+ load =
133
+ ->(raw) {
134
+ id = raw.dig('sys', 'id')
135
+ already_resolved = context[:backlinks]&.find { |m| m.id == id }
136
+
137
+ new_context = context.merge({ backlinks: [self, *context[:backlinks]].freeze })
138
+
139
+ if already_resolved && %i[ignore raise].include?(options[:circular_reference])
140
+ raise WCC::Contentful::CircularReferenceError.new(
141
+ new_context[:backlinks].map(&:id).reverse,
142
+ id
143
+ )
144
+ end
145
+
146
+ # Use the already resolved circular reference, or resolve a link, or
147
+ # instantiate from already resolved raw entry data.
148
+ m = already_resolved ||
149
+ if raw.dig('sys', 'type') == 'Link'
150
+ WCC::Contentful::Model.find(id, new_context)
151
+ else
152
+ WCC::Contentful::Model.new_from_raw(raw, new_context)
153
+ end
154
+
155
+ m.resolve(depth: depth - 1, context: new_context, **options) if m && depth > 1
156
+ m
157
+ }
158
+
159
+ begin
160
+ val = _try_map(val) { |v| load.call(v) }
161
+
162
+ instance_variable_set(var_name + '_resolved', val)
163
+ rescue WCC::Contentful::CircularReferenceError
164
+ raise unless options[:circular_reference] == :ignore
165
+ end
166
+ end
167
+
168
+ def _resolved_field?(field_name, depth = 1)
169
+ var_name = '@' + field_name
170
+ raw = instance_variable_get(var_name)
171
+ return true if raw.nil? || (raw.is_a?(Array) && raw.all?(&:nil?))
172
+ return false unless val = instance_variable_get(var_name + '_resolved')
173
+ return true if depth <= 1
174
+
175
+ return val.resolved?(depth: depth - 1) unless val.is_a? Array
176
+
177
+ val.all? { |i| i.nil? || i.resolved?(depth: depth - 1) }
178
+ end
179
+
180
+ def _try_map(val)
181
+ if val.is_a? Array
182
+ return val&.map do |item|
183
+ yield item unless item.nil?
184
+ end
185
+ end
186
+
187
+ yield val unless val.nil?
188
+ end
189
+ end