Cistern
Cistern helps you consistenly 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'.
Requests
Requests are enumerated using the request
method and required immediately via the relative path specified via request_path
.
class Foo::Client < Cistern::Service
request_path "my-foo/requests"
request :get_bar # require my-foo/requests/get_bar.rb
request :get_bars # require my-foo/requests/get_bars.rb
class Real
def request(url)
Net::HTTP.get(url)
end
end
end
A request is method defined within the context of service and mode (Real or Mock). Defining requests within the service mock class is optional.
# my-foo/requests/get_bar.rb
class Foo::Client
class Real
def ()
request("http://example.org/bar/#{}")
end
end # Real
# optional, but encouraged
class Mock
def
# do some mock things
end
end # Mock
end # Foo::client
All declared requests can be listed via Cistern::Service#requests
.
Foo::Client.requests # => [:get_bar, :get_bars]
Models and Collections
Models and collections have declaration semantics similar to requests. Models and collections are enumerated via model
and collection
respectively.
class Foo::Client < Cistern::Service
model_path "my-foo/models"
model :bar # require my-foo/models/bar.rb
collection :bars # require my-foo/models/bars.rb
end
Initialization
Service initialization parameters are enumerated by requires
and recognizes
. recognizes
parameters 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")
Mocking
Cistern strongly encourages you to generate mock support for 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
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 # 70199868566840
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
Requests
Mock requests should be defined within the contextual Mock
module and interact with the data
object directly.
# lib/foo/requests/create_bar.rb
class Foo::Client
class Mock
def (={})
id = Foo.random_hex(6)
= {
"id" => id
}.merge()
self.data[:bars][id] =
response(
:body => {"bar" => },
:status => 201,
:path => '/bar',
)
end
end # Mock
end # Foo::Client
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)
Model
connection
represents the associatedFoo::Client
instance.collection
represents the related collection (if applicable)
Example
class Foo::Client::Bar < Cistern::Model
identity :id
attribute :flavor
attribute :keypair_id, aliases: "keypair", squash: "id"
attribute :private_ips, type: :array
def destroy
params = {
"id" => self.identity
}
self.connection.(params).body["request"]
end
def save
requires :keypair_id
params = {
"keypair" => self.keypair_id,
"bar" => {
"flavor" => self.flavor,
},
}
if new_record?
merge_attributes(connection.(params).body["bar"])
else
requires :identity
merge_attributes(connection.(params).body["bar"])
end
end
end
Collection
model
tells Cistern which class is contained within the collection. Cistern::Collection
inherits from Array
and lazy loads where applicable.
class Foo::Client::Bars < Cistern::Collection
model Foo::Client::Bar
def all(params = {})
response = connection.(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)
connection.requests.new(connection.(params).body["request"])
end
def get(id)
if data = connection.("id" => id).body["bar"]
new(data)
else
nil
end
end
end
Request
module Foo
class Client
class Real
def (={})
request(
:body => {"bar" => },
:method => :post,
:path => '/bar'
)
end
end # Real
class Mock
def (={})
id = Foo.random_hex(6)
= {
"id" => id
}.merge()
self.data[:bars][id]=
response(
:body => {"bar" => },
:status => 201,
:path => '/bar',
)
end
end # Mock
end # Client
end # Foo
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