wcc-contentful 0.2.2 → 0.3.0.pre.rc

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