README.md in cistern-2.3.0 vs README.md in cistern-2.4.0
- old
+ new
@@ -7,114 +7,156 @@
Cistern helps you consistently build your API clients and faciliates building mock support.
## Usage
-### Custom Architecture
+### Notice: Cistern 3.0
-By default a service's `Request`, `Collection`, and `Model` are all classes. In cistern `~> 3.0`, the default will be modules.
+Cistern 3.0 will change the way Cistern interacts with your `Request`, `Collection` and `Model` classes.
-You can modify your client's architecture to be forwards compatible by using `Cistern::Client.with`
+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`
+
```ruby
-class Foo::Client
+class Blog
include Cistern::Client.with(interface: :module)
end
```
Now request classes would look like:
```ruby
-class Foo::GetBar
- include Foo::Request
+class Blog::GetPost
+ include Blog::Request
def real
- "bar"
+ "post"
end
end
```
-Other options include `:collection`, `:request`, and `:model`. This options define the name of module or class interface for the service component.
-If `Request` is to reserved for a model, then the `Request` component name can be remapped to `Prayer`
+### Service
-For example:
+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.
+
```ruby
-class Foo::Client
- include Cistern::Client.with(request: "Prayer")
+# 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")
```
-allows a model named `Request` to exist
+Cistern will define for you two classes, `Mock` and `Real`. Create the corresponding files and initialzers for your
+new service.
```ruby
-class Foo::Request < Foo::Model
- identity :jovi
+# 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
```
-while living on a `Prayer`
-
```ruby
-class Foo::GetBar < Foo::Prayer
- def real
- cistern.request.get("/wing")
+# lib/blog/mock.rb
+class Blog::Mock
+ attr_reader :url
+
+ def initialize(attributes)
+ @url = attributes[:url]
end
end
```
+### Mocking
-### Service
+Cistern strongly encourages you to generate mock support for your service. Mocking can be enabled using `mock!`.
-This represents the remote service that you are wrapping. If the service name is `foo` then a good name is `Foo::Client`.
+```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
+```
-Service initialization parameters are enumerated by `requires` and `recognizes`. Parameters defined using `recognizes` are optional.
+### Working with data
-```ruby
-class Foo::Client
- include Cistern::Client
+`Cistern::Hash` contains many useful functions for working with data normalization and transformation.
- requires :hmac_id, :hmac_secret
- recognizes :url
-end
+**#stringify_keys**
-# 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
+```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}
+```
-# ArgumentError
-Foo::Client.new(hmac_id: "1", url: "http://example.org")
-Foo::Client.new(hmac_id: "1")
+**#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}
```
-Cistern will define for you two classes, `Mock` and `Real`.
+**#except**
-### Mocking
+```ruby
+# anywhere
+Cistern::Hash.except({a: 1, b: 2}, :a) #=> {b: 2}
+# within a Resource
+hash_except({a: 1, b: 2}, :a) #=> {b: 2}
+```
-Cistern strongly encourages you to generate mock support for your service. Mocking can be enabled using `mock!`.
+**#except!**
+
```ruby
-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
+# 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 associated `Foo::Client` instance.
+* `cistern` represents the associated `Blog` instance.
```ruby
-class Foo::Client::GetBar < Foo::Client::Request
+class Blog::GetPost < Blog::Request
def real(params)
# make a real request
"i'm real"
end
@@ -122,177 +164,291 @@
# return a fake response
"imposter!"
end
end
-Foo::Client.new.get_bar # "i'm real"
+Blog.new.get_post # "i'm real"
```
The `#cistern_method` function allows you to specify the name of the generated method.
```ruby
-class Foo::Client::GetBars < Foo::Client::Request
- cistern_method :get_all_the_bars
+class Blog::GetPosts < Blog::Request
+ cistern_method :get_all_the_posts
def real(params)
- "all the bars"
+ "all the posts"
end
end
-Foo::Client.new.respond_to?(:get_bars) # false
-Foo::Client.new.get_all_the_bars # "all the bars"
+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
-Foo::Client.requests # => [Foo::Client::GetBars, Foo::Client::GetBar]
+Blog.requests # => [Blog::GetPosts, Blog::GetPost]
```
### Models
-* `cistern` represents the associated `Foo::Client` instance.
-* `collection` represents the related collection (if applicable)
+* `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
-Attributes are designed to be a flexible way of parsing service request responses.
+Cistern attributes are designed to make your model flexible and developer friendly.
-`identity` is special but not required.
+* `attribute :post_id` adds an accessor to the model.
+ ```ruby
+ attribute :post_id
-`attribute :flavor` makes `Foo::Client::Bar.new.respond_to?(:flavor)`
+ 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
+ ```
-* `:aliases` or `:alias` allows a attribute key to be different then a response key. `attribute :keypair_id, alias: "keypair"` with `merge_attributes("keypair" => 1)` sets `keypair_id` to `1`
-* `:type` automatically casts the attribute do the specified type. `attribute :private_ips, type: :array` with `merge_attributes("private_ips" => 2)` sets `private_ips` to `[2]`
-* `:squash` traverses nested hashes for a key. `attribute :keypair_id, aliases: "keypair", squash: "id"` with `merge_attributes("keypair" => {"id" => 3})` sets `keypair_id` to `3`
+ creates an attribute called `name` that is aliased to identity.
-Example
+ ```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.
+ ```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 Foo::Client::Bar < Foo::Client::Model
- identity :id
+class Blog::Post < Blog::Model
+ identity :id, type: :integer
- attribute :flavor
- attribute :keypair_id, aliases: "keypair", squash: "id"
- attribute :private_ips, type: :array
+ attribute :body
+ attribute :author_id, aliases: "author", squash: "id"
+ attribute :deleted_at, type: :time
def destroy
- params = {
- "id" => self.identity
- }
- self.cistern.destroy_bar(params).body["request"]
+ requires :identity
+
+ data = cistern.destroy_post(params).body['post']
end
def save
- requires :keypair_id
+ requires :author_id
- params = {
- "keypair" => self.keypair_id,
- "bar" => {
- "flavor" => self.flavor,
- },
- }
+ response = if new_record?
+ cistern.create_post(attributes)
+ else
+ cistern.update_post(dirty_attributes)
+ end
- if new_record?
- merge_attributes(cistern.create_bar(params).body["bar"])
- else
- requires :identity
+ merge_attributes(response.body['post'])
+ end
+end
+```
- merge_attributes(cistern.update_bar(params).body["bar"])
- 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 class is contained within the collection.
-* `cistern` is the associated `Foo::Client` instance
+* `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 Foo::Client::Bars < Foo::Client::Collection
+class Blog::Posts < Blog::Collection
attribute :count, type: :integer
- model Foo::Client::Bar
+ model Blog::Post
def all(params = {})
- response = cistern.get_bars(params)
+ response = cistern.get_posts(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
+ load(data["posts"]) # store post records in collection
+ merge_attributes(data) # store any other attributes of the response on the collection
end
- def discover(provisioned_id, options={})
+ def discover(author_id, options={})
params = {
- "provisioned_id" => provisioned_id,
+ "author_id" => author_id,
}
- params.merge!("location" => options[:location]) if options.key?(:location)
+ params.merge!("topic" => options[:topic]) if options.key?(:topic)
- cistern.requests.new(cistern.discover_bar(params).body["request"])
+ cistern.blogs.new(cistern.discover_blog(params).body["blog"])
end
def get(id)
- if data = cistern.get_bar("id" => id).body["bar"]
- new(data)
- else
- nil
- end
+ 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.
```ruby
-Foo::Client.mock!
-client = Foo::Client.new # Foo::Client::Mock
+Blog.mock!
+client = Blog.new # Blog::Mock
client.data # Cistern::Data::Hash
-client.data["bars"] += ["x"] # ["x"]
+client.data["posts"] += ["x"] # ["x"]
```
Mock data is class-level by default
```ruby
-Foo::Client::Mock.data["bars"] # ["x"]
+Blog::Mock.data["posts"] # ["x"]
```
`reset!` dimisses the `data` object.
```ruby
client.data.object_id # 70199868585600
client.reset!
-client.data["bars"] # []
+client.data["posts"] # []
client.data.object_id # 70199868566840
```
`clear` removes existing keys and values but keeps the same object.
```ruby
-client.data["bars"] += ["y"] # ["y"]
-client.data.object_id # 70199868378300
+client.data["posts"] += ["y"] # ["y"]
+client.data.object_id # 70199868378300
client.clear
-client.data["bars"] # []
-client.data.object_id # 70199868378300
+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 Foo::Client
+class Blog
include Cistern::Client
class Mock
def self.data
@data ||= {}
@@ -328,25 +484,61 @@
* `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
-bar = Foo::Client::Bar.new(id: 1, flavor: "x") # => <#Foo::Client::Bar>
+post = Blog::Post.new(id: 1, flavor: "x") # => <#Blog::Post>
-bar.dirty? # => false
-bar.changed # => {}
-bar.dirty_attributes # => {}
+post.dirty? # => false
+post.changed # => {}
+post.dirty_attributes # => {}
-bar.flavor = "y"
+post.flavor = "y"
-bar.dirty? # => true
-bar.changed # => {flavor: ["x", "y"]}
-bar.dirty_attributes # => {flavor: "y"}
+post.dirty? # => true
+post.changed # => {flavor: ["x", "y"]}
+post.dirty_attributes # => {flavor: "y"}
-bar.save
-bar.dirty? # => false
-bar.changed # => {}
-bar.dirty_attributes # => {}
+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
```
## Examples
* [zendesk2](https://github.com/lanej/zendesk2)