tp_client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ab9aaf3befe004379de592b2c5013cf917eacf5e
4
+ data.tar.gz: 77c525ffff5cd8be2a655f921db1326fa39fa883
5
+ SHA512:
6
+ metadata.gz: 5c6d7870119bd22e8387aef07b94a454fd33c956150ac07e7a9b0ca58fa7197e8544802f047954a54826d97543bf68dcd7d91422fbac0c2950a52502568fb0a7
7
+ data.tar.gz: 0f451d61b4b1d4314836781de07afc846ffec1eb45adf0a4ff24b173384e599741c938d15d5c448b89105a539c2d96975012872ddf4ac1fc8913d73e8de616d8
@@ -0,0 +1,55 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /gems/
6
+ /InstalledFiles
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/examples.txt
10
+ /test/tmp/
11
+ /test/version_tmp/
12
+ /tmp/
13
+
14
+ # Byebug
15
+ .byebug_history
16
+
17
+ # Used by dotenv library to load environment variables.
18
+ # .env
19
+
20
+ ## Specific to RubyMotion:
21
+ .dat*
22
+ .repl_history
23
+ build/
24
+ *.bridgesupport
25
+ build-iPhoneOS/
26
+ build-iPhoneSimulator/
27
+
28
+ ## Specific to RubyMotion (use of CocoaPods):
29
+ #
30
+ # We recommend against adding the Pods directory to your .gitignore. However
31
+ # you should judge for yourself, the pros and cons are mentioned at:
32
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
33
+ #
34
+ # vendor/Pods/
35
+
36
+ ## Documentation cache and generated files:
37
+ /.yardoc/
38
+ /_yardoc/
39
+ /doc/
40
+ /rdoc/
41
+
42
+ ## Environment normalization:
43
+ /.bundle/
44
+ /vendor/bundle
45
+ /lib/bundler/man/
46
+
47
+ # for a library or gem, you might want to ignore these files since the code is
48
+ # intended to run in multiple environments; otherwise, check them in:
49
+ Gemfile.lock
50
+ .ruby-version
51
+ .ruby-gemset
52
+
53
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
54
+ .rvmrc
55
+ *tags
@@ -0,0 +1,2 @@
1
+ Metrics/LineLength:
2
+ Max: 100
@@ -0,0 +1,12 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.4.0
4
+ deploy:
5
+ provider: rubygems
6
+ api_key:
7
+ secure: rhnpzPKF7/SYka8XSo8ohtIT/YZB+eltG+uK1Yrup+RHYUvvjL5VbGuVXK71ouNtA5g0iA0xHB8lYyXBkC/DsovURjwhYQXymykqWz68yGjCQgl3i0IP8bgntAP5+PYbeOvYpy8DlH+6qakoTewa/cOhYvLLVlE91zk92UFORIW2b0TVxxMASB2cuKBOkI5a7vRpncpBYupIWLMIHwQd2IGPlWCPOx53QumZkuG1Xze8wuxGTdT+Oo4pIJsNZi1Oz5KKbOaYgYOZa29TlXesz43HcTruIIeMJgKuVMOMgSO0OuQGZa2wK5r7frWqepL8fOvkxS1MYM/9CBC2oUDPQMfX8OIbKQrvgmmIWy5gb78PXQzc8nzYXi+D8HZSydL+7lGnW6Jj5wUUcZ/s8pzfUtC5vgVq+mA7HssKwt3vFACh2prDa+EZuas7jDz36ek9GZsKooKHzMaN2gO9um8JfwgonWbqvXJvhz64QOqTu8ySaWX4is0Y93wc+3fLHPtS6gnyG/ClhLtm7PtJ1+LPo6xrn+9dMfcMfpLTq/z1urHJyzj+8NspMp+ZAZVu+WgfmfcX3/UmzOUBcTOCjA4i2L+jEydFgrOJhYXgGp3iCueKUhHmAAFzWkG8Vtt/KgmvW400kjCAjqIKYo+2DZjlqILZbT19Kr/ZMqDiwnB5ozI=
8
+ gem: tp_client
9
+ gemspec: tiny_client.gemspec
10
+ on:
11
+ tags: true
12
+ repo: TINYhr/tiny_client
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ # gem are specified in tiny-client.gemspec
6
+
7
+ group :test, :development do
8
+ gem 'byebug'
9
+ gem 'minitest'
10
+ gem 'mocha'
11
+ gem 'rake'
12
+ gem 'webmock'
13
+ gem 'yard'
14
+ end
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2017 TINYpulse
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,203 @@
1
+ # TINYclient, a tiny HTTP/JSON crud client toolkit
2
+ [![Gem Version](https://badge.fury.io/rb/tiny_client.svg)](https://badge.fury.io/rb/tiny_client) [![Build Status](https://travis-ci.org/TINYhr/tiny_client.svg)](https://travis-ci.org/TINYhr/tiny_client) [![Code Climate](https://codeclimate.com/github/TINYhr/tiny_client/badges/gpa.svg)](https://codeclimate.com/github/TINYhr/tiny_client)
3
+
4
+ TINYclient is inspired by [Active Record](http://guides.rubyonrails.org/active_record_basics.html) and based on [Curb](https://github.com/taf2/curb).
5
+
6
+ ### Setup
7
+
8
+ * Be sure that Curb/Curl works properly on your machine.
9
+ * install the gem
10
+
11
+ ```sh
12
+ gem install tiny_client
13
+ ```
14
+
15
+ ### Getting Started
16
+
17
+
18
+ #### Configuration
19
+
20
+ You can initialize your API by extending the `TinyClient::Configuration`
21
+
22
+
23
+ ```ruby
24
+ class MyConf < TinyClient::Configuration
25
+
26
+ def initialize
27
+ @url = 'http://localhost:3000/api/1.0'
28
+ @headers = { 'Authorization' => 'token asdfasf4ffsafasdf@12rfsdfa' }
29
+ @limit = 100
30
+ @connect_timeout = 30 # seconds
31
+ end
32
+ end
33
+
34
+ ```
35
+
36
+ You can use that configuration in your resource.
37
+
38
+
39
+ ```ruby
40
+ class Author < TinyClient::Resource
41
+ conf MyConf.instance
42
+
43
+ path 'authors' # query will be made on http://localhost:3000/api/1.0/authors
44
+
45
+ fields :id, :name # your resource attributes
46
+
47
+ nested Books # your resource nested resource
48
+ end
49
+
50
+ class Book < TinyClient::Resource
51
+ conf MyConf.instance
52
+ path 'books'
53
+ fields :id, :title
54
+ end
55
+ ```
56
+
57
+ #### Usage
58
+
59
+ Now you will be able to this:
60
+
61
+ ```ruby
62
+ author = Author.show(1) # Get /authors/1.json
63
+ author.name = 'P. K. D.'
64
+ author.save! # PUT /authors/1.json { "author" : { "name" : "Bob" } }
65
+
66
+ book = Book.new
67
+ book.title = 'Confessions of a crap artist'
68
+ book = author.add_book(book) # POST /authors/1/books.json { "book" : { "title" : ".." }
69
+
70
+ book.id.present? # true
71
+
72
+ books = Book.index(limit: 10) # GET /books.json?limit=10
73
+
74
+ ed = Author.new
75
+ ed.name = 'Poe'
76
+ ed.save! # POST /authors.json { "author" : { "name" : "Poe" } }
77
+ ed.id.present?
78
+
79
+ ed_books = ed.books(limit: 10) # GET /authors/{ed.id}/books.json
80
+ first = ed_books.first
81
+ first.load! # GET /books/{first.id}.json
82
+ first.name.present?
83
+
84
+ # You can also navigate through all resources
85
+
86
+ Author.index_all do |author| # It will retrieve all the authors, using limit, and offset query params to paginate
87
+ # Do something for each author
88
+ end
89
+
90
+
91
+ Author.index_in_batches(limit: 1000) do |authors|
92
+ # retrieve authors by batch of 1000
93
+ end
94
+
95
+ ```
96
+
97
+ ### Instance methods behavior
98
+
99
+ #### load!
100
+
101
+ It will perform a get request on the resource id and set the resource fields value to the value retrived by the response.
102
+
103
+ ```
104
+ author.load! # GET /authors/{author.id}.json -> { id: 1, name: 'Toto' ... }
105
+ author.name # 'Toto'
106
+ ```
107
+
108
+ #### save!
109
+
110
+ It will create the resource if `id` is not set, otherwise it will update it.
111
+ The resource fields value will be updated by the response body content.
112
+
113
+ ```
114
+ toto = MyModule::Author.new
115
+ toto.save! # POST /authors.json { author: {} }
116
+
117
+ toto.id # should have been set by through the reponse
118
+
119
+ toto.name = 'Toto'
120
+ toto.save! # PUT {author: {name: 'Toto'}} -> /authors/{toto.id}.json
121
+ ```
122
+
123
+ Only `changed` values will be passed through the body content.
124
+
125
+ You can `clear` changes with `#clear_changes!`
126
+ You can now which fields has been marked has changed with `#changes`
127
+
128
+ Changes is automatically clear when you perform a request ( i.e call, `#show #index #get #put #post save!` and so on)
129
+
130
+ ### Nested resource
131
+
132
+ You can add a nested resource thanks to the `nested` class methods.
133
+
134
+ ```ruby
135
+ class Author < TinyClient::Resource
136
+ nested Books, Magazines
137
+ end
138
+ ```
139
+
140
+ It will allows you to call your nested resource directly from an instance of your parent resource.
141
+
142
+ ```ruby
143
+ author = Author.show(1)
144
+ author.books(limit: 100) # index GET /authors/1/books.json?limit=100
145
+ book = author.book(1) # show GET/authors/1/books/1.json
146
+ book.title = 'New title'
147
+ author.update_book(book) # update PUT /authors/1/books/1.json -- { 'book': { 'title': 'New title' } }
148
+ author.remove_book(book) # destroy DELETE /authors/1/books/1.json
149
+ author.add_book(book) # create POST /author/1/books.json -- { 'book': { 'title': 'New title' } }
150
+ author.books_all.each # x GET /authors/1/books.json?limit=.. -- Enumerator -- Retrieve ALL books using limit and offset to handle pagination
151
+ ```
152
+
153
+ This is equivalent to the following:
154
+
155
+ ```ruby
156
+ author = Author.show(1)
157
+ author.nested_index(Book, limit: 100)
158
+ book = author.nested_show(Book, 1)
159
+ book.title = 'New title'
160
+ author.nested_update(book)
161
+ author.nested_delete(book)
162
+ author.nested_create(book)
163
+ author.nested_all(Book, limit: 10) # retrieve all books, quering the server by batch of 10;
164
+ ```
165
+
166
+ ### Constraint & Support
167
+
168
+ #### JSON only
169
+
170
+ TinyClient supports only JSON data.
171
+ `Accept: application/json` header is set.
172
+
173
+ #### POST/PUT create/update
174
+
175
+ The content passed to the server will always be prefixed by the class name in lower case.
176
+
177
+ ```
178
+ toto = MyModule::Author.new
179
+ toto.save! # POST { author: {} }
180
+
181
+ ```
182
+
183
+ #### Pagination / Buffer
184
+
185
+ Pagination, buffer is achieve through `limit` and `offset` params.
186
+
187
+ ```
188
+ Author.index_all({limit: 100}) # Will queries the server by batch of 100, until all authors has been retrieved through the enumerator.
189
+
190
+ ```
191
+
192
+ #### Content-Encoding support
193
+
194
+ TinyClient support `gzip` Content-Encoding. Response with `gzip` Content-Encoding will be automatically decompressed.
195
+ You can set the `Accept-Encoding: gzip` through the configuration headers.
196
+
197
+ ### Development
198
+
199
+ You can run the test using:
200
+
201
+ ```shell
202
+ rake
203
+ ```
@@ -0,0 +1,26 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+ require 'yard'
4
+
5
+ task default: [:test]
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << 'test'
9
+ t.test_files = FileList['test/tiny_client/*_test.rb', 'test/tiny_client_test.rb']
10
+ end
11
+
12
+ YARD::Rake::YardocTask.new do |t|
13
+ t.files = ['lib/**/*.rb']
14
+ t.options = ['-o docs']
15
+ end
16
+
17
+ desc 'Build the gem'
18
+ task gem: [:test] do
19
+ sh 'gem build tiny_client.gemspec'
20
+ end
21
+
22
+ desc 'Clean up'
23
+ task :clean do
24
+ sh 'rm -rf docs'
25
+ sh 'rm -f tiny_client*.gem'
26
+ end
@@ -0,0 +1,15 @@
1
+ require 'tiny_client/curb_requestor'
2
+ require 'tiny_client/response'
3
+ require 'tiny_client/nested_support'
4
+ require 'tiny_client/pagination_support'
5
+ require 'tiny_client/request_error'
6
+ require 'tiny_client/response_error'
7
+ require 'tiny_client/resource_error'
8
+ require 'tiny_client/resource'
9
+ require 'tiny_client/url_builder'
10
+ require 'tiny_client/configuration'
11
+ require 'tiny_client/remote_client'
12
+
13
+ # Placeholder
14
+ module TinyClient
15
+ end
@@ -0,0 +1,6 @@
1
+ module TinyClient
2
+ # A base class for all errors of tiny client
3
+ # This class provides an error which we can rescue and catch all tiny client
4
+ # errors with
5
+ class BaseError < StandardError; end
6
+ end
@@ -0,0 +1,35 @@
1
+ module TinyClient
2
+ # Provides the default client configuration
3
+ # Subclass and override {#initialize} to implement a client confiuration.
4
+ # @abstract
5
+ # @attr_reader [String] url the api root url (i.e: http://localhost/api/1.0)
6
+ # @attr_reader [Integer] limit default limit used as a query param
7
+ class Configuration
8
+ include Singleton
9
+ attr_reader :url, :limit
10
+
11
+ # You need to initialize the api {#url}, default {#headers}, and default limit.
12
+ def initialize
13
+ raise NotImplementedError
14
+ end
15
+
16
+ # @return [Integer] request connection timeout in seconds
17
+ def connect_timeout
18
+ @connect_timeout ||= 30
19
+ end
20
+
21
+ # @return [Hash] headers default headers you want to pass along every request
22
+ def headers
23
+ @headers ||= {}
24
+ end
25
+
26
+ # @return [Boolean] true if curl verbose option is set
27
+ def verbose
28
+ @verbose ||= false
29
+ end
30
+
31
+ def requestor
32
+ @requestor ||= TinyClient::RemoteClient.new(self)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,85 @@
1
+ require 'curb'
2
+
3
+ module TinyClient
4
+ # Allows to perform request with Curb and wrapped the response.
5
+ # Curb client are attached to a current thread Fiber. ( One curb per Fiber. )
6
+ module CurbRequestor
7
+ class << self
8
+ # Perform a get request with Curl
9
+ # @param [String] url the full url
10
+ # @param [Hash] headers the request headers
11
+ # @param [Integer] connect_timeout timeout if the request connection go over (in second)
12
+ # @param [Boolean] verbose set curl verbose mode
13
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
14
+ # @return [TinyClient::Response] the request response
15
+ def perform_get(url, headers, connect_timeout, verbose)
16
+ perform(:GET, url, nil, nil, headers: headers, connect_timeout: connect_timeout,
17
+ verbose: verbose)
18
+ end
19
+
20
+ # Perform a put request with Curl
21
+ # @param [String] url the full url
22
+ # @param [Hash] headers the request headers
23
+ # @param [String] content the request body content
24
+ # @param [Integer] connect_timeout timeout if the request connection go over (in second)
25
+ # @param [Boolean] verbose set curl verbose mode
26
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
27
+ # @return [TinyClient::Response] the request response
28
+ def perform_put(url, headers, content, connect_timeout, verbose)
29
+ perform(:PUT, url, nil, content, headers: headers, connect_timeout: connect_timeout,
30
+ verbose: verbose)
31
+ end
32
+
33
+ # Perform a post request with Curl
34
+ # @param [String] url the full url
35
+ # @param [Hash] headers the request headers
36
+ # @param [String] content the request body content
37
+ # @param [Integer] connect_timeout timeout if the request connection go over (in second)
38
+ # @param [Boolean] verbose set curl verbose mode
39
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
40
+ # @return [TinyClient::Response] the request response
41
+ def perform_post(url, headers, content, connect_timeout, verbose)
42
+ perform(:POST, url, content, nil, headers: headers, connect_timeout: connect_timeout,
43
+ verbose: verbose)
44
+ end
45
+
46
+ # Perform a delete request with Curl
47
+ # @param [String] url the full url
48
+ # @param [Hash] headers the request headers
49
+ # @param [Integer] connect_timeout timeout if the request connection go over (in second)
50
+ # @param [Boolean] verbose set curl verbose mode
51
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
52
+ # @return [TinyClient::Response] the request response
53
+ def perform_delete(url, headers, connect_timeout, verbose)
54
+ perform(:DELETE, url, nil, nil, headers: headers, connect_timeout: connect_timeout,
55
+ verbose: verbose)
56
+ end
57
+
58
+ # Perform a head request with Curl
59
+ # @param [String] url the full url
60
+ # @param [Hash] headers the request headers
61
+ # @param [Integer] connect_timeout timeout if the request connection go over (in second)
62
+ # @param [Boolean] verbose set curl verbose mode
63
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
64
+ # @return [TinyClient::Response] the request response
65
+ def perform_head(url, headers, connect_timeout, verbose)
66
+ perform(:HEAD, url, nil, nil, headers: headers, connect_timeout: connect_timeout,
67
+ verbose: verbose)
68
+ end
69
+
70
+ private
71
+
72
+ def perform(verb, url, post_body, put_data, options = {})
73
+ response = Response.new(Curl.http(verb, url, post_body, put_data) do |c|
74
+ c.headers = options[:headers]
75
+ c.connect_timeout = options[:connect_timeout]
76
+ c.verbose = options[:verbose]
77
+ end)
78
+ raise ResponseError, response if response.error?
79
+ response
80
+ rescue Curl::Err::ConnectionFailedError => e
81
+ raise RequestError, e.message
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,95 @@
1
+ require 'active_support/inflector'
2
+
3
+ module TinyClient
4
+ #
5
+ # Mixin that add support for nested resource to {TinyClient::Resource}
6
+ # Each nested resource will be accessible with:
7
+ # <resource_name>s # List the existing ( index )
8
+ # <resource_name>(id) # Show an existing ( show )
9
+ # add_<resource_name>(resource) # To create a new one ( post )
10
+ # remove_<resource_name>(resource) # Remove an existing ( delete )
11
+ # update_<resource_name>(resource) # Update an existing ( put )
12
+ # @see file:README.md#label-Nested+resource README - Nested Resource
13
+ module NestedSupport
14
+ # @raise [ArgumentError] if the given resource_class is not a Resource
15
+ def self.included(resource_class)
16
+ raise ArgumentError, 'Works only for TinyClient::Resource' unless resource_class <= Resource
17
+ resource_class.extend(ClassMethods)
18
+ end
19
+
20
+ # @raise [ArgumentError] if the given resource_class is not a Resource
21
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
22
+ def nested_show(resource_class, id, params = {})
23
+ raise ArgumentError, 'Works only for TinyClient::Resource' unless resource_class <= Resource
24
+ path = UrlBuilder.url(resource_class.path).path(id).build!
25
+ self.class.get(params, @id, path, resource_class)
26
+ end
27
+
28
+ # @raise [ArgumentError] if the given resource_class is not a Resource
29
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
30
+ def nested_index(resource_class, params = {})
31
+ raise ArgumentError, 'Works only for TinyClient::Resource' unless resource_class <= Resource
32
+ self.class.get(params, @id, resource_class.path, resource_class)
33
+ end
34
+
35
+ # @raise [ArgumentError] if the given resource does not have an id or is not Resource instance
36
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
37
+ def nested_update(resource)
38
+ raise ArgumentError, 'resource must be an TinyClient::Resource' unless resource.is_a? Resource
39
+ raise ArgumentError, 'resource must have id set' if resource.id.nil?
40
+ path = UrlBuilder.url(resource.class.path).path(resource.id).build!
41
+ data = resource.changes.to_a.each_with_object({}) { |fld, h| h[fld] = resource.send(fld) }
42
+ self.class.put({ resource.class.low_name => data }, @id, path, resource.class)
43
+ end
44
+
45
+ # @raise [ArgumentError] if the given resource does not have an id or is not a Resource instance
46
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
47
+ def nested_delete(resource)
48
+ raise ArgumentError, 'resource must be an TinyClient::Resource' unless resource.is_a? Resource
49
+ raise ArgumentError, 'resource must have id set' if resource.id.nil?
50
+ path = UrlBuilder.url(resource.class.path).path(resource.id).build!
51
+ self.class.delete(@id, path, resource.class)
52
+ end
53
+
54
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
55
+ def nested_create(resource)
56
+ raise ArgumentError, 'resource must be an TinyClient::Resource' unless resource.is_a? Resource
57
+ data = resource.changes.to_a.each_with_object({}) { |fld, h| h[fld] = resource.send(fld) }
58
+ self.class.post({ resource.class.low_name => data }, @id, resource.class.path, resource.class)
59
+ end
60
+
61
+ # @see PaginationSupport::ClassMethods.get_all
62
+ # @raise [ArgumentError] if the given resource_class is not a Resource
63
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
64
+ def nested_all(resource_class, params = {})
65
+ raise ArgumentError, 'Works only for TinyClient::Resource' unless resource_class <= Resource
66
+ self.class.get_all(params, @id, resource_class.path, resource_class)
67
+ end
68
+
69
+ # Add support for the {#nested} class methods as well as default actions.
70
+ module ClassMethods
71
+ # Set nested resources. Nested resource creation and getters method will be created.
72
+ # If the resource class is called Post, then `add_post` and `posts` methods will be created.
73
+ # @param [Resource] clazz the nested resource class.
74
+ def nested(*clazz)
75
+ @nested ||= nested_actions(clazz) && clazz
76
+ end
77
+
78
+ private
79
+
80
+ def nested_actions(nested)
81
+ nested.each do |clazz|
82
+ plural_name = ActiveSupport::Inflector.pluralize(clazz.low_name)
83
+ class_eval <<-RUBY
84
+ def #{plural_name}(params = {}); nested_index(#{clazz}, params) end
85
+ def #{clazz.low_name}(id, params = {}); nested_show(#{clazz}, id, params) end
86
+ def add_#{clazz.low_name}(#{clazz.low_name}); nested_create(#{clazz.low_name}) end
87
+ def update_#{clazz.low_name}(#{clazz.low_name}); nested_update(#{clazz.low_name}) end
88
+ def remove_#{clazz.low_name}(#{clazz.low_name}); nested_delete(#{clazz.low_name}) end
89
+ def #{plural_name}_all(params = {}); nested_all(#{clazz}, params) end
90
+ RUBY
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,53 @@
1
+ module TinyClient
2
+ # Mixin that add support for limit/offset pagination to {TinyClient::Resource}
3
+ module PaginationSupport
4
+ def self.included(resource_class)
5
+ raise ArgumentError, 'Works only for TinyClient::Resource' unless resource_class <= Resource
6
+ resource_class.extend(ClassMethods)
7
+ end
8
+
9
+ # Add methods that allows to walk fully through collections thanks to limit/offset pagination.
10
+ # All methods return an enumerator that will query the server in batch based on the limit size
11
+ # and total number of items.
12
+ module ClassMethods
13
+ # Similar to {Resource.index} but return all resources available at this path.
14
+ # It use limit and offset
15
+ # params to retrieved all resources. ( buffered by the limit size)
16
+ def index_all(params = {})
17
+ get_all(params)
18
+ end
19
+
20
+ # Similar to {index_all}, the return enumerator will yield on the buffered ( limit )
21
+ # rather than each element.
22
+ def index_in_batches(params = {})
23
+ get_in_batches(params)
24
+ end
25
+
26
+ def get_all(params = {}, id = nil, name = nil, resource_class = nil)
27
+ Enumerator.new do |y|
28
+ count = limit = params.fetch(:limit, @conf.limit || 100)
29
+ offset = params.fetch(:offset, 0)
30
+ while limit == count
31
+ inner = get(params.merge(limit: limit, offset: offset), id, name, resource_class)
32
+ loop { y << inner.next }
33
+ offset += limit
34
+ count = inner.count
35
+ end
36
+ end
37
+ end
38
+
39
+ def get_in_batches(params = {}, id = nil, name = nil, resource_class = nil)
40
+ Enumerator.new do |y|
41
+ count = limit = params.fetch(:limit, @conf.limit || 100)
42
+ offset = params.fetch(:offset, 0)
43
+ while limit == count
44
+ inner = get(params.merge(limit: limit, offset: offset), id, name, resource_class)
45
+ loop { y << inner }
46
+ offset += limit
47
+ count = inner.count
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ module TinyClient
2
+ # Remote Http client which delegates to the {CurbRequestor}.
3
+ class RemoteClient
4
+ def initialize(config)
5
+ @config = config
6
+ end
7
+
8
+ # GET /<path>/<id>/<name>?<params>
9
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
10
+ # @return [Response]
11
+ def get(path, params, id, name)
12
+ url = build_url(path, id, name).query(params).build!
13
+ CurbRequestor.perform_get(url, {
14
+ 'Accept' => 'application/json',
15
+ 'Content-Type' => 'application/x-www-form-urlencoded'
16
+ }.merge!(@config.headers), @config.connect_timeout, @config.verbose)
17
+ end
18
+
19
+ # POST /<path>/<id>/<name>
20
+ # @param [Hash] data
21
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
22
+ # @return [Response]
23
+ def post(data, path, id, name)
24
+ url = build_url(path, id, name).build!
25
+ verify_json(data)
26
+ CurbRequestor.perform_post(url, {
27
+ 'Accept' => 'application/json',
28
+ 'Content-Type' => 'application/json'
29
+ }.merge!(@config.headers), data.to_json, @config.connect_timeout, @config.verbose)
30
+ end
31
+
32
+ # PUT /<path>/<id>/<name>
33
+ # @param [Hash] data the resource data
34
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
35
+ # @return [Response]
36
+ def put(data, path, id, name)
37
+ url = build_url(path, id, name).build!
38
+ verify_json(data)
39
+ CurbRequestor.perform_put(url, {
40
+ 'Accept' => 'application/json',
41
+ 'Content-Type' => 'application/json'
42
+ }.merge!(@config.headers), data.to_json, @config.connect_timeout, @config.verbose)
43
+ end
44
+
45
+ # DELETE /<path>/<id>.json
46
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
47
+ # @return [Response]
48
+ def delete(path, id, name)
49
+ url = build_url(path, id, name).build!
50
+ CurbRequestor.perform_delete(url, {
51
+ 'Accept' => 'application/json',
52
+ 'Content-Type' => 'application/x-www-form-urlencoded'
53
+ }.merge!(@config.headers), @config.connect_timeout, @config.verbose)
54
+ end
55
+
56
+ private
57
+
58
+ def verify_json(data)
59
+ raise ArgumentError, 'data must respond to .to_json' unless data.respond_to? :to_json
60
+ end
61
+
62
+ def build_url(path, id, name)
63
+ UrlBuilder.url(@config.url).path(path).path(id).path(name)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,7 @@
1
+ require 'tiny_client/base_error'
2
+
3
+ module TinyClient
4
+ # Raised when an Curb error occured during the request.
5
+ # We usually wrap Curl::Err::ConnectionFailedError in this error
6
+ class RequestError < BaseError; end
7
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+ require 'set'
3
+ require 'active_support/json'
4
+ require 'active_support/core_ext/object/json'
5
+ module TinyClient
6
+ # This is the core of TinyClient.
7
+ # Subclass {TinyClient::Resource} in order to create an HTTP/JSON tiny client.
8
+ #
9
+ # {file:README.md Getting Started}
10
+ # @author @barjo
11
+ class Resource
12
+ include PaginationSupport
13
+ include NestedSupport
14
+
15
+ # A resource always have an id
16
+ attr_accessor :id
17
+
18
+ class << self
19
+ # Set this resource client configuration
20
+ # @param [Configuration] config the api url and client default headers.
21
+ def conf(config)
22
+ @conf ||= config
23
+ end
24
+
25
+ # Set the resource path, default is the class name in lower case.
26
+ # @param [String] path the resource path
27
+ def path(path = nil)
28
+ @path ||= path || low_name
29
+ end
30
+
31
+ # @param [*String] names the resource field names
32
+ def fields(*names)
33
+ @fields ||= field_accessor(names) && names
34
+ end
35
+
36
+ # GET /<path>.json
37
+ # @param [Hash] params query parameters
38
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
39
+ # @return [Enumerator] enumerate the resources available at this path.
40
+ def index(params = {})
41
+ get(params)
42
+ end
43
+
44
+ # POST /<resource_path>.json
45
+ # Create a new resource. The resource will be indexed by it's name.
46
+ # @param [Object] content the resource/attributes to be created.
47
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
48
+ # @return the created resource
49
+ def create(content)
50
+ data = { low_name => content }
51
+ post(data)
52
+ end
53
+
54
+ # GET /<resource_path>/{id}
55
+ # @param [String, Integer] id the resource id
56
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
57
+ # @return the resource available at that path
58
+ def show(id, params = {})
59
+ get(params, id)
60
+ end
61
+
62
+ # GET /<path>/{id}/<name>
63
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
64
+ def get(params = {}, id = nil, name = nil, resource_class = nil)
65
+ resp = @conf.requestor.get(@path, params, id, name)
66
+ (resource_class || self).from_response resp
67
+ end
68
+
69
+ # POST /<path>/{id}/<name>
70
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
71
+ # @raise [ArgumentError] if data cannot be serialized as a json string ( .to_json )
72
+ def post(data, id = nil, name = nil, resource_class = nil)
73
+ resp = @conf.requestor.post(data, @path, id, name)
74
+ (resource_class || self).from_response resp
75
+ end
76
+
77
+ # Will query PUT /<path>/{id}
78
+ # @param [String, Integer] id the id of the resource that needs to be updated
79
+ # @param [Object] content the updated attributes/fields/resource
80
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
81
+ # @return the updated resource
82
+ def update(id, content)
83
+ data = { low_name => content }
84
+ put(data, id)
85
+ end
86
+
87
+ # PUT /<path>/{id}/<name>
88
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
89
+ # @raise [ArgumentError] if data cannot be serialized as a json string ( .to_json )
90
+ def put(data, id = nil, name = nil, resource_class = nil)
91
+ resp = @conf.requestor.put(data, @path, id, name)
92
+ (resource_class || self).from_response resp
93
+ end
94
+
95
+ # delete /<path>/{id}.json
96
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
97
+ def delete(id = nil, name = nil, resource_class = nil)
98
+ resp = @conf.requestor.delete(@path, id, name)
99
+ (resource_class || self).from_response resp
100
+ end
101
+
102
+ def low_name
103
+ @low_name ||= name.demodulize.underscore
104
+ end
105
+
106
+ # Create a resouce instance from an Hash.
107
+ # @param [Hash] hash the resource fields with their values
108
+ # @param [Boolean] track_changes if true all fields will be marked has changed
109
+ # @return [Resource] the newly created resource
110
+ def build(hash, track_changes = true)
111
+ resource = fields.each_with_object(new) do |field, r|
112
+ value = hash.fetch(field.to_s, hash[field.to_sym])
113
+ r.send("#{field}=", value)
114
+ end
115
+ resource.clear_changes! unless track_changes
116
+ resource
117
+ end
118
+
119
+ # @return [Response] the last response that has been received for that resource
120
+ def last_response
121
+ Thread.current[:_tclr]
122
+ end
123
+
124
+ protected
125
+
126
+ # Create a resource instance from a {Response}.
127
+ # If the response contains an Array of resource hash, an Enumerator will be return.
128
+ # @param [Response] response obtained from making a request.
129
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
130
+ # @return [Resource, Enumerator, nil] the resources created from the given response.
131
+ def from_response(response)
132
+ Thread.current[:_tclr] = response
133
+ case body = response.parse_body
134
+ when Hash
135
+ build(body, false)
136
+ when Array
137
+ Enumerator.new(body.size) do |yielder|
138
+ inner = body.each
139
+ loop { yielder << build(inner.next, false) }
140
+ end
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def field_accessor(names)
147
+ names.each do |name|
148
+ class_eval <<-RUBY
149
+ def #{name}; @#{name} end
150
+
151
+ def #{name}=(#{name})
152
+ @#{name}= #{name}
153
+ @changes << :#{name} # keep track of fields that has been modified
154
+ end
155
+ RUBY
156
+ end
157
+ end
158
+ end
159
+
160
+ # the fields that has beem modified, and will be save on {save!}
161
+ attr_reader :changes
162
+
163
+ def initialize(*_args)
164
+ @changes = Set.new # store the fields change here
165
+ end
166
+
167
+ # Save the resource fields that has changed, or create it, if it's a new one!
168
+ # Create the a new resource if id is not set or update the corresonding resource.
169
+ # Create is done by calling POST on the resource path
170
+ # Update is done by calling PUT on the resource id ( path/id )
171
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
172
+ # @return [Resource] the updated resource
173
+ def save!
174
+ data = @changes.to_a.each_with_object({}) { |field, h| h[field] = send(field) }
175
+ saved = id.present? ? self.class.update(id, data) : self.class.create(data)
176
+ clone_fields(saved)
177
+ clear_changes!
178
+ self
179
+ end
180
+
181
+ # Destroy this resource. It will call delete on this resource id.
182
+ # DELETE /path/id
183
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
184
+ # @raise [ResourceError] if this resource does not have an id.
185
+ # @return the deleted resource
186
+ def destroy!
187
+ raise ResourceError, 'Cannot delete resource if @id not present' if id.blank?
188
+ self.class.delete(id)
189
+ self
190
+ end
191
+
192
+ # Load/Reload this resource from the server.
193
+ # It will reset all fields that has been retrieved through the request.
194
+ # It will do a GET request on the resource id (:show)
195
+ # @param [Hash] params optional query parameters
196
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
197
+ # @raise [ResourceError] if this resource does not have an id.
198
+ # @return self with updated fields.
199
+ def load!(params = {})
200
+ raise ResourceError, 'Cannot load resource if @id not present' if id.blank?
201
+ # get the values from the persistence layer
202
+ reloaded = self.class.show(@id, params)
203
+ clone_fields(reloaded)
204
+ clear_changes!
205
+ reloaded
206
+ end
207
+
208
+ # Mark all fields has not changed. This mean that calling save! will not modify this resource
209
+ # until a field attribute has been changed.
210
+ def clear_changes!
211
+ @changes.clear
212
+ end
213
+
214
+ # see http://edgeguides.rubyonrails.org/active_support_core_extensions.html#json-support
215
+ # @param [Hash] options for the hash transformation
216
+ # @option [Array] only limit the hash content to those fields
217
+ # @return [Hash] a json ready representation of this resource
218
+ def as_json(options = {})
219
+ self.class.fields.each_with_object({}) do |field, h|
220
+ h[field] = send(field)
221
+ end.as_json(options)
222
+ end
223
+
224
+ alias to_h as_json
225
+
226
+ private
227
+
228
+ def clone_fields(resource)
229
+ self.class.fields.each { |f| send("#{f}=", resource.send(f)) }
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,7 @@
1
+ require 'tiny_client/base_error'
2
+
3
+ module TinyClient
4
+ # Raised when an trying to {Resource#load!} or {Resource#destroy!} a resource that does not have
5
+ # an id.
6
+ class ResourceError < BaseError; end
7
+ end
@@ -0,0 +1,73 @@
1
+ require 'active_support/json/decoding'
2
+ require 'active_support/gzip'
3
+
4
+ module TinyClient
5
+ # Wrap the curl request response.
6
+ class Response
7
+ attr_reader :status, :body_str, :header_str, :url, :code
8
+
9
+ def initialize(curb)
10
+ @status = curb.status
11
+ @body_str = curb.body_str
12
+ @header_str = curb.header_str
13
+ @code = @status.to_i
14
+ @url = curb.url
15
+ end
16
+
17
+ # Convert the response json body into an hash.
18
+ # @return the parsed response body
19
+ def parse_body
20
+ body = gzip? ? gzip_decompress : body_str
21
+ ActiveSupport::JSON.decode(body) if body.present?
22
+ end
23
+
24
+ # Parse the X-Total-Count header
25
+ # @return [Integer] the value of the X-Total-Count header, or nil if not present
26
+ def total_count
27
+ count = header_str[/X-Total-Count: ([0-9]+)/, 1]
28
+ count.present? ? count.to_i : nil
29
+ end
30
+
31
+ # @return true if this response Content-Encoding is gzip
32
+ def gzip?
33
+ /Content-Encoding: gzip/ =~ header_str
34
+ end
35
+
36
+ # @return true if the http request has been successful.
37
+ def success?
38
+ (200..299).cover?(@code)
39
+ end
40
+
41
+ # @return true if the HTTP status code of this response correspond to an client or server error.
42
+ def error?
43
+ @code >= 400
44
+ end
45
+
46
+ def client_error?
47
+ (400..499).cover?(@code)
48
+ end
49
+
50
+ def server_error?
51
+ @code >= 500
52
+ end
53
+
54
+ def redirect?
55
+ (300..399).cover?(@code)
56
+ end
57
+
58
+ def to_s
59
+ {
60
+ url: url,
61
+ status: status,
62
+ body: body_str,
63
+ headers: header_str
64
+ }.to_s
65
+ end
66
+
67
+ protected
68
+
69
+ def gzip_decompress
70
+ ActiveSupport::Gzip.decompress(body_str)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,13 @@
1
+ require 'tiny_client/base_error'
2
+
3
+ module TinyClient
4
+ # Raised when an HTTP error occured during the request. See {Response#error?}
5
+ class ResponseError < BaseError
6
+ attr_reader :response
7
+
8
+ def initialize(response)
9
+ @response = response
10
+ @message = "Error #{response.status} occured when calling #{response.url}"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,43 @@
1
+ require 'active_support/core_ext/hash'
2
+
3
+ module TinyClient
4
+ # Convenient class used to build a request URL.
5
+ class UrlBuilder
6
+ SEPARATOR = '/'.freeze
7
+ attr_writer :query
8
+
9
+ def self.url(url)
10
+ new(url)
11
+ end
12
+
13
+ def path(path)
14
+ @path << fix_path(path) unless path.blank?
15
+ self
16
+ end
17
+
18
+ def query(params = {})
19
+ @query.merge!(params) unless params.empty?
20
+ self
21
+ end
22
+
23
+ def build!
24
+ query_s = "?#{@query.to_query}" unless @query.empty?
25
+ "#{@path.join(SEPARATOR)}.json#{query_s}"
26
+ end
27
+
28
+ private
29
+
30
+ def initialize(url)
31
+ @path = [url]
32
+ @query = {}
33
+ end
34
+
35
+ def fix_path(path)
36
+ if path.respond_to?(:gsub)
37
+ path.gsub(/\.json$/, '')
38
+ else
39
+ path
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,33 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'tp_client'
3
+ s.authors = ['TINYpulse Devops']
4
+ s.version = '0.1.0'
5
+ s.description = 'TINYclient, an HTTP/JSON crud client toolkit.'
6
+ s.email = 'devops@tinypulse.com'
7
+ s.extra_rdoc_files = ['LICENSE', 'README.md']
8
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
9
+
10
+ if s.respond_to?(:metadata)
11
+ s.metadata["allowed_push_host"] = 'https://rubygems.org'
12
+ else
13
+ raise "RubyGems 2.0 or newer is required to protect against " \
14
+ "public gem pushes."
15
+ end
16
+ #### Load-time details
17
+ s.require_paths = %w(lib ext)
18
+ s.rubyforge_project = 'tiny_client'
19
+ s.summary = 'TINYclient is an HTTP/JSON crud toolkit inspired by ActiveRecord and based on Curb.'
20
+ s.test_files = ['test/tiny_client/']
21
+
22
+ #### Documentation and testing.
23
+ s.has_rdoc = 'yard'
24
+ s.homepage = 'https://github.com/TINYhr/tiny_client'
25
+ s.rdoc_options = ['--main', 'README.md']
26
+
27
+ s.platform = Gem::Platform::RUBY
28
+
29
+ s.license = 'MIT'
30
+
31
+ s.add_runtime_dependency 'curb', '> 0.7.0', '< 1.0.0'
32
+ s.add_runtime_dependency 'activesupport', '>= 4.0', '< 6.0'
33
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tp_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TINYpulse Devops
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: curb
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.7.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 1.0.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">"
28
+ - !ruby/object:Gem::Version
29
+ version: 0.7.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '6.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '4.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '6.0'
53
+ description: TINYclient, an HTTP/JSON crud client toolkit.
54
+ email: devops@tinypulse.com
55
+ executables: []
56
+ extensions: []
57
+ extra_rdoc_files:
58
+ - LICENSE
59
+ - README.md
60
+ files:
61
+ - ".gitignore"
62
+ - ".rubocop.yml"
63
+ - ".travis.yml"
64
+ - Gemfile
65
+ - LICENSE
66
+ - README.md
67
+ - Rakefile
68
+ - lib/tiny_client.rb
69
+ - lib/tiny_client/base_error.rb
70
+ - lib/tiny_client/configuration.rb
71
+ - lib/tiny_client/curb_requestor.rb
72
+ - lib/tiny_client/nested_support.rb
73
+ - lib/tiny_client/pagination_support.rb
74
+ - lib/tiny_client/remote_client.rb
75
+ - lib/tiny_client/request_error.rb
76
+ - lib/tiny_client/resource.rb
77
+ - lib/tiny_client/resource_error.rb
78
+ - lib/tiny_client/response.rb
79
+ - lib/tiny_client/response_error.rb
80
+ - lib/tiny_client/url_builder.rb
81
+ - tiny_client.gemspec
82
+ homepage: https://github.com/TINYhr/tiny_client
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ allowed_push_host: https://rubygems.org
87
+ post_install_message:
88
+ rdoc_options:
89
+ - "--main"
90
+ - README.md
91
+ require_paths:
92
+ - lib
93
+ - ext
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubyforge_project: tiny_client
106
+ rubygems_version: 2.6.8
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: TINYclient is an HTTP/JSON crud toolkit inspired by ActiveRecord and based
110
+ on Curb.
111
+ test_files: []