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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful::ActiveRecordShim
4
+ extend ActiveSupport::Concern
5
+
6
+ def attributes
7
+ @attributes ||= to_h['fields'].tap { |fields| fields['id'] = id }
8
+ end
9
+
10
+ def cache_key
11
+ return cache_key_with_version unless ActiveRecord::Base.try(:cache_versioning) == true
12
+
13
+ "#{self.class.model_name}/#{id}"
14
+ end
15
+
16
+ def cache_key_with_version
17
+ "#{self.class.model_name}/#{id}-#{cache_version}"
18
+ end
19
+
20
+ def cache_version
21
+ sys.revision.to_s
22
+ end
23
+
24
+ included do
25
+ unless defined?(ActiveRecord)
26
+ raise NotImplementedError, 'WCC::Contentful::ActiveRecordShim requires ActiveRecord to be loaded'
27
+ end
28
+ end
29
+
30
+ class_methods do
31
+ def model_name
32
+ WCC::Contentful::Helpers.constant_from_content_type(content_type)
33
+ end
34
+
35
+ def const_get(name)
36
+ # Because our pattern is `class MyModel < WCC::Contentful::Model::MyModel`
37
+ # if you do MyModel.const_get('MyModel') Algolia expects you to return
38
+ # ::MyModel not WCC::Contentful::Model::MyModel
39
+ return self if name == model_name
40
+
41
+ super
42
+ end
43
+
44
+ def table_name
45
+ model_name.tableize
46
+ end
47
+
48
+ def unscoped
49
+ yield
50
+ end
51
+
52
+ def find_in_batches(options, &block)
53
+ options ||= {}
54
+ batch_size = options.delete(:batch_size) || 1000
55
+ filter = {
56
+ options: {
57
+ limit: batch_size,
58
+ skip: options.delete(:start) || 0,
59
+ include: options.delete(:include) || 1
60
+ }
61
+ }
62
+
63
+ find_all(filter).each_slice(batch_size, &block)
64
+ end
65
+
66
+ def where(**conditions)
67
+ # TODO: return a Query object that implements more of the ActiveRecord query interface
68
+ # https://guides.rubyonrails.org/active_record_querying.html#conditions
69
+ find_all(conditions)
70
+ end
71
+ end
72
+ end
@@ -1,22 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # This object contains all the configuration options for the `wcc-contentful` gem.
3
4
  class WCC::Contentful::Configuration
4
5
  ATTRIBUTES = %i[
6
+ space
5
7
  access_token
6
8
  app_url
7
9
  management_token
8
- space
9
10
  environment
10
11
  default_locale
11
- content_delivery
12
12
  preview_token
13
- http_adapter
14
- sync_cache_store
15
13
  webhook_username
16
14
  webhook_password
17
15
  webhook_jobs
16
+ connection
17
+ connection_options
18
+ update_schema_file
19
+ schema_file
20
+ store
21
+ instrumentation_adapter
18
22
  ].freeze
