Ruby API client framework
Ruby
Latest commit 642a551 Jul 19, 2016 @lanej Bump to 2.5.0
Failed to load latest commit information.
gemfiles test(ci): use `appraisal` for gemfile splitting Jul 7, 2016
lib Bump to 2.5.0 Jul 19, 2016
spec test(*): recycle Sample const to prevent test state pollution Jul 19, 2016
.gemrelease gem release file Nov 14, 2014
.gitignore test(ci): use `appraisal` for gemfile splitting Jul 8, 2016
.travis.yml test(ci): only use matrix/include for overrides Jul 7, 2016
Appraisals test(ci): use `appraisal` for gemfile splitting Jul 8, 2016
CHANGELOG.md update CHANGELOG.md Jul 19, 2016
Gemfile test(ci): use `appraisal` for gemfile splitting Jul 8, 2016
Guardfile allow attribute to be aliased from 'attributes' Oct 13, 2014
LICENSE.txt MIT license Mar 2, 2014
README.md doc(README): add singular documentation Jul 8, 2016
Rakefile
TODO.md todo Jan 16, 2013
cistern.gemspec

README.md

Cistern

Build Status Dependencies Gem Version Code Climate

Cistern helps you consistently build your API clients and faciliates building mock support.

Usage

Notice: Cistern 3.0

Cistern 3.0 will change the way Cistern interacts with your Request, Collection and Model classes.

Prior to 3.0, your Request, Collection and Model classes would have inherited from <service>::Client::Request, <service>::Client::Collection and <service>::Client::Model classes, respectively.

In cistern ~> 3.0, the default will be for Request, Collection and Model classes to instead include their respective <service>::Client modules.

If you want to be forwards-compatible today, you can configure your client by using Cistern::Client.with

class Blog
  include Cistern::Client.with(interface: :module)
end

Now request classes would look like:

class Blog::GetPost
  include Blog::Request

  def real
    "post"
  end
end

Service

This represents the remote service that you are wrapping. If the service name is blog then a good name is Blog.

Service initialization parameters are enumerated by requires and recognizes. Parameters defined using recognizes are optional.

# lib/blog.rb
class Blog
  include Cistern::Client

  requires :hmac_id, :hmac_secret
  recognizes :url
end

# Acceptable
Blog.new(hmac_id: "1", hmac_secret: "2")                            # Blog::Real
Blog.new(hmac_id: "1", hmac_secret: "2", url: "http://example.org") # Blog::Real

# ArgumentError
Blog.new(hmac_id: "1", url: "http://example.org")
Blog.new(hmac_id: "1")

Cistern will define for you two classes, Mock and Real. Create the corresponding files and initialzers for your new service.

# lib/blog/real.rb
class Blog::Real
  attr_reader :url, :connection

  def initialize(attributes)
    @hmac_id, @hmac_secret = attributes.values_at(:hmac_id, :hmac_secret)
    @url = attributes[:url] || 'http://blog.example.org'
    @connection = Faraday.new(url)
  end
end
# lib/blog/mock.rb
class Blog::Mock
  attr_reader :url

  def initialize(attributes)
    @url = attributes[:url]
  end
end

Mocking

Cistern strongly encourages you to generate mock support for your service. Mocking can be enabled using mock!.

Blog.mocking?          # falsey
real = Blog.new        # Blog::Real
Blog.mock!
Blog.mocking?          # true
fake = Blog.new        # Blog::Mock
Blog.unmock!
Blog.mocking?          # false
real.is_a?(Blog::Real) # true
fake.is_a?(Blog::Mock) # true

Working with data

Cistern::Hash contains many useful functions for working with data normalization and transformation.

#stringify_keys

# anywhere
Cistern::Hash.stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}
# within a Resource
hash_stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}

#slice

# anywhere
Cistern::Hash.slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}
# within a Resource
hash_slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}

#except

# anywhere
Cistern::Hash.except({a: 1, b: 2}, :a) #=> {b: 2}
# within a Resource
hash_except({a: 1, b: 2}, :a) #=> {b: 2}

#except!

