frenetic 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c762ec3d9f1ece32685aa4e6e6acf19d02db55fe
4
- data.tar.gz: 44c50996e3b1b7e8a8261d7d2919636383f0de70
3
+ metadata.gz: 9feae0cbb4a8f08f8e7cc97d8417e0e3f1347778
4
+ data.tar.gz: 71b2c0f9ced48a48dba39177a52e1fa7a0da6f43
5
5
  SHA512:
6
- metadata.gz: 17ca597ab9f13709f63067da856c157ae6505256ce9ec1f0feee51acb2be029c933da95fd6ce2868138f6fe0399824cf5755214845b914303685b2e2378ed539
7
- data.tar.gz: 7d0571cd0e36780c57b4a9edd6e6d805fcfdbc0f0b58fddbc4b9258d642a2c5970d33ae7e3d9ff28f714c9c64fdb6abce317030a753f63a07505152df5687f58
6
+ metadata.gz: 9b54379136e8dbac6c6be19a6dfe75e21bc4f715c2197ca53a57b0743c005469c3d46c788fb839f94c79d386ce3ecbe971b47d6038f5bd44f6fb26f2b31ef515
7
+ data.tar.gz: 5caddca24783caeb5d1f2a163b9a77c65b094a420d576f93f25e176ea22adb471548845934cf782163e0e11922b8840022be5f1146867bbd88d375dd9e6336a1
@@ -1,3 +1,3 @@
1
1
  rvm:
2
2
  - 2.1.2
3
- script: bundle exec rspec --require spec_helper --order rand --color --format documentation
3
+ script: bundle exec rake
data/README.md CHANGED
@@ -349,6 +349,15 @@ like so:
349
349
  > Order.find(1)
350
350
  # <Order id=1 total=54.47>
351
351
 
352
+ > Order.find_by(id:1)
353
+ # <Order id=1 total=54.47>
354
+
355
+ > Order.find_by!(id:-1, state:'active')
356
+ # Frenetic::ResourceNotFound: Couldn't find Order with id=-1, state=active
357
+
358
+ > Order.find_by(id:-1)
359
+ # nil
360
+
352
361
  > Order.all
353
362
  # [<Order id=1 total=54.47>,<Order id=2 total=42.00>]
