Cistern
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.
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::Client#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 < Foo::Client::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
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.
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

