contentful_redis 0.0.1

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.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # ContentfulRedis
2
+ A lightweight read-only contentful API wrapper which caches your responses in Redis.
3
+
4
+ # Features
5
+ - Lightweight easy to configure ruby contentful integration.
6
+ - Faster load times due to having a Redis cache.
7
+ - All content models responses are cached.
8
+ - Webhooks update
9
+ - Multiple space support
10
+ - Preview and production API support on a single environment
11
+
12
+ ## WIP
13
+ - Migrate tests
14
+ - logger
15
+ - Experiment Redis size optimization
16
+ - auto clean up of dead Redis keys
17
+ - code clean up
18
+
19
+ ContentfulRedis also supports multiple API endpoints(preview and published) within a single application.
20
+
21
+ ## Installation
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'redis-store'
26
+ gem 'contentful_redis'
27
+ ```
28
+
29
+ And then execute:
30
+
31
+ $ bundle
32
+
33
+ Or install it yourself as:
34
+
35
+ $ gem install contentful_redis
36
+
37
+ ## Configuration
38
+
39
+ Heres a default example, however, I will go over all of the individual configurations options below
40
+
41
+ ```ruby
42
+ # config/initializers/contentful_redis.rb
43
+ ContentfulRedis.configure do |config|
44
+ config.default_env = :preview # unless production env
45
+ config.model_scope = 'Contentful' # models live in a Contentful module
46
+ config.logging = true
47
+
48
+ config.spaces = {
49
+ test_space: {
50
+ id: 'xxxx',
51
+ access_token: 'xxxx',
52
+ preview_access_token: 'xxxx'
53
+ }
54
+ }
55
+
56
+ config.redis = Redis::Store.new(
57
+ host: (ENV['REDIS_HOST']) || 'localhost',
58
+ port: 6379,
59
+ db: 1,
60
+ namespace: 'contentful'
61
+ )
62
+
63
+ ```
64
+
65
+ ### Spaces (required)
66
+ Contentful Redis supports multiple space configurations with your first space being the default
67
+
68
+ ```ruby
69
+ # config/initializers/contentful_redis.rb
70
+ ContentfulRedis.configure do |config|
71
+ config.spaces = {
72
+ test_space: {
73
+ id: 'xxxx',
74
+ access_token: 'xxxx',
75
+ preview_access_token: 'xxxx'
76
+ },
77
+
78
+ test_space_2: {
79
+ id: 'xxxy',
80
+ access_token: 'xxxx',
81
+ preview_access_token: 'xxxx'
82
+ }
83
+ }
84
+ ```
85
+
86
+ To use a different space for a model override the classes `#space` method
87
+
88
+ ```ruby
89
+ # app/models/my_model.rb
90
+ class MyModel < ContentfulRedis::ModelBase
91
+
92
+ # override default space
93
+ def self.space
94
+ ContentfulRedis.configuration.spaces[:test_space_2]
95
+ end
96
+ end
97
+ ```
98
+
99
+ ### Redis (required)
100
+ There are various ways you can integrate with Redis.
101
+ I suggest using [redis-store](https://github.com/redis-store/redis-store) unless your application already has Redis adapter installed.
102
+ I recommend having a separate Redis database for all of your contentful data so that you can isolate your application Redis from your content.
103
+
104
+ ```ruby
105
+ # config/initializers/contentful_redis.rb
106
+ ContentfulRedis.configure do |config|
107
+ config.redis = Redis::Store.new(
108
+ host: (ENV['REDIS_HOST']) || 'localhost',
109
+ port: 6379,
110
+ db: 1,
111
+ namespace: 'contentful'
112
+ )
113
+ end
114
+ ```
115
+
116
+ ### Default env
117
+
118
+ If unset the default call is to the `:published` data. however, setting default_env to `:preview` will request to the preview API.
119
+ The Find methods can have an additional argument to force the non-default endpoint.
120
+
121
+ ```ruby
122
+ # config/initializers/contentful_redis.rb
123
+ ContentfulRedis.configure do |config|
124
+ # if unset defaults to :published
125
+ config.default_env = :preview
126
+ end
127
+ ```
128
+
129
+ ### Model scope
130
+ Set the scope for where your models live.
131
+
132
+ ```ruby
133
+ # config/initializers/contentful_redis.rb
134
+ ContentfulRedis.configure do |config|
135
+ config.model_scope = 'Contentful'
136
+ end
137
+
138
+ # app/models/contentful/page.rb
139
+ module Contentful
140
+ class Page < ContentfulRedis::ModelBase
141
+
142
+ end
143
+ end
144
+ ```
145
+
146
+ ## Models
147
+ All content models will need to be defined, prior to integration especially when using references.
148
+ The example model we are going to define has a slug(input field) and a body(references other content models)
149
+
150
+ ```ruby
151
+ # app/models/page.rb
152
+ class Page < ContentfulRedis::ModelBase
153
+ # allows the field to be queried from
154
+ define_searchable_fields :slug
155
+
156
+ # Set default readers which can return nil
157
+ attr_reader: :slug
158
+
159
+ # define your desired return types manually
160
+ def body
161
+ @body || []
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### Querying
167
+
168
+ All content models are found by their contentful ID. Contentful Redis only stores only one cache of the content model
169
+ This Redis key is generated and is unique to a content model, space and endpoint.
170
+
171
+ ```ruby
172
+ Contentful::Page.find('<contentful_uid>')
173
+ ```
174
+
175
+ Contentful Redis does not store a duplicate object from searchable attributes,
176
+ Instead, it builds a glossary of searchable attributes mapping to their content models ids.
177
+ These attributes are defined in the class declaration as `define_searchable_fields :slug`
178
+
179
+ ```ruby
180
+ Contentful::Page.find_by(slug: 'about-us')
181
+ ```
182
+
183
+ ### Content model overriding
184
+
185
+ Classes should match their content model name, however, if they don't you can override the classes `#name` method.
186
+
187
+ ```ruby
188
+ # app/models/page.rb
189
+ class Page < ContentfulRedis::ModelBase
190
+
191
+ # Overwrite to match contentful model using ruby class syntax
192
+ def self.name
193
+ 'NameThatMatchesContentfulModel'
194
+ end
195
+ end
196
+ ```
197
+
198
+ ## Webhooks
199
+
200
+ Instead of creating rails specific implementation it is up to the developers to create your controllers and manage your webhook into your applications.
201
+ See the [Contentful webhooks docs](https://www.contentful.com/developers/docs/concepts/webhooks/) creating your own
202
+
203
+ Examples below will get you started!
204
+
205
+ Required Contentful webhooks to update the Redis cache are:
206
+ ```json
207
+ {
208
+ "id": "{ /payload/sys/id }",
209
+ "environment": "{ /payload/sys/environment/sys/id }",
210
+ "model": "{ /payload/sys/contentType/sys/id }"
211
+ }
212
+ ```
213
+
214
+ When pushing text attributes make sure you are using the correct language endpoint.
215
+ ```json
216
+ {
217
+ "title": "{ /payload/fields/title/en-US }",
218
+ "slug": "{ /payload/fields/slug/en-US }",
219
+ }
220
+ ```
221
+
222
+ ### Webhook Controllers
223
+
224
+ #### Rails
225
+ ```ruby
226
+ # app/controllers/contentful/webhook_controller.rb
227
+ module Contentful
228
+ class WebhookController < ApplicationController
229
+ # before_action :some_auth_layer
230
+
231
+ def update
232
+ payload = JSON.parse request.raw_post
233
+
234
+ contentful_model = ContentfulRedis::ClassFinder.search(payload['model'])
235
+ contentful_model.update(payload['id'])
236
+
237
+ render json: { status: :ok }
238
+ end
239
+
240
+ def delete
241
+ payload = JSON.parse request.raw_post
242
+
243
+ contentful_model = ContentfulRedis::ClassFinder.search(payload['model'])
244
+ contentful_model.destroy(payload['id'])
245
+
246
+ render json: { status: :ok }
247
+ end
248
+ end
249
+ end
250
+
251
+ # config/routes
252
+ #...
253
+ namespace :contentful do
254
+ resource 'webhooks', only: :update, :delete
255
+ end
256
+ # ...
257
+ ```
258
+
259
+ #### Other
260
+ Feel free to create a PR for other ruby frameworks :)
261
+
262
+ ## Content Seeding
263
+ Seeding the data is a great way to get started in building your content models, there is a couple of ways this can be done.
264
+
265
+ Create a service object inside your application and get it to fetch the root pages of your content tree by their ID.
266
+ The find method will build your Redis cache as well as link your content models with their searchable fields.
267
+
268
+ ```ruby
269
+ # app/services/seed_content.rb
270
+ class SeedContent
271
+ # trigger a cascading content model seeding process
272
+ def call
273
+ ['xxContentfulModelIdxx'].each do |page|
274
+ Contentful::Page.find(page)
275
+ end
276
+ end
277
+ end
278
+ ```
279
+
280
+ ## Development
281
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
282
+
283
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a Git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
284
+
285
+ ## Contributing
286
+
287
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/contentful-redis.
288
+
289
+ ## License
290
+
291
+ The gem is available as open source under the terms of the [GNU General Public License](https://www.gnu.org/licenses/#GPL)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "contentful_redis/rb"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ lib = File.expand_path('lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+ require 'contentful_redis'
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = 'contentful_redis'
10
+ spec.version = ContentfulRedis::VERSION
11
+ spec.authors = ['DanHenton']
12
+ spec.email = ['Dan.Henton@gmail.com']
13
+
14
+ spec.summary = 'Contentful api wrapper which caches responses from contentful'
15
+ spec.homepage = 'https://github.com/DigitalNZ/contentful-redis'
16
+ spec.license = 'GNU'
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.bindir = 'exe'
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_dependency 'faraday'
28
+ spec.add_dependency 'redis-store'
29
+
30
+ spec.add_development_dependency 'bundler'
31
+ spec.add_development_dependency 'factory_bot'
32
+ spec.add_development_dependency 'pry'
33
+ spec.add_development_dependency 'rake'
34
+ spec.add_development_dependency 'rspec'
35
+ spec.add_development_dependency 'rubocop'
36
+ spec.add_development_dependency 'webmock'
37
+ end
38
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentfulRedis
4
+ class Asset
5
+ attr_reader :id, :title, :description, :url, :details, :file_name, :content_type
6
+
7
+ def initialize(model)
8
+ @id = model.dig('sys', 'id')
9
+ @title = model.dig('fields', 'title')
10
+ @description = model.dig('fields', 'description')
11
+ @url = model.dig('fields', 'file', 'url')
12
+ @details = model.dig('fields', 'file', 'details')
13
+ @file_name = model.dig('fields', 'file', 'fileName')
14
+ @content_type = model.dig('fields', 'file', 'contentType')
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentfulRedis
4
+ module ClassFinder
5
+ def self.search(type)
6
+ begin
7
+ "#{ContentfulRedis.configuration.model_scope}#{type.classify}".constantize
8
+ rescue NameError => _e
9
+ raise ContentfulRedis::Error::ClassNotFound, "Content type: #{type} is undefined"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentfulRedis
4
+ class Configuration
5
+ attr_writer :model_scope
6
+ attr_accessor :spaces, :redis, :default_env, :logging
7
+
8
+ def model_scope
9
+ "#{@model_scope}::" unless @model_scope.nil?
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentfulRedis
4
+ module Error
5
+ class ArgumentError < StandardError; end
6
+ class RecordNotFound < StandardError; end
7
+ class ClassNotFound < StandardError; end
8
+ class InternalServerError < StandardError; end
9
+ end
10
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContentfulRedis
4
+ module KeyManager
5
+ class << self
6
+ # Links a contentful models attribute to its contentful_id
7
+ def attribute_index(klass, attribute)
8
+ "#{klass.space.fetch(:space_id)}/#{klass.content_model}/#{attribute}"
9
+ end
10
+
11
+ # Links content model request to its contentful json response
12
+ def content_model_key(space, endpoint, parameters)
13
+ "#{space.fetch(:space_id)}/#{endpoint}/#{parameters.map { |k, v| "#{k}-#{v}" }.join('/')}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'asset'
4
+ require_relative 'request'
5
+ require_relative 'key_manager'
6
+ require_relative 'error'
7
+ require_relative 'class_finder'
8
+
9
+ # Base class for contentful redis intergation.
10
+ module ContentfulRedis
11
+ class ModelBase
12
+ class << self
13
+ def find(id, env = nil)
14
+ parameters = { 'sys.id': id, content_type: content_model }
15
+
16
+ new(ContentfulRedis::Request.new(space, parameters, :get, request_env(env)).call)
17
+ end
18
+
19
+ def find_by(args, env = ContentfulRedis.configuration.default_env || :published)
20
+ raise ContentfulRedis::Error::ArgumentError, "#{args} contain fields which are not a declared as a searchable field" unless (args.keys - searchable_fields).empty?
21
+
22
+ id = args.values.map do |value|
23
+ key = ContentfulRedis::KeyManager.attribute_index(self, value)
24
+ key.nil? || key.empty? ? nil : ContentfulRedis.redis.get(key)
25
+ end.compact.first
26
+
27
+ raise ContentfulRedis::Error::RecordNotFound, 'Missing attribute in glossary' if id.nil?
28
+
29
+ find(id, env)
30
+ end
31
+
32
+ def update(id, env = nil)
33
+ parameters = { 'sys.id': id, content_type: content_model }
34
+
35
+ new(ContentfulRedis::Request.new(space, parameters, :update, request_env(env)).call)
36
+ end
37
+
38
+ def destroy(id, env = nil)
39
+ keys = []
40
+ keys << ContentfulRedis::KeyManager.content_model_key(space, request_env(env), 'sys.id': id, content_type: content_model)
41
+
42
+ searchable_fields.each do |field|
43
+ keys << ContentfulRedis::KeyManager.attribute_index(self, field)
44
+ end
45
+
46
+ ContentfulRedis.redis.del(*keys)
47
+ end
48
+
49
+ def space
50
+ ContentfulRedis.configuration.spaces.first[1]
51
+ end
52
+
53
+ def content_model
54
+ model_name = name.demodulize
55
+
56
+ "#{model_name[0].downcase}#{model_name[1..-1]}"
57
+ end
58
+
59
+ def searchable_fields
60
+ []
61
+ end
62
+
63
+ def define_searchable_fields(*fields)
64
+ instance_eval("def searchable_fields; #{fields}; end")
65
+ end
66
+
67
+ private
68
+
69
+ def request_env(env)
70
+ env || ContentfulRedis.configuration.default_env || :published
71
+ end
72
+ end
73
+
74
+ def initialize(model)
75
+ instance_variable_set(:@id, model['items'].first.dig('sys', 'id'))
76
+ self.class.send(:attr_reader, :id)
77
+
78
+ entries = entries_as_objects(model)
79
+
80
+ model['items'].first['fields'].each do |key, value|
81
+ value = case value
82
+ when Array
83
+ value.map { |val| entries[val.dig('sys', 'id')] || val }
84
+ when Hash
85
+ extract_object_from_hash(model, value, entries)
86
+ else
87
+ value
88
+ end
89
+
90
+ instance_variable_set("@#{key.underscore}", value)
91
+ end
92
+
93
+ create_searchable_attribute_links if self.class.searchable_fields.any?
94
+ end
95
+
96
+ def content_type
97
+ self.class.name.demodulize.underscore
98
+ end
99
+
100
+ private
101
+
102
+ def entries_as_objects(model)
103
+ entries = model.dig('includes', 'Entry')
104
+
105
+ return {} if entries.nil? || entries.empty?
106
+
107
+ entries.each_with_object({}) do |entry, hash|
108
+ type = entry.dig('sys', 'contentType', 'sys', 'id')
109
+ id = entry.dig('sys', 'id')
110
+
111
+ hash[id] = ContentfulRedis::ClassFinder.search(type).find(id)
112
+ end
113
+ end
114
+
115
+ def extract_object_from_hash(model, value, entries)
116
+ entry_id = value.dig('sys', 'id')
117
+
118
+ assets = model.dig('includes', 'Asset')
119
+ asset = if !assets.nil? && assets.is_a?(Array)
120
+ model.dig('includes', 'Asset').first
121
+ end
122
+
123
+ if entries.key?(entry_id)
124
+ entries[entry_id]
125
+ elsif !asset.nil?
126
+ ContentfulRedis::Asset.new(asset)
127
+ else
128
+ value
129
+ end
130
+ end
131
+
132
+ def create_searchable_attribute_links
133
+ self.class.searchable_fields.each do |field|
134
+ begin
135
+ instance_attribute = send(field)
136
+ rescue NoMethodError => _e
137
+ raise ContentfulRedis::Error::ArgumentError, "Undefined attribute: #{field} when creating attribute glossary"
138
+ end
139
+
140
+ raise ContentfulRedis::Error::ArgumentError, 'Searchable fields cannot be blank and must be required' if instance_attribute.nil?
141
+ raise ContentfulRedis::Error::ArgumentError, 'Searchable fields must be singular and cannot be references' if instance_attribute.is_a?(Array)
142
+
143
+ key = ContentfulRedis::KeyManager.attribute_index(self.class, send(field))
144
+ next if ContentfulRedis.redis.exists(key)
145
+
146
+ ContentfulRedis.redis.set(key, id)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'key_manager'
4
+ require_relative 'error'
5
+
6
+ # Request from contentful api and store in redis.
7
+ # Atempt to fetch response from redis before requesting to the contentful api
8
+ module ContentfulRedis
9
+ class Request
10
+ def initialize(space, params, action = :get, env = ContentfulRedis.configuration.default_env || :published)
11
+ @space = space
12
+ @action = action
13
+
14
+ if env.to_s.downcase == 'published'
15
+ @endpoint = 'cdn'
16
+ @access_token = @space[:access_token]
17
+ else
18
+ @endpoint = 'preview'
19
+ @access_token = @space[:preview_access_token]
20
+ end
21
+
22
+ params[:include] = 1
23
+
24
+ @parameters = params
25
+ end
26
+
27
+ def call
28
+ generated_key = ContentfulRedis::KeyManager.content_model_key(@space, @endpoint, @parameters)
29
+
30
+ return fetch_from_origin(generated_key) if @action == :update || !ContentfulRedis.redis.exists(generated_key)
31
+
32
+ JSON.parse(ContentfulRedis.redis.get(generated_key))
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_from_origin(generated_key)
38
+ response = perform_request
39
+
40
+ raise ContentfulRedis::Error::RecordNotFound, 'Contentful entry was not found' if response.match?(/"total":0/)
41
+
42
+ ContentfulRedis.redis.set(generated_key, response)
43
+
44
+ JSON.parse(response)
45
+ end
46
+
47
+ def perform_request
48
+ res = faraday_connection.get do |req|
49
+ req.url "https://#{@endpoint}.contentful.com/spaces/#{@space[:space_id]}/environments/master/entries"
50
+
51
+ req.params = @parameters
52
+ end
53
+
54
+ catch_errors(res)
55
+
56
+ # decompress then use JSON.parse to remove any blank characters to reduce bytesize
57
+ # Even when we ask for gzip encoding if content model is small contentfull wont gzib the response body
58
+ #
59
+ # Futher storage optimizations can be made to reduce the total redis size.
60
+ begin
61
+ JSON.parse(Zlib::GzipReader.new(StringIO.new(res.body)).read).to_json
62
+ rescue Zlib::GzipFile::Error
63
+ JSON.parse(res.body).to_json
64
+ end
65
+ end
66
+
67
+ def faraday_connection
68
+ ::Faraday.new do |faraday|
69
+ faraday.request :url_encoded
70
+
71
+ if ContentfulRedis.configuration.logging
72
+ faraday.response :logger do |logger|
73
+ logger.filter(/(Authorization:)(.*)/, '\1[REMOVED]')
74
+ end
75
+ end
76
+
77
+ faraday.adapter Faraday.default_adapter
78
+ faraday.headers = {
79
+ 'Authorization': "Bearer #{@access_token}",
80
+ 'Content-Type': 'application/vnd.contentful.delivery.v1+json',
81
+ 'Accept-Encoding': 'gzip'
82
+ }
83
+ end
84
+ end
85
+
86
+ def catch_errors(res)
87
+ send("__#{res.status.to_s[0]}00_error__") unless res.status == 200
88
+ end
89
+
90
+ def __400_error__
91
+ raise ContentfulRedis::Error::RecordNotFound, 'Contentful could not find the content entry'
92
+ end
93
+
94
+ def __500_error__
95
+ raise ContentfulRedis::Error::InternalServerError, 'An external Contentful error has occured'
96
+ end
97
+ end
98
+ end