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)