Cistern

Build Status Dependencies Gem Version Code Climate

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

Usage

Custom Architecture

By default a service's Request, Collection, and Model are all classes. In Cistern ~> 3.0, the default will be modules.

You can modify your client's architecture to be forwards compatible by using Cistern::Client.with

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

Now request classes would look like:

class Foo::GetBar
  include Foo::Request

  def real
    "bar"
  end
end

Other options include :collection, :request, and :model. This options define the name of module or class interface for the service component.

If Request is to reserved for a model, then the Request component name can be remapped to Prayer

For example:

class Foo::Client
  include Cistern::Client.with(request: "Prayer")
end

allows a model named Request to exist

class Foo::Request < Foo::Model
  identity :jovi
end

while living on a Prayer

class Foo::GetBar < Foo::Prayer
  def real
    service.request.get("/wing")
  end
end

Service

This represents the remote service that you are wrapping. If the service name is foo then a good name is Foo::Client.

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

class Foo::Client
  include Cistern::Client

  requires :hmac_id, :hmac_secret
  recognizes :url
end

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

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

Cistern will define for you two classes, Mock and Real.

Mocking

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

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

Requests

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

  • service represents the associated Foo::Client instance.
class Foo::Client::GetBar < Foo::Client::Request
  def real(params)
    # make a real request
    "i'm real"
  end

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

Foo::Client.new.get_bar # "i'm real"

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

class Foo::Client::GetBars < Foo::Client::Request
  service_method :get_all_the_bars

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

Foo::Client.new.respond_to?(:get_bars) # false
Foo::Client.new.get_all_the_bars       # "all the bars"

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

Foo::Client.requests # => [Foo::Client::GetBars, Foo::Client::GetBar]

Models

  • service represents the associated Foo::Client instance.
  • collection represents the related collection (if applicable)
  • new_record? checks if identity is present
  • requires(*requirements) throws ArgumentError if an attribute matching a requirement isn't set
  • merge_attributes(attributes) sets attributes for the current model instance

Attributes

Attributes are designed to be a flexible way of parsing service request responses.

identity is special but not required.

attribute :flavor makes Foo::Client::Bar.new.respond_to?(:flavor)

  • :aliases or :alias allows a attribute key to be different then a response key. attribute :keypair_id, alias: "keypair" with merge_attributes("keypair" => 1) sets keypair_id to 1
  • :type automatically casts the attribute do the specified type. attribute :private_ips, type: :array with merge_attributes("private_ips" => 2) sets private_ips to [2]
  • :squash traverses nested hashes for a key. attribute :keypair_id, aliases: "keypair", squash: "id" with merge_attributes("keypair" => {"id" => 3}) sets keypair_id to 3

Example

class Foo::Client::Bar < Foo::Client::Model
  identity :id

  attribute :flavor
  attribute :keypair_id, aliases: "keypair",  squash: "id"
  attribute :private_ips, type: :array

  def destroy
    params  = {
      "id" => self.identity
    }
    self.service.destroy_bar(params).body["request"]
  end

  def save
    requires :keypair_id

    params = {
      "keypair" => self.keypair_id,
      "bar"     => {
        "flavor" => self.flavor,
      },
    }

    if new_record?
      merge_attributes(service.create_bar(params).body["bar"])
    else
      requires :identity

      merge_attributes(service.update_bar(params).body["bar"])
    end
  end
end

Collection

  • model tells Cistern which class is contained within the collection.
  • service is the associated Foo::Client instance
  • attribute specifications on collections are allowed. use merge_attributes
  • load consumes an Array of data and constructs matching model instances
class Foo::Client::Bars < Foo::Client::Collection

  attribute :count, type: :integer

  model Foo::Client::Bar

  def all(params = {})
    response = service.get_bars(params)

    data = response.body

    self.load(data["bars"])     # store bar records in collection
    self.merge_attributes(data) # store any other attributes of the response on the collection
  end

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

    service.requests.new(service.discover_bar(params).body["request"])
  end

  def get(id)
    if data = service.get_bar("id" => id).body["bar"]
      new(data)
    else
      nil
    end
  end
end

Data

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

Foo::Client.mock!
client = Foo::Client.new     # Foo::Client::Mock
client.data                  # Cistern::Data::Hash
client.data["bars"] += ["x"] # ["x"]

Mock data is class-level by default

Foo::Client::Mock.data["bars"] # ["x"]

reset! dimisses the data object.

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

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

client.data["bars"] += ["y"] # ["y"]
client.data.object_id        # 70199868378300
client.clear
client.data["bars"]          # []
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 Foo::Client
  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.
bar = Foo::Client::Bar.new(id: 1, flavor: "x") # => <#Foo::Client::Bar>

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

bar.flavor = "y"

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

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

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