# same as #except but modify specified Hash in-place
Cistern::Hash.except!({:a => 1, :b => 2}, :a) #=> {:b => 2}
# within a Resource
hash_except!({:a => 1, :b => 2}, :a) #=> {:b => 2}

Requests

Requests are defined by subclassing #{service}::Request.

  • cistern represents the associated Blog instance.
class Blog::GetPost < Blog::Request
  def real(params)
    # make a real request
    "i'm real"
  end

  def mock(params)
    # return a fake response
    "imposter!"
  end
end

Blog.new.get_post # "i'm real"

The #cistern_method function allows you to specify the name of the generated method.

class Blog::GetPosts < Blog::Request
  cistern_method :get_all_the_posts

  def real(params)
    "all the posts"
  end
end

Blog.new.respond_to?(:get_posts) # false
Blog.new.get_all_the_posts       # "all the posts"

All declared requests can be listed via Cistern::Client#requests.

Blog.requests # => [Blog::GetPosts, Blog::GetPost]

Models

  • cistern represents the associated Blog::Real or Blog::Mock instance.
  • collection represents the related collection.
  • new_record? checks if identity is present
  • requires(*requirements) throws ArgumentError if an attribute matching a requirement isn't set
  • requires_one(*requirements) throws ArgumentError if no attribute matching requirement is set
  • merge_attributes(attributes) sets attributes for the current model instance
  • dirty_attributes represents attributes changed since the last merge_attributes. This is useful for using update

Attributes

Cistern attributes are designed to make your model flexible and developer friendly.

  • attribute :post_id adds an accessor to the model.

    attribute :post_id
    
    model.post_id #=> nil
    model.post_id = 1 #=> 1
    model.post_id #=> 1
    model.attributes #=> {'post_id' => 1 }
    model.dirty_attributes #=> {'post_id' => 1 }
  • identity represents the name of the model's unique identifier. As this is not always available, it is not required.

    identity :name

    creates an attribute called name that is aliased to identity.

    model.name = 'michelle'
    
    model.identity   #=> 'michelle'
    model.name       #=> 'michelle'
    model.attributes #=> {  'name' => 'michelle' }
  • :aliases or :alias allows a attribute key to be different then a response key.

    attribute :post_id, alias: "post"

    allows

    model.merge_attributes("post" => 1)
    model.post_id #=> 1
  • :type automatically casts the attribute do the specified type.

    attribute :private_ips, type: :array
    
    model.merge_attributes("private_ips" => 2)
    model.private_ips #=> [2]
  • :squash traverses nested hashes for a key.

    attribute :post_id, aliases: "post", squash: "id"
    
    model.merge_attributes("post" => {"id" => 3})
    model.post_id #=> 3

Persistence

  • save is used to persist the model into the remote service. save is responsible for determining if the operation is an update to an existing resource or a new resource.
  • reload is used to grab the latest data and merge it into the model. reload uses collection.get(identity) by default.
  • update(attrs) is a merge_attributes and a save. When calling update, dirty_attributes can be used to persist only what has changed locally.

For example:

class Blog::Post < Blog::Model
  identity :id, type: :integer

  attribute :body
  attribute :author_id, aliases: "author",  squash: "id"
  attribute :deleted_at, type: :time

  def destroy
    requires :identity

    data = cistern.destroy_post(params).body['post']
  end

  def save
    requires :author_id

    response = if new_record?
                 cistern.create_post(attributes)
               else
                 cistern.update_post(dirty_attributes)
               end

    merge_attributes(response.body['post'])
  end
end

Usage:

create

blog.posts.create(author_id: 1, body: 'text')

is equal to

post = blog.posts.new(author_id: 1, body: 'text')
post.save

update

post = blog.posts.get(1)
post.update(author_id: 1) #=> calls #save with #dirty_attributes == { 'author_id' => 1 }
post.author_id #=> 1

Singular

Singular resources do not have an associated collection and the model contains the get andsave methods.

For instance:

