Some weeks ago I posted about kickstarting a ruby application with Contentful as its data backend. Now that I gained some field experience with that setup, it made some caveats pretty obvious. This blog post will show you some improvements and also some nicites we added to our app in the last weeks.
We found out that the previously suggested approach had three major issues, which get addressed in this post:
A model used too look like this:
class BlogPost < ContentModel
CONTENT_TYPE_ID = "5FPTMiJ7R6uycIcC4EiCYc"
end
Besides the fact that it looks kinda ugly, it is also pretty hard to change that value for example in a test setup. Imagine that you want to write tests which actually call the Contentful backend. Wouldn't it be nice to be independent from the production data? Or what if you would like to have another data space for different server environments?
In order to fix that, it is a good idea to move the configuration to a separate file. We called it contentful.yml
:
shared:
space_id: svq072ikci2q
mapping:
blog_post: 5FPTMiJ7R6uycIcC4EiCYc
tag: 1XvAy0wOG8WEUQGsYAMYcU
And here is the respective initializer load_contentful.rb
:
require "yaml"
config_yml = YAML.load_file("./contentful.yml")
CONTENTFUL_CONFIG = config_yml["shared"].merge(config_yml[ENV['RACK_ENV']] || {})
Requiring the file will make the constant CONTENTFUL_CONFIG
available. Having that said, it is no longer necessary to have the content type id in the model, which makes the blog post model look like this:
class BlogPost < ContentModel
end
In order to have clear separation of business logic and content, we decided to move all the contentful models to a namespace called Content
. That basically meant moving all files from models
to models/content
and to rename models/content_model.rb
to models/content/base.rb
. Once that was done, we just wrapped everything in the respective module:
module Content
class BlogPost < Base
end
end
Our new and shiny base.rb
now utilizes the config file and went straight into the new namespace:
require "contentful"
require_relative "../../config/contentful"
module Content
class Base < Contentful::Entry
class << self
def entry_mapping
@entry_mapping ||= CONTENTFUL_CONFIG.fetch("mapping").map do |klass, content_type_id|
require_relative(klass)
[content_type_id, Content.const_get(klass.classify)]
end.to_h
end
alias_method :populate_classes, :entry_mapping
def delivery_client
@delivery_client ||= Contentful::Client.new(
access_token: ENV.fetch("CONTENTFUL_ACCESS_TOKEN"),
space: CONTENTFUL_CONFIG.fetch("space_id"),
dynamic_entries: :auto,
entry_mapping: entry_mapping
)
end
end
# the other code
end
Base.populate_classes
end
Please note the second last line. Here we are making the other content classes available. The idea behind it, is to load the respective files and to return a hash with the content type ids as key and the classes as value.
If you are in an environment where autoloading of files is supported, you might think that the require_relative(klass)
statement in the entry_mapping
method looks ugly. And by that you are perfectly right. The good thing is: Just delete that line and your framework will hopefully find out by itself how to handle Content.const_get(klass.classify)
. Furthermore you can also remove the second last line and eventually the config loader. The result will look like this:
require "contentful"
module Content
class Base < Contentful::Entry
class << self
def entry_mapping
@entry_mapping ||= CONTENTFUL_CONFIG.fetch("mapping").map do |klass, content_type_id|
[content_type_id, Content.const_get(klass.classify)]
end.to_h
end
def delivery_client
@delivery_client ||= Contentful::Client.new(
access_token: ENV.fetch("CONTENTFUL_ACCESS_TOKEN"),
space: CONTENTFUL_CONFIG.fetch("space_id"),
dynamic_entries: :auto,
entry_mapping: entry_mapping
)
end
end
# the other code
end
end
If you trust your CVS enough to store sensitive data such as credentials in it, you can also add the access token in your config file รก la:
shared:
access_token: 985174a630cf3203f578e747250bd9a9a9b6250e0a0be61367c2e9338b82d983
space_id: svq072ikci2q
mapping:
blog_post: 5FPTMiJ7R6uycIcC4EiCYc
tag: 1XvAy0wOG8WEUQGsYAMYcU
Now you can read the access token in Content::Base.delivery_client
and alternatively fall back to the environment variables:
def delivery_client
@delivery_client ||= Contentful::Client.new(
access_token: CONTENTFUL_CONFIG.fetch("access_token", ENV["CONTENTFUL_ACCESS_TOKEN"]),
space: CONTENTFUL_CONFIG.fetch("space_id"),
dynamic_entries: :auto,
entry_mapping: entry_mapping
)
end
As we don't want to type fields[:name]
all the time, let's introduce a little helper method called has_fields
, which takes an arbitrary amount of symbols and creates instance methods for the respective fields. The usage looks like this:
module Content
class BlogPost < Base
has_fields :headline, :published_at, :content, :tags
end
end
Having defined the fields, we can later call the respective methods on the instances:
post = Content::BlogPost.first
post.headline # Will return the result of fields[:headline]
post.published_at # Will return the result of fields[:publishedAt]
And here is the helper itself:
module Content
class Base < Contentful::Entry
class << self
def has_fields(*args)
[*args].each do |field_name|
define_method(field_name.to_s) do
fields.public_send(:[], field_name.to_s.camelize(:lower).to_sym)
end
end
end
# here comes the method missing definition
end
end
end
As pure text is pretty boring, I extended the blog post model with an additional field that references assets. In order to fetch the image's url, we can do this:
blog_post.fields[:image].fields[:file].url
As this is quite a lot to type, let's define a helper method that returns the image's url as well as a method that returns whether or not there is an image. The usage would look like this, where image is the name of the field in Contentful:
module Content
class BlogPost < Base
has_image :image
end
end
Having that defined, we can now use the following instance methods:
post = Content::BlogPost.first
post.image? # Returns true or false
post.image_url # Returns the URL of the image
And here is the helper method:
module Content
class Base < Contentful::Entry
class << self
def has_image(field_name)
define_method("#{field_name}_url") do
if public_send("#{field_name}?")
if asset = fields[field_name]
asset.fields[:file].url
end
end
end
define_method("#{field_name}?") do
fields.key?(field_name)
end
end
end
end
end
This post decribed the introduction of a namespace for contentful models as well the usage of a configuration file which contains the mapping of classes to content type ids. Furthermore it explained some handy helper methods which makes the development easier and more ruby-esque. Having that said, I think it would be a good idea to either move that stuff right into the contentful gem or to create another gem which does all that stuff for us, so that we basically only have to inherit from Content::Base
. Depending on my spare time I might come up with such a gem and write another blog post about, once its done.