项目作者: lanej

项目描述 :
Ruby API client framework
高级语言: Ruby
项目地址: git://github.com/lanej/cistern.git
创建时间: 2012-06-05T21:46:01Z
项目社区:https://github.com/lanej/cistern

开源协议:MIT License

下载


Cistern

Join the chat at https://gitter.im/lanej/cistern
Build Status
Dependencies
Gem Version
Code Climate

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.

  1. # lib/blog.rb
  2. class Blog
  3. include Cistern::Client
  4. requires :hmac_id, :hmac_secret
  5. recognizes :url
  6. end
  7. # Acceptable
  8. Blog.new(hmac_id: "1", hmac_secret: "2") # Blog::Real
  9. Blog.new(hmac_id: "1", hmac_secret: "2", url: "http://example.org") # Blog::Real
  10. # ArgumentError
  11. Blog.new(hmac_id: "1", url: "http://example.org")
  12. 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.

  1. # lib/blog/real.rb
  2. class Blog::Real
  3. attr_reader :url, :connection
  4. def initialize(attributes)
  5. @hmac_id, @hmac_secret = attributes.values_at(:hmac_id, :hmac_secret)
  6. @url = attributes[:url] || 'http://blog.example.org'
  7. @connection = Faraday.new(url)
  8. end
  9. end
  1. # lib/blog/mock.rb
  2. class Blog::Mock
  3. attr_reader :url
  4. def initialize(attributes)
  5. @url = attributes[:url]
  6. end
  7. end

Mocking

Cistern strongly encourages you to generate mock support for your service. Mocking can be enabled using mock!.

  1. Blog.mocking? # falsey
  2. real = Blog.new # Blog::Real
  3. Blog.mock!
  4. Blog.mocking? # true
  5. fake = Blog.new # Blog::Mock
  6. Blog.unmock!
  7. Blog.mocking? # false
  8. real.is_a?(Blog::Real) # true
  9. 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:

  1. class Blog::UpdatePost
  2. include Blog::Request
  3. def real(id, parameters)
  4. cistern.connection.patch("/post/#{id}", parameters)
  5. end
  6. def mock(id, parameters)
  7. post = cistern.data[:posts].fetch(id)
  8. post.merge!(stringify_keys(parameters))
  9. response(post: post)
  10. end
  11. 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.

  1. class Blog::UpdatePost
  2. include Blog::Request
  3. attr_reader :parameters
  4. def call(post_id, parameters)
  5. @parameters = stringify_keys(parameters)
  6. dispatch(Integer(post_id))
  7. end
  8. def real(id)
  9. cistern.connection.patch("/post/#{id}", parameters)
  10. end
  11. def mock(id)
  12. post = cistern.data[:posts].fetch(id)
  13. post.merge!(parameters)
  14. response(post: post)
  15. end
  16. end

The #cistern_method function allows you to specify the name of the generated method.

  1. class Blog::GetPosts
  2. include Blog::Request
  3. cistern_method :get_all_the_posts
  4. def real(params)
  5. "all the posts"
  6. end
  7. end
  8. Blog.new.respond_to?(:get_posts) # false
  9. Blog.new.get_all_the_posts # "all the posts"

All declared requests can be listed via Cistern::Client#requests.

  1. 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.

    1. attribute :post_id
    2. model.post_id #=> nil
    3. model.post_id = 1 #=> 1
    4. model.post_id #=> 1
    5. model.attributes #=> {'post_id' => 1 }
    6. 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.

    1. identity :name

    creates an attribute called name that is aliased to identity.

    1. model.name = 'michelle'
    2. model.identity #=> 'michelle'
    3. model.name #=> 'michelle'
    4. model.attributes #=> { 'name' => 'michelle' }
  • :aliases or :alias allows a attribute key to be different then a response key.

    1. attribute :post_id, alias: "post"

    allows

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

    1. attribute :private_ips, type: :array
    2. model.merge_attributes("private_ips" => 2)
    3. model.private_ips #=> [2]
  • :squash traverses nested hashes for a key.

    1. attribute :post_id, aliases: "post", squash: "id"
    2. model.merge_attributes("post" => {"id" => 3})
    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:

  1. class Blog::Post
  2. include Blog::Model
  3. identity :id, type: :integer
  4. attribute :body
  5. attribute :author_id, aliases: "author", squash: "id"
  6. attribute :deleted_at, type: :time
  7. def destroy
  8. requires :identity
  9. data = cistern.destroy_post(params).body['post']
  10. end
  11. def save
  12. requires :author_id
  13. response = if new_record?
  14. cistern.create_post(attributes)
  15. else
  16. cistern.update_post(dirty_attributes)
  17. end
  18. merge_attributes(response.body['post'])
  19. end
  20. end

Usage:

create

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

is equal to

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

update

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

Singular

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

For instance:

  1. class Blog::PostData
  2. include Blog::Singular
  3. attribute :post_id, type: :integer
  4. attribute :upvotes, type: :integer
  5. attribute :views, type: :integer
  6. attribute :rating, type: :float
  7. def get
  8. response = cistern.get_post_data(post_id)
  9. merge_attributes(response.body['data'])
  10. end
  11. def save
  12. response = cistern.update_post_data(post_id, dirty_attributes)
  13. merge_attributes(response.data['data'])
  14. end
  15. end

Singular resources often hang off of other models or collections.

  1. class Blog::Post
  2. include Cistern::Model
  3. identity :id, type: :integer
  4. def data
  5. cistern.post_data(post_id: identity).load
  6. end
  7. end

