tiny_client 0.4.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: 8a1afc402b9bc240ed9b9b266ffd193a995641b9
4
+ data.tar.gz: cb6089a98ab2881b533461d25f8aa6d1f23cffc5
5
+ SHA512:
6
+ metadata.gz: 9c3c53dd73646d52f0ffa0ffcfcb408e9c161701cdfbd38ef475d0d8f422e5dd7507e6bafcddf48a0e4c69e6f072b9960613399ddfa8e907d86d6992afbb614e
7
+ data.tar.gz: 8d376ef61b8abea735d90284ce1a43c6a3f2bb0a286da53bfb305948a9d62a71fffc9773171c678e7847e3d8ccc89f2f4971e61ab10cf81137dd78098472cbc9
@@ -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,10 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.4.0
4
+ deploy:
5
+ provider: rubygems
6
+ api_key:
7
+ secure: ph/5I0x4nQebMsUUUJelts/S69KFLJxgPqDltsfrgATVesukd4XjCZXzCHq+1xtViKqzhOHm4LC1AaQlNjGpiJGe0/VeTnI/Xdp+YQsR8JiIE/FZ93CZ8qHJI6R5CgMqNi1rBgUi2qjZNoXOM3g0YJ0mTGVO6UlxhXrs+nU6Vf9vYypUMilHWuioWUVji2pvF3KK4yKssfpZ+jd+1z0d7Ei2sUE0CuOINcZ60HVK9dMvMVd2I98sPQZrnZYZ77R0Lp8pmTJ+bByZdcG28v1zCdf6m9pank4ABF2m8rDkbyieKJ280CgCv756pNzebQEu1ZiycitFO/n881PbHFRZRevvH6txFN3oc4Whg4VQkwI/O4NL2XmjgbiQPEldU3PFK6y7Vm/zbSdPEFBGkb7G5oyaDcxIeqxZzHXBPvVKHUX/GNMTGVli6PIt+tfX2HaGtC+k0NbxZP7g7+C1380d20rNKnokMiGZP+GgdBttHnnCdgXu7GjR2l/xNL7R48squ+LlH05haOaKJcybAGxOwEzRke6BQxsF0iHEg5/EYbpP5F7LEmxcOdep6n1c6X7yJHaAP8TTsivTMlBJKr3Oo9yXrBkAdZWRCpfWMkQabSxI+m6K/KU98ynm6Je8NYqmH0jLR4Wn6lZFyeBZlSBmXKpUlerdA34F09+OGE0sziY=
8
+ ruby: 2.4.0
9
+ on:
10
+ tags: true
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 'rake'
9
+ gem 'minitest'
10
+ gem 'mocha'
11
+ gem 'webmock'
12
+ gem 'yard'
13
+ gem 'byebug'
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)
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,25 @@
1
+ require 'rake/testtask'
2
+ require 'yard'
3
+
4
+ task default: [:test]
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << 'test'
8
+ t.test_files = FileList['test/tiny_client/*_test.rb', 'test/tiny_client_test.rb']
9
+ end
10
+
11
+ YARD::Rake::YardocTask.new do |t|
12
+ t.files = ['lib/**/*.rb']
13
+ t.options = ['-o docs']
14
+ end
15
+
16
+ desc 'Build the gem'
17
+ task gem: [:test] do
18
+ sh 'gem build tiny_client.gemspec'
19
+ end
20
+
21
+ desc 'Clean up'
22
+ task :clean do
23
+ sh 'rm -rf docs'
24
+ sh 'rm -f tiny_client*.gem'
25
+ end
@@ -0,0 +1,14 @@
1
+ require 'tiny_client/curb_requestor'
2
+ require 'tiny_client/response_error'
3
+ require 'tiny_client/response'
4
+ require 'tiny_client/nested_support'
5
+ require 'tiny_client/pagination_support'
6
+ require 'tiny_client/resource_error'
7
+ require 'tiny_client/resource'
8
+ require 'tiny_client/url_builder'
9
+ require 'tiny_client/configuration'
10
+ require 'tiny_client/remote_client'
11
+
12
+ # Placeholder
13
+ module TinyClient
14
+ end
@@ -0,0 +1,36 @@
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 if @verbose.nil?
29
+ @verbose
30
+ end
31
+
32
+ def requestor
33
+ @requestor ||= TinyClient::RemoteClient.new(self)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,83 @@
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
+ end
81
+ end
82
+ end
83
+ 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,60 @@
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
+ CurbRequestor.perform_post(url, {
26
+ 'Accept' => 'application/json',
27
+ 'Content-Type' => 'application/json'
28
+ }.merge!(@config.headers), data.to_json, @config.connect_timeout, @config.verbose)
29
+ end
30
+
31
+ # PUT /<path>/<id>/<name>
32
+ # @param [Hash] data the resource data
33
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
34
+ # @return [Response]
35
+ def put(data, path, id, name)
36
+ url = build_url(path, id, name).build!
37
+ CurbRequestor.perform_put(url, {
38
+ 'Accept' => 'application/json',
39
+ 'Content-Type' => 'application/json'
40
+ }.merge!(@config.headers), data.to_json, @config.connect_timeout, @config.verbose)
41
+ end
42
+
43
+ # DELETE /<path>/<id>.json
44
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
45
+ # @return [Response]
46
+ def delete(path, id, name)
47
+ url = build_url(path, id, name).build!
48
+ CurbRequestor.perform_delete(url, {
49
+ 'Accept' => 'application/json',
50
+ 'Content-Type' => 'application/x-www-form-urlencoded'
51
+ }.merge!(@config.headers), @config.connect_timeout, @config.verbose)
52
+ end
53
+
54
+ private
55
+
56
+ def build_url(path, id, name)
57
+ UrlBuilder.url(@config.url).path(path).path(id).path(name)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,235 @@
1
+ require 'set'
2
+ require 'active_support/json'
3
+ require 'active_support/core_ext/object/json'
4
+ module TinyClient
5
+ # This is the core of TinyClient.
6
+ # Subclass {TinyClient::Resource} in order to create an HTTP/JSON tiny client.
7
+ #
8
+ # {file:README.md Getting Started}
9
+ # @author @barjo
10
+ class Resource
11
+ include PaginationSupport
12
+ include NestedSupport
13
+
14
+ # A resource always have an id
15
+ attr_accessor :id
16
+
17
+ class << self
18
+ # Set this resource client configuration
19
+ # @param [Configuration] config the api url and client default headers.
20
+ def conf(config)
21
+ @conf ||= config
22
+ end
23
+
24
+ # Set the resource path, default is the class name in lower case.
25
+ # @param [String] path the resource path
26
+ def path(path = nil)
27
+ @path ||= path || low_name
28
+ end
29
+
30
+ # @param [*String] names the resource field names
31
+ def fields(*names)
32
+ @fields ||= field_accessor(names) && names
33
+ end
34
+
35
+ # GET /<path>.json
36
+ # @param [Hash] params query parameters
37
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
38
+ # @return [Enumerator] enumerate the resources available at this path.
39
+ def index(params = {})
40
+ get(params)
41
+ end
42
+
43
+ # POST /<resource_path>.json
44
+ # Create a new resource. The resource will be indexed by it's name.
45
+ # @param [Object] content the resource/attributes to be created.
46
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
47
+ # @return the created resource
48
+ def create(content)
49
+ data = { low_name => content }
50
+ post(data)
51
+ end
52
+
53
+ # GET /<resource_path>/{id}
54
+ # @param [String, Integer] id the resource id
55
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
56
+ # @return the resource available at that path
57
+ def show(id, params = {})
58
+ get(params, id)
59
+ end
60
+
61
+ # GET /<path>/{id}/<name>
62
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
63
+ def get(params = {}, id = nil, name = nil, resource_class = nil)
64
+ resp = @conf.requestor.get(@path, params, id, name)
65
+ (resource_class || self).from_response resp
66
+ end
67
+
68
+ # POST /<path>/{id}/<name>
69
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
70
+ # @raise [ArgumentError] if data cannot be serialized as a json string ( .to_json )
71
+ def post(data, id = nil, name = nil, resource_class = nil)
72
+ verify_json(data)
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
+ verify_json(data)
92
+ resp = @conf.requestor.put(data, @path, id, name)
93
+ (resource_class || self).from_response resp
94
+ end
95
+
96
+ # delete /<path>/{id}.json
97
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
98
+ def delete(id = nil, name = nil, resource_class = nil)
99
+ resp = @conf.requestor.delete(@path, id, name)
100
+ (resource_class || self).from_response resp
101
+ end
102
+
103
+ def low_name
104
+ @low_name ||= name.demodulize.underscore
105
+ end
106
+
107
+ # Create a resouce instance from an Hash.
108
+ # @param [Hash] hash the resource fields with their values
109
+ # @param [Boolean] track_changes if true all fields will be marked has changed
110
+ # @return [Resource] the newly created resource
111
+ def build(hash, track_changes = true)
112
+ resource = fields.each_with_object(new) do |field, r|
113
+ value = hash.fetch(field.to_s, hash[field.to_sym])
114
+ r.send("#{field}=", value)
115
+ end
116
+ resource.clear_changes! unless track_changes
117
+ resource
118
+ end
119
+
120
+ # @return [Response] the last response that has been received for that resource
121
+ def last_response
122
+ Thread.current[:_tclr]
123
+ end
124
+
125
+ protected
126
+
127
+ # Create a resource instance from a {Response}.
128
+ # If the response contains an Array of resource hash, an Enumerator will be return.
129
+ # @param [Response] response obtained from making a request.
130
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
131
+ # @return [Resource, Enumerator, nil] the resources created from the given response.
132
+ def from_response(response)
133
+ Thread.current[:_tclr] = response
134
+ body = response.parse_body
135
+ return build(body, false) if body.is_a? Hash
136
+ return Enumerator.new(body.size) do |yielder|
137
+ inner = body.each
138
+ loop { yielder << build(inner.next, false) }
139
+ end if body.is_a? Array
140
+ body # no content
141
+ end
142
+
143
+ private
144
+
145
+ def verify_json(data)
146
+ raise ArgumentError, 'data must respond to .to_json' unless data.respond_to? :to_json
147
+ end
148
+
149
+ def field_accessor(names)
150
+ names.each do |name|
151
+ class_eval <<-RUBY
152
+ def #{name}; @#{name} end
153
+
154
+ def #{name}=(#{name})
155
+ @#{name}= #{name}
156
+ @changes << :#{name} # keep track of fields that has been modified
157
+ end
158
+ RUBY
159
+ end
160
+ end
161
+ end
162
+
163
+ # the fields that has beem modified, and will be save on {save!}
164
+ attr_reader :changes
165
+
166
+ def initialize(*_args)
167
+ @changes = Set.new # store the fields change here
168
+ end
169
+
170
+ # Save the resource fields that has changed, or create it, if it's a new one!
171
+ # Create the a new resource if id is not set or update the corresonding resource.
172
+ # Create is done by calling POST on the resource path
173
+ # Update is done by calling PUT on the resource id ( path/id )
174
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
175
+ # @return [Resource] the updated resource
176
+ def save!
177
+ data = @changes.to_a.each_with_object({}) { |field, h| h[field] = send(field) }
178
+ saved = id.present? ? self.class.update(id, data) : self.class.create(data)
179
+ clone_fields(saved)
180
+ clear_changes!
181
+ self
182
+ end
183
+
184
+ # Destroy this resource. It will call delete on this resource id.
185
+ # DELETE /path/id
186
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
187
+ # @raise [ResourceError] if this resource does not have an id.
188
+ # @return the deleted resource
189
+ def destroy!
190
+ raise ResourceError, 'Cannot delete resource if @id not present' if id.blank?
191
+ self.class.delete(id)
192
+ self
193
+ end
194
+
195
+ # Load/Reload this resource from the server.
196
+ # It will reset all fields that has been retrieved through the request.
197
+ # It will do a GET request on the resource id (:show)
198
+ # @param [Hash] params optional query parameters
199
+ # @raise [ResponseError] if the server respond with an error status (i.e 404, 500..)
200
+ # @raise [ResourceError] if this resource does not have an id.
201
+ # @return self with updated fields.
202
+ def load!(params = {})
203
+ raise ResourceError, 'Cannot load resource if @id not present' if id.blank?
204
+ # get the values from the persistence layer
205
+ reloaded = self.class.show(@id, params)
206
+ clone_fields(reloaded)
207
+ clear_changes!
208
+ reloaded
209
+ end
210
+
211
+ # Mark all fields has not changed. This mean that calling save! will not modify this resource
212
+ # until a field attribute has been changed.
213
+ def clear_changes!
214
+ @changes.clear
215
+ end
216
+
217
+ # see http://edgeguides.rubyonrails.org/active_support_core_extensions.html#json-support
218
+ # @param [Hash] options for the hash transformation
219
+ # @option [Array] only limit the hash content to those fields
220
+ # @return [Hash] a json ready representation of this resource
221
+ def as_json(options = {})
222
+ self.class.fields.each_with_object({}) do |field, h|
223
+ h[field] = send(field)
224
+ end.as_json(options)
225
+ end
226
+
227
+ alias to_h as_json
228
+
229
+ private
230
+
231
+ def clone_fields(resource)
232
+ self.class.fields.each { |f| send("#{f}=", resource.send(f)) }
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,5 @@
1
+ module TinyClient
2
+ # Raised when an trying to {Resource#load!} or {Resource#destroy!} a resource that does not have
3
+ # an id.
4
+ class ResourceError < StandardError; end
5
+ 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,11 @@
1
+ module TinyClient
2
+ # Raised when an HTTP error occured during the request. See {Response#error?}
3
+ class ResponseError < StandardError
4
+ attr_reader :response
5
+
6
+ def initialize(response)
7
+ @response = response
8
+ @message = "Error #{response.status} occured when calling #{response.url}"
9
+ end
10
+ end
11
+ 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,27 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'tiny_client'
3
+ s.authors = ['TINYpulse swat team']
4
+ s.version = '0.4.0'
5
+ s.description = 'TINYclient, an HTTP/JSON crud client toolkit.'
6
+ s.email = 'jonathan@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
+ #### Load-time details
11
+ s.require_paths = %w(lib ext)
12
+ s.rubyforge_project = 'tiny_client'
13
+ s.summary = 'TINYclient is an HTTP/JSON crud toolkit inspired by ActiveRecord and based on Curb.'
14
+ s.test_files = ['test/tiny_client/']
15
+
16
+ #### Documentation and testing.
17
+ s.has_rdoc = 'yard'
18
+ s.homepage = 'https://github.com/TINYhr/tiny_client'
19
+ s.rdoc_options = ['--main', 'README.md']
20
+
21
+ s.platform = Gem::Platform::RUBY
22
+
23
+ s.license = 'MIT'
24
+
25
+ s.add_runtime_dependency 'curb', '> 0.7.0', '< 1.0.0'
26
+ s.add_runtime_dependency 'activesupport', '>= 4.0', '< 6.0'
27
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tiny_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - TINYpulse swat team
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-05-16 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: jonathan@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/configuration.rb
70
+ - lib/tiny_client/curb_requestor.rb
71
+ - lib/tiny_client/nested_support.rb
72
+ - lib/tiny_client/pagination_support.rb
73
+ - lib/tiny_client/remote_client.rb
74
+ - lib/tiny_client/resource.rb
75
+ - lib/tiny_client/resource_error.rb
76
+ - lib/tiny_client/response.rb
77
+ - lib/tiny_client/response_error.rb
78
+ - lib/tiny_client/url_builder.rb
79
+ - tiny_client.gemspec
80
+ homepage: https://github.com/TINYhr/tiny_client
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options:
86
+ - "--main"
87
+ - README.md
88
+ require_paths:
89
+ - lib
90
+ - ext
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubyforge_project: tiny_client
103
+ rubygems_version: 2.6.8
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: TINYclient is an HTTP/JSON crud toolkit inspired by ActiveRecord and based
107
+ on Curb.
108
+ test_files: []