protip 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9f3d97797f1ded3cce21e24d653a5960c292f3af
4
+ data.tar.gz: d1fa2cea1e89f3fdb5ebf9be570e5dde1144397a
5
+ SHA512:
6
+ metadata.gz: 5775bd4b337ab6d1f31ea6de1c0fc3d2b2f07a8075dda4bdcdb0581b69dd61b5154d1dd5bffd7dbfc069e34d8bd533740411d3cb97e107657ee433d8729c35c4
7
+ data.tar.gz: 496f91676d206299a1ebbc4328722be7976b685d1d02988ac44a76472519e19e740896a6abb9f93d8d46a600a46aee2d9cae4ecf4dc34fb7ae45b8d8d945f688
@@ -0,0 +1,7 @@
1
+ require 'protip/client'
2
+ require 'protip/resource'
3
+
4
+ # Register the mime type with Rails, if Rails exists.
5
+ if defined?(Mime::Type)
6
+ Mime::Type.register 'application/x-protobuf', :protobuf
7
+ end
@@ -0,0 +1,68 @@
1
+ require 'protip/error'
2
+ require 'net/http'
3
+
4
+ module Protip
5
+ module Client
6
+ extend ActiveSupport::Concern
7
+
8
+ attr_accessor :base_uri
9
+
10
+ # Makes a request and parses the response as a message of the given type.
11
+ # For internal use only; use the appropriate resource to make your requests.
12
+ #
13
+ # @param path [String] the URI path (exluding the base URI)
14
+ # @param method [Class] the HTTP method (e.g. `::Net::HTTP::Get`, `::Net::HTTP::Post`)
15
+ # @param message [Protobuf::Message|nil] the message to send as the request body
16
+ # @param response_type [Class] the `::Protobuf::Message` subclass that should be
17
+ # expected as a response
18
+ # @return [::Protobuf::Message] the decoded response from the server
19
+ def request(path:, method:, message:, response_type:)
20
+
21
+ raise RuntimeError.new('base_uri is not set') unless base_uri
22
+
23
+ uri = URI.join base_uri, path
24
+
25
+ request = method.new uri
26
+ request.body = (message == nil ? nil : message.encode)
27
+ request['Accept'] = 'application/x-protobuf'
28
+ request.content_type = 'application/x-protobuf'
29
+
30
+ prepare_request(request)
31
+
32
+ # TODO: Shared connection object for persisent connections.
33
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
34
+ http.request request
35
+ end
36
+
37
+ if response.is_a?(Net::HTTPUnprocessableEntity)
38
+ raise ::Protip::UnprocessableEntityError.new(request, response)
39
+ elsif response.is_a?(Net::HTTPNotFound)
40
+ raise ::Protip::NotFoundError.new(request, response)
41
+ elsif !response.is_a?(Net::HTTPSuccess)
42
+ raise ::Protip::Error.new(request, response)
43
+ end
44
+
45
+ if response_type
46
+ begin
47
+ response_type.decode response.body
48
+ # NotImplementedError catches the "Group is deprecated" exception raised by protobuf on some bad inputs.
49
+ # We may be able to remove it if we switch to a different protobuf gem.
50
+ rescue StandardError, NotImplementedError => error
51
+ raise ::Protip::ParseError.new error, request, response
52
+ end
53
+ else
54
+ nil
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Invoked just before a request is sent to the API server. No-op by default, but
61
+ # implementations can override to add e.g. secret keys and user agent headers.
62
+ #
63
+ # @param request [Net::HTTPGenericRequest] the raw request object which is about to be sent
64
+ def prepare_request(request)
65
+ # No-op by default.
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,37 @@
1
+ # Missing dependency from the protobuf require
2
+ require 'protobuf'
3
+
4
+ require 'protip/messages/errors.pb'
5
+
6
+ module Protip
7
+ class Error < RuntimeError
8
+ attr_reader :request, :response
9
+ def initialize(request, response)
10
+ @request = request
11
+ @response = response
12
+ end
13
+
14
+ def inspect
15
+ "[#{self.class}] #{request.uri} -> code #{response.code}"
16
+ end
17
+ end
18
+
19
+ class ParseError < Error
20
+ attr_reader :original_error
21
+ def initialize(original_error, *args)
22
+ super(*args)
23
+ @original_error = original_error
24
+ end
25
+ end
26
+
27
+ class UnprocessableEntityError < Error
28
+ # Get the parsed errors object from a 422 response.
29
+ #
30
+ # @return ::Protip::Messages::Errors
31
+ def errors
32
+ ::Protip::Messages::Errors.decode response.body
33
+ end
34
+ end
35
+
36
+ class NotFoundError < Error ; end
37
+ end
@@ -0,0 +1,27 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # This file is auto-generated. DO NOT EDIT!
5
+ #
6
+ require 'protobuf/message'
7
+
8
+ module Protip
9
+ module Messages
10
+
11
+ ##
12
+ # Message Classes
13
+ #
14
+ class Array < ::Protobuf::Message; end
15
+
16
+
17
+ ##
18
+ # Message Fields
19
+ #
20
+ class Array
21
+ repeated :bytes, :messages, 1
22
+ end
23
+
24
+ end
25
+
26
+ end
27
+
@@ -0,0 +1,34 @@
1
+ # encoding: utf-8
2
+
3
+ ##
4
+ # This file is auto-generated. DO NOT EDIT!
5
+ #
6
+ require 'protobuf/message'
7
+
8
+ module Protip
9
+ module Messages
10
+
11
+ ##
12
+ # Message Classes
13
+ #
14
+ class Errors < ::Protobuf::Message; end
15
+ class FieldError < ::Protobuf::Message; end
16
+
17
+
18
+ ##
19
+ # Message Fields
20
+ #
21
+ class Errors
22
+ repeated :string, :messages, 1
23
+ repeated ::Protip::Messages::FieldError, :field_errors, 2
24
+ end
25
+
26
+ class FieldError
27
+ optional :string, :field, 1
28
+ optional :string, :message, 2
29
+ end
30
+
31
+ end
32
+
33
+ end
34
+
@@ -0,0 +1,273 @@
1
+ # Missing dependencies from the other requires
2
+ require 'active_model/callbacks'
3
+ require 'active_model/validator'
4
+ require 'active_support/callbacks'
5
+ require 'active_support/core_ext/module/delegation'
6
+
7
+ require 'active_support/concern'
8
+ require 'active_support/core_ext/object/blank'
9
+
10
+ require 'active_model/validations'
11
+ require 'active_model/conversion'
12
+ require 'active_model/naming'
13
+ require 'active_model/translation'
14
+ require 'active_model/errors'
15
+
16
+ require 'protip/error'
17
+
18
+ require 'protip/messages/array.pb'
19
+
20
+ module Protip
21
+ module Resource
22
+
23
+ # Internal handlers for index/show actions. Never use these directly; instead, use `.all` and
24
+ # `.find` on the resource you're working with, since those methods will adjust their
25
+ # signatures to correctly parse a set of query parameters if supported.
26
+ module SearchMethods
27
+ # Fetch a list from the server at the collection's base endpoint. Expects the server response
28
+ # to be an array containing encoded messages that can be used to instantiate our resource.
29
+ #
30
+ # @param resource_class [Class] The resource type that we're fetching.
31
+ # @param query [::Protobuf::Message|NilClass] An optional query to send along with the request.
32
+ # @return [Array] The array of resources (each is an instance of the resource class we were
33
+ # initialized with).
34
+ def self.index(resource_class, query)
35
+ response = resource_class.client.request path: resource_class.base_path,
36
+ method: Net::HTTP::Get,
37
+ message: query,
38
+ response_type: Protip::Messages::Array
39
+ response.messages.map do |message|
40
+ resource_class.new resource_class.message.decode(message)
41
+ end
42
+ end
43
+
44
+ # Fetch a single resource from the server.
45
+ #
46
+ # @param resource_class [Class] The resource type that we're fetching.
47
+ # @param id [String] The ID to be used in the URL to fetch the resource.
48
+ # @param query [::Protobuf::Message|NilClass] An optional query to send along with the request.
49
+ # @return [Protip::Resource] An instance of our resource class, created from the server
50
+ # response.
51
+ def self.show(resource_class, id, query)
52
+ response = resource_class.client.request path: "#{resource_class.base_path}/#{id}",
53
+ method: Net::HTTP::Get,
54
+ message: query,
55
+ response_type: resource_class.message
56
+ resource_class.new response
57
+ end
58
+ end
59
+
60
+ # Mixin for a resource that has an active `:create` action. Should be treated as private,
61
+ # and will be included automatically when appropriate.
62
+ module Creatable
63
+ private
64
+ # POST the resource to the server and update our internal message. Private, since
65
+ # we should generally do this through the `save` method.
66
+ def create!
67
+ raise RuntimeError.new("Can't re-create a persisted object") if persisted?
68
+ @message = self.class.client.request path: self.class.base_path,
69
+ method: Net::HTTP::Post,
70
+ message: message,
71
+ response_type: self.class.message
72
+ end
73
+ end
74
+
75
+ # Mixin for a resource that has an active `:update` action. Should be treated as private,
76
+ # and will be included automatically when appropriate.
77
+ module Updatable
78
+ private
79
+ # PUT the resource on the server and update our internal message. Private, since
80
+ # we should generally do this through the `save` method.
81
+ def update!
82
+ raise RuntimeError.new("Can't update a non-persisted object") if !persisted?
83
+ @message = self.class.client.request path: "#{self.class.base_path}/#{id}",
84
+ method: Net::HTTP::Put,
85
+ message: message,
86
+ response_type: self.class.message
87
+ end
88
+ end
89
+
90
+ # Mixin for a resource that has an active `:destroy` action. Should be treated as private,
91
+ # and will be included automatically when appropriate.
92
+ module Destroyable
93
+ def destroy
94
+ raise RuntimeError.new("Can't destroy a non-persisted object") if !persisted?
95
+ @message = self.class.client.request path: "#{self.class.base_path}/#{id}",
96
+ method: Net::HTTP::Delete,
97
+ message: nil,
98
+ response_type: self.class.message
99
+ end
100
+ end
101
+
102
+ # Internal helpers for non-resourceful member/collection methods. Never use these directly;
103
+ # instead, use the instance/class methods which have been dynamically defined on the resource
104
+ # you're working with.
105
+ module ExtraMethods
106
+ def self.member(resource, action, method, message, response_type)
107
+ resource.class.client.request path: "#{resource.class.base_path}/#{resource.id}/#{action}",
108
+ method: method,
109
+ message: message,
110
+ response_type: response_type
111
+ end
112
+ def self.collection(resource_class, action, method, message, response_type)
113
+ resource_class.client.request path: "#{resource_class.base_path}/#{action}",
114
+ method: method,
115
+ message: message,
116
+ response_type: response_type
117
+ end
118
+ end
119
+
120
+ extend ActiveSupport::Concern
121
+
122
+ # Backport the ActiveModel::Model functionality - https://github.com/rails/rails/blob/097ca3f1f84bb9a2d3cda3f2cce7974a874efdf4/activemodel/lib/active_model/model.rb#L95
123
+ include ActiveModel::Validations
124
+ include ActiveModel::Conversion
125
+
126
+ included do
127
+ extend ActiveModel::Naming
128
+ extend ActiveModel::Translation
129
+ end
130
+ module ClassMethods
131
+
132
+ attr_accessor :client
133
+ attr_reader :message
134
+
135
+ attr_writer :base_path
136
+ def base_path
137
+ @base_path == nil ? raise(RuntimeError.new 'Base path not yet set') : @base_path.gsub(/\/$/, '')
138
+ end
139
+
140
+ private
141
+
142
+ # Primary entry point for defining resourceful behavior.
143
+ def resource(actions:, message:, query: nil)
144
+ if @message
145
+ raise RuntimeError.new('Only one call to `resource` is allowed')
146
+ end
147
+
148
+ # Define attribute readers/writers
149
+ @message = message
150
+ @message.all_fields.each do |field|
151
+ define_method :"#{field.name}" do
152
+ @message.public_send field.name
153
+ end
154
+ define_method :"#{field.name}=" do |value|
155
+ @message.public_send :"#{field.name}=", value
156
+ end
157
+ end
158
+
159
+ # Validate arguments
160
+ actions.map!{|action| action.to_sym}
161
+ (actions - %i(show index create update destroy)).each do |action|
162
+ raise ArgumentError.new("Unrecognized action: #{action}")
163
+ end
164
+
165
+ # For index/show, we want a different number of method arguments
166
+ # depending on whehter a query message was provided.
167
+ if query
168
+ if actions.include?(:show)
169
+ define_singleton_method :find do |id, query_params = {}|
170
+ SearchMethods.show(self, id, query.new(query_params))
171
+ end
172
+ end
173
+
174
+ if actions.include?(:index)
175
+ define_singleton_method :all do |query_params = {}|
176
+ SearchMethods.index(self, query.new(query_params))
177
+ end
178
+ end
179
+ else
180
+ if actions.include?(:show)
181
+ define_singleton_method :find do |id|
182
+ SearchMethods.show(self, id, nil)
183
+ end
184
+ end
185
+
186
+ if actions.include?(:index)
187
+ define_singleton_method :all do
188
+ SearchMethods.index(self, nil)
189
+ end
190
+ end
191
+ end
192
+
193
+ include(Creatable) if actions.include?(:create)
194
+ include(Updatable) if actions.include?(:update)
195
+ include(Destroyable) if actions.include?(:destroy)
196
+ end
197
+
198
+ def member(action:, method:, request: nil, response: nil)
199
+ if request
200
+ define_method action do |request_params = {}|
201
+ ExtraMethods.member self, action, method, request.new(request_params), response
202
+ end
203
+ else
204
+ define_method action do
205
+ ExtraMethods.member self, action, method, nil, response
206
+ end
207
+ end
208
+ end
209
+
210
+ def collection(action:, method:, request: nil, response: nil)
211
+ if request
212
+ define_singleton_method action do |request_params = {}|
213
+ ExtraMethods.collection self, action, method, request.new(request_params), response
214
+ end
215
+ else
216
+ define_singleton_method action do
217
+ ExtraMethods.collection self, action, method, nil, response
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ attr_reader :message
224
+ def initialize(message_or_params = {})
225
+ if self.class.message == nil
226
+ raise RuntimeError.new('Must define a message class using `resource`')
227
+ end
228
+ if message_or_params.is_a?(self.class.message)
229
+ @message = message_or_params
230
+ else
231
+ @message = self.class.message.new(message_or_params)
232
+ end
233
+
234
+ super()
235
+ end
236
+
237
+ def save
238
+ success = true
239
+ begin
240
+ if persisted?
241
+ # TODO: use `ActiveModel::Dirty` to only send changed attributes?
242
+ update!
243
+ else
244
+ create!
245
+ end
246
+ rescue Protip::UnprocessableEntityError => error
247
+ success = false
248
+ error.errors.messages.each do |message|
249
+ errors.add :base, message
250
+ end
251
+ error.errors.field_errors.each do |field_error|
252
+ errors.add field_error.field, field_error.message
253
+ end
254
+ end
255
+ success
256
+ end
257
+
258
+ def persisted?
259
+ message.field?(:id)
260
+ end
261
+
262
+ def attributes
263
+ self.class.message.all_fields.map{|field| field.name}.inject({}) do |hash, attribute_name|
264
+ hash[attribute_name] = message.field?(attribute_name) ? public_send(attribute_name) : nil
265
+ hash
266
+ end
267
+ end
268
+
269
+ def errors
270
+ @errors ||= ActiveModel::Errors.new(self)
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,191 @@
1
+ require 'test_helper'
2
+
3
+ require 'protip'
4
+
5
+ module Protip::ResourceTestFunctional # Namespace for internal constants
6
+ describe 'Protip::Resource (functional)' do
7
+
8
+ before do
9
+ WebMock.disable_net_connect!
10
+ end
11
+
12
+ # Make sure none of these are structurally identical (e.g. give fields
13
+ # different positions), to avoid potential errors where a message is
14
+ # incorrectly encode. Also define them as local constants so they don't
15
+ # interfere with any other tests.
16
+ class ResourceMessage < ::Protobuf::Message
17
+ optional :int64, :id, 1
18
+ optional :string, :ordered_tests, 2
19
+ end
20
+
21
+ class ResourceQuery < ::Protobuf::Message
22
+ optional :string, :param, 3
23
+ end
24
+
25
+ class NameResponse < ::Protobuf::Message
26
+ optional :string, :name, 4
27
+ end
28
+
29
+ class SearchRequest < ::Protobuf::Message
30
+ optional :string, :term, 5
31
+ end
32
+
33
+ class SearchResponse < ::Protobuf::Message
34
+ repeated :string, :results, 6
35
+ end
36
+
37
+ class FetchRequest < ::Protobuf::Message
38
+ repeated :string, :names, 7
39
+ end
40
+
41
+ class Client
42
+ include Protip::Client
43
+ def base_uri
44
+ 'https://external.service'
45
+ end
46
+ end
47
+
48
+ class Resource
49
+ include Protip::Resource
50
+ resource actions: [:index, :show, :create, :update, :destroy],
51
+ query: ResourceQuery, message: ResourceMessage
52
+
53
+ member action: :archive, method: Net::HTTP::Put
54
+ member action: :name, method: Net::HTTP::Get, response: NameResponse
55
+
56
+ collection action: :search, method: Net::HTTP::Get, request: SearchRequest, response: SearchResponse
57
+ collection action: :fetch, method: Net::HTTP::Post, request: FetchRequest
58
+
59
+ self.base_path = 'resources'
60
+ self.client = Client.new
61
+ end
62
+
63
+ describe '.all' do
64
+ describe 'with a successful server response' do
65
+ before do
66
+ response = Protip::Messages::Array.new(messages: ['bilbo', 'baggins'].each_with_index.map do |name, index|
67
+ ResourceMessage.new(id: index, ordered_tests: name).encode
68
+ end)
69
+ stub_request(:get, 'https://external.service/resources')
70
+ .to_return body: response.encode
71
+ end
72
+
73
+ it 'requests resources from the index endpoint' do
74
+ results = Resource.all param: 'val'
75
+
76
+ assert_requested :get, 'https://external.service/resources',
77
+ times: 1, body: ResourceQuery.new(param: 'val').encode
78
+
79
+ assert_equal 2, results.length, 'incorrect number of resources were returned'
80
+ results.each { |result| assert_instance_of Resource, result, 'incorrect type was parsed'}
81
+
82
+ assert_equal({ordered_tests: 'bilbo', id: 0}, results[0].attributes)
83
+ assert_equal({ordered_tests: 'baggins', id: 1}, results[1].attributes)
84
+ end
85
+
86
+ it 'allows requests without parameters' do
87
+ results = Resource.all
88
+ assert_requested :get, 'https://external.service/resources',
89
+ times: 1, body: ResourceQuery.new.encode
90
+ assert_equal 2, results.length, 'incorrect number of resources were returned'
91
+ end
92
+ end
93
+ end
94
+
95
+ describe '.find' do
96
+ describe 'with a successful server response' do
97
+ before do
98
+ response = ResourceMessage.new(id: 311, ordered_tests: 'i_suck_and_my_tests_are_order_dependent!').encode
99
+ stub_request(:get, 'https://external.service/resources/311').to_return body: response.encode
100
+ end
101
+
102
+ it 'requests the resource from the show endpoint' do
103
+ resource = Resource.find 311, param: 'val'
104
+ assert_requested :get, 'https://external.service/resources/311', times: 1,
105
+ body: ResourceQuery.new(param: 'val').encode
106
+ assert_instance_of Resource, resource
107
+ assert_equal 311, resource.id
108
+ assert_equal 'i_suck_and_my_tests_are_order_dependent!', resource.ordered_tests
109
+ end
110
+
111
+ it 'allows requests without parameters' do
112
+ resource = Resource.find 311
113
+ assert_requested :get, 'https://external.service/resources/311', times: 1,
114
+ body: ResourceQuery.new.encode
115
+ assert_equal 'i_suck_and_my_tests_are_order_dependent!', resource.ordered_tests
116
+ end
117
+ end
118
+ end
119
+
120
+ describe '#save' do
121
+ let :resource_message do
122
+ ResourceMessage.new(id: 666, ordered_tests: 'yes')
123
+ end
124
+ let :errors_message do
125
+ Protip::Messages::Errors.new({
126
+ messages: ['base1', 'base2'],
127
+ field_errors: [
128
+ {field: 'ordered_tests', message: 'are not OK'}
129
+ ]
130
+ })
131
+ end
132
+
133
+ # Create and update cases are similar - we just modify the ID attribute on
134
+ # the initial resource, the HTTP method, and the expected URL.
135
+ [
136
+ [nil, :post, 'https://external.service/resources'],
137
+ [666, :put, 'https://external.service/resources/666']
138
+ ].each do |id, method, uri|
139
+ describe "with a #{id ? 'persisted' : 'non-persisted'} resource" do
140
+ before do
141
+ @resource = Resource.new id: id
142
+ end
143
+
144
+ describe 'with a successful server response' do
145
+ before do
146
+ stub_request(method, uri).to_return body: resource_message.encode
147
+ end
148
+
149
+ it 'returns true' do
150
+ assert @resource.save, 'save was not successful'
151
+ end
152
+
153
+ it 'saves the resource and parses the server response' do
154
+ @resource.ordered_tests = 'no'
155
+ @resource.save
156
+
157
+ assert_requested method, uri,
158
+ times: 1, body: ResourceMessage.new(id: id, ordered_tests: 'no').encode
159
+ assert_equal 'yes', @resource.ordered_tests
160
+ end
161
+ end
162
+
163
+ describe 'with a 422 server response' do
164
+ before do
165
+ stub_request(method, uri)
166
+ .to_return body: errors_message.encode, status: 422
167
+ end
168
+
169
+ it 'returns false' do
170
+ refute @resource.save, 'save appeared successful'
171
+ end
172
+
173
+ it 'adds errors based on the server response' do
174
+ @resource.save
175
+ assert_equal ['base1', 'base2'], @resource.errors['base']
176
+ assert_equal ['are not OK'], @resource.errors['ordered_tests']
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ describe '.member' do
184
+ # TODO
185
+ end
186
+
187
+ describe '.collection' do
188
+ # TODO
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,3 @@
1
+ require 'minitest/autorun'
2
+ require 'mocha/mini_test'
3
+ require 'webmock/minitest'
@@ -0,0 +1,494 @@
1
+ require 'test_helper'
2
+
3
+ require 'protip/client'
4
+ require 'protip/resource'
5
+
6
+ module Protip::ResourceTest # Namespace for internal constants
7
+ describe Protip::Resource do
8
+ class ResourceMessage < ::Protobuf::Message
9
+ optional :int64, :id, 1
10
+ optional :string, :string, 2
11
+ optional :string, :string2, 3
12
+ end
13
+ class ResourceQuery < ::Protobuf::Message
14
+ optional :string, :param, 1
15
+ end
16
+
17
+ # Give these things a different structure than ResourceQuery,
18
+ # just to avoid any possibility of decoding as the incorrect
19
+ # type but still yielding correct results.
20
+ class ActionQuery < ::Protobuf::Message
21
+ optional :string, :param, 4
22
+ end
23
+ class ActionResponse < ::Protobuf::Message
24
+ optional :string, :response, 3
25
+ end
26
+
27
+ # Stubbed API client
28
+ let :client do
29
+ mock.responds_like_instance_of(Class.new { include Protip::Client })
30
+ end
31
+
32
+ # Call `resource_class` to get an empty resource type.
33
+ let :resource_class do
34
+ resource_class = Class.new do
35
+ include Protip::Resource
36
+ self.base_path = 'base_path'
37
+ class << self
38
+ attr_accessor :client
39
+ end
40
+ end
41
+ resource_class.client = client
42
+ resource_class
43
+ end
44
+
45
+ describe '.resource' do
46
+ before do
47
+ resource_class.class_eval do
48
+ resource actions: [], message: ResourceMessage
49
+ end
50
+ end
51
+ it 'can only be invoked once' do
52
+ assert_raises RuntimeError do
53
+ resource_class.class_eval do
54
+ resource actions: [], message: ResourceMessage
55
+ end
56
+ end
57
+ end
58
+
59
+ it 'defines accessors for the fields on its message' do
60
+ resource = resource_class.new
61
+ [:id, :id=, :string, :string=].each do |method|
62
+ assert_respond_to resource, method
63
+ end
64
+ refute_respond_to resource, :foo
65
+ end
66
+
67
+ it 'sets fields on the underlying message when setters are called' do
68
+ resource = resource_class.new
69
+ resource.string = 'intern'
70
+ assert_equal 'intern', resource.message.string
71
+ assert_equal 'intern', resource.string
72
+ end
73
+ end
74
+
75
+ describe '.all' do
76
+ let :response do
77
+ Protip::Messages::Array.new({
78
+ messages: [
79
+ ResourceMessage.new(string: 'banjo', id: 1),
80
+ ResourceMessage.new(string: 'kazooie', id: 2),
81
+ ].map(&:encode)
82
+ })
83
+ end
84
+
85
+ it 'does not exist if the resource has not been defined' do
86
+ refute_respond_to resource_class, :all
87
+ end
88
+
89
+ it 'does not exist if the resource is defined without the index action' do
90
+ resource_class.class_eval do
91
+ resource actions: [:show], message: ResourceMessage
92
+ end
93
+ refute_respond_to resource_class, :all
94
+ end
95
+
96
+ describe 'without a query' do
97
+ before do
98
+ resource_class.class_eval do
99
+ resource actions: [:index], message: ResourceMessage
100
+ end
101
+ end
102
+
103
+ it 'requests an array from the index URL' do
104
+ client.expects(:request)
105
+ .once
106
+ .with(method: Net::HTTP::Get, path: 'base_path', message: nil, response_type: Protip::Messages::Array)
107
+ .returns(response)
108
+ resource_class.all
109
+ end
110
+
111
+ it 'fails if we try to pass in a query' do
112
+ assert_raises ArgumentError do
113
+ resource_class.all(query: 'param')
114
+ end
115
+ end
116
+
117
+ # Doesn't matter whether we have a query or not
118
+ it 'parses the response into an array of resources' do
119
+ client.stubs(:request).returns(response)
120
+ results = resource_class.all
121
+
122
+ assert_equal 2, results.length
123
+ results.each { |result| assert_instance_of resource_class, result }
124
+
125
+ assert_equal 'banjo', results[0].string
126
+ assert_equal 1, results[0].id
127
+
128
+ assert_equal 'kazooie', results[1].string
129
+ assert_equal 2, results[1].id
130
+ end
131
+
132
+ # Doesn't matter whether we have a query or not
133
+ it 'allows an empty array' do
134
+ client.stubs(:request).returns(Protip::Messages::Array.new)
135
+ results = resource_class.all
136
+
137
+ assert_equal [], results
138
+ end
139
+ end
140
+
141
+ describe 'with a query' do
142
+ before do
143
+ resource_class.class_eval do
144
+ resource actions: [:index], message: ResourceMessage, query: ResourceQuery
145
+ end
146
+ end
147
+
148
+ it 'requests an array from the index URL with the query' do
149
+ client.expects(:request)
150
+ .once
151
+ .with(method: Net::HTTP::Get, path: 'base_path',
152
+ message: ResourceQuery.new(param: 'val'), response_type: Protip::Messages::Array
153
+ ).returns(response)
154
+ resource_class.all(param: 'val')
155
+ end
156
+
157
+ it 'allows a request with an empty query' do
158
+ client.expects(:request)
159
+ .with(method: Net::HTTP::Get, path: 'base_path',
160
+ message: ResourceQuery.new, response_type: Protip::Messages::Array)
161
+ .returns(response)
162
+ resource_class.all
163
+ end
164
+ end
165
+ end
166
+
167
+ describe '.find' do
168
+ let :response do
169
+ ResourceMessage.new(string: 'pitbull', id: 100)
170
+ end
171
+
172
+ it 'does not exist if the resource has not been defined' do
173
+ refute_respond_to resource_class, :find
174
+ end
175
+
176
+ it 'does not exist if the resource is defined without the show action' do
177
+ resource_class.class_eval do
178
+ resource actions: [:index], message: ResourceMessage
179
+ end
180
+ refute_respond_to resource_class, :find
181
+ end
182
+
183
+ describe 'without a query' do
184
+ before do
185
+ resource_class.class_eval do
186
+ resource actions: [:show], message: ResourceMessage
187
+ end
188
+ end
189
+
190
+ it 'requests its message type from the show URL' do
191
+ client.expects(:request)
192
+ .once
193
+ .with(method: Net::HTTP::Get, path: 'base_path/3', message: nil, response_type: ResourceMessage)
194
+ .returns(response)
195
+ resource_class.find 3
196
+ end
197
+
198
+ it 'fails if we try to pass in a query' do
199
+ assert_raises ArgumentError do
200
+ resource_class.find 2, param: 'val'
201
+ end
202
+ end
203
+
204
+ # Doesn't matter whether we have a query or not
205
+ it 'parses the response message into a resource' do
206
+ client.stubs(:request).returns(response)
207
+ resource = resource_class.find 100
208
+ assert_instance_of resource_class, resource
209
+
210
+ assert_equal 100, resource.id
211
+ assert_equal 'pitbull', resource.string
212
+ end
213
+ end
214
+
215
+ describe 'with a query' do
216
+ before do
217
+ resource_class.class_eval do
218
+ resource actions: [:show], message: ResourceMessage, query: ResourceQuery
219
+ end
220
+ end
221
+
222
+ it 'requests its message type from the show URL with the query' do
223
+ client.expects(:request)
224
+ .once
225
+ .with(method: Net::HTTP::Get, path: 'base_path/5',
226
+ message: ResourceQuery.new(param: 'val'), response_type: ResourceMessage)
227
+ .returns(response)
228
+ resource_class.find 5, param: 'val'
229
+ end
230
+
231
+ it 'allows a request with an empty query' do
232
+ client.expects(:request)
233
+ .once
234
+ .with(method: Net::HTTP::Get, path: 'base_path/6',
235
+ message: ResourceQuery.new, response_type: ResourceMessage)
236
+ .returns(response)
237
+ resource_class.find 6
238
+ end
239
+ end
240
+ end
241
+
242
+ describe '#save' do
243
+ let :response do
244
+ ResourceMessage.new(string: 'pit', string2: 'bull', id: 200)
245
+ end
246
+
247
+ describe 'for a new record' do
248
+ before do
249
+ resource_class.class_eval do
250
+ resource actions: [:create], message: ResourceMessage
251
+ end
252
+ resource_class.any_instance.stubs(:persisted?).returns(false)
253
+ end
254
+
255
+ it 'sends the resource to the server' do
256
+ client.expects(:request)
257
+ .once
258
+ .with(method: Net::HTTP::Post, path: 'base_path',
259
+ message: ResourceMessage.new(string: 'time', string2: 'flees'), response_type: ResourceMessage)
260
+ .returns(response)
261
+
262
+ # Set via initializer and direct setter
263
+ resource = resource_class.new(string: 'time')
264
+ resource.string2 = 'flees'
265
+ resource.save
266
+ end
267
+
268
+ it 'returns true' do
269
+ client.stubs(:request).returns(response)
270
+ resource = resource_class.new string: 'flees'
271
+ assert resource.save, 'save returned false'
272
+ end
273
+
274
+ it 'updates its internal message store with the server response' do
275
+ client.stubs(:request).returns(response)
276
+ resource = resource_class.new
277
+ resource.save
278
+ assert_equal response, resource.message
279
+ end
280
+ end
281
+
282
+ describe 'for an existing record' do
283
+ before do
284
+ resource_class.class_eval do
285
+ resource actions: [:update], message: ResourceMessage
286
+ end
287
+ resource_class.any_instance.stubs(:persisted?).returns(true)
288
+ end
289
+
290
+ it 'sends the resource to the server' do
291
+ client.expects(:request)
292
+ .once
293
+ .with(method: Net::HTTP::Put, path: 'base_path/4',
294
+ message: ResourceMessage.new(id: 4, string: 'pitbull'), response_type: ResourceMessage)
295
+ .returns(response)
296
+
297
+ resource = resource_class.new(id: 4, string: 'pitbull')
298
+ resource.save
299
+ end
300
+
301
+ it 'returns true' do
302
+ client.stubs(:request).returns(response)
303
+ resource = resource_class.new id: 3
304
+ assert resource.save, 'save returned false'
305
+ end
306
+
307
+ it 'updates its internal message store with the server repsonse' do
308
+ client.stubs(:request).returns(response)
309
+ resource = resource_class.new id: 5
310
+ resource.save
311
+ assert_equal response, resource.message
312
+ end
313
+ end
314
+
315
+ describe 'when validation errors are thrown' do
316
+ before do
317
+ # Set up an errors instance variable that we can set actual messages on
318
+ @errors = Protip::Messages::Errors.new
319
+
320
+ exception = Protip::UnprocessableEntityError.new mock, mock
321
+ exception.stubs(:errors).returns @errors
322
+ client.stubs(:request).raises(exception)
323
+
324
+ resource_class.class_eval do
325
+ resource actions: [:update, :create], message: ResourceMessage
326
+ end
327
+ @resource = resource_class.new
328
+ end
329
+
330
+ it 'parses base errors' do
331
+ @errors.messages = ['message1', 'message2']
332
+ @resource.save
333
+
334
+ assert_equal ['message1', 'message2'], @resource.errors['base']
335
+ end
336
+
337
+ it 'parses field errors' do
338
+ @errors.field_errors = [
339
+ Protip::Messages::FieldError.new(field: 'string', message: 'message1'),
340
+ Protip::Messages::FieldError.new(field: 'id', message: 'message2'),
341
+ Protip::Messages::FieldError.new(field: 'string', message: 'message3'),
342
+ ]
343
+ @resource.save
344
+
345
+ assert_equal ['message1', 'message3'], @resource.errors['string']
346
+ assert_equal ['message2'], @resource.errors['id']
347
+ end
348
+
349
+ it 'returns false' do
350
+ refute @resource.save, 'save returned true'
351
+ end
352
+ end
353
+ end
354
+
355
+ describe '#destroy' do
356
+ describe 'for an existing record' do
357
+ let :response do
358
+ ResourceMessage.new(id: 5, string: 'deleted')
359
+ end
360
+ before do
361
+ resource_class.class_eval do
362
+ resource actions: [:destroy], message: ResourceMessage
363
+ end
364
+ resource_class.any_instance.stubs(:persisted?).returns(true)
365
+ end
366
+
367
+ it 'sends a delete request to the server' do
368
+ client.expects(:request)
369
+ .once
370
+ .with(method: Net::HTTP::Delete, path: 'base_path/79', message: nil, response_type: ResourceMessage)
371
+ .returns(response)
372
+ resource_class.new(id: 79).destroy
373
+ end
374
+
375
+ it 'updates its internal message with the server response' do
376
+ client.stubs(:request).returns(response)
377
+ resource = resource_class.new(id: 80)
378
+
379
+ resource.destroy
380
+ assert_equal response, resource.message
381
+ end
382
+ end
383
+ end
384
+
385
+ # member/collection have almost the same behavior, except for the URL and the target on which they're
386
+ # called. We assume that a `let(:target)` block has already been defined, which will yield the receiver
387
+ # of the non-resourceful method to be defined (e.g. a resource instance or resource class).
388
+ #
389
+ # @param defining_method [String] member or collection, e.g. the method to call in a `class_eval` block
390
+ # @param path [String] the URI that the client should expect to receive for an action of this type
391
+ # named 'action'
392
+ def self.describe_non_resourceful_action(defining_method, path)
393
+
394
+ # let(:target) is assumed to have been defined
395
+
396
+ let :response do
397
+ ActionResponse.new(response: 'bilbo')
398
+ end
399
+
400
+ before do
401
+ resource_class.class_eval do
402
+ resource actions: [], message: ResourceMessage
403
+ end
404
+ end
405
+ describe 'without a request or response type' do
406
+ before do
407
+ resource_class.class_eval do
408
+ send defining_method, action: :action, method: Net::HTTP::Put
409
+ end
410
+ end
411
+
412
+ it 'sends a request with no body and no response type to the expected endpoint' do
413
+ client.expects(:request)
414
+ .once
415
+ .with(method: Net::HTTP::Put, path: path, message: nil, response_type: nil)
416
+ .returns(nil)
417
+ target.action
418
+ end
419
+
420
+ it 'does not accept request parameters' do
421
+ assert_raises ArgumentError do
422
+ target.action param: 'val'
423
+ end
424
+ end
425
+
426
+ it 'returns nil' do
427
+ client.stubs(:request).returns(nil)
428
+ assert_nil target.action
429
+ end
430
+ end
431
+ describe 'with a request type' do
432
+ before do
433
+ resource_class.class_eval do
434
+ send defining_method, action: :action, method: Net::HTTP::Post, request: ActionQuery
435
+ end
436
+ end
437
+
438
+ it 'sends a request with a body to the expected endpoint' do
439
+ client.expects(:request)
440
+ .once
441
+ .with(method: Net::HTTP::Post, path: path,
442
+ message: ActionQuery.new(param: 'tom cruise'), response_type: nil)
443
+ .returns(nil)
444
+ target.action param: 'tom cruise'
445
+ end
446
+
447
+ it 'allows a request with no parameters' do
448
+ client.expects(:request)
449
+ .once
450
+ .with(method: Net::HTTP::Post, path: path,
451
+ message: ActionQuery.new, response_type: nil)
452
+ .returns(nil)
453
+ target.action
454
+ end
455
+ end
456
+
457
+ describe 'with a response type' do
458
+ before do
459
+ resource_class.class_eval do
460
+ send defining_method, action: :action, method: Net::HTTP::Get, response: ActionResponse
461
+ end
462
+ end
463
+
464
+ it 'sends a request with a specified response type to the expected endpoint' do
465
+ client.expects(:request)
466
+ .once
467
+ .with(method: Net::HTTP::Get, path: path,
468
+ message: nil, response_type: ActionResponse)
469
+ .returns(response)
470
+ target.action
471
+ end
472
+
473
+ it 'returns the server response' do
474
+ client.stubs(:request).returns(response)
475
+ assert_equal response, target.action
476
+ end
477
+ end
478
+ end
479
+
480
+ describe '.member' do
481
+ let :target do
482
+ resource_class.new id: 42
483
+ end
484
+ describe_non_resourceful_action 'member', 'base_path/42/action'
485
+ end
486
+
487
+ describe '.collection' do
488
+ let :target do
489
+ resource_class
490
+ end
491
+ describe_non_resourceful_action 'collection', 'base_path/action'
492
+ end
493
+ end
494
+ end
metadata ADDED
@@ -0,0 +1,177 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: protip
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.7
5
+ platform: ruby
6
+ authors:
7
+ - AngelList
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-05-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 3.0.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 3.0.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.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: 3.0.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '5.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.0.0
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '5.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: protobuf
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '3.5'
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.5'
67
+ - !ruby/object:Gem::Dependency
68
+ name: minitest
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '5.0'
74
+ type: :development
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '5.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: mocha
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '1.1'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.1'
95
+ - !ruby/object:Gem::Dependency
96
+ name: rake
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '10.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '10.0'
109
+ - !ruby/object:Gem::Dependency
110
+ name: simplecov
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '0.10'
116
+ type: :development
117
+ prerelease: false
118
+ version_requirements: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.10'
123
+ - !ruby/object:Gem::Dependency
124
+ name: webmock
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '1.20'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '1.20'
137
+ description:
138
+ email:
139
+ - team@angel.co
140
+ - k2@angel.co
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - lib/protip.rb
146
+ - lib/protip/client.rb
147
+ - lib/protip/error.rb
148
+ - lib/protip/messages/array.pb.rb
149
+ - lib/protip/messages/errors.pb.rb
150
+ - lib/protip/resource.rb
151
+ - test/functional/protip/resource_test.rb
152
+ - test/test_helper.rb
153
+ - test/unit/protip/resource_test.rb
154
+ homepage:
155
+ licenses: []
156
+ metadata: {}
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 2.1.0
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubyforge_project:
173
+ rubygems_version: 2.4.5
174
+ signing_key:
175
+ specification_version: 4
176
+ summary: Resources backed by protobuf messages
177
+ test_files: []