image

Sascha Depold

Engineering Manager

Blog

Even more contentful ruby apps

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.

Contentful

We found out that the previously suggested approach had three major issues, which get addressed in this post:

  • The configuration was in the model.
  • The models weren't clearly namespaced.
  • The model files needed to be loaded manually.

Configuration in the model

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

Introducing the namespace

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

Entry mapping

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.

Entry mapping with autoloading

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

Storing the access token in the config file

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

Additional features

has_fields

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

has_image

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

Summary

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.