Cistern
Cistern helps you consistently build your API clients and faciliates building mock support.
Usage
Notice: Cistern 3.0
Cistern 3.0 will change the way Cistern interacts with your Request
, Collection
and Model
classes.
Prior to 3.0, your Request
, Collection
and Model
classes would have inherited from <service>::Client::Request
, <service>::Client::Collection
and <service>::Client::Model
classes, respectively.
In cistern ~> 3.0
, the default will be for Request
, Collection
and Model
classes to instead include their respective <service>::Client
modules.
If you want to be forwards-compatible today, you can configure your client by using Cistern::Client.with
class Blog
include Cistern::Client.with(interface: :module)
end
Now request classes would look like:
class Blog::GetPost
include Blog::Request
def real
"post"
end
end
Service
This represents the remote service that you are wrapping. If the service name is blog
then a good name is Blog
.
Service initialization parameters are enumerated by requires
and recognizes
. Parameters defined using recognizes
are optional.
# lib/blog.rb
class Blog
include Cistern::Client
requires :hmac_id, :hmac_secret
recognizes :url
end
# Acceptable
Blog.new(hmac_id: "1", hmac_secret: "2") # Blog::Real
Blog.new(hmac_id: "1", hmac_secret: "2", url: "http://example.org") # Blog::Real
# ArgumentError
Blog.new(hmac_id: "1", url: "http://example.org")
Blog.new(hmac_id: "1")
Cistern will define for you two classes, Mock
and Real
. Create the corresponding files and initialzers for your
new service.
# lib/blog/real.rb
class Blog::Real
attr_reader :url, :connection
def initialize(attributes)
@hmac_id, @hmac_secret = attributes.values_at(:hmac_id, :hmac_secret)
@url = attributes[:url] || 'http://blog.example.org'
@connection = Faraday.new(url)
end
end
# lib/blog/mock.rb
class Blog::Mock
attr_reader :url
def initialize(attributes)
@url = attributes[:url]
end
end
Mocking
Cistern strongly encourages you to generate mock support for your service. Mocking can be enabled using mock!
.
Blog.mocking? # falsey
real = Blog.new # Blog::Real
Blog.mock!
Blog.mocking? # true
fake = Blog.new # Blog::Mock
Blog.unmock!
Blog.mocking? # false
real.is_a?(Blog::Real) # true
fake.is_a?(Blog::Mock) # true
Working with data
Cistern::Hash
contains many useful functions for working with data normalization and transformation.
#stringify_keys
# anywhere
Cistern::Hash.stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}
# within a Resource
hash_stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}
#slice
# anywhere
Cistern::Hash.slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}
# within a Resource
hash_slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}
#except
# anywhere
Cistern::Hash.except({a: 1, b: 2}, :a) #=> {b: 2}
# within a Resource
hash_except({a: 1, b: 2}, :a) #=> {b: 2}
#except!
# same as #except but modify specified Hash in-place
Cistern::Hash.except!({:a => 1, :b => 2}, :a) #=> {:b => 2}
# within a Resource
hash_except!({:a => 1, :b => 2}, :a) #=> {:b => 2}
Requests
Requests are defined by subclassing #{service}::Request
.
cistern
represents the associatedBlog
instance.
class Blog::GetPost < Blog::Request
def real(params)
# make a real request
"i'm real"
end
def mock(params)
# return a fake response
"imposter!"
end
end
Blog.new.get_post # "i'm real"
The #cistern_method
function allows you to specify the name of the generated method.
class Blog::GetPosts < Blog::Request
cistern_method :get_all_the_posts
def real(params)
"all the posts"
end
end
Blog.new.respond_to?(:get_posts) # false
Blog.new.get_all_the_posts # "all the posts"
All declared requests can be listed via Cistern::Client#requests
.
Blog.requests # => [Blog::GetPosts, Blog::GetPost]
Models
cistern
represents the associatedBlog::Real
orBlog::Mock
instance.collection
represents the related collection.new_record?
checks ifidentity
is presentrequires(*requirements)
throwsArgumentError
if an attribute matching a requirement isn't setrequires_one(*requirements)
throwsArgumentError
if no attribute matching requirement is setmerge_attributes(attributes)
sets attributes for the current model instancedirty_attributes
represents attributes changed since the lastmerge_attributes
. This is useful for usingupdate
Attributes
Cistern attributes are designed to make your model flexible and developer friendly.
attribute :post_id
adds an accessor to the model.attribute :post_id model.post_id #=> nil model.post_id = 1 #=> 1 model.post_id #=> 1 model.attributes #=> {'post_id' => 1 } model.dirty_attributes #=> {'post_id' => 1 }
identity
represents the name of the model's unique identifier. As this is not always available, it is not required.identity :name
creates an attribute called
name
that is aliased to identity.model.name = 'michelle' model.identity #=> 'michelle' model.name #=> 'michelle' model.attributes #=> { 'name' => 'michelle' }
:aliases
or:alias
allows a attribute key to be different then a response key.attribute :post_id, alias: "post"
allows
model.merge_attributes("post" => 1) model.post_id #=> 1
:type
automatically casts the attribute do the specified type.attribute :private_ips, type: :array model.merge_attributes("private_ips" => 2) model.private_ips #=> [2]
:squash
traverses nested hashes for a key.attribute :post_id, aliases: "post", squash: "id" model.merge_attributes("post" => {"id" => 3}) model.post_id #=> 3
Persistence
save
is used to persist the model into the remote service.save
is responsible for determining if the operation is an update to an existing resource or a new resource.reload
is used to grab the latest data and merge it into the model.reload
usescollection.get(identity)
by default.update(attrs)
is amerge_attributes
and asave
. When callingupdate
,dirty_attributes
can be used to persist only what has changed locally.
For example:
class Blog::Post < Blog::Model
identity :id, type: :integer
attribute :body
attribute :author_id, aliases: "author", squash: "id"
attribute :deleted_at, type: :time
def destroy
requires :identity
data = cistern.destroy_post(params).body['post']
end
def save
requires :author_id
response = if new_record?
cistern.create_post(attributes)
else
cistern.update_post(dirty_attributes)
end
merge_attributes(response.body['post'])
end
end
Usage:
create
blog.posts.create(author_id: 1, body: 'text')
is equal to
post = blog.posts.new(author_id: 1, body: 'text')
post.save
update
post = blog.posts.get(1)
post.update(author_id: 1) #=> calls #save with #dirty_attributes == { 'author_id' => 1 }
post. #=> 1
Singular
Singular resources do not have an associated collection and the model contains the get
andsave
methods.
For instance:
class Blog::PostData
include Blog::Singular
attribute :post_id, type: :integer
attribute :upvotes, type: :integer
attribute :views, type: :integer
attribute :rating, type: :float
def get
response = cistern.get_post_data(post_id)
merge_attributes(response.body['data'])
end
def save
response = cistern.update_post_data(post_id, dirty_attributes)
merge_attributes(response.data['data'])
end
end
Singular resources often hang off of other models or collections.
class Blog::Post
include Cistern::Model
identity :id, type: :integer
def data
cistern.post_data(post_id: identity).load
end
end
They are special cases of Models and have similar interfaces.
post.data.views #=> nil
post.data.update(views: 3)
post.data.views #=> 3
Collection
model
tells Cistern which resource class this collection represents.cistern
is the associatedBlog::Real
orBlog::Mock
instanceattribute
specifications on collections are allowed. usemerge_attributes
load
consumes an Array of data and constructs matchingmodel
instances
class Blog::Posts < Blog::Collection
attribute :count, type: :integer
model Blog::Post
def all(params = {})
response = cistern.get_posts(params)
data = response.body
load(data["posts"]) # store post records in collection
merge_attributes(data) # store any other attributes of the response on the collection
end
def discover(, ={})
params = {
"author_id" => ,
}
params.merge!("topic" => [:topic]) if .key?(:topic)
cistern.blogs.new(cistern.discover_blog(params).body["blog"])
end
def get(id)
data = cistern.get_post(id).body["post"]
new(data) if data
end
end
Data
A uniform interface for mock data is mixed into the Mock
class by default.
Blog.mock!
client = Blog.new # Blog::Mock
client.data # Cistern::Data::Hash
client.data["posts"] += ["x"] # ["x"]
Mock data is class-level by default
Blog::Mock.data["posts"] # ["x"]
reset!
dimisses the data
object.
client.data.object_id # 70199868585600
client.reset!
client.data["posts"] # []
client.data.object_id # 70199868566840
clear
removes existing keys and values but keeps the same object.
client.data["posts"] += ["y"] # ["y"]
client.data.object_id # 70199868378300
client.clear
client.data["posts"] # []
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 Blog
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 valuedirty_attributes
returns Hash of changed attributes with there current value. This should be used in the modelsave
function.
post = Blog::Post.new(id: 1, flavor: "x") # => <#Blog::Post>
post.dirty? # => false
post.changed # => {}
post.dirty_attributes # => {}
post.flavor = "y"
post.dirty? # => true
post.changed # => {flavor: ["x", "y"]}
post.dirty_attributes # => {flavor: "y"}
post.save
post.dirty? # => false
post.changed # => {}
post.dirty_attributes # => {}
Custom Architecture
When configuring your client, you can use :collection
, :request
, and :model
options to define the name of module or class interface for the service component.
For example: if you'd Request
is to be used for a model, then the Request
component name can be remapped to Demand
For example:
class Blog
include Cistern::Client.with(interface: :modules, request: "Demand")
end
allows a model named Request
to exist
class Blog::Request
include Blog::Model
identity :jovi
end
while living on a Demand
class Blog::GetPost
include Blog::Demand
def real
cistern.request.get("/wing")
end
end
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