Cistern
Cistern helps you consistently build your API clients and faciliates building mock support.
Usage
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 < Cistern::Service
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.
servicerepresents the associatedFoo::Clientinstance.
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. # "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. # "all the bars"
All declared requests can be listed via Cistern::Service#requests.
Foo::Client.requests # => [Foo::Client::GetBars, Foo::Client::GetBar]
Models
servicerepresents the associatedFoo::Clientinstance.collectionrepresents the related collection (if applicable)new_record?checks ifidentityis presentrequires(*requirements)throwsArgumentErrorif an attribute matching a requirement isn't setmerge_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)
:aliasesor:aliasallows a attribute key to be different then a response key.attribute :keypair_id, alias: "keypair"withmerge_attributes("keypair" => 1)setskeypair_idto1:typeautomatically casts the attribute do the specified type.attribute :private_ips, type: :arraywithmerge_attributes("private_ips" => 2)setsprivate_ipsto[2]:squashtraverses nested hashes for a key.attribute :keypair_id, aliases: "keypair", squash: "id"withmerge_attributes("keypair" => {"id" => 3})setskeypair_idto3
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.(params).body["request"]
end
def save
requires :keypair_id
params = {
"keypair" => self.keypair_id,
"bar" => {
"flavor" => self.flavor,
},
}
if new_record?
merge_attributes(service.(params).body["bar"])
else
requires :identity
merge_attributes(service.(params).body["bar"])
end
end
end
Collection
modeltells Cistern which class is contained within the collection.serviceis the associatedFoo::Clientinstanceattributespecifications on collections are allowed. usemerge_attributesloadconsumes an Array of data and constructs matchingmodelinstances
class Foo::Client::Bars < Cistern::Collection
attribute :count, type: :integer
model Foo::Client::Bar
def all(params = {})
response = service.(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, ={})
params = {
"provisioned_id" => provisioned_id,
}
params.merge!("location" => [:location]) if .key?(:location)
service.requests.new(service.(params).body["request"])
end
def get(id)
if data = service.("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
storeand[]=writefetchand[]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 < Cistern::Service
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.
changedreturns a Hash of changed attributes mapped to there initial value and current valuedirty_attributesreturns Hash of changed attributes with there current value. This should be used in the modelsavefunction.
= Foo::Client::Bar.new(id: 1, flavor: "x") # => <#Foo::Client::Bar>
.dirty? # => false
.changed # => {}
.dirty_attributes # => {}
.flavor = "y"
.dirty? # => true
.changed # => {flavor: ["x", "y"]}
.dirty_attributes # => {flavor: "y"}
.save
.dirty? # => false
.changed # => {}
.dirty_attributes # => {}
Examples
Releasing
$ gem bump -trv (major|minor|patch)
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Added some feature') - Push to the branch (
git push origin my-new-feature) - Create new Pull Request