19
- attr_accessor(*ATTRIBUTES)
23
+
24
+ # (required) Sets the Contentful Space ID.
25
+ attr_accessor :space
26
+ # (required) Sets the Content Delivery API access token.
27
+ attr_accessor :access_token
28
+
29
+ # Sets the app's root URL for a Rails app. Used by the WCC::Contentful::Engine
30
+ # to automatically set up webhooks to point at the WCC::Contentful::WebhookController
31
+ attr_accessor :app_url
32
+ # Sets the Content Management Token used to communicate with the Management API.
33
+ # This is required for automatically setting up webhooks, and to create the
34
+ # WCC::Contentful::Services#management_client.
35
+ attr_accessor :management_token
36
+ # Sets the Environment ID. Leave blank to use master.
37
+ attr_accessor :environment
38
+ # Sets the default locale. Defaults to 'en-US'.
39
+ attr_accessor :default_locale
40
+ # Sets the Content Preview API access token. Only required if you use the
41
+ # preview flag.
42
+ attr_accessor :preview_token
43
+ # Sets an optional basic auth username that will be validated by the webhook controller.
44
+ # You must ensure the configured webhook sets the "HTTP Basic Auth username"
45
+ attr_accessor :webhook_username
46
+ # Sets an optional basic auth password that will be validated by the webhook controller.
47
+ # You must ensure the configured webhook sets the "HTTP Basic Auth password"
48
+ attr_accessor :webhook_password
49
+ # An array of jobs that are run whenever a webhook is received by the webhook controller.
50
+ # The job can be an ActiveJob class which responds to `:perform_later`, or a lambda or
51
+ # other object that responds to `:call`.
52
+ # Example:
53
+ # config.webhook_jobs << MyJobClass
54
+ # config.webhook_jobs << ->(event) { ... }
55
+ #
56
+ # See the source code for WCC::Contentful::SyncEngine::Job for an example of how
57
+ # to implement a webhook job.
58
+ attr_accessor :webhook_jobs
20
59
 
21
60
  # Returns true if the currently configured environment is pointing at `master`.
22
61
  def master?
@@ -25,22 +64,23 @@ class WCC::Contentful::Configuration
25
64
 
26
65
  # Defines the method by which content is downloaded from the Contentful CDN.
27
66
  #
28
- # [:direct] `config.content_delivery = :direct`
67
+ # [:direct] `config.store :direct`
29
68
  # with the `:direct` method, all queries result in web requests to
30
69
  # 'https://cdn.contentful.com' via the
31
70
  # {WCC::Contentful::SimpleClient::Cdn SimpleClient}
32
71
  #
33
- # [:eager_sync] `config.content_delivery = :eager_sync, [sync_store], [options]`
72
+ # [:eager_sync] `config.store :eager_sync, [sync_store], [options]`
34
73
  # with the `:eager_sync` method, the entire content of the Contentful
35
74
  # space is downloaded locally and stored in the
36
75
  # {WCC::Contentful::Services#store configured store}. The application is
37
- # responsible to periodically call `WCC::Contentful.sync!` to keep the store
38
- # updated. Alternatively, the provided {WCC::Contentful::Engine Engine}
39
- # can be mounted to receive a webhook from the Contentful space
40
- # on publish events:
41
- # mount WCC::Contentful::Engine, at: '/wcc/contentful'
76
+ # responsible to periodically call the WCC::Contentful::SyncEngine#next to
77
+ # keep the store updated. Alternatively, the provided {WCC::Contentful::Engine Engine}
78
+ # can be mounted to automatically call WCC::Contentful::SyncEngine#next on
79
+ # webhook events.
80
+ # In `routes.rb` add the following:
81
+ # mount WCC::Contentful::Engine, at: '/'
42
82
  #
43
- # [:lazy_sync] `config.content_delivery = :lazy_sync, [cache]`
83
+ # [:lazy_sync] `config.store :lazy_sync, [cache]`
44
84
  # The `:lazy_sync` method is a hybrid between the other two methods.
45
85
  # Frequently accessed data is stored in an ActiveSupport::Cache implementation
46
86
  # and is kept up-to-date via the Sync API. Any data that is not present
@@ -48,62 +88,153 @@ class WCC::Contentful::Configuration
48
88
  # The application is still responsible to periodically call `sync!`
49
89
  # or to mount the provided Engine.
50
90
  #
51
- def content_delivery=(params)
52
- cd, *cd_params = params
53
- unless cd.is_a? Symbol
54
- raise ArgumentError, 'content_delivery must be a symbol, use store= to '\
55
- 'directly set contentful CDN access adapter'
91
+ # [:custom] `config.store :custom, do ... end`
92
+ # The block is executed in the context of a WCC::Contentful::Store::Factory.
93
+ # this can be used to apply middleware, etc.
94
+ def store(*params, &block)
95
+ type, *params = params
96
+ if type
97
+ @store_factory = WCC::Contentful::Store::Factory.new(
98
+ self,
99
+ type,
100
+ params
101
+ )
56
102
  end
