Cistern
![Code Climate](https://codeclimate.com/github/lanej/cistern/badges/gpa.svg)
Cistern helps you consistently build your API clients and faciliates building mock support.
Usage
Client
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.
class Blog
include Cistern::Client
requires :hmac_id, :hmac_secret
recognizes :url
end
Blog.new(hmac_id: "1", hmac_secret: "2")
Blog.new(hmac_id: "1", hmac_secret: "2", url: "http://example.org")
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.
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
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?
real = Blog.new
Blog.mock!
Blog.mocking?
fake = Blog.new
Blog.unmock!
Blog.mocking?
real.is_a?(Blog::Real)
fake.is_a?(Blog::Mock)
Requests
Requests are defined by subclassing #{service}::Request
.
cistern
represents the associated Blog
instance.#call
represents the primary entrypoint. Invoked when calling client#{request_method}
.#dispatch
determines which method to call. (#mock
or #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
end
However, 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
end
The #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)
Blog.new.get_all_the_posts
All declared requests can be listed via Cistern::Client#requests
.
Blog.requests
Models
cistern
represents the associated Blog::Real
or Blog::Mock
instance.collection
represents the related collection.new_record?
checks if identity
is presentrequires(*requirements)
throws ArgumentError
if an attribute matching a requirement isn't setrequires_one(*requirements)
throws ArgumentError
if no attribute matching requirement is setmerge_attributes(attributes)
sets attributes for the current model instancedirty_attributes
represents attributes changed since the last merge_attributes
. This is useful for using update
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
model.post_id = 1
model.post_id
model.attributes
model.dirty_attributes
-
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
model.name
model.attributes
-
: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
-
:type
automatically 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
-
:squash
traverses nested hashes for a key.
attribute :post_id, aliases: "post", squash: "id"
model.merge_attributes("post" => {"id" => 3})
model.post_id
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
uses collection.get(identity)
by default.update(attrs)
is a merge_attributes
and a save
. When calling update
, dirty_attributes
can 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
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)
post.author_id
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
post.data.update(views: 3)
post.data.views
Collection
model
tells Cistern which resource class this collection represents.cistern
is the associated Blog::Real
or Blog::Mock
instanceattribute
specifications on collections are allowed. use merge_attributes
load
consumes an Array of data and constructs matching model
instances
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"])
merge_attributes(data)
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
end
Associations
Associations 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_to
references a specific resource and defines a reader.has_many
references 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) }
end
Relationships 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]
tag.creator = blogs.author.get(name: 'phil')
tag.attributes[:creator]
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')
tag.author_id
Data
A uniform interface for mock data is mixed into the Mock
class by default.
Blog.mock!
client = Blog.new
client.data
client.data["posts"] += ["x"]
Mock data is class-level by default
Blog::Mock.data["posts"]
reset!
dimisses the data
object.
client.data.object_id
client.reset!
client.data["posts"]
client.data.object_id
clear
removes existing keys and values but keeps the same object.
client.data["posts"] += ["y"]
client.data.object_id
client.clear
client.data["posts"]
client.data.object_id
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
Working with data
Cistern::Hash
contains many useful functions for working with data normalization and transformation.
#stringify_keys
Cistern::Hash.stringify_keys({a: 1, b: 2})
hash_stringify_keys({a: 1, b: 2})
#slice
Cistern::Hash.slice({a: 1, b: 2, c: 3}, :a, :c)
hash_slice({a: 1, b: 2, c: 3}, :a, :c)
#except
Cistern::Hash.except({a: 1, b: 2}, :a)
hash_except({a: 1, b: 2}, :a)
#except!
Cistern::Hash.except!({:a => 1, :b => 2}, :a)
hash_except!({:a => 1, :b => 2}, :a)
Storage
Currently supported storage backends are:
:hash
: Cistern::Data::Hash
(default):redis
: Cistern::Data::Redis
Backends can be switched by using store_in
.
Patient::Mock.store_in(:redis)
Patient::Mock.store_in(:redis, client: Redis::Namespace.new("cistern", redis: Redis.new(host: "10.1.0.1"))
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 model save
function.
post = Blog::Post.new(id: 1, flavor: "x")
post.dirty?
post.changed
post.dirty_attributes
post.flavor = "y"
post.dirty?
post.changed
post.dirty_attributes
post.save
post.dirty?
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
~> 3.0
Request Dispatch
Default 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
end
In 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
end
Client definition
Default resource definition is done by inheritance.
class Blog::Post < Blog::Model
end
In cistern 3, resource definition is done by module inclusion.
class Blog::Post
include Blog::Post
end
Prepare for cistern 3 by using Cistern::Client.with(interface: :module)
when defining the client.
class Blog
include Cistern::Client.with(interface: :module)
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