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