Cistern helps you consistently build your API clients and faciliates building mock support.
This represents the remote service that you are wrapping. It defines the client's namespace and initialization parameters.
Client 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 two namespaced classes, Blog::Mock and Blog::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
endCistern 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) # trueRequests are defined by subclassing #{service}::Request.
cisternrepresents the associatedBloginstance.#callrepresents the primary entrypoint. Invoked when callingclient#{request_method}.#dispatchdetermines which method to call. (#mockor#real)
For example:
class Blog::UpdatePost
include Blog::Request
def real(id, parameters)
cistern.connection.patch("/post/#{id}", parameters)
end
def mock(id, parameters)
post = cistern.data[:posts].fetch(id)
post.merge!(stringify_keys(parameters))
response(post: post)
end
endHowever, if you want to add some preprocessing to your request's arguments override #call and call #dispatch. You
can also alter the response method's signatures based on the arguments provided to #dispatch.
class Blog::UpdatePost
include Blog::Request
attr_reader :parameters
def call(post_id, parameters)
@parameters = stringify_keys(parameters)
dispatch(Integer(post_id))
end
def real(id)
cistern.connection.patch("/post/#{id}", parameters)
end
def mock(id)
post = cistern.data[:posts].fetch(id)
post.merge!(parameters)
response(post: post)
end
endThe #cistern_method function allows you to specify the name of the generated method.
class Blog::GetPosts
include 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]cisternrepresents the associatedBlog::RealorBlog::Mockinstance.collectionrepresents the related collection.new_record?checks ifidentityis presentrequires(*requirements)throwsArgumentErrorif an attribute matching a requirement isn't setrequires_one(*requirements)throwsArgumentErrorif no attribute matching requirement is setmerge_attributes(attributes)sets attributes for the current model instancedirty_attributesrepresents attributes changed since the lastmerge_attributes. This is useful for usingupdate
Cistern attributes are designed to make your model flexible and developer friendly.
-
attribute :post_idadds 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 }
-
identityrepresents the name of the model's unique identifier. As this is not always available, it is not required.identity :name
creates an attribute called
namethat is aliased to identity.model.name = 'michelle' model.identity #=> 'michelle' model.name #=> 'michelle' model.attributes #=> { 'name' => 'michelle' }
-
:aliasesor:aliasallows 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
-
:typeautomatically casts the attribute do the specified type. Supported types:array,boolean,date,float,integer,string,time.attribute :private_ips, type: :array model.merge_attributes("private_ips" => 2) model.private_ips #=> [2]
-
:squashtraverses nested hashes for a key.attribute :post_id, aliases: "post", squash: "id" model.merge_attributes("post" => {"id" => 3}) model.post_id #=> 3
saveis used to persist the model into the remote service.saveis responsible for determining if the operation is an update to an existing resource or a new resource.reloadis used to grab the latest data and merge it into the model.reloadusescollection.get(identity)by default.update(attrs)is amerge_attributesand asave. When callingupdate,dirty_attributescan be used to persist only what has changed locally.
For example:
class Blog::Post
include 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
endUsage:
create
blog.posts.create(author_id: 1, body: 'text')is equal to
post = blog.posts.new(author_id: 1, body: 'text')
post.saveupdate
post = blog.posts.get(1)
post.update(author_id: 1) #=> calls #save with #dirty_attributes == { 'author_id' => 1 }
post.author_id #=> 1Singular 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
endSingular 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
endThey are special cases of Models and have similar interfaces.
post.data.views #=> nil
post.data.update(views: 3)
post.data.views #=> 3modeltells Cistern which resource class this collection represents.cisternis the associatedBlog::RealorBlog::Mockinstanceattributespecifications on collections are allowed. usemerge_attributesloadconsumes an Array of data and constructs matchingmodelinstances
class Blog::Posts
include 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(author_id, options={})
params = {
"author_id" => author_id,
}
params.merge!("topic" => options[:topic]) if options.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
endAssociations allow the use of a resource's attributes to reference other resources. They act as lazy loaded attributes
and push any loaded data into the resource's attributes.
There are two types of associations available.
belongs_toreferences a specific resource and defines a reader.has_manyreferences a collection of resources and defines a reader / writer.
class Blog::Tag
include Blog::Model
identity :id
attribute :author_id
has_many :posts -> { cistern.posts(tag_id: identity) }
belongs_to :creator -> { cistern.authors.get(author_id) }
endRelationships store the collection's attributes within the resources' attributes on write / load.
tag = blog.tags.get('ruby')
tag.posts = blog.posts.load({'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3})
tag.attributes[:posts] #=> {'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3}
tag.creator = blogs.author.get(name: 'phil')
tag.attributes[:creator] #=> { 'id' => 2, 'name' => 'phil' }Foreign keys can be updated by overriding the association writer.
Blog::Tag.class_eval do
def creator=(creator)
super
self.author_id = attributes[:creator][:id]
end
end
tag = blog.tags.get('ruby')
tag.author_id = 4
tag.creator = blogs.author.get(name: 'phil') #=> #<Blog::Author id=2 name='phil'>
tag.author_id #=> 2A 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 # 70199868566840clear 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 # 70199868378300storeand[]=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 Blog
include Cistern::Client
class Mock
def self.data
@data ||= {}
end
end
endCistern::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}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 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.
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 # => {}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")
endallows a model named Request to exist
class Blog::Request
include Blog::Model
identity :jovi
endwhile living on a Demand
class Blog::GetPost
include Blog::Demand
def real
cistern.request.get("/wing")
end
endDefault request interface passes through #_mock and #_real depending on the client mode.
class Blog::GetPost
include Blog::Request
def setup(post_id, parameters)
[post_id, stringify_keys(parameters)]
end
def _mock(*args, **kwargs)
mock(*setup(*args, **kwargs))
end
def _real(post_id, parameters)
real(*setup(*args, **kwargs))
end
endIn cistern 3, requests pass through #call in both modes. #dispatch is responsible for determining the mode and
calling the appropriate method.
class Blog::GetPost
include Blog::Request
def call(post_id, parameters)
normalized_parameters = stringify_keys(parameters)
dispatch(post_id, normalized_parameters)
end
endDefault resource definition is done by inheritance.
class Blog::Post < Blog::Model
endIn cistern 3, resource definition is done by module inclusion.
class Blog::Post
include Blog::Post
endPrepare for cistern 3 by using Cistern::Client.with(interface: :module) when defining the client.
class Blog
include Cistern::Client.with(interface: :module)
end$ gem bump -trv (major|minor|patch)
- 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