class Blog::PostData
  include Blog::Singular

  attribute :post_id, type: :integer
  attribute :upvotes, type: :integer
  attribute :views, type: :integer
  attribute :rating, type: :float

  def get
    response = cistern.get_post_data(post_id)
    merge_attributes(response.body['data'])
  end

  def save
    response = cistern.update_post_data(post_id, dirty_attributes)
    merge_attributes(response.data['data'])
  end
end

Singular resources often hang off of other models or collections.

class Blog::Post
  include Cistern::Model

  identity :id, type: :integer

  def data
    cistern.post_data(post_id: identity).load
  end
end

They are special cases of Models and have similar interfaces.

post.data.views #=> nil
post.data.update(views: 3)
post.data.views #=> 3

Collection

  • model tells Cistern which resource class this collection represents.
  • cistern is the associated Blog::Real or Blog::Mock instance
  • attribute specifications on collections are allowed. use merge_attributes
  • load consumes an Array of data and constructs matching model instances
class Blog::Posts < Blog::Collection

  attribute :count, type: :integer

  model Blog::Post

  def all(params = {})
    response = cistern.get_posts(params)

    data = response.body

    load(data["posts"])    # store post records in collection
    merge_attributes(data) # store any other attributes of the response on the collection
  end

  def discover(author_id, options={})
    params = {
      "author_id" => author_id,
    }
    params.merge!("topic" => options[:topic]) if options.key?(:topic)

    cistern.blogs.new(cistern.discover_blog(params).body["blog"])
  end

  def get(id)
    data = cistern.get_post(id).body["post"]

    new(data) if data
  end
end

Data

A uniform interface for mock data is mixed into the Mock class by default.

Blog.mock!
client = Blog.new     # Blog::Mock
client.data                  # Cistern::Data::Hash
client.data["posts"] += ["x"] # ["x"]

Mock data is class-level by default

Blog::Mock.data["posts"] # ["x"]

reset! dimisses the data object.

client.data.object_id # 70199868585600
client.reset!
client.data["posts"]   # []
client.data.object_id # 70199868566840

clear removes existing keys and values but keeps the same object.

client.data["posts"] += ["y"] # ["y"]
client.data.object_id         # 70199868378300
client.clear
client.data["posts"]          # []
client.data.object_id         # 70199868378300
  • store and []= write
  • fetch and [] read

You can make the service bypass Cistern's mock data structures by simply creating a self.data function in your service Mock declaration.

class Blog
  include Cistern::Client

  class Mock
    def self.data
      @data ||= {}
    end
  end
end

Storage

Currently supported storage backends are:

  • :hash : Cistern::Data::Hash (default)
  • :redis : Cistern::Data::Redis

Backends can be switched by using store_in.

# use redis with defaults
Patient::Mock.store_in(:redis)
# use redis with a specific client
Patient::Mock.store_in(:redis, client: Redis::Namespace.new("cistern", redis: Redis.new(host: "10.1.0.1"))
# use a hash
Patient::Mock.store_in(:hash)

Dirty

Dirty attributes are tracked and cleared when merge_attributes is called.

  • changed returns a Hash of changed attributes mapped to there initial value and current value
  • dirty_attributes returns Hash of changed attributes with there current value. This should be used in the model save function.
post = Blog::Post.new(id: 1, flavor: "x") # => <#Blog::Post>

post.dirty?           # => false
post.changed          # => {}
post.dirty_attributes # => {}

post.flavor = "y"

post.dirty?           # => true
post.changed          # => {flavor: ["x", "y"]}
post.dirty_attributes # => {flavor: "y"}

post.save
post.dirty?           # => false
post.changed          # => {}
post.dirty_attributes # => {}

Custom Architecture

When configuring your client, you can use :collection, :request, and :model options to define the name of module or class interface for the service component.

For example: if you'd Request is to be used for a model, then the Request component name can be remapped to Demand

For example:

class Blog
  include Cistern::Client.with(interface: :modules, request: "Demand")
end

allows a model named Request to exist

class Blog::Request
  include Blog::Model

  identity :jovi
end

while living on a Demand

class Blog::GetPost
  include Blog::Demand

  def real
    cistern.request.get("/wing")
  end
end

Examples

Releasing

$ gem bump -trv (major|minor|patch)

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request