GSD

Launching Your Own Ruby Gem - Part 1: Build It

Posted by Paweł Dąbrowski on October 4, 2023

Creating software requires from us building common functionalities. In order to not reinvent the wheel again, developers are using packages. In Ruby such packages are called Ruby gems. Gems are self-contained pieces of code shared publicly by other developers for you to use in your own application.

As a developer, writing a package is a stepping stone to a whole new level. By creating your own gem you can contribute back to the Ruby developers community and build your personal brand by presenting your code to a larger audience.

Only practice makes perfect. Writing a Ruby gem is like creating a tiny application but it requires a different approach. You have to think of how to solve only one problem, isolate the logic responsible for this and allow other developers to use it in their applications with success. You also learn how to organize your code so it’s readable and extendable for other developers and it’s challenging because it requires taking a wider perspective.

This may seem overwhelming at first, but in this article, I'll show you how to build a simple Ruby gem from scratch and publish it.

Building Ruby gem from scratch - ButterCMS use case

ButterCMS is a headless CMS and Content API. It has its own Ruby gem already but in this tutorial, we will build it from a scratch, in a slightly different way. We will use the ButterCMS API documentation and we will follow Test Driven Development rules in order to create high-quality code without any bugs.

Preparation

We want to fully integrate our ButterCMS account with any Ruby on Rails app. The only requirement is setting the API key in the application initializer in order to get the data from your account. You can get your API key by signing in on the https://buttercms.com account and it will be visible on the welcome page. After doing this we should be able to use models with the ButterCMS prefix, for example ButterCMS::Blog.

To achieve our main goal, we will use two other gems inside our gem: RSpec and RestClient. RSpec is a testing framework and will be a development dependency, meaning we only need it when building the Ruby gem. RestClient is a library for performing requests, which we will use to send requests to the API.

The gem file structure is quite simple. Here are the main parts:

  • butter_cms.gemspec - in this file, we store information about the gem and its dependencies
  • /spec - this is the directory for our tests
  • lib/butter_cms.rb - this is the main file of the gem, we will require all other files here
  • lib/butter_cms - this is the directory for the main logic

 Our code should be also well-documented which means that for each method we have to add proper comments so that later we can create a documentation without any additional actions.

banner-cta-ruby-blue.webp

The Tutorial

Gem configuration

Start with creating a new repository on Github. Then create a new directory on your own machine for our gem source:

mkdir butter_cms
cd butter_cms/

Now it’s time to create an empty GIT repository, add the first commit, and push changes to the remote repo:

echo "# butter_cms" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin git@github.com:your_username/butter_cms.git
git push -u origin master

Note that you should replace your_username/butter_cms part with your Github username and the repository name you created.

Now we can create the butter_cms.gemspec file which will contain all information about our gem. Create it and add the following contents:

Gem::Specification.new do |s|
  s.name        = 'butter_cms_v2'
  s.date        = '2018-08-15'
  s.summary     = "Ruby wrapper for the ButterCMS API"
  s.authors     = ["Paweł Dąbrowski"]
  s.email       = 'dziamber@gmail.com'
  s.homepage    ='http://github.com/rubyhero/butter_cms'
  s.license     = 'MIT'
end

Replace the above text to match your own personal information.

This is a great start, but when you run gem build butter_cms.gemspec  it will fail with the following error:

missing value for attribute version

To fix this error, let’s add the version number for our gem. We will put the current version into a separate file. Create lib/butter_cms/version.rb and add the following contents:

module ButterCMS
  module Version
    module_function

    # Gem current version
    #
    # @return [String]
    def to_s
      "0.0.0.1"
    end
  end
end

Note that we also added comments that will be automatically transformed into documentation when the gem is pushed. Now, we have to update our gemspec  file and add the version attribute:

lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'butter_cms/version'
Gem::Specification.new do |s|
  s.name        = 'butter_cms_v2'
  s.date        = '2018-08-15'
  s.summary     = "Ruby wrapper for the ButterCMS API"
  s.authors     = ["Paweł Dąbrowski"]
  s.email       = 'dziamber@gmail.com'
  s.homepage    =
    'http://github.com/rubyhero/butter_cms'
  s.license     = 'MIT'
  s.version     = ButterCMS::Version
end

You can now run gem build butter_cms.gemspec which will create the butter_cms_v2-0.0.0.1.gem file. There is no sense in installing this gem locally, as it does nothing yet.

Tests configuration

The last step before we start the development process is to configure the test environment. We will be following Test Driven Development (TDD) rules using RSpec - it means that we will write a test first, it will fail and we will fix the test by writing code. We will repeat such order for each new class, feature or method.