They are special cases of Models and have similar interfaces.

  1. post.data.views #=> nil
  2. post.data.update(views: 3)
  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
  1. class Blog::Posts
  2. include Blog::Collection
  3. attribute :count, type: :integer
  4. model Blog::Post
  5. def all(params = {})
  6. response = cistern.get_posts(params)
  7. data = response.body
  8. load(data["posts"]) # store post records in collection
  9. merge_attributes(data) # store any other attributes of the response on the collection
  10. end
  11. def discover(author_id, options={})
  12. params = {
  13. "author_id" => author_id,
  14. }
  15. params.merge!("topic" => options[:topic]) if options.key?(:topic)
  16. cistern.blogs.new(cistern.discover_blog(params).body["blog"])
  17. end
  18. def get(id)
  19. data = cistern.get_post(id).body["post"]
  20. new(data) if data
  21. end
  22. 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.
  1. class Blog::Tag
  2. include Blog::Model
  3. identity :id
  4. attribute :author_id
  5. has_many :posts -> { cistern.posts(tag_id: identity) }
  6. belongs_to :creator -> { cistern.authors.get(author_id) }
  7. end

Relationships store the collection’s attributes within the resources’ attributes on write / load.

  1. tag = blog.tags.get('ruby')
  2. tag.posts = blog.posts.load({'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3})
  3. tag.attributes[:posts] #=> {'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3}
  4. tag.creator = blogs.author.get(name: 'phil')
  5. tag.attributes[:creator] #=> { 'id' => 2, 'name' => 'phil' }

Foreign keys can be updated by overriding the association writer.

  1. Blog::Tag.class_eval do
  2. def creator=(creator)
  3. super
  4. self.author_id = attributes[:creator][:id]
  5. end
  6. end
  7. tag = blog.tags.get('ruby')
  8. tag.author_id = 4
  9. tag.creator = blogs.author.get(name: 'phil') #=> #<Blog::Author id=2 name='phil'>
  10. tag.author_id #=> 2

Data

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

  1. Blog.mock!
  2. client = Blog.new # Blog::Mock
  3. client.data # Cistern::Data::Hash
  4. client.data["posts"] += ["x"] # ["x"]

Mock data is class-level by default

  1. Blog::Mock.data["posts"] # ["x"]

reset! dimisses the data object.

  1. client.data.object_id # 70199868585600
  2. client.reset!
  3. client.data["posts"] # []
  4. client.data.object_id # 70199868566840

clear removes existing keys and values but keeps the same object.

  1. client.data["posts"] += ["y"] # ["y"]
  2. client.data.object_id # 70199868378300
  3. client.clear
  4. client.data["posts"] # []
  5. 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.

  1. class Blog
  2. include Cistern::Client
  3. class Mock
  4. def self.data
  5. @data ||= {}
  6. end
  7. end
  8. end

Working with data

Cistern::Hash contains many useful functions for working with data normalization and transformation.

#stringify_keys

  1. # anywhere
  2. Cistern::Hash.stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}
  3. # within a Resource
  4. hash_stringify_keys({a: 1, b: 2}) #=> {'a' => 1, 'b' => 2}

#slice

  1. # anywhere
  2. Cistern::Hash.slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}
  3. # within a Resource
  4. hash_slice({a: 1, b: 2, c: 3}, :a, :c) #=> {a: 1, c: 3}

#except

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

#except!

  1. # same as #except but modify specified Hash in-place
  2. Cistern::Hash.except!({:a => 1, :b => 2}, :a) #=> {:b => 2}
  3. # within a Resource
  4. 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.

  1. # use redis with defaults
  2. Patient::Mock.store_in(:redis)
  3. # use redis with a specific client
  4. Patient::Mock.store_in(:redis, client: Redis::Namespace.new("cistern", redis: Redis.new(host: "10.1.0.1"))
  5. # use a hash
  6. 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.
  1. post = Blog::Post.new(id: 1, flavor: "x") # => <#Blog::Post>
  2. post.dirty? # => false
  3. post.changed # => {}
  4. post.dirty_attributes # => {}
  5. post.flavor = "y"
  6. post.dirty? # => true
  7. post.changed # => {flavor: ["x", "y"]}
  8. post.dirty_attributes # => {flavor: "y"}
  9. post.save
  10. post.dirty? # => false
  11. post.changed # => {}
  12. 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:

  1. class Blog
  2. include Cistern::Client.with(interface: :modules, request: "Demand")
  3. end

allows a model named Request to exist

  1. class Blog::Request
  2. include Blog::Model
  3. identity :jovi
  4. end

while living on a Demand

  1. class Blog::GetPost
  2. include Blog::Demand
  3. def real
  4. cistern.request.get("/wing")
  5. end
  6. end

~> 3.0

Request Dispatch

Default request interface passes through #_mock and #_real depending on the client mode.

  1. class Blog::GetPost
  2. include Blog::Request
  3. def setup(post_id, parameters)
  4. [post_id, stringify_keys(parameters)]
  5. end
  6. def _mock(*args, **kwargs)
  7. mock(*setup(*args, **kwargs))
  8. end
  9. def _real(post_id, parameters)
  10. real(*setup(*args, **kwargs))
  11. end
  12. end

In cistern 3, requests pass through #call in both modes. #dispatch is responsible for determining the mode and
calling the appropriate method.

  1. class Blog::GetPost
  2. include Blog::Request
  3. def call(post_id, parameters)
  4. normalized_parameters = stringify_keys(parameters)
  5. dispatch(post_id, normalized_parameters)
  6. end
  7. end

Client definition

Default resource definition is done by inheritance.

  1. class Blog::Post < Blog::Model
  2. end

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

  1. class Blog::Post
  2. include Blog::Post
  3. end

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

  1. class Blog
  2. include Cistern::Client.with(interface: :module)
  3. end

Examples

Releasing

  1. $ 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