lanej/cistern

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# Cistern

[![Join the chat at https://gitter.im/lanej/cistern](https://badges.gitter.im/lanej/cistern.svg)](https://gitter.im/lanej/cistern?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Build Status](https://secure.travis-ci.org/lanej/cistern.png)](http://travis-ci.org/lanej/cistern)
[![Dependencies](https://gemnasium.com/lanej/cistern.png)](https://gemnasium.com/lanej/cistern.png)
[![Gem Version](https://badge.fury.io/rb/cistern.svg)](http://badge.fury.io/rb/cistern)
[![Code Climate](https://codeclimate.com/github/lanej/cistern/badges/gpa.svg)](https://codeclimate.com/github/lanej/cistern)

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.

```ruby
# 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.

```ruby
# 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
```

```ruby
# 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!`.

```ruby
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
```

### 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:

```ruby
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`.


```ruby
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.

```ruby
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`.

```ruby
Blog.requests # => [Blog::GetPosts, Blog::GetPost]
```

### Models

* `cistern` represents the associated `Blog::Real` or `Blog::Mock` instance. 
* `collection` represents the related collection.
* `new_record?` checks if `identity` is present
* `requires(*requirements)` throws `ArgumentError` if an attribute matching a requirement isn't set
* `requires_one(*requirements)` throws `ArgumentError` if no attribute matching requirement is set
* `merge_attributes(attributes)` sets attributes for the current model instance
* `dirty_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.
    ```ruby
    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.
    ```ruby
    identity :name
    ```

    creates an attribute called `name` that is aliased to identity.

    ```ruby
    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. 
    ```ruby
    attribute :post_id, alias: "post"
    ```

    allows

    ```ruby
    model.merge_attributes("post" => 1)
    model.post_id #=> 1
    ```
* `:type` automatically casts the attribute do the specified type. Supported types: `array`, `boolean`, `date`, `float`, `integer`, `string`, `time`.
    ```ruby
    attribute :private_ips, type: :array

    model.merge_attributes("private_ips" => 2)
    model.private_ips #=> [2]
    ```
* `:squash` traverses nested hashes for a key. 
    ```ruby
    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` 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:

```ruby
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**

```ruby
blog.posts.create(author_id: 1, body: 'text')
```

is equal to

```ruby
post = blog.posts.new(author_id: 1, body: 'text')
post.save
```

**update**

```ruby
post = blog.posts.get(1)
post.update(author_id: 1) #=> calls #save with #dirty_attributes == { 'author_id' => 1 }
post.author_id #=> 1
```

### Singular

Singular resources do not have an associated collection and the model contains the `get` and`save` methods.

For instance:

```ruby
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.

```ruby
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.

```ruby
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 associated `Blog::Real` or `Blog::Mock` instance
* `attribute` specifications on collections are allowed. use `merge_attributes`
* `load` consumes an Array of data and constructs matching `model` instances

```ruby
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
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.

```ruby
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.

```ruby
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.

```ruby
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 #=> 2
```

#### Data

A uniform interface for mock data is mixed into the `Mock` class by default.

```ruby
Blog.mock!
client = Blog.new # Blog::Mock
client.data       # Cistern::Data::Hash
client.data["posts"] += ["x"] # ["x"]
```

Mock data is class-level by default

```ruby
Blog::Mock.data["posts"] # ["x"]
```

`reset!` dimisses the `data` object.

```ruby
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.

```ruby
client.data["posts"] += ["y"] # ["y"]
client.data.object_id         # 70199868378300
client.clear
client.data["posts"]          # []
client.data.object_id         # 70199868378300
```

* `store` and `[]=` write
* `fetch` 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.

```ruby
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**

```ruby
# 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**

```ruby
# 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**

```ruby
# anywhere
Cistern::Hash.except({a: 1, b: 2}, :a) #=> {b: 2}
# within a Resource
hash_except({a: 1, b: 2}, :a) #=> {b: 2}
```


**#except!**

```ruby
# 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}
```


#### Storage

Currently supported storage backends are:

* `:hash` : `Cistern::Data::Hash` (default)
* `:redis` : `Cistern::Data::Redis`


Backends can be switched by using `store_in`.

```ruby
# 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 value
* `dirty_attributes` returns Hash of changed attributes with there current value.  This should be used in the model `save` function.


```ruby
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:

```ruby
class Blog
  include Cistern::Client.with(interface: :modules, request: "Demand")
end
```

allows a model named `Request` to exist

```ruby
class Blog::Request
  include Blog::Model

  identity :jovi
end
```

while living on a `Demand`

```ruby
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.

```ruby
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.

```ruby
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.

```ruby
class Blog::Post < Blog::Model
end
```

In cistern 3, resource definition is done by module inclusion.

```ruby
class Blog::Post
  include Blog::Post
end
```

Prepare for cistern 3 by using `Cistern::Client.with(interface: :module)` when defining the client.

```ruby
class Blog
  include Cistern::Client.with(interface: :module)
end
```

## Examples

* [zendesk2](https://github.com/lanej/zendesk2)
* [you_track](https://github.com/lanej/you_track)
* [ey-core](https://github.com/engineyard/core-client-rb)


## Releasing

    $ gem bump -trv (major|minor|patch)

## Contributing

1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Added some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request