Open butter_cms.gemspec and add this line:

s.add_development_dependency "rspec", '~> 3.7', '>= 3.7.0'

Now create spec/spec_helper.rb :

require 'rspec'

Update butter_cms.gemspec again to let Ruby know that we want to auto-load our spec helper:

s.files = Dir['spec/helper.rb']

The last step is to create Rakefile. This is needed in order to be able to run rake spec and run all tests. Create Rakefile in the main directory with the following code:

require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec)

# If you want to make this the default task
task default: :spec

API key configuration storage

To access our ButterCMS account we have to pass api_key to each request we want to send. To make the gem configuration easier, we will provide a nice way to define api_key in the Rails initializer.

We have to create a Configuration class first. By default we should use PLEASE PROVIDE VALID API KEY as the api_key value. It should also be possible to define the api_key inside the Rails initializer.

Let’s start by writing a test which expects the api key. Create spec/lib/butter_cms/configuration_spec.rb  file and add the following:

require 'spec_helper'

describe ButterCMS::Configuration do
  describe 'initialization' do
    it 'sets default value for the api_key attribute' do
      configuration = described_class.new
      expect(configuration.api_key).to eq('PLEASE PROVIDE VALID API KEY')
    end
  end
end

Run rake spec to run the tests. The test will not pass because we didn’t add the Configuration class yet. Create lib/butter_cms/configuration.rb  and add the following code:

module ButterCMS
  class Configuration
    attr_accessor :api_key

    def initialize
      @api_key = 'PLEASE PROVIDE VALID API KEY'
    end
  end
end

Now let Ruby know that we want to load all files inside the lib directory. In order to do that we have to update butter_cms.gemspec:

s.files = Dir['spec/helper.rb', 'lib/**/*.rb']

The last step is to create the main file located under lib/butter_cms.rb path:

require 'butter_cms/configuration'

and load it in the spec_helper:

require 'rspec'
require 'butter_cms'

Now we can run rake spec and the test should pass. Thanks to using TDD approach we know that our code is working as expected. Instead of creating code first and then test it we created expectations and we created code that satisfies them.

Setting API key

Now we have a place for storing the api_key, but we want to provide an easy way to set it. We want something like this:

ButterCMS.configure do |config|
  config.api_key = "api_key"
end

Since we are following TDD principles, let’s start by writing a test in spec/lib/butter_cms_spec.rb:

require 'spec_helper'

describe ButterCMS::Configuration do
  describe 'initialization' do
    it 'sets default value for the api_key attribute' do
      configuration = described_class.new
      expect(configuration.api_key).to eq('PLEASE PROVIDE VALID API KEY')
    end
  end
end

For the test to pass, we have to update lib/butter_cms.rb:

require 'butter_cms/configuration'

module ButterCMS
  class << self
    attr_accessor :configuration
  end
  
  # Returns the configuration class instance if block given
  #
  # @return [ButterCMS::Configuration]
  def self.configure
    self.configuration ||= ::ButterCMS::Configuration.new

    yield(configuration) if block_given?
  end
end

ButterCMS.configure unless ButterCMS.configuration

Performing requests

Now that we have a way to set the api_key, we can start performing requests. In order to do this we will use the excellent and popular RestClient gem. Add the following line to  butter_cms.gemspec:

s.add_runtime_dependency 'rest-client', '~> 2.0'

Let’s take a look at the example API url:

https://api.buttercms.com/v2/posts/?page=1&page_size=10&auth_token=api_key

We need to write a service for parsing params. It should form a query string from the given options hash (if present) and add the auth_token param at the end.

Following TDD principles, create spec/lib/butter_cms/url_params_service_spec.rb and add two tests:

require 'spec_helper'

describe ButterCMS::UrlParamsService do
  describe '.call' do
    it 'returns query string with the auth_token param' do
      api_key = 'api_key'
      allow(ButterCMS.configuration).to receive(:api_key).and_return(api_key)
      options = { page: 1, per_page: 10 }

      expect(described_class.call(
        options
      )).to eq("?page=1&per_page=10&auth_token=api_key")
    end

    it 'returns query string with the auth_token param when given options are blank' do
      api_key = 'api_key'
      allow(ButterCMS.configuration).to receive(:api_key).and_return(api_key)

      expect(described_class.call).to eq("?auth_token=api_key")
    end
  end
end

Now we have to satisfy these tests by creating a UrlParamsService class in lib/butter_cms/url_params_service.rb:

module ButterCMS
  class UrlParamsService
    
    # Returns the query part of the request to the API
    #
    # @return [String]
    def self.call(options = {})
      options[:auth_token] = ButterCMS.configuration.api_key

      "?" << options.map { |(key, value)| "#{key.to_s}=#{value}" }.join("&")
    end

  end