57
103
 
58
- WCC::Contentful::Store::Factory.new(
59
- self,
60
- nil,
61
- cd,
62
- cd_params
63
- ).validate!
104
+ @store_factory.instance_exec(&block) if block_given?
105
+ @store_factory
106
+ end
64
107
 
65
- @content_delivery = cd
66
- @content_delivery_params = cd_params
108
+ # Convenience for setting store without a block
109
+ def store=(param_array)
110
+ store(*param_array)
67
111
  end
68
112
 
69
- attr_reader :content_delivery_params
113
+ # Explicitly read the store factory
114
+ attr_reader :store_factory
115
+
116
+ # Sets the connection which is used to make HTTP requests.
117
+ # If left unset, the gem attempts to load 'faraday' or 'typhoeus'.
118
+ # You can pass your own adapter which responds to 'get' and 'post', and returns
119
+ # a response that quacks like Faraday.
120
+ attr_accessor :connection
121
+
122
+ # Sets the connection options which are given to the client. This can include
123
+ # an alternative Cdn API URL, timeouts, etc.
124
+ # See WCC::Contentful::SimpleClient constructor for details.
125
+ attr_accessor :connection_options
70
126
 
71
- # Directly sets the adapter layer for communicating with Contentful
72
- def store=(value)
73
- @content_delivery = :custom
74
- store, *cd_params = value
75
- @store = store
76
- @content_delivery_params = cd_params
127
+ # Indicates whether to update the contentful-schema.json file for building models.
128
+ # The schema can also be updated with `rake wcc_contentful:download_schema`
129
+ # Valid values are:
130
+ #
131
+ # [:never] wcc-contentful will not update the schema even if a management token is available.
132
+ # If your schema file is out of date this could cause null-reference errors or
133
+ # not found errors at runtime. If your schema file does not exist or is invalid,
134
+ # WCC::Contentful.init! will raise a WCC::Contentful::InitializitionError
135
+ #
136
+ # [:if_missing] wcc-contentful will only download the schema if the schema file
137
+ # doesn't exist.
138
+ #
139
+ # [:if_possible] wcc-contentful will attempt to reach out to the management API for
140
+ # content types, and will fall back to the schema file if the API
141
+ # cannot be reached. This is the default.
142
+ #
143
+ # [:always] wcc-contentful will check either the management API or the CDN for the
144
+ # most up-to-date content types and will raise a
145
+ # WCC::Contentful::InitializationError if the API cannot be reached.
146
+ def update_schema_file=(sym)
147
+ valid_syms = %i[never if_possible if_missing always]
148
+ unless valid_syms.include?(sym)
149
+ raise ArgumentError, "update_schema_file must be one of #{valid_syms}"
150
+ end
151
+
152
+ @update_schema_file = sym
77
153
  end
154
+ attr_reader :update_schema_file
78
155
 
79
- attr_reader :store
156
+ # The file to store the Contentful schema in. You should check this into source
157
+ # control, similar to `db/schema.rb`. This filename is relative to the rails root.
158
+ # Defaults to 'db/contentful-schema.json
159
+ attr_writer :schema_file
160
+
161
+ def schema_file
162
+ if defined?(Rails)
163
+ Rails.root.join(@schema_file)
164
+ else
165
+ @schema_file
166
+ end
167
+ end
80
168
 
81
- # Sets the adapter which is used to make HTTP requests.
82
- # If left unset, the gem attempts to load either 'http' or 'typhoeus'.
83
- # You can pass your own adapter which responds to 'call', or even a lambda
84
- # that accepts the following parameters:
85
- # ->(url, query, headers = {}, proxy = {}) { ... }
86
- attr_writer :http_adapter
169
+ # Overrides the use of ActiveSupport::Notifications throughout this library to
170
+ # emit instrumentation events. The object or module provided here must respond
171
+ # to :instrument like ActiveSupport::Notifications.instrument
172
+ attr_accessor :instrumentation_adapter
87
173
 
