ghost_rb: A ghost REST API client


As I became more familiar with the Ghost platform, I found myself looking for a client implementation in Ruby for the Ghost API. Unfortunately, I didn’t succeed on my search. Thus, I decided to roll out my own implementation. If you want to go straight head and check the code, go to the github repo.

Before diving into the implementation, let’s mention some essential details of the Ghost API.

APIs

The Ghost API is split between two types:

  • Public API: Basically, it reflects the behavior of a blog. It gives access to public posts and tags, among other data.
  • Private API: Provides access to sensitive data in accordance with the permissions of the user who did the request.

Authentication

Ghost currently supports two types of authentication:

  • Client Authentication: Provides access to the public API of the ghost blog. It authenticates against the public API by using a client_id and a client_secret to gain access for non-secure applications.
  • User Authentication: Allows to make HTTP request to write to the API or read private data. It requires the exchange of user credentials to obtain a bearer token, which will be used, in turn, for subsequent requests.

Implementation

The current version the REST API client in Ruby only supports gathering public data from a given blog.

Client

The client handles making request to the blog to get the information. The current implementation is relatively simple and is only capable of performing GET requests.

class Client
  attr_reader :base_url, :client_id, :client_secret, :default_query

  def initialize(base_url, client_id, client_secret)
    @base_url = URI.join(base_url, 'ghost/', 'api/', 'v0.1/')
    @client_id = client_id
    @client_secret = client_secret
    @http = HTTPClient.new(base_url: @base_url)
    @default_query = Support::HashWithIndifferentAccess.new(
      client_id: @client_id,
      client_secret: @client_secret
    )
  end

  def posts
    Controllers::PostsController.new(self)
  end

  def tags
    Controllers::TagsController.new(self)
  end

  def get(endpoint, query)
    response = @http.get(endpoint, query, {}, follow_redirect: true)
    content = Support::HashWithIndifferentAccess.new(
      JSON.parse(response.body)
    )
    [response.status_code, content]
  end
end

The get’s result is stored as a HashWithIndifferentAccess, which is an object derived from the Hash class, that allows access to the same keys either by string or symbol. The implementation is based on the one used by RoR.

As it is shown above, the client provides a get method to perform a request directly against the Ghost API on the given blog. But producing directly the correct request can be a bit cumbersome, thus the client provides access to controllers for managing tags and posts, with a more easy-to-use interface.

Controllers

The goal of a controller is to encapsulate the preparation of the request parameters. To achieve this, it brings a fluent interface to generate API requests. If you are familiar with Rails and ActiveModel, then you should be right at home :).

As the gem evolves, I can envision the controller building all types of requests and handling creation and edition of posts and tags as well, but the current release is limited to only access readable data.

BaseController, as hinted by its name, contains the common blocks for the controllers, such as, common fluent methods used to build the API request as well as the calling methods to perform the requests, either to fetch a single resource or multiple resources.

class BaseController
  attr_reader :client, :params

  def initialize(client)
    @client = client
    @params = Support::HashWithIndifferentAccess.new
  end

  def all
    fetch_list.map { |r| @resource_klass.generate(r) }
  end

  def limit(limit)
    where(limit: limit)
  end

  def page(page)
    where(page: page)
  end

  def order(order_str)
    where(order: order_str)
  end

  def fields(fields_str)
    where(fields: fields_str)
  end

  def filter(filter_query)
    where(filter: filter_query)
  end

  def include(resources_str)
    where(include: resources_str)
  end

  def where(hash)
    @params.merge!(hash)
    self
  end

  def find_by(kvp)
    @params.keys.reject { |k| k == :include }.each do |key|
      @params.delete(key)
    end

    content = fetch_single(kvp)
    resource_klass.generate(content)
  end

  private

  def fetch_single(kvp)
    endpoint = format_endpoint(kvp)
    query = client.default_query.merge(@params)
    status, content = client.get(endpoint, query)

    if error?(status)
      raise_fetch_single_error(kvp, status, content['errors'])
    end

    content
  end

  def fetch_list
    query = client.default_query.merge(@params)
    status, content = client.get(endpoint, query)

    raise_fetch_list_error(status, content['errors']) if error?(status)

    content[@endpoint]
  end

  def error?(status)
    status >= 400
  end

  def format_endpoint(kvp)
    return [endpoint, kvp[:id]].join('/') if kvp.key?(:id)
    return [endpoint, 'slug', kvp[:slug]].join('/') if kvp.key?(:slug)

    raise Errors::InvalidEndpointError,
          "Invalid endpoint for #{endpoint}. Should be either :id or :slug"
  end
