contentful_redis 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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