resource_set 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +12 -0
  5. data/CHANGELOG.md +11 -0
  6. data/Gemfile +2 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +165 -0
  9. data/Rakefile +5 -0
  10. data/examples/digitalocean_droplets.rb +60 -0
  11. data/examples/httpbin_client.rb +15 -0
  12. data/lib/resource_set/action.rb +66 -0
  13. data/lib/resource_set/action_invoker.rb +58 -0
  14. data/lib/resource_set/endpoint_resolver.rb +46 -0
  15. data/lib/resource_set/inheritable_attribute.rb +20 -0
  16. data/lib/resource_set/method_factory.rb +20 -0
  17. data/lib/resource_set/resource.rb +40 -0
  18. data/lib/resource_set/resource_collection.rb +55 -0
  19. data/lib/resource_set/status_code_mapper.rb +59 -0
  20. data/lib/resource_set/testing/action_handler_matchers.rb +42 -0
  21. data/lib/resource_set/testing/have_action_matchers.rb +85 -0
  22. data/lib/resource_set/testing.rb +20 -0
  23. data/lib/resource_set/version.rb +3 -0
  24. data/lib/resource_set.rb +17 -0
  25. data/resource_set.gemspec +30 -0
  26. data/spec/integration/resource_actions_spec.rb +41 -0
  27. data/spec/lib/resource_set/action_invoker_spec.rb +167 -0
  28. data/spec/lib/resource_set/action_spec.rb +87 -0
  29. data/spec/lib/resource_set/endpoint_resolver_spec.rb +60 -0
  30. data/spec/lib/resource_set/inheritable_attribute_spec.rb +54 -0
  31. data/spec/lib/resource_set/method_factory_spec.rb +50 -0
  32. data/spec/lib/resource_set/resource_collection_spec.rb +67 -0
  33. data/spec/lib/resource_set/resource_spec.rb +66 -0
  34. data/spec/lib/resource_set/status_code_mapper_spec.rb +9 -0
  35. data/spec/lib/resource_set/testing/action_handler_matchers_spec.rb +68 -0
  36. data/spec/lib/resource_set/testing/have_action_matchers_spec.rb +157 -0
  37. data/spec/spec_helper.rb +8 -0
  38. metadata +202 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 79aa7ef9a5449e0663ad6ec7d0bd3b038d46beda