end

Below, we introduce an example of an resource-specific controller, in this case the PostsController class. It handles some specific methods for the posts API, such as formats and is able to raise detailed request errors.

class PostsController < BaseController
  attr_reader :endpoint, :resource_klass

  def initialize(client)
    super
    @endpoint = 'posts'
    @resource_klass = Resources::Post
  end

  def formats(formats)
    where(formats: formats)
  end

  private

  def raise_fetch_single_error(kvp, status, errors)
    key = kvp.key?(:id) ? :id : :slug
    message = "Unable to fetch post with #{key} = #{kvp[key]}"
    raise Errors::RequestError.new(message,
                                   status,
                                   errors)
  end

  def raise_fetch_list_error(status, errors)
    raise Errors::RequestError.new('Unable to fetch posts',
                                   status,
                                   errors)
  end
end

Similar to this, TagsController object defines the methods and errors particular to tags in the Ghost API.

Resources

A resource is a class that holds the data received from an API request and provide accessor methods to facilitate working with the data itself. They inherit from the BaseResource class, shown below, which defines a class method to generate a new instance from a hash parameter.

class BaseResource
  include Support::Hydratable

  def self.generate(hash)
    res_instance = new
    res_instance.hydrate(hash)
    res_instance
  end

  def self.hash_value?(data, key)
    data.key?(key) && data[key].is_a?(Hash)
  end
end

As can be seen above, the class definition includes the Hydratable module. This module provides the hydrate method that allows to map key value pairs in the hash parameter with existing accessor methods in the resource instance.

Below, we show the code for a particular resource. The Post implementation adds several accessor methods, such as title and published_at, and aliases the page and featured accessors to better comply with ruby code guidelines.

class Post < BaseResource
  attr_accessor :id, :title, :slug, :html, :page,
                :status, :published_at, :created_at, :author,
                :visibility, :featured, :plaintext, :tags

  alias page? page

  alias featured? featured

  def self.generate(hash)
    inst = super(hash)
    inst.author = User.generate(hash[:author]) if hash_value?(data, :author)
    inst.tags = hash[:tags].map { |t| Tag.generate(t) } if hash.key?(:tags)

    inst
  end
end

When generating a Post instance from hash data, it will automatically generate the corresponding resources objects for author and tags if these are present in the hash parameter.

Usage

So far, we have seen a fair amount of code and analyzed the API client design. It is time for fun part!! Let’s see what we can do with the gem.

Instantiate a client

client = GhostRb::Client.new(BASE_URL, CLIENT_ID, CLIENT_SECRET)

In a real scenario, you should probably be getting the values to instantiate the a Client object from environment variables or any sort of secrets configuration.

PostsController instance

We can create a controller from scratch as follow:

ctrl = GhostRb::Controllers::PostsControllers.new(client)

I guess that’s to much to write. There has to be an easier way.

Fortunately, we can get a post controller instance doing the following instead:

ctrl = client.posts

Requesting a single post

Using our previous defined ctrl variable, it is as easy as:

post = ctrl.include('author,tags').find_by(id: 'id')

# Using the GhostRb::Resources::Post
puts post.title
puts post.author # Will return a User object with the public data for the author
puts post.tags # Will return an Array of Tag objects

The previous code example showed how to get a post by id and include the author and tags information. If we prefer to get a single post by the slug instead, the change is rather simple:

post = ctrl.find_by(slug: 'post-slug')

Get 5 first published posts

The following request is going to return an array of Post objects sorted by publish date.

posts = ctrl.order('published_at asc').limit(5).all

If you like better, you could get the same result by writing the following:

posts = ctrl.where(order: 'published_at asc', limit: 5).all

Conclusion

To sum up, we mentioned first some key aspects of the Ghost API, relevant to our client implementation. Then we proceeded to highlight the main details of the Ruby client and analyzed some key points. Finally, we showcased some ways to use the gem to read data from a blog API.

To conclude, I must say that getting the API client done is a huge step towards several ideas I have been thinking about lately, since they require gathering data from the blog programmatically. It is a very simple implementation for now, but as different ideas come to fruition I am sure the ruby client will be expanded.

Besides that, it was a great exercise on its own and a way to contribute back to the community. So fork it and contribute back if you can. Pull requests are more than welcome.

Finally, I hope it has been worth for you as it was for me to create it. Maybe you learnt a little bit. Me, I did it for sure. Stay tuned for more!!

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer’s view in any way.