354
363
  ```
data/Rakefile CHANGED
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env rake
2
2
  require 'bundler/gem_tasks'
3
+
4
+ Dir['tasks/**/*.rake'].each { |task| load task }
5
+
6
+ task default: %w(rubocop spec)
@@ -28,4 +28,5 @@ Gem::Specification.new do |gem|
28
28
  gem.add_development_dependency 'webmock', '~> 1.18.0'
29
29
  gem.add_development_dependency 'timecop', '~> 0.7.1'
30
30
  gem.add_development_dependency 'appraisal', '~> 1.0.2'
31
+ gem.add_development_dependency 'rubocop', '~> 0.29.1'
31
32
  end
@@ -6,6 +6,7 @@ require 'active_support/core_ext/hash/reverse_merge'
6
6
 
7
7
  require 'frenetic/version'
8
8
  require 'frenetic/errors'
9
+ require 'frenetic/behaviors'
9
10
  require 'frenetic/briefly_memoizable'
10
11
  require 'frenetic/connection'
11
12
  require 'frenetic/middleware/hal_json'
@@ -0,0 +1,6 @@
1
+ require 'frenetic/behaviors/alternate_string_identifier'
2
+
3
+ class Frenetic
4
+ module Behaviors
5
+ end
6
+ end
@@ -0,0 +1,56 @@
1
+ # Allows a resource to be found by a string-based alternative key
2
+ #
3
+ # For example
4
+ #
5
+ # module MyClient
6
+ # module MyResource < Frenetic::Resource
7
+ # extend Frenetic::Behaviors::AlternateStringIdentifier
8
+ #
9
+ # def self.find(id)
10
+ # super(finder_params(id, :username))
11
+ # end
12
+ # end
13
+ # end
14
+ #
15
+ # Given an Api Schema such as:
16
+ #
17
+ # {
18
+ # _links: {
19
+ # my_resource: [{
20
+ # { href: '/api/my_resource/{id}', rel: 'id' },
21
+ # { href: '/api/my_resource/{username}?specific_to=username', rel: 'username' },
22
+ # }]
23
+ # }
24
+ # }
25
+ #
26
+ # MyClient::MyResource.find will choose the alternate link relation based on
27
+ # the string-based ID passed in.
28
+ #
29
+ # MyClient::MyResource.find(1)
30
+ # # Executes /api/my_resource/1
31
+ #
32
+ # MyClient::MyResource.find('100')
33
+ # # Executes /api/my_resource/100
34
+ #
35
+ # MyClient::MyResource.find('jdoe')
36
+ # Executes /api/my_resource/jdoe?specific_to=username
37
+ #
38
+ class Frenetic
39
+ module Behaviors
40
+ module AlternateStringIdentifier
41
+ def finder_params(unique_id, alternate_key)
42
+ return unique_id if unique_id.is_a? Hash
43
+ params = {}
44
+ return params if unique_id.blank?
45
+ key =
46
+ if unique_id.to_i.to_s == unique_id.to_s
47
+ :id
48
+ elsif !unique_id.nil?
49
+ alternate_key
50
+ end
51
+ params[key] = unique_id
52
+ params
53
+ end
54
+ end
55
+ end
56
+ end
@@ -12,6 +12,7 @@ class Frenetic
12
12
 
13
13
  def member_url(params = {})
14
14
  resource = @resource_type || self.class.to_s.demodulize.underscore
15
+ return self.class.member_url(params) unless links.is_a?(Hash)
15
16
  link = links[resource] || links['self']
16
17
  fail MissingResourceUrl.new(resource) if !link
17
18
  HypermediaLinkSet.new(link).href params
@@ -25,13 +26,13 @@ class Frenetic
25
26
  def member_url(params = {})
26
27
  link = links[namespace]
27
28
  fail MissingResourceUrl.new(namespace) if !link
28
- HypermediaLinkSet.new(link).href params
29
+ HypermediaLinkSet.new(link).href(params)
29
30
  end
30
31
 
31
- def collection_url
32
+ def collection_url(*params)
32
33
  link = links[namespace.pluralize]
33
34
  fail MissingResourceUrl.new(namespace.pluralize) if !link
34
- HypermediaLinkSet.new(link).href
35
+ HypermediaLinkSet.new(link).href(*params)
35
36
  end
36
37
  end
37
38
  end
@@ -6,17 +6,39 @@ class Frenetic
6
6
 
7
7
  module ClassMethods
8
8
  def find(params)
9
+ fail ResourceNotFound.new(self, params) if params.blank?
9
10
  params = { id:params } unless params.is_a?(Hash)
10
11
  return as_mock(params) if test_mode?
11
- response = api.get(member_url(params))
12
- new(response.body) if response.success?
12
+ fetch_resource(params)
13
+ end
14
+
15
+ def find_by!(params)
16
+ find(params)
17
+ end
18
+
19
+ def find_by(params)
20
+ find_by!(params)
21
+ rescue ClientError
22
+ nil
13
23
  end
14
24
 
15
- def all
25
+ def all(*params)
16
26
  return [] if test_mode?
17
- response = api.get(collection_url)
27
+ response = api.get(collection_url(*params))
18
28
  Frenetic::ResourceCollection.new(self, response.body) if response.success?
19
29
  end
30
+
31
+ private
32
+
33
+ def fetch_resource(params)
34
+ begin
35
+ response = api.get(member_url(params))
36
+ rescue ClientParsingError, ClientError => ex
37
+ raise if ex.status != 404
38
+ raise ResourceNotFound.new(self, params)
39
+ end
40
+ new(response.body) if response.success?
41
+ end
20
42
  end
21
43
  end
22
44
  end
@@ -0,0 +1,36 @@
1
+ class Frenetic
2
+ module Persistence
3
+ def save
4
+ persist_resource
5
+ end
6
+
7
+ def save!
8
+ save || fail(ResourceInvalid.new(self))
9
+ end
10
+
11
+ def errors=( errs )
12
+ @_errors = errs
13
+ end
14
+
15
+ def errors
16
+ @_errors
17
+ end
18
+
19
+ def valid?
20
+ @_errors ? @_errors.empty? : true
21
+ end
22
+
23
+ private
24
+
25
+ def persist_resource
26
+ response = api.post(member_url(attributes), note:attributes)
27
+ initialize_with(response.body) if response.success?
28
+ rescue ClientError => ex
29
+ raise if ex.status != 422
30
+ self.errors = ex.body.fetch('errors', base: ex.error )
31
+ return false
32
+ else
33
+ return true
34
+ end
35
+ end
36
+ end
@@ -124,15 +124,17 @@ class Frenetic
124
124
  # Parent class for all specific exceptions which are raised as a result of a
125
125
  # network response.
126
126
  class ResponseError < Error
127
- attr_reader :env, :error, :method, :status, :url
127
+ attr_reader :body, :env, :error, :method, :status, :url
128
128
  def initialize(env)
129
129
  env ||= {}
130
- body = env.fetch(:body, {})
131
- @env = env
132
- @error = body['error']
133
- @method = env[:method]
134
- @status = env[:status]
135
- @url = env[:url]
130
+ if env.respond_to?(:fetch)
131
+ @body = env.fetch(:body, {})
132
+ @env = env
133
+ @error = @body['error']
134
+ @method = env[:method]
135
+ @status = env[:status]
136
+ @url = env[:url]
137
+ end
136
138
  super(message)
137
139
  end
138
140
 
@@ -144,6 +146,65 @@ class Frenetic
144
146
  # Raised when a network response returns a 400-level error
145
147
  ClientError = Class.new(ResponseError)
146
148
 
149
+ # Raise when a network reponse returns a 404 Not Found error
150
+ class ResourceNotFound < ClientError
151
+ def initialize(resource, params)
152
+ @resource = resource.to_s.demodulize
153
+ @params = params
154
+ @status = 404
155
+ super(message)
156
+ end
157
+
158
+ def message
159
+ if @params.blank?
160
+ "Couldn't find #{@resource} without an ID"
161
+ else
162
+ "Couldn't find #{@resource} with #{stringified_params}"
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def stringified_params
169
+ pairs = @params.each_with_object([]) do |*tuple, agg|
170
+ agg.concat(tuple)
171
+ end
172
+ assignments = pairs.map do |pair|
173
+ pair.join('=')
174
+ end
175
+ assignments.join(', ')
176
+ end
177
+ end
178
+
179
+ # Raise when Resource#save! is called and the request fails with a 422
180
+ class ResourceInvalid < ClientError
181
+ attr_reader :errors
182
+
183
+ def initialize(resource)
184
+ @errors = resource.errors
185
+ @status = 422
186
+ super(message)
187
+ end
188
+
189
+ def message
190
+ "Validation failed: #{validation_errors}"
191
+ end
192
+
193
+ private
194
+
195
+ def validation_errors
196
+ errors.map do |attribute, error_messages|
197
+ error_messages.map do |error_message|
198
+ if attribute == 'base'
199
+ error_message.capitalize
200
+ else
201
+ "#{attribute.titleize} #{error_message}"
202
+ end
203
+ end
204
+ end.flatten.join(', ')
205
+ end
206
+ end
207
+
147
208
  # Raised when a network response returns a 500-level error
148
209
  ServerError = Class.new(ResponseError)
149
210
 
@@ -6,12 +6,14 @@ require 'active_support/core_ext/hash/indifferent_access'
6
6
  require 'frenetic/concerns/structured'
7
7
  require 'frenetic/concerns/hal_linked'
8
8
  require 'frenetic/concerns/member_rest_methods'
9
+ require 'frenetic/concerns/persistence'
9
10
 
10
11
  class Frenetic
11
12
  class Resource < Delegator
12
13
  include Structured
13
14
  include HalLinked
14
15
  include MemberRestMethods
16
+ include Persistence
15
17
 
16
18
  def self.api_client(client = nil)
17
19
  if client
@@ -54,16 +56,15 @@ class Frenetic
54
56
  mock_class.new params
55
57
  end
56
58
 
57
- def initialize(p = {})
58
- build_params p
59
+ def initialize(params = {})
59
60
  @attrs = {}
61
+ initialize_with(params)
62
+ end
60
63
 
61
- properties.keys.each do |k|
62
- @attrs[k] = @params[k]
63
- end
64
-
64
+ def initialize_with(p)
65
+ build_params(p)
66
+ assign_attributes(@params)
65
67
  extract_embedded_resources
66
-
67
68
  build_structure
68
69
  end
69
70
 
@@ -72,6 +73,12 @@ class Frenetic
72
73
  end
73
74
  alias_method :api, :api_client
74
75
 
76
+ def assign_attributes(params)
77
+ properties.keys.each do |k|
78
+ @attrs[k] = params[k]
79
+ end
80
+ end
81
+
75
82
  def attributes
76
83
  @attributes ||= begin
77
84
  @structure.each_pair.each_with_object({}) do |(k, v), attrs|
@@ -8,7 +8,7 @@ class Frenetic
8
8
  extend Forwardable
9
9
  extend ActiveSupport::Concern
10
10
 
11
- def_delegators :@params, :to_json
11
+ def_delegators :@params, :as_json, :to_json
12
12
 
13
13
  included do
14
14
  # I'm sure this violates some sort of CS principle or best practice,
@@ -47,11 +47,26 @@ class Frenetic
47
47
  def build_params(params)
48
48
  raw_params = (params || {}).with_indifferent_access
49
49
  defaults = default_attributes.with_indifferent_access
50
- @params = defaults.deep_merge(raw_params)
50
+ @params = cast_types(defaults.deep_merge(raw_params))
51
51
  end
52
52
 
53
53
  def build_structure
54
54
  @structure = OpenStruct.new(@attrs)
55
55
  end
56
+
57
+ # A naive attempt to cast the attribute types of the incoming mock data
58
+ # based on any available type information provided in :default_attributes
59
+ def cast_types(params)
60
+ default_attributes.each do |key, value|
61
+ params[key] =
62
+ case value
63
+ when String then String(params[key])
64
+ when Float then Float(params[key])
65
+ when Integer then Integer(params[key])
66
+ else params[key]
67
+ end
68
+ end
69
+ params
70
+ end
56
71
  end
57
72
  end
@@ -1,3 +1,3 @@
1
1
  class Frenetic
2
- VERSION = '1.0.0'
2
+ VERSION = '2.0.0'
3
3
  end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ describe Frenetic::Behaviors::AlternateStringIdentifier do
4
+ let(:my_temp_resource) do
5
+ Class.new(Frenetic::Resource)
6
+ end
7
+
8
+ before do
9
+ stub_const 'MyTempResource', my_temp_resource
10
+ MyTempResource.send(:extend, described_class)
11
+ end
12
+
13
+ describe '.finder_params' do
14
+ let(:id) { }
15
+ let(:alternate_key) { }
16
+
17
+ subject { MyTempResource.finder_params(id, alternate_key) }
18
+
19
+ context 'with a Fixnum identifier' do
20
+ let(:id) { 1 }
21
+
22
+ it 'uses :id for the finder key' do
23
+ expect(subject).to include id:id
24
+ end
25
+ end
26
+
27
+ context 'with a String identifier representing a Fixnum' do
28
+ let(:id) { '100' }
29
+
30
+ it 'uses :id for the finder key' do
31
+ expect(subject).to include id:id
32
+ end
33
+ end
34
+
35
+ context 'with a String identifier' do
36
+ let(:id) { 'foo' }
37
+ let(:alternate_key) { 'alt' }
38
+
39
+ it 'uses :id for the finder key' do
40
+ expect(subject).to include 'alt' => id
41
+ end
42
+ end
43
+ end
44
+ end