88
174
  def initialize
89
- @access_token = ''
175
+ @access_token = ENV['CONTENTFUL_ACCESS_TOKEN']
90
176
  @app_url = ENV['APP_URL']
91
- @management_token = ''
92
- @preview_token = ''
93
- @space = ''
177
+ @connection_options = {
178
+ api_url: 'https://cdn.contentful.com/',
179
+ preview_api_url: 'https://preview.contentful.com/',
180
+ management_api_url: 'https://api.contentful.com'
181
+ }
182
+ @management_token = ENV['CONTENTFUL_MANAGEMENT_TOKEN']
183
+ @preview_token = ENV['CONTENTFUL_PREVIEW_TOKEN']
184
+ @space = ENV['CONTENTFUL_SPACE_ID']
94
185
  @default_locale = nil
95
- @content_delivery = :direct
186
+ @middleware = []
187
+ @update_schema_file = :if_possible
188
+ @schema_file = 'db/contentful-schema.json'
96
189
  @webhook_jobs = []
190
+ @store_factory = WCC::Contentful::Store::Factory.new(self, :direct)
97
191
  end
98
192
 
193
+ # Validates the configuration, raising ArgumentError if anything is wrong. This
194
+ # is called by WCC::Contentful.init!
99
195
  def validate!
100
196
  raise ArgumentError, 'Please provide "space"' unless space.present?
101
197
  raise ArgumentError, 'Please provide "access_token"' unless access_token.present?
102
198
 
103
- webhook_jobs&.each do |job|
199
+ store_factory.validate!
200
+
201
+ if update_schema_file == :always && management_token.blank?
202
+ raise ArgumentError, 'A management_token is required in order to update the schema file.'
203
+ end
204
+
205
+ webhook_jobs.each do |job|
104
206
  next if job.respond_to?(:call) || job.respond_to?(:perform_later)
105
207
 
106
208
  raise ArgumentError, "The job '#{job}' must be an instance of ActiveJob::Base or respond to :call"
107
209
  end
108
210
  end
211
+
212
+ def frozen?
213
+ false
214
+ end
215
+
216
+ def freeze
217
+ FrozenConfiguration.new(self)
218
+ end
219
+
220
+ class FrozenConfiguration
221
+ attr_reader(*ATTRIBUTES)
222
+
223
+ def initialize(configuration)
224
+ ATTRIBUTES.each do |att|
225
+ val = configuration.public_send(att)
226
+ val.freeze if val.is_a?(Hash) || val.is_a?(Array)
227
+ instance_variable_set("@#{att}", val)
228
+ end
229
+ end
230
+
231
+ # Returns true if the currently configured environment is pointing at `master`.
232
+ def master?
233
+ !environment.present?
234
+ end
235
+
236
+ def frozen?
237
+ true
238
+ end
239
+ end
109
240
  end
@@ -6,6 +6,20 @@ module WCC::Contentful
6
6
  class ContentTypeIndexer
7
7
  include WCC::Contentful::Helpers
8
8
 
9
+ class << self
10
+ def load(schema_file)
11
+ from_json_schema(
12
+ JSON.parse(File.read(schema_file))['contentTypes']
13
+ )
14
+ end
15
+
16
+ def from_json_schema(schema)
17
+ new.tap do |ixr|
18
+ schema.each { |type| ixr.index(type) }
19
+ end
20
+ end
21
+ end
22
+
9
23
  attr_reader :types
10
24
 
