frenetic 1.0.0 → 2.0.0

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.
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