Cistern

Join the chat at https://gitter.im/lanej/cistern Build Status Dependencies Gem Version Code Climate

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

Usage

Client

This represents the remote service that you are wrapping. It defines the client's namespace and initialization parameters.

Client 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 two namespaced classes, Blog::Mock and Blog::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

Requests

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

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

Attributes

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

Persistence

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

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

Associations

Associations allow the use of a resource's attributes to reference other resources. They act as lazy loaded attributes and push any loaded data into the resource's attributes.

There are two types of associations available.

class Blog::Tag < Blog::Model
  identity :id
  attribute :author_id

  has_many :posts -> { cistern.posts(tag_id: identity) }
  belongs_to :creator -> { cistern.authors.get(author_id) }
end

Relationships store the collection's attributes within the resources' attributes on write / load.

tag = blog.tags.get('ruby')
tag.posts = blog.posts.load({'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3})
tag.attributes[:posts] #=> {'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3}

tag.creator = blogs.author.get(name: 'phil')
tag.attributes[:creator] #=> { 'id' => 2, 'name' => 'phil' }

Foreign keys can be updated with with the association writer by aliasing the original writer and accessing the underlying attributes.

Blog::Tag.class_eval do
  alias cistern_creator= creator=
  def creator=(creator)
    self.cistern_creator = creator
    self.author_id = attributes[:creator][:id]
  end
end

tag = blog.tags.get('ruby')
tag.author_id = 4
tag.creator = blogs.author.get(name: 'phil') #=> #<Blog::Author id=2 name='phil'>
tag.author_id #=> 2

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

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

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}

Storage

Currently supported storage backends are:

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.

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

~> 3.0

Client definition

Default resource definition is done by inheritance.

class Blog::Post < Blog::Model
end

In cistern 3, resource definition is done by module inclusion.

class Blog::Post
  include Blog::Post
end

Prepare for cistern 3 by using Cistern::Client.with(interface: :module) when defining the client.

class Blog
  include Cistern::Client.with(interface: :module)
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