end

Finally, we can load our new service by editing lib/butter_cms.rb:

require 'butter_cms/url_params_service'

Now when we run rake spec, all specs should pass.

Now that we have a service for parsing params, we can create a class responsible for sending requests. Because API URL won’t change often, we should have it saved somewhere. In order to do this, create a new file lib/butter_cms/requests/api.rb and add the following contents:

module ButterCMS
  module Requests
    module API
      URL = "https://api.buttercms.com/v2/".freeze
    end
  end
end

Ensure it’s loaded by updating lib/butter_cms.rb:

require 'butter_cms/requests/api'

It’s time to create the request class. Create a test for it in spec/lib/butter_cms/requests/get_spec.rb:

require 'spec_helper'

describe ButterCMS::Requests::Get do
  describe '.call' do
    it 'performs request' do
      options = { page: 1 }
      path = "posts"
      query = "?page=1&auth_token=api_token"
      allow(ButterCMS::UrlParamsService).to receive(:call).with(
        options
      ).and_return(query)
      full_url = "https://api.buttercms.com/v2/posts?page=1&auth_token=api_token"
      response = double('response')
      allow(RestClient).to receive(:get).with(full_url).and_return(response)

      result = described_class.call("posts", options)

      expect(result).to eq(response)
      expect(ButterCMS::UrlParamsService).to have_received(:call).with(
        options
      ).once
      expect(RestClient).to have_received(:get).with(full_url).once
    end
  end
end

and implement the logic by creating lib/butter_cms/requests/get.rb:

require 'rest_client'

module ButterCMS
  module Requests
    class Get
      
      # Returns response from the request to the given API endpoint
      #
      # @return [RestClient::Response]
      def self.call(path, options = {})
        full_url = [
          ::ButterCMS::Requests::API::URL,
          path,
          ::ButterCMS::UrlParamsService.call(options)
        ].join

        ::RestClient.get(full_url)
      end

    end
  end
end

As always, don’t forget to automatically load our new file by updating lib/butter_cms.rb:

require 'butter_cms/requests/get'

Parsing the response

We now know how to make a request, but we still have to take care of parsing the response out of the JSON format. We have to decode the response and transform it into objects - just like what normally happens in Rails when you request records from a database.

Let’s start by creating a new test. Create spec/lib/butter_cms/parsers/posts_spec.rb and add the following tests:

require 'spec_helper'

describe ButterCMS::Parsers::Posts do
  let(:response_body) do
    {
      "meta" => {
        "count" => 3,
        "next_page" => 3,
        "previous_page" => 1
      },
      "data" => [
        { "url" => "sample-title" }
      ]
    }.to_json
  end

  let(:response) { double(body: response_body) }

  describe '#next_page' do
    it 'returns next page' do
      parser = described_class.new(response)

      expect(parser.next_page).to eq(3)
    end
  end

  describe '#previous_page' do
    it 'returns previous page' do
      parser = described_class.new(response)

      expect(parser.previous_page).to eq(1)
    end
  end

  describe '#count' do
    it 'returns the total count of posts' do
      parser = described_class.new(response)

      expect(parser.count).to eq(3)
    end
  end

  describe '#posts' do
    it 'returns posts' do
      parser = described_class.new(response)

      expect(parser.posts).to eq([{ "url" => "sample-title" }])
    end
  end
end

We want to be able to fetch the following values from the posts response:

  • count - the total count of posts available in the database
  • next_page - the number of the next page of the results if available
  • previous_page - the number of the previous page of the results if available
  • posts - array with our posts and associated objects

Now it’s time to satisfy the tests we wrote by creating a new file lib/butter_cms/parsers/posts.rb with the following code:

require 'json'

module ButterCMS
  module Parsers
    class Posts

      def initialize(response)
        @response = response
      end
      
      # Returns the number of the next page or nil if not available
      #
      # @return [String]
      def next_page
        parsed_json['meta']['next_page']
      end
      
      # Returns the number of the previous page or nil if not available
      #
      # @return [String]
      def previous_page
        parsed_json['meta']['previous_page']
      end
      
      # Returns the count of existing posts
      #
      # @return [String]
      def count
        parsed_json['meta']['count']
      end
      
      # Returns array of posts attributes available in the response
      #
      # @return [Array]
      def posts
        parsed_json['data']
      end

      private

      attr_reader :response

      def parsed_json
        @parsed_json ||= ::JSON.parse(response.body)
      end
    end
  end
end

The last step is to let our gem know that we want this file to be loaded. In order to do this we have to update lib/butter_cms.rb:

require 'butter_cms/parsers/posts'

In the posts response, we have access to the following associations:

  • author - a hash of author data attributes
  • tags - an array of tags associated with the given post
  • categories - an array of categories associated with the given post

Now, our job is to create the ButterCMS::Post.all method which will return an array of ButterCMS::Post objects. We should have access to the associated objects just like in Rails.

First, we have to create a base class which will dynamically replace a hash of attributes with a class with attributes. Create spec/lib/butter_cms/resource_spec.rb and add new tests:

require 'spec_helper'

describe ButterCMS::Resource do
  describe 'attributes assignment' do
    it 'assigns attributes from the given hash' do
      attributes = { 'title' => 'my title', 'slug' => 'sample-slug' }

      resource = described_class.new(attributes)

      expect(resource.title).to eq(attributes['title'])
      expect(resource.slug).to eq(attributes['slug'])
    end
  end
end

As you can see, for each key and value in the given hash we have to create an attribute in our class. We want to do this dynamically, so the following definition would not work:

module ButterCMS
  class Resource
    def initialize(attributes)
      @title = attributes['title']
      @slug = attributes['slug']
    end
    
    attr_reader :title, :slug
  end
end

Instead, we have to do this dynamically without knowing the names of attributes. Create lib/butter_cms/resource.rb and add the following code:

module ButterCMS
  class Resource
    def initialize(attributes = {})
      attributes.each do |attr, value|
        define_singleton_method(attr) { attributes[attr] }
      end
    end
  end
end

As always, open lib/butter_cms.rb and load our new file:

require 'butter_cms/resource'

Now, we have to create our models: Post, Category, Tag and Author. We do not need specs for them now because we will create empty classes.

lib/butter_cms/post.rb:

module ButterCMS
  class Post < ButterCMS::Resource

  end
end

lib/butter_cms/category.rb:

module ButterCMS
  class Category < ButterCMS::Resource

  end
end

lib/butter_cms/tag.rb:

module ButterCMS
  class Tag < ButterCMS::Resource

  end
end

lib/butter_cms/author.rb :

module ButterCMS
  class Author < ButterCMS::Resource

  end
end

Now we can move to the step where we implement the ButterCMS::Post.all method. For each association in the single post, we have to create a parser class which will turn the attributes into one class with those attributes.

First create a spec for the author association. The file should be named lib/butter_cms/parsers/author_object_spec.rb:

require 'spec_helper'

describe ButterCMS::Parsers::AuthorObject do
  describe '.call' do
    it 'returns new instance of ButterCMS::Author' do
      attributes = { name: 'John Doe' }

      author = described_class.call(attributes)

      expect(author).to be_instance_of(ButterCMS::Author)
      expect(author.name).to eq('John Doe')
    end
  end
end

Now, satisfy the test:

module ButterCMS
  module Parsers
    class AuthorObject
      
      # Returns the new instance of author object from the given attributes
      #
      # @return [ButterCMS::Author]
      def self.call(author)
        ::ButterCMS::Author.new(author)
      end

    end
  end
end

and load files inside the lib/butter_cms.rb:

require 'butter_cms/author'
require 'butter_cms/parsers/author_object'

Now it’s time for the categories association. This time our class logic will be a little more complicated than the class for author association. Create spec/lib/butter_cms/parsers/categories_objects_spec.rb:

require 'spec_helper'

describe ButterCMS::Parsers::CategoriesObjects do
  describe '.call' do
    it 'returns the array of new instances of ButterCMS::Category' do
      category_attributes = { name: 'Category 1' }
      another_category_attributes = { name: 'Category 2' }
      attributes = [category_attributes, another_category_attributes]

      categories = described_class.call(attributes)

      expect(categories.first).to be_instance_of(ButterCMS::Category)
      expect(categories.first.name).to eq('Category 1')
      expect(categories.last).to be_instance_of(ButterCMS::Category)
      expect(categories.last.name).to eq('Category 2')
      expect(categories.size).to eq(2)
    end
  end
end

Satisfy the test by creating a CategoriesObjects class in lib/butter_cms/parsers/categories_objects.rb:

module ButterCMS
  module Parsers
    class CategoriesObjects
      
      # Returns array of category objects created from given array of attributes
      #
      # @return [Array<ButterCMS::Category>]
      def self.call(categories)
        categories.map do |category_attributes|
          ::ButterCMS::Category.new(category_attributes)
        end
      end

    end
  end
end

And load the new files in lib/butter_cms.rb:

require 'butter_cms/category'
require 'butter_cms/parsers/categories_objects'

We will repeat the same action for the tags. Create tests in spec/lib/butter_cms/parsers/tags_objects_spec.rb:

require 'spec_helper'

describe ButterCMS::Parsers::TagsObjects do
  describe '.call' do
    it 'returns the array of new instances of ButterCMS::Tag' do
      tag_attributes = { name: 'Tag 1' }
      another_tag_attributes = { name: 'Tag 2' }
      attributes = [tag_attributes, another_tag_attributes]

      tags = described_class.call(attributes)

      expect(tags.first).to be_instance_of(ButterCMS::Tag)
      expect(tags.first.name).to eq('Tag 1')
      expect(tags.last).to be_instance_of(ButterCMS::Tag)
      expect(tags.last.name).to eq('Tag 2')
      expect(tags.size).to eq(2)
    end
  end
end

Then satisfy those tests by creating  a TagsObjects class in lib/butter_cms/parsers/tags_objects.rb:

module ButterCMS
  module Parsers
    class TagsObjects
      
      # Returns array of tag objects created from given array of attributes
      #
      # @return [Array<ButterCMS::Tag>]
      def self.call(tags)
        tags.map do |tag_attributes|
          ::ButterCMS::Tag.new(tag_attributes)
        end
      end

    end
  end
end

Load the new files in lib/butter_cms.rb:

require 'butter_cms/tag'
require 'butter_cms/parsers/tags_objects'

Finally, we can create a parser class for our post attributes. Parser should handle all associations and classes we created in the previous steps.

Add tests in spec/lib/butter_cms/parsers/post_object_spec.rb:

require 'spec_helper'

describe ButterCMS::Parsers::PostObject do
  describe '.call' do
    it 'returns new ButterCMS::Post instance' do
      category_attributes = { 'name' => 'Category 1' }
      author_attributes = { 'name' => 'John Doe' }
      tag_attributes = { 'name' => 'Tag 1' }
      post_attributes = {
        'title' => 'post title',
        'categories' => [category_attributes],
        'author' => author_attributes,
        'tags' => [tag_attributes]
      }
      category = instance_double(ButterCMS::Category)
      tag = instance_double(ButterCMS::Tag)
      author = instance_double(ButterCMS::Author)
      post = instance_double(ButterCMS::Post)
      allow(ButterCMS::Parsers::TagsObjects).to receive(:call).with(
        [tag_attributes]
      ).and_return([tag])
      allow(ButterCMS::Parsers::CategoriesObjects).to receive(:call).with(
        [category_attributes]
      ).and_return([category])
      allow(ButterCMS::Parsers::AuthorObject).to receive(:call).with(
        author_attributes
      ).and_return(author)
      updated_attributes = {
        'title' => 'post title',
        'categories' => [category],
        'author' => author,
        'tags' => [tag]
      }
      allow(ButterCMS::Post).to receive(:new).with(
        updated_attributes
      ).and_return(post)

      result = described_class.call(
        post_attributes
      )

      expect(result).to eq(post)
      expect(ButterCMS::Parsers::TagsObjects).to have_received(:call).with(
        [tag_attributes]
      ).once
      expect(ButterCMS::Parsers::CategoriesObjects).to have_received(:call).with(
        [category_attributes]
      ).once
      expect(ButterCMS::Parsers::AuthorObject).to have_received(:call).with(
        author_attributes
      ).once
    end
  end
end

This spec is larger than the other specs because we have to stub other parsers. By stubbing, I mean calling a mock instead of the original class. Thanks to the stubbing we can control given class behavior without calling the logic. We are doing this because we only want to test the current class body not the code in other classes. In this class we will connect all the pieces of the response into one ButterCMS::Post object with the relevant associations.

banner-cta-ruby-blue.webp

Let’s satisfy the test by creating a PostObject class in lib/butter_cms/parsers/post_object.rb:

module ButterCMS
  module Parsers
    class PostObject
      
      # Returns the new instance of post with the associations included
      #
      # @return [ButterCMS::Post]
      def self.call(post_attributes)
        updated_post_attributes = {
          'tags' => ::ButterCMS::Parsers::TagsObjects.call(post_attributes.delete('tags')),
          'categories' => ::ButterCMS::Parsers::CategoriesObjects.call(post_attributes.delete('categories')),
          'author' => ::ButterCMS::Parsers::AuthorObject.call(post_attributes.delete('author'))
        }

        ::ButterCMS::Post.new(post_attributes.merge(updated_post_attributes))
      end

    end
  end
end

The last step is to update lib/butter_cms.rb:

require 'butter_cms/post'
require 'butter_cms/parsers/post_object'

We will now move to the fetch service creation. It will be responsible for returning the array of post objects for the given request options. Create tests in spec/lib/butter_cms/posts_fetch_service_spec.rb:

