{"id":13880352,"url":"https://github.com/lanej/cistern","last_synced_at":"2025-07-06T10:36:02.892Z","repository":{"id":3508482,"uuid":"4565899","full_name":"lanej/cistern","owner":"lanej","description":"Ruby API client framework ","archived":false,"fork":false,"pushed_at":"2023-10-26T21:25:46.000Z","size":396,"stargazers_count":83,"open_issues_count":6,"forks_count":18,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-06-10T01:52:18.712Z","etag":null,"topics":["api-client","backend","cistern","mock-data","persistence","reader","ruby","singular-resources","tolerant-parser","writer"],"latest_commit_sha":null,"homepage":"http://lanej.io/cistern","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":"JakeLin/IBAnimatable","license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lanej.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2012-06-05T21:46:01.000Z","updated_at":"2021-12-12T18:13:22.000Z","dependencies_parsed_at":"2024-01-13T20:58:23.161Z","dependency_job_id":"80389067-1b5f-4e7f-aa7f-ecb97d03ab38","html_url":"https://github.com/lanej/cistern","commit_stats":null,"previous_names":[],"tags_count":83,"template":false,"template_full_name":null,"purl":"pkg:github/lanej/cistern","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanej%2Fcistern","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanej%2Fcistern/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanej%2Fcistern/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanej%2Fcistern/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lanej","download_url":"https://codeload.github.com/lanej/cistern/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanej%2Fcistern/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":259410149,"owners_count":22852970,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api-client","backend","cistern","mock-data","persistence","reader","ruby","singular-resources","tolerant-parser","writer"],"created_at":"2024-08-06T08:02:58.144Z","updated_at":"2025-06-12T06:10:54.326Z","avatar_url":"https://github.com/lanej.png","language":"Ruby","readme":"# Cistern\n\n[![Join the chat at https://gitter.im/lanej/cistern](https://badges.gitter.im/lanej/cistern.svg)](https://gitter.im/lanej/cistern?utm_source=badge\u0026utm_medium=badge\u0026utm_campaign=pr-badge\u0026utm_content=badge)\n[![Build Status](https://secure.travis-ci.org/lanej/cistern.png)](http://travis-ci.org/lanej/cistern)\n[![Dependencies](https://gemnasium.com/lanej/cistern.png)](https://gemnasium.com/lanej/cistern.png)\n[![Gem Version](https://badge.fury.io/rb/cistern.svg)](http://badge.fury.io/rb/cistern)\n[![Code Climate](https://codeclimate.com/github/lanej/cistern/badges/gpa.svg)](https://codeclimate.com/github/lanej/cistern)\n\nCistern helps you consistently build your API clients and faciliates building mock support.\n\n## Usage\n\n### Client\n\nThis represents the remote service that you are wrapping.  It defines the client's namespace and initialization parameters.\n\nClient initialization parameters are enumerated by `requires` and `recognizes`. Parameters defined using `recognizes` are optional.\n\n```ruby\n# lib/blog.rb\nclass Blog\n  include Cistern::Client\n\n  requires :hmac_id, :hmac_secret\n  recognizes :url\nend\n\n# Acceptable\nBlog.new(hmac_id: \"1\", hmac_secret: \"2\")                            # Blog::Real\nBlog.new(hmac_id: \"1\", hmac_secret: \"2\", url: \"http://example.org\") # Blog::Real\n\n# ArgumentError\nBlog.new(hmac_id: \"1\", url: \"http://example.org\")\nBlog.new(hmac_id: \"1\")\n```\n\nCistern will define for two namespaced classes, `Blog::Mock` and `Blog::Real`. Create the corresponding files and initialzers for your new service.\n\n```ruby\n# lib/blog/real.rb\nclass Blog::Real\n  attr_reader :url, :connection\n\n  def initialize(attributes)\n    @hmac_id, @hmac_secret = attributes.values_at(:hmac_id, :hmac_secret)\n    @url = attributes[:url] || 'http://blog.example.org'\n    @connection = Faraday.new(url)\n  end\nend\n```\n\n```ruby\n# lib/blog/mock.rb\nclass Blog::Mock\n  attr_reader :url\n\n  def initialize(attributes)\n    @url = attributes[:url]\n  end\nend\n```\n\n### Mocking\n\nCistern strongly encourages you to generate mock support for your service. Mocking can be enabled using `mock!`.\n\n```ruby\nBlog.mocking?          # falsey\nreal = Blog.new        # Blog::Real\nBlog.mock!\nBlog.mocking?          # true\nfake = Blog.new        # Blog::Mock\nBlog.unmock!\nBlog.mocking?          # false\nreal.is_a?(Blog::Real) # true\nfake.is_a?(Blog::Mock) # true\n```\n\n### Requests\n\nRequests are defined by subclassing `#{service}::Request`.\n\n* `cistern` represents the associated `Blog` instance.\n* `#call` represents the primary entrypoint.  Invoked when calling `client#{request_method}`.\n* `#dispatch` determines which method to call. (`#mock` or `#real`)\n\nFor example:\n\n```ruby\nclass Blog::UpdatePost\n  include Blog::Request\n\n  def real(id, parameters)\n    cistern.connection.patch(\"/post/#{id}\", parameters)\n  end\n\n  def mock(id, parameters)\n    post = cistern.data[:posts].fetch(id)\n\n    post.merge!(stringify_keys(parameters))\n\n    response(post: post)\n  end\nend\n```\n\nHowever, if you want to add some preprocessing to your request's arguments override `#call` and call `#dispatch`.  You\ncan also alter the response method's signatures based on the arguments provided to `#dispatch`.\n\n\n```ruby\nclass Blog::UpdatePost\n  include Blog::Request\n\n  attr_reader :parameters\n\n  def call(post_id, parameters)\n    @parameters = stringify_keys(parameters)\n    dispatch(Integer(post_id))\n  end\n\n  def real(id)\n    cistern.connection.patch(\"/post/#{id}\", parameters)\n  end\n\n  def mock(id)\n    post = cistern.data[:posts].fetch(id)\n\n    post.merge!(parameters)\n\n    response(post: post)\n  end\nend\n```\n\nThe `#cistern_method` function allows you to specify the name of the generated method.\n\n```ruby\nclass Blog::GetPosts\n  include Blog::Request\n\n  cistern_method :get_all_the_posts\n\n  def real(params)\n    \"all the posts\"\n  end\nend\n\nBlog.new.respond_to?(:get_posts) # false\nBlog.new.get_all_the_posts       # \"all the posts\"\n```\n\nAll declared requests can be listed via `Cistern::Client#requests`.\n\n```ruby\nBlog.requests # =\u003e [Blog::GetPosts, Blog::GetPost]\n```\n\n### Models\n\n* `cistern` represents the associated `Blog::Real` or `Blog::Mock` instance. \n* `collection` represents the related collection.\n* `new_record?` checks if `identity` is present\n* `requires(*requirements)` throws `ArgumentError` if an attribute matching a requirement isn't set\n* `requires_one(*requirements)` throws `ArgumentError` if no attribute matching requirement is set\n* `merge_attributes(attributes)` sets attributes for the current model instance\n* `dirty_attributes` represents attributes changed since the last `merge_attributes`.  This is useful for using `update`\n\n#### Attributes\n\nCistern attributes are designed to make your model flexible and developer friendly.\n\n* `attribute :post_id` adds an accessor to the model.\n\t```ruby\n\tattribute :post_id\n\n\tmodel.post_id #=\u003e nil\n\tmodel.post_id = 1 #=\u003e 1\n\tmodel.post_id #=\u003e 1\n\tmodel.attributes #=\u003e {'post_id' =\u003e 1 }\n\tmodel.dirty_attributes #=\u003e {'post_id' =\u003e 1 }\n\t```\n* `identity` represents the name of the model's unique identifier.  As this is not always available, it is not required.\n\t```ruby\n\tidentity :name\n\t```\n\n\tcreates an attribute called `name` that is aliased to identity.\n\n\t```ruby\n\tmodel.name = 'michelle'\n\n\tmodel.identity   #=\u003e 'michelle'\n\tmodel.name       #=\u003e 'michelle'\n\tmodel.attributes #=\u003e {  'name' =\u003e 'michelle' }\n\t```\n* `:aliases` or `:alias` allows a attribute key to be different then a response key. \n\t```ruby\n\tattribute :post_id, alias: \"post\"\n\t```\n\n\tallows\n\n\t```ruby\n\tmodel.merge_attributes(\"post\" =\u003e 1)\n\tmodel.post_id #=\u003e 1\n\t```\n* `:type` automatically casts the attribute do the specified type. Supported types: `array`, `boolean`, `date`, `float`, `integer`, `string`, `time`.\n\t```ruby\n\tattribute :private_ips, type: :array\n\n\tmodel.merge_attributes(\"private_ips\" =\u003e 2)\n\tmodel.private_ips #=\u003e [2]\n\t```\n* `:squash` traverses nested hashes for a key. \n\t```ruby\n\tattribute :post_id, aliases: \"post\", squash: \"id\"\n\n\tmodel.merge_attributes(\"post\" =\u003e {\"id\" =\u003e 3})\n\tmodel.post_id #=\u003e 3\n\t```\n\n#### Persistence\n\n* `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.\n* `reload` is used to grab the latest data and merge it into the model.  `reload` uses `collection.get(identity)` by default.\n* `update(attrs)` is a `merge_attributes` and a `save`.  When calling `update`, `dirty_attributes` can be used to persist only what has changed locally.\n\n\nFor example:\n\n```ruby\nclass Blog::Post\n  include Blog::Model\n  identity :id, type: :integer\n\n  attribute :body\n  attribute :author_id, aliases: \"author\",  squash: \"id\"\n  attribute :deleted_at, type: :time\n\n  def destroy\n    requires :identity\n\n    data = cistern.destroy_post(params).body['post']\n  end\n\n  def save\n    requires :author_id\n\n    response = if new_record?\n                 cistern.create_post(attributes)\n               else\n                 cistern.update_post(dirty_attributes)\n               end\n\n    merge_attributes(response.body['post'])\n  end\nend\n```\n\nUsage:\n\n**create**\n\n```ruby\nblog.posts.create(author_id: 1, body: 'text')\n```\n\nis equal to\n\n```ruby\npost = blog.posts.new(author_id: 1, body: 'text')\npost.save\n```\n\n**update**\n\n```ruby\npost = blog.posts.get(1)\npost.update(author_id: 1) #=\u003e calls #save with #dirty_attributes == { 'author_id' =\u003e 1 }\npost.author_id #=\u003e 1\n```\n\n### Singular\n\nSingular resources do not have an associated collection and the model contains the `get` and`save` methods.\n\nFor instance:\n\n```ruby\nclass Blog::PostData\n  include Blog::Singular\n\n  attribute :post_id, type: :integer\n  attribute :upvotes, type: :integer\n  attribute :views, type: :integer\n  attribute :rating, type: :float\n\n  def get\n    response = cistern.get_post_data(post_id)\n    merge_attributes(response.body['data'])\n  end\n  \n  def save\n    response = cistern.update_post_data(post_id, dirty_attributes)\n    merge_attributes(response.data['data'])\n  end\nend\n```\n\nSingular resources often hang off of other models or collections.\n\n```ruby\nclass Blog::Post\n  include Cistern::Model\n\n  identity :id, type: :integer\n\n  def data\n    cistern.post_data(post_id: identity).load\n  end\nend\n```\n\nThey are special cases of Models and have similar interfaces.\n\n```ruby\npost.data.views #=\u003e nil\npost.data.update(views: 3)\npost.data.views #=\u003e 3\n```\n\n\n### Collection\n\n* `model` tells Cistern which resource class this collection represents.\n* `cistern` is the associated `Blog::Real` or `Blog::Mock` instance\n* `attribute` specifications on collections are allowed. use `merge_attributes`\n* `load` consumes an Array of data and constructs matching `model` instances\n\n```ruby\nclass Blog::Posts\n  include Blog::Collection\n\n  attribute :count, type: :integer\n\n  model Blog::Post\n\n  def all(params = {})\n    response = cistern.get_posts(params)\n\n    data = response.body\n\n    load(data[\"posts\"])    # store post records in collection\n    merge_attributes(data) # store any other attributes of the response on the collection\n  end\n\n  def discover(author_id, options={})\n    params = {\n      \"author_id\" =\u003e author_id,\n    }\n    params.merge!(\"topic\" =\u003e options[:topic]) if options.key?(:topic)\n\n    cistern.blogs.new(cistern.discover_blog(params).body[\"blog\"])\n  end\n\n  def get(id)\n    data = cistern.get_post(id).body[\"post\"]\n\n    new(data) if data\n  end\nend\n```\n\n### Associations\n\nAssociations allow the use of a resource's attributes to reference other resources.  They act as lazy loaded attributes\nand push any loaded data into the resource's `attributes`.\n\nThere are two types of associations available.\n\n* `belongs_to` references a specific resource and defines a reader.\n* `has_many` references a collection of resources and defines a reader / writer.\n\n```ruby\nclass Blog::Tag\n  include Blog::Model\n\n  identity :id\n  attribute :author_id\n\n  has_many :posts -\u003e { cistern.posts(tag_id: identity) }\n  belongs_to :creator -\u003e { cistern.authors.get(author_id) }\nend\n```\n\nRelationships store the collection's attributes within the resources' attributes on write / load.\n\n```ruby\ntag = blog.tags.get('ruby')\ntag.posts = blog.posts.load({'id' =\u003e 1, 'author_id' =\u003e '2'}, {'id' =\u003e 2, 'author_id' =\u003e 3})\ntag.attributes[:posts] #=\u003e {'id' =\u003e 1, 'author_id' =\u003e '2'}, {'id' =\u003e 2, 'author_id' =\u003e 3}\n\ntag.creator = blogs.author.get(name: 'phil')\ntag.attributes[:creator] #=\u003e { 'id' =\u003e 2, 'name' =\u003e 'phil' }\n```\n\nForeign keys can be updated by overriding the association writer.\n\n```ruby\nBlog::Tag.class_eval do\n  def creator=(creator)\n    super\n    self.author_id = attributes[:creator][:id]\n  end\nend\n\ntag = blog.tags.get('ruby')\ntag.author_id = 4\ntag.creator = blogs.author.get(name: 'phil') #=\u003e #\u003cBlog::Author id=2 name='phil'\u003e\ntag.author_id #=\u003e 2\n```\n\n#### Data\n\nA uniform interface for mock data is mixed into the `Mock` class by default.\n\n```ruby\nBlog.mock!\nclient = Blog.new # Blog::Mock\nclient.data       # Cistern::Data::Hash\nclient.data[\"posts\"] += [\"x\"] # [\"x\"]\n```\n\nMock data is class-level by default\n\n```ruby\nBlog::Mock.data[\"posts\"] # [\"x\"]\n```\n\n`reset!` dimisses the `data` object.\n\n```ruby\nclient.data.object_id # 70199868585600\nclient.reset!\nclient.data[\"posts\"]  # []\nclient.data.object_id # 70199868566840\n```\n\n`clear` removes existing keys and values but keeps the same object.\n\n```ruby\nclient.data[\"posts\"] += [\"y\"] # [\"y\"]\nclient.data.object_id         # 70199868378300\nclient.clear\nclient.data[\"posts\"]          # []\nclient.data.object_id         # 70199868378300\n```\n\n* `store` and `[]=` write\n* `fetch` and `[]` read\n\nYou can make the service bypass Cistern's mock data structures by simply creating a `self.data` function in your service `Mock` declaration.\n\n```ruby\nclass Blog\n  include Cistern::Client\n\n  class Mock\n    def self.data\n      @data ||= {}\n    end\n  end\nend\n```\n\n### Working with data\n\n`Cistern::Hash` contains many useful functions for working with data normalization and transformation.\n\n**#stringify_keys**\n\n```ruby\n# anywhere\nCistern::Hash.stringify_keys({a: 1, b: 2}) #=\u003e {'a' =\u003e 1, 'b' =\u003e 2}\n# within a Resource\nhash_stringify_keys({a: 1, b: 2}) #=\u003e {'a' =\u003e 1, 'b' =\u003e 2}\n```\n\n**#slice**\n\n```ruby\n# anywhere\nCistern::Hash.slice({a: 1, b: 2, c: 3}, :a, :c) #=\u003e {a: 1, c: 3}\n# within a Resource\nhash_slice({a: 1, b: 2, c: 3}, :a, :c) #=\u003e {a: 1, c: 3}\n```\n\n**#except**\n\n```ruby\n# anywhere\nCistern::Hash.except({a: 1, b: 2}, :a) #=\u003e {b: 2}\n# within a Resource\nhash_except({a: 1, b: 2}, :a) #=\u003e {b: 2}\n```\n\n\n**#except!**\n\n```ruby\n# same as #except but modify specified Hash in-place\nCistern::Hash.except!({:a =\u003e 1, :b =\u003e 2}, :a) #=\u003e {:b =\u003e 2}\n# within a Resource\nhash_except!({:a =\u003e 1, :b =\u003e 2}, :a) #=\u003e {:b =\u003e 2}\n```\n\n\n#### Storage\n\nCurrently supported storage backends are:\n\n* `:hash` : `Cistern::Data::Hash` (default)\n* `:redis` : `Cistern::Data::Redis`\n\n\nBackends can be switched by using `store_in`.\n\n```ruby\n# use redis with defaults\nPatient::Mock.store_in(:redis)\n# use redis with a specific client\nPatient::Mock.store_in(:redis, client: Redis::Namespace.new(\"cistern\", redis: Redis.new(host: \"10.1.0.1\"))\n# use a hash\nPatient::Mock.store_in(:hash)\n```\n\n\n#### Dirty\n\nDirty attributes are tracked and cleared when `merge_attributes` is called.\n\n* `changed` returns a Hash of changed attributes mapped to there initial value and current value\n* `dirty_attributes` returns Hash of changed attributes with there current value.  This should be used in the model `save` function.\n\n\n```ruby\npost = Blog::Post.new(id: 1, flavor: \"x\") # =\u003e \u003c#Blog::Post\u003e\n\npost.dirty?           # =\u003e false\npost.changed          # =\u003e {}\npost.dirty_attributes # =\u003e {}\n\npost.flavor = \"y\"\n\npost.dirty?           # =\u003e true\npost.changed          # =\u003e {flavor: [\"x\", \"y\"]}\npost.dirty_attributes # =\u003e {flavor: \"y\"}\n\npost.save\npost.dirty?           # =\u003e false\npost.changed          # =\u003e {}\npost.dirty_attributes # =\u003e {}\n```\n\n### Custom Architecture\n\nWhen configuring your client, you can use `:collection`, `:request`, and `:model` options to define the name of module or class interface for the service component.\n\nFor example: if you'd `Request` is to be used for a model, then the `Request` component name can be remapped to `Demand`\n\nFor example:\n\n```ruby\nclass Blog\n  include Cistern::Client.with(interface: :modules, request: \"Demand\")\nend\n```\n\nallows a model named `Request` to exist\n\n```ruby\nclass Blog::Request\n  include Blog::Model\n\n  identity :jovi\nend\n```\n\nwhile living on a `Demand`\n\n```ruby\nclass Blog::GetPost\n  include Blog::Demand\n\n  def real\n    cistern.request.get(\"/wing\")\n  end\nend\n```\n\n## ~\u003e 3.0\n\n### Request Dispatch\n\nDefault request interface passes through `#_mock` and `#_real` depending on the client mode.\n\n```ruby\nclass Blog::GetPost\n  include Blog::Request\n\n  def setup(post_id, parameters)\n    [post_id, stringify_keys(parameters)]\n  end\n\n  def _mock(*args, **kwargs)\n    mock(*setup(*args, **kwargs))\n  end\n\n  def _real(post_id, parameters)\n    real(*setup(*args, **kwargs))\n  end\nend\n```\n\nIn cistern 3, requests pass through `#call` in both modes. `#dispatch` is responsible for determining the mode and\ncalling the appropriate method.\n\n```ruby\nclass Blog::GetPost\n  include Blog::Request\n\n  def call(post_id, parameters)\n    normalized_parameters = stringify_keys(parameters)\n    dispatch(post_id, normalized_parameters)\n  end\nend\n```\n\n### Client definition\n\nDefault resource definition is done by inheritance.\n\n```ruby\nclass Blog::Post \u003c Blog::Model\nend\n```\n\nIn cistern 3, resource definition is done by module inclusion.\n\n```ruby\nclass Blog::Post\n  include Blog::Post\nend\n```\n\nPrepare for cistern 3 by using `Cistern::Client.with(interface: :module)` when defining the client.\n\n```ruby\nclass Blog\n  include Cistern::Client.with(interface: :module)\nend\n```\n\n## Examples\n\n* [zendesk2](https://github.com/lanej/zendesk2)\n* [you_track](https://github.com/lanej/you_track)\n* [ey-core](https://github.com/engineyard/core-client-rb)\n\n\n## Releasing\n\n    $ gem bump -trv (major|minor|patch)\n\n## Contributing\n\n1. Fork it\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Added some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n","funding_links":[],"categories":["Ruby"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flanej%2Fcistern","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flanej%2Fcistern","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flanej%2Fcistern/lists"}