11
25
  def initialize
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wcc/contentful'
4
+
5
+ class WCC::Contentful::DownloadsSchema
6
+ def self.call(file = nil, management_client: nil)
7
+ new(file, management_client: management_client).call
8
+ end
9
+
10
+ def initialize(file = nil, management_client: nil)
11
+ @client = management_client || WCC::Contentful::Services.instance.management_client
12
+ @file = file || WCC::Contentful.configuration&.schema_file
13
+ raise ArgumentError, 'Please configure your management token' unless @client
14
+ raise ArgumentError, 'Please pass filename or call WCC::Contentful.configure' unless @file
15
+ end
16
+
17
+ def call
18
+ return unless needs_update?
19
+
20
+ update!
21
+ end
22
+
23
+ def update!
24
+ FileUtils.mkdir_p(File.dirname(@file))
25
+
26
+ File.write(@file, format_json({
27
+ 'contentTypes' => content_types,
28
+ 'editorInterfaces' => editor_interfaces
29
+ }))
30
+ end
31
+
32
+ def needs_update?
33
+ return true unless File.exist?(@file)
34
+
35
+ contents =
36
+ begin
37
+ JSON.parse(File.read(@file))
38
+ rescue JSON::ParserError
39
+ return true
40
+ end
41
+
42
+ existing_cts = contents['contentTypes'].sort_by { |ct| ct.dig('sys', 'id') }
43
+ return true unless content_types.count == existing_cts.count
44
+ return true unless deep_contains_all(content_types, existing_cts)
45
+
46
+ existing_eis = contents['editorInterfaces'].sort_by { |i| i.dig('sys', 'contentType', 'sys', 'id') }
47
+ return true unless editor_interfaces.count == existing_eis.count
48
+
49
+ !deep_contains_all(editor_interfaces, existing_eis)
50
+ end
51
+
52
+ def content_types
53
+ @content_types ||=
54
+ @client.content_types(limit: 1000)
55
+ .items
56
+ .map { |ct| strip_sys(ct) }
57
+ .sort_by { |ct| ct.dig('sys', 'id') }
58
+ end
59
+
60
+ def editor_interfaces
61
+ @editor_interfaces ||=
62
+ content_types
63
+ .map { |ct| @client.editor_interface(ct.dig('sys', 'id')).raw }
64
+ .map { |i| sort_controls(strip_sys(i)) }
65
+ .sort_by { |i| i.dig('sys', 'contentType', 'sys', 'id') }
66
+ end
67
+
68
+ private
69
+
70
+ def strip_sys(obj)
71
+ obj.merge!({
72
+ 'sys' => obj['sys'].slice('id', 'type', 'contentType')
73
+ })
74
+ end
75
+
76
+ def sort_controls(editor_interface)
77
+ {
78
+ 'sys' => editor_interface['sys'],
79
+ 'controls' => editor_interface['controls']
80
+ .sort_by { |c| c['fieldId'] }
81
+ .map { |c| c.slice('fieldId', 'settings', 'widgetId') }
82
+ }
83
+ end
84
+
85
+ def deep_contains_all(expected, actual)
86
+ if expected.is_a? Array
87
+ expected.each_with_index do |val, i|
88
+ return false unless actual[i]
89
+ return false unless deep_contains_all(val, actual[i])
90
+ end
91
+ true
92
+ elsif expected.is_a? Hash
93
+ expected.each do |(key, val)|
94
+ return false unless actual.key?(key)
95
+ return false unless deep_contains_all(val, actual[key])
96
+ end
97
+ true
98
+ else
99
+ expected == actual
100
+ end
101
+ end
102
+
103
+ def format_json(hash)
104
+ json_string = JSON.pretty_generate(hash)
105
+
106
+ # The pretty_generate format differs from contentful-shell and nodejs formats
107
+ # only in its treatment of empty arrays in the "validations" field.
108
+ json_string = json_string.gsub(/\[\n\n\s+\]/, '[]')
109
+ # contentful-shell also adds a newline at the end.
110
+ json_string + "\n"
111
+ end
112
+ end