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
.
service
represents the associatedFoo::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. # "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
service
represents the associatedFoo::Client
instance.collection
represents the related collection (if applicable)new_record?
checks ifidentity
is presentrequires(*requirements)
throwsArgumentError
if 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)
:aliases
or:alias
allows a attribute key to be different then a response key.attribute :keypair_id, alias: "keypair"
withmerge_attributes("keypair" => 1)
setskeypair_id
to1
:type
automatically casts the attribute do the specified type.attribute :private_ips, type: :array
withmerge_attributes("private_ips" => 2)
setsprivate_ips
to[2]
:squash
traverses nested hashes for a key.attribute :keypair_id, aliases: "keypair", squash: "id"
withmerge_attributes("keypair" => {"id" => 3})
setskeypair_id
to3
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
model
tells Cistern which class is contained within the collection.service
is the associatedFoo::Client
instanceattribute
specifications on collections are allowed. usemerge_attributes
load
consumes an Array of data and constructs matchingmodel
instances
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
store
and[]=
writefetch
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 < 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.
changed
returns a Hash of changed attributes mapped to there initial value and current valuedirty_attributes
returns Hash of changed attributes with there current value. This should be used in the modelsave
function.
= 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