require 'spec_helper'

describe ButterCMS::PostsFetchService do
  describe '#posts' do
    it 'returns posts from given request' do
      request_options = { page: 1, per_page: 10 }
      post_attributes = { title: 'Post title' }
      parser = instance_double(ButterCMS::Parsers::Posts, posts: [post_attributes])
      post = instance_double(ButterCMS::Post)
      response = double('response')
      allow(ButterCMS::Requests::Get).to receive(:call).with(
        "posts", request_options
      ).and_return(response)
      allow(ButterCMS::Parsers::Posts).to receive(:new).with(response).and_return(parser)
      allow(ButterCMS::Parsers::PostObject).to receive(:call).with(
        post_attributes
      ).and_return(post)

      service = described_class.new(request_options)
      result = service.posts

      expect(result).to eq([post])
      expect(ButterCMS::Parsers::PostObject).to have_received(:call).with(
        post_attributes
      ).once
      expect(ButterCMS::Requests::Get).to have_received(:call).with(
        "posts", request_options
      ).once
    end
  end
end

Create lib/butter_cms/posts_fetch_service.rb:

module ButterCMS
  class PostsFetchService
    def initialize(request_options)
      @request_options = request_options
    end
    
    # Returns array of post objects with the associated records included
    #
    # @return [Array<ButterCMS::Post>]
    def posts
      parser.posts.map do |post_attributes|
        ::ButterCMS::Parsers::PostObject.call(post_attributes)
      end
    end

    private

    attr_reader :request_options

    def response
      ::ButterCMS::Requests::Get.call("posts", request_options)
    end

    def parser
      @parser ||= ::ButterCMS::Parsers::Posts.new(response)
    end
  end
end

Load the new file in lib/butter_cms.rb:

require 'butter_cms/posts_fetch_service'

We also need a method inside ButterCMS::PostsFetchService which will detect if there are any ‘next’ pages to fetch. We will name it #more_posts?:

describe '#more_posts?' do
  it 'returns true if there are more posts to fetch' do
    request_options = { page: 1, per_page: 10 }
    post_attributes = { title: 'Post title' }
    parser = instance_double(ButterCMS::Parsers::Posts, next_page: 2)
     response = double('response')
    allow(ButterCMS::Requests::Get).to receive(:call).with(
      "posts", request_options
    ).and_return(response)
    allow(ButterCMS::Parsers::Posts).to receive(:new).with(response).and_return(parser)

    service = described_class.new(request_options)
    result = service.more_posts?

    expect(result).to eq(true)
    expect(ButterCMS::Requests::Get).to have_received(:call).with(
      "posts", request_options
    ).once
  end

  it 'returns false if there are no more posts to fetch' do
    request_options = { page: 1, per_page: 10 }
    post_attributes = { title: 'Post title' }
    parser = instance_double(ButterCMS::Parsers::Posts, next_page: nil)
     response = double('response')
    allow(ButterCMS::Requests::Get).to receive(:call).with(
      "posts", request_options
    ).and_return(response)
    allow(ButterCMS::Parsers::Posts).to receive(:new).with(response).and_return(parser)

    service = described_class.new(request_options)
    result = service.more_posts?

    expect(result).to eq(false)
    expect(ButterCMS::Requests::Get).to have_received(:call).with(
      "posts", request_options
    ).once
  end
end

Implementation is simple:

# Returns true if the next page is available, false otherwise
#
# @return [Boolean]
def more_posts?
  !parser.next_page.nil?
end

Yes, it's time to move to the ButterCMS::Postclass. We will implement the ButterCMS::Post.allclass which will return all available posts.

Create new test spec/lib/butter_cms/post_spec.rb:

require 'spec_helper'

describe ButterCMS::Post do
  describe '.all' do
    it 'returns all available posts' do
      request_options = { page_size: 10, page: 1 }
      post = instance_double(ButterCMS::Post)
      fetch_service = instance_double(ButterCMS::PostsFetchService,
        posts: [post], more_posts?: true
      )
      allow(ButterCMS::PostsFetchService).to receive(:new).with(
        request_options
      ).and_return(fetch_service)

      another_post = instance_double(ButterCMS::Post)
      another_request_options = { page_size: 10, page: 2 }
      another_fetch_service = instance_double(ButterCMS::PostsFetchService,
        posts: [another_post], more_posts?: false
      )
      allow(ButterCMS::PostsFetchService).to receive(:new).with(
        another_request_options
      ).and_return(another_fetch_service)

      expect(described_class.all).to eq([post, another_post])
    end
  end
end

and satisfy the test in the previously created Post class in lib/butter_cms/post.rb:

module ButterCMS
  class Post < ButterCMS::Resource
    
    # Returns all available posts from the API
    #
    # @return [Array<ButterCMS::Post>]
    def self.all
      posts = []
      request_options = { page_size: 10, page: 0 }
      more_posts = true

      while more_posts
        request_options = request_options.merge(page: request_options[:page] + 1)
        fetch_service = ButterCMS::PostsFetchService.new(request_options)
        posts = posts | fetch_service.posts
        more_posts = fetch_service.more_posts?
      end

      posts
    end

  end
end

Now that was a lot of coding!

Find single post

We have code for pulling all posts but we want to also be able to pull only one post using its slug. Slug is a unique string used in the URL to identify given resource. It looks better than the standard id and it provides better positioning in the Google search engine. We need to create a new parser and add a new method to the ButterCMS::Post class. Create a new test in spec/butter_cms/parsers/post.rb and add the following:

require 'spec_helper'

describe ButterCMS::Parsers::Post do
  let(:response_body) do
    {
      "meta" => {
        "count" => 3,
        "next_page" => 3,
        "previous_page" => 1
      },
      "data" => { "url" => "sample-title" }
    }.to_json
  end

  let(:response) { double(body: response_body) }

  describe '#post' do
    it 'returns post attributes' do
      parser = described_class.new(response)

      expect(parser.post).to eq({ "url" => "sample-title" })
    end
  end
end

Satisfy it by creating logic in ButterCMS::Parsers::Postclass:

module ButterCMS
  module Parsers
    class Post < ButterCMS::Parsers::Posts
      # Returns post attributes
      #
      # @return [Hash]
      def post
        parsed_json['data']
      end
    end
  end
end

Don’t forget to load the new parser class inside lib/butter_cms.rb:

require 'butter_cms/parsers/post'

Open spec/lib/butter_cms/post_spec.rb and add new a new test  for the .find method:

describe '.find' do
  it 'raises RecordNotFound error when post does not exist for given slug' do
    allow(ButterCMS::Requests::Get).to receive(:call).with("posts/slug").and_raise(RestClient::NotFound)

    expect { described_class.find("slug") }.to raise_error(ButterCMS::Post::RecordNotFound)
  end

  it 'returns post for the given slug' do
    response = double('response')
    post_attributes = { "slug" => "some-slug" }
    post_object = instance_double(ButterCMS::Post)
    allow(ButterCMS::Requests::Get).to receive(:call).with("posts/slug").and_return(response)
    parser = instance_double(ButterCMS::Parsers::Post, post: post_attributes)
    allow(ButterCMS::Parsers::Post).to receive(:new).with(response).and_return(parser)
    allow(ButterCMS::Parsers::PostObject).to receive(:call).with(post_attributes).and_return(post_object)

    result = described_class.find("slug")

    expect(result).to eq(post_object)
    expect(ButterCMS::Parsers::PostObject).to have_received(:call).with(post_attributes).once
    expect(ButterCMS::Requests::Get).to have_received(:call).with("posts/slug").once
  end
end

If post is found we want to return a new instance of ButterCMS::Post. If a post is not found, we want to raise a ButterCMS::Post::RecordNotFound error. Let’s implement it:

class RecordNotFound < StandardError; end
# Returns post for given slug if available or raises RecordNotFound error
#
# @return [ButterCMS::Post]
def self.find(slug)
  response = ::ButterCMS::Requests::Get.call("posts/#{slug}")
  post_attributes = ::ButterCMS::Parsers::Post.new(response).post
  ::ButterCMS::Parsers::PostObject.call(post_attributes)
rescue RestClient::NotFound
  raise RecordNotFound
end

Testing what we have done so far

Finally we’ve reached the  moment that you have been waiting for from the beginning. We can now build our gem, install it locally, and then test it in the irb console.

Build it:

gem build butter_cms.gemspec

Install it:

gem install butter_cms_v2-0.0.0.1.gem

And open interactive ruby console:

irb

As you remember, we have to define the api_key first:

require 'butter_cms'

ButterCMS.configure do |c|
  c.api_key = 'your_api_key'
end

You can get your api_key for free after creating an account on buttercms.com.

Now, we can test our posts:

posts = ButterCMS::Post.all
post = ButterCMS::Post.find(posts.first.slug)
ButterCMS::Post.find("fake-slug")

Categories support

Now that we are sure that our code is working well, we can move to the next step: categories support. We want to add the ability to pull all categories and find a given category by slug.

The categories response is quite simple. It contains only the data attribute and an array of the category attributes. We will start by creating the parser class for the response. Create spec/lib/butter_cms/parsers/categories_spec.rb and the following test:

