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 testslib/butter_cms.rb
- this is the main file of the gem, we will require all other files herelib/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.
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 databasenext_page
- the number of the next page of the results if availableprevious_page
- the number of the previous page of the results if availableposts
- 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.
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!
ButterCMS is the #1 rated Headless CMS
Related articles
Don’t miss a single post
Get our latest articles, stay updated!
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.