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 +4 -4
- data/.travis.yml +1 -1
- data/README.md +9 -0
- data/Rakefile +4 -0
- data/frenetic.gemspec +1 -0
- data/lib/frenetic.rb +1 -0
- data/lib/frenetic/behaviors.rb +6 -0
- data/lib/frenetic/behaviors/alternate_string_identifier.rb +56 -0
- data/lib/frenetic/concerns/hal_linked.rb +4 -3
- data/lib/frenetic/concerns/member_rest_methods.rb +26 -4
- data/lib/frenetic/concerns/persistence.rb +36 -0
- data/lib/frenetic/errors.rb +68 -7
- data/lib/frenetic/resource.rb +14 -7
- data/lib/frenetic/resource_mockery.rb +17 -2
- data/lib/frenetic/version.rb +1 -1
- data/spec/behaviors/alternate_string_identifier_spec.rb +44 -0
- data/spec/concerns/member_rest_methods_spec.rb +85 -2
- data/spec/concerns/persistence_spec.rb +95 -0
- data/spec/fixtures/test_api_requests.rb +70 -16
- data/spec/frenetic_spec.rb +172 -186
- data/spec/middleware/hal_json_spec.rb +1 -1
- data/spec/resource_mockery_spec.rb +13 -4
- data/tasks/rspec.rake +7 -0
- data/tasks/rubocop.rake +2 -0
- metadata +25 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9feae0cbb4a8f08f8e7cc97d8417e0e3f1347778
|
4
|
+
data.tar.gz: 71b2c0f9ced48a48dba39177a52e1fa7a0da6f43
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9b54379136e8dbac6c6be19a6dfe75e21bc4f715c2197ca53a57b0743c005469c3d46c788fb839f94c79d386ce3ecbe971b47d6038f5bd44f6fb26f2b31ef515
|
7
|
+
data.tar.gz: 5caddca24783caeb5d1f2a163b9a77c65b094a420d576f93f25e176ea22adb471548845934cf782163e0e11922b8840022be5f1146867bbd88d375dd9e6336a1
|
data/.travis.yml
CHANGED
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
data/frenetic.gemspec
CHANGED
data/lib/frenetic.rb
CHANGED
@@ -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
|
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
|
-
|
12
|
-
|
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
|
data/lib/frenetic/errors.rb
CHANGED
@@ -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
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
|
data/lib/frenetic/resource.rb
CHANGED
@@ -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(
|
58
|
-
build_params p
|
59
|
+
def initialize(params = {})
|
59
60
|
@attrs = {}
|
61
|
+
initialize_with(params)
|
62
|
+
end
|
60
63
|
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
data/lib/frenetic/version.rb
CHANGED
@@ -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
|