require 'spec_helper'

describe ButterCMS::Parsers::Categories do
  let(:response_body) do
    {
      "data" => [
        { "slug" => "sample-title" }
      ]
    }.to_json
  end

  let(:response) { double(body: response_body) }

  describe '#categories' do
    it 'returns categories' do
      parser = described_class.new(response)

      expect(parser.categories).to eq([{ "slug" => "sample-title" }])
    end
  end
end

We already created similar code in ButterCMS::Parsers::Posts so it will be easier to satisfy the above test. Create lib/butter_cms/parsers/categories.rb:

require 'json'

module ButterCMS
  module Parsers
    class Categories

      def initialize(response)
        @response = response
      end

      # Returns array of category attributes available in the response
      #
      # @return [Array]
      def categories
        parsed_json['data']
      end

      private

      attr_reader :response

      def parsed_json
        @parsed_json ||= ::JSON.parse(response.body)
      end
    end
  end
end

Don’t forget to load our parser class inside lib/butter_cms.rb:

require 'butter_cms/parsers/categories'

Now we have to create spec/lib/butter_cms/category_spec.rb and add a test  for the ButterCMS::Category.all method:

require 'spec_helper'

describe ButterCMS::Category do
  describe '.all' do
    it 'returns all categories' do
      response = double('response')
      allow(ButterCMS::Requests::Get).to receive(:call).with("categories").and_return(response)
      attributes = [{"slug" => "some-slug"}]
      parser = instance_double(ButterCMS::Parsers::Categories, categories: attributes)
      allow(ButterCMS::Parsers::Categories).to receive(:new).with(response).and_return(parser)
      category = instance_double(ButterCMS::Category)
      allow(ButterCMS::Parsers::CategoriesObjects).to receive(:call).with(attributes).and_return([category])

      result = described_class.all

      expect(result).to eq([category])
      expect(parser).to have_received(:categories).once
      expect(ButterCMS::Requests::Get).to have_received(:call).with("categories").once
    end
  end
end

Update lib/butter_cms/category.rb:

module ButterCMS
  class Category < ButterCMS::Resource

    # Returns all categories
    #
    # @return [Array<ButterCMS::Category>]
    def self.all
      response = ::ButterCMS::Requests::Get.call("categories")
      attributes = ::ButterCMS::Parsers::Categories.new(response).categories

      ::ButterCMS::Parsers::CategoriesObjects.call(attributes)
    end

  end
end

And we are done.

Summary

Our gem is ready for the first release. We implemented the following base functionality:

  • Setting the API key using nice DSL (Domain Specific Language) - it’s a more human-friendly way of setting different things in the code - in this case variable values. DSL is a one of the metaprogramming advantages
  • Fetching all posts with the related data included
  • Fetching a single post
  • Fetching all categories

Publishing gem

Sign up on rubygems.org if you don’t have an account already. Now, push the gem using the same credentials you used to create your account on the rubygems.org website:

gem push butter_cms_v2-0.0.0.1.gem

Pushing gem to https://rubygems.org...

Successfully registered gem: butter_cms_v2 (0.0.0.1)

Testing our gem with the Ruby on Rails application

Since our gem is published, we can create a new Rails application and see how it works:

rails new butter_cms_test_app
cd butter_cms_test_app

Open Gemfile and add our gem:

gem 'butter_cms_v2'

Then run bundle install.

As you remember, we have to add your api_key in order to use ButterCMS API. Let’s create a new initializer config/initializers/butter_cms.rb and add the following contents:

require 'butter_cms'

ButterCMS.configure do |c|
  c.api_key = "your_api_key"
end

Now use rails console rails c and pull your blog posts:

ButterCMS::Post.all

And that’s it!

Ideas for future development

There are plenty of ways that you can improve the gem we just built. You can start with implementing support for all API endpoints provided by the ButterCMS or caching API responses to prevent you from fetching the same resource twice. Experiment and try out new things and approaches, this is your place to explore

What is next?

Building a Ruby gem is just the beginning of any developer’s journey. Any piece of software is a work in progress: it's never really finished and there are always ways of improving it. In the next article, we will demonstrate how to promote your gem among the Ruby community and how to write a good README to make it easier to use and and for other developers to contribute to. Stay tuned!

Don't miss out on the next part of our Ruby gem series.
Paweł Dąbrowski

Paweł is a self-made writer of the digital era, IT samurai and problem solver who loves Ruby. He writes for human beings and computers at pdabrowski.com and focuses on soft skills in the software creation process.

ButterCMS is the #1 rated Headless CMS

G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award G2 crowd review award

Don’t miss a single post

Get our latest articles, stay updated!