4
+ data.tar.gz: 5a54cdfa3b831b445bbcfd422deec1547a34bc8c
5
+ SHA512:
6
+ metadata.gz: 38309396161c178f16b9c45c0720d2d65f928cd0aeb719e71d4491cecbda381b4bc92afe5be60a85be1abf1ddd093b8d9f7e57ff87028c75421cfea9fcb69290
7
+ data.tar.gz: a39ce7e9f13d7e1de224aad0eee5b9824c14866c22c706ac4a5957f1183c16f78ff16c43debde781a5c7dd9a19c02b217cf9868cd08a9388384e2bae4811974f
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,12 @@
1
+ language: ruby
2
+ sudo: false
3
+
4
+ rvm:
5
+ - 2.0.0
6
+ - 2.1.8
7
+ - 2.2.4
8
+ - 2.3.3
9
+ - 2.4.1
10
+
11
+ cache:
12
+ bundler: true
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ Resource Set Changelog
2
+ ======================
3
+
4
+ ### master
5
+
6
+ ### [v1.0.0][v1.0.0] (September 8, 2017)
7
+
8
+ * Fixed Fixnum deprecation warnings
9
+ ([#4](https://github.com/kyrylo/resource_set/pull/4))
10
+
11
+ [v1.0.0]: https://github.com/kyrylo/resource_set/releases/tag/v1.0.0
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Robert Ross
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # Resource Set
2
+
3
+ Resource Set provides tools to aid in making API Clients. Such as URL resolving, Request / Response layer, and more.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'resource_set'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install resource_set
18
+
19
+ ## Usage
20
+
21
+ This library recommends using [Cartograph](http://github.com/kyrylo/cartograph) for representing and deserializing response bodies.
22
+ You'll see it in the examples provided below.
23
+
24
+ ### Resource classes
25
+
26
+ Resource Set provides a comprehensive but intuitive DSL where you describe the remote resources capabilities.
27
+ For example, where can I get a list of users? Where do I get a single user? How do I create a user?
28
+
29
+ When you're able to answer these questions, you can describe them in your resource class like this:
30
+
31
+ ```ruby
32
+ class DropletResource < ResourceSet::Resource
33
+ resources do
34
+ default_handler(422) { |response| ErrorMapping.extract_single(response.body, :read) }
35
+ default_handler(:ok, :created) { |response| DropletMapping.extract_single(response.body, :read) }
36
+ default_handler { |response| raise "Unexpected response status #{response.status}... #{response.body}" }
37
+
38
+ # Defining actions will create instance methods on the resource class to call them.
39
+ action :find do
40
+ verb :get # get is assumed if this is omitted
41
+ path '/droplets/:id'
42
+ handler(200) { |response| DropletMapping.extract_single(response.body, :read) }
43
+ end
44
+
45
+ action :all do
46
+ path '/droplets'
47
+ handler(200) { |body| DropletMapping.extract_collection(body, :read) }
48
+ end
49
+
50
+ action :create do
51
+ path '/droplets'
52
+ verb :post
53
+ body { |object| DropletMapping.representation_for(:create, object) } # Generate a response body from a passed object
54
+ handler(202) { |response| DropletMapping.extract_single(response.body, :read) }
55
+ end
56
+ end
57
+ end
58
+ ```
59
+
60
+ You also have the option to use a shorter version to describe actions like this:
61
+
62
+ ```ruby
63
+ class DropletResource < ResourceSet::Resource
64
+ resources do
65
+ action :all, 'GET /v2/droplets' do
66
+ handler(:ok) { |response| DropletMapping.extract_collection(response.body, :read) }
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ Instead of using `#action`, you can use any of the supported HTTP verb methods including `#get`, `#post`, `#put`, `#delete`, `#head`, `#patch`, and `#options`. Thus, the above example can be also written as:
73
+
74
+ ```ruby
75
+ class DropletResource < ResourceSet::Resource
76
+ resources do
77
+ get :all, '/v2/droplets' do
78
+ handler(:ok) { |response| DropletMapping.extract_collection(response.body, :read) }
79
+ end
80
+ end
81
+ end
82
+ ```
83
+
84
+ Now that we've described our resources. We can instantiate our class with a connection object. ResourceSet relies on the interface that Faraday provides. For example:
85
+
86
+ ```ruby
87
+ conn = Faraday.new(url: 'http://api.digitalocean.com') do |req|
88
+ req.adapter :net_http
89
+ end
90
+
91
+ resource = DropletResource.new(connection: conn)
92
+ ```
93
+
94
+ Now that we've instantiated a resource with our class, we can call the actions we've defined on it.
95
+
96
+ ```
97
+ all_droplets = resource.all
98
+ single_droplet = resource.find(id: 123)
99
+ create = resource.create(Droplet.new)
100
+ ```
101
+
102
+ ## Scope
103
+
104
+ ResourceSet classes give you the option to pass in an optional scope object, so that you may interact with the resource with it that way.
105
+
106
+ For example, you may want to use this for nested resources:
107
+
108
+ ```ruby
109
+ class CommentResource < ResourceSet::Resource
110
+ resources do
111
+ action :all do
112
+ path { "/users/#{user_id}/comments" }
113
+ handler(200) { |resp| CommentMapping.extract_collection(resp.body, :read) }
114
+ end
115
+ end
116
+
117
+ def user_id
118
+ scope.user_id
119
+ end
120
+ end
121
+
122
+ user = User.find(123)
123
+ resource = CommentResource.new(connection: conn, scope: user)
124
+ comments = resource.all #=> Will fetch from /users/123/comments
125
+ ```
126
+
127
+ ## Test Helpers
128
+
129
+ ResourceSet supplys test helpers that assist in certain things you'd want your resource classes to do.
130
+
131
+ Make sure you:
132
+
133
+ require 'resource_set/testing'
134
+
135
+ Testing a certain action:
136
+
137
+ ```ruby
138
+ # Tag the spec with resource_set to bring in the helpers
139
+ RSpec.describe MyResourceClass, resource_set: true do
140
+ it 'has an all action' do
141
+ expect(MyResourceClass).to have_action(:all).that_handles(:ok, :no_content).at_path('/users')
142
+ end
143
+
144
+ it 'handles a 201 with response body' do
145
+ expect(MyResourceClass).to handle_response(:create).with(status: 201, body: '{"users":[]}') do |handled|
146
+ expect(handled).to all(be_kind_of(User))
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Nice to have's
153
+
154
+ Things we've thought about but just haven't implemented are:
155
+
156
+ * Pagination capabilities
157
+
158
+
159
+ ## Contributing
160
+
161
+ 1. Fork it ( https://github.com/kyrylo/resource_set/fork )
162
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
163
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
164
+ 4. Push to the branch (`git push origin my-new-feature`)
165
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new(:spec)
4
+
5
+ task default: :spec
@@ -0,0 +1,60 @@
1
+ require 'kartograph'
2
+
3
+ class Droplet
4
+ attr_accessor :id, :name, :region, :size, :image
5
+ end
6
+
7
+ class DropletMapping
8
+ include Kartograph::DSL
9
+
10
+ kartograph do
11
+ mapping Droplet
12
+ root_key plural: 'droplets', singular: 'droplet', scopes: [:read]
13
+
14
+ property :id, scopes: [:read]
15
+ property :name, scopes: [:read, :create]
16
+ property :size, scopes: [:read, :create]
17
+ property :image, scopes: [:read, :create]
18
+ property :region, scopes: [:read, :create]
19
+ end
20
+ end
21
+
22
+ class DropletResource < ResourceSet::Resource
23
+ resources do
24
+ action :all do
25
+ verb :get
26
+ path '/v2/droplets'
27
+ handler(200) { |response| DropletMapping.extract_collection(response.body, :read) }
28
+ end
29
+
30
+ action :find do
31
+ verb :get
32
+ path '/v2/droplets/:id'
33
+ handler(200) { |response| DropletMapping.extract_single(response.body, :read) }
34
+ end
35
+
36
+ action :create do
37
+ verb :post
38
+ path '/v2/droplets'
39
+ body { |object| DropletMapping.representation_for(:create, object) }
40
+ handler(202) { |response| DropletMapping.extract_single(response.body, :read) }
41
+ end
42
+
43
+ action :update do
44
+ verb :put
45
+ path '/v2/droplets/123'
46
+ body { |object| DropletMapping.representation_for(:create, object) }
47
+ handler(200) { |response, object| DropletMapping.extract_into_object(object, response.body, :read) }
48
+ end
49
+ end
50
+ end
51
+
52
+ token = 'YOUR_ACCESS_TOKEN'
53
+ conn = Faraday.new(url: 'https://api.digitalocean.com', headers: { content_type: 'application/json', authorization: "Bearer #{token}" }) do |req|
54
+ req.adapter :net_http
55
+ end
56
+
57
+ resource = DropletResource.new(connection: conn)
58
+
59
+ # Retrieve all droplets
60
+ puts resource.all
@@ -0,0 +1,15 @@
1
+ require 'resource_set'
2
+
3
+ class HTTPBinResource < ResourceSet::Resource
4
+ resources do
5
+ get '/ip' => :ip
6
+ get '/status/:code' => :status
7
+ end
8
+ end
9
+
10
+ conn = Faraday.new(url: 'http://httpbin.org')
11
+ resource = HTTPBinResource.new(connection: conn)
12
+
13
+ puts resource.ip
14
+ puts
15
+ puts resource.status(code: 418)
@@ -0,0 +1,66 @@
1
+ module ResourceSet
2
+ class Action
3
+ attr_reader :name
4
+
5
+ def initialize(name, verb = nil, path = nil)
6
+ @name = name
7
+ @verb = (verb && verb.downcase.to_sym) || :get
8
+ @path = path
9
+ @query_keys = []
10
+ end
11
+
12
+ def verb(v = nil)
13
+ @verb = v if v
14
+ @verb
15
+ end
16
+
17
+ def path(path = nil, &block)
18
+ raise "You must pass either a block or a string for paths" if path and block_given?
19
+ @path = path if path
20
+ @path = block if block_given?
21
+ @path
22
+ end
23
+
24
+ def query_keys(*keys)
25
+ return @query_keys if keys.empty?
26
+ @query_keys += keys
27
+ end
28
+
29
+ def handlers
30
+ @handlers ||= {}
31
+ end
32
+
33
+ def handler(*response_codes, &block)
34
+ if response_codes.empty?
35
+ handlers[:any] = block
36
+ else
37
+ response_codes.each do |code|
38
+ code = StatusCodeMapper.code_for(code) unless code.is_a?(Integer)
39
+ handlers[code] = block
40
+ end
41
+ end
42
+ end
43
+
44
+ def body(&block)
45
+ @body_handler = block if block_given?
46
+ @body_handler
47
+ end
48
+
49
+ def hooks
50
+ @hooks ||= {}
51
+ end
52
+
53
+ def before_request(method_name = nil, &block)
54
+ hooks[:before] ||= []
55
+
56
+ if block_given?
57
+ hooks[:before] << block
58
+ else
59
+ raise "Must include a method name" unless method_name
60
+ hooks[:before] << method_name
61
+ end
62
+
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,58 @@
1
+ module ResourceSet
2
+ class ActionInvoker
3
+ attr_reader :action, :connection, :args, :options, :resource
4
+
5
+ def initialize(action, resource, *args)
6
+ @action = action
7
+ @resource = resource
8
+ @connection = resource.connection
9
+ @args = args
10
+ @options = args.last.kind_of?(Hash) ? args.last : {}
11
+ end
12
+
13
+ def self.call(action, resource, *args)
14
+ new(action, resource, *args).handle_response
15
+ end
16
+
17
+ def handle_response
18
+ if handler = action.handlers[response.status] || action.handlers[:any]
19
+ resource.instance_exec(response, *args, &handler) # Since the handler is a block, it does not enforce parameter length checking
20
+ else
21
+ response.body
22
+ end
23
+ end
24
+
25
+ def construct_body
26
+ action.body.call(*args[0..(action.body.arity - 1)])
27
+ end
28
+
29
+ def response
30
+ return @response if @response
31
+
32
+ raise ArgumentError, "Verb '#{action.verb}' is not allowed" unless ALLOWED_VERBS.include?(action.verb)
33
+
34
+ @response = connection.send(action.verb, resolver.resolve(options)) do |request|
35
+ request.body = construct_body if action.body and [:post, :put, :patch, :delete].include?(action.verb)
36
+ append_hooks(:before, request)
37
+ end
38
+ end
39
+
40
+ def resolver
41
+ path = action.path.kind_of?(Proc) ? resource.instance_eval(&action.path) : action.path
42
+ EndpointResolver.new(path: path, query_param_keys: action.query_keys)
43
+ end
44
+
45
+ private
46
+
47
+ def append_hooks(hook_type, request)
48
+ (action.hooks[hook_type] || []).each do |hook|
49
+ case hook
50
+ when Proc
51
+ resource.instance_exec(*args, request, &hook)
52
+ when Symbol
53
+ resource.send(hook, *args, request)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end