frenchy 0.0.9 → 0.2.1
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/.rspec +3 -0
- data/.travis.yml +4 -0
- data/Guardfile +6 -0
- data/README.md +13 -11
- data/Rakefile +7 -0
- data/frenchie.gemspec +6 -5
- data/lib/frenchy.rb +8 -13
- data/lib/frenchy/client.rb +74 -46
- data/lib/frenchy/collection.rb +1 -1
- data/lib/frenchy/core_ext.rb +34 -0
- data/lib/frenchy/error.rb +31 -0
- data/lib/frenchy/instrumentation.rb +45 -41
- data/lib/frenchy/model.rb +71 -44
- data/lib/frenchy/request.rb +20 -11
- data/lib/frenchy/resource.rb +36 -33
- data/lib/frenchy/veneer.rb +24 -20
- data/lib/frenchy/version.rb +1 -1
- data/spec/lib/frenchy/client_spec.rb +63 -0
- data/spec/lib/frenchy/collection_spec.rb +38 -0
- data/spec/lib/frenchy/core_ext_spec.rb +42 -0
- data/spec/lib/frenchy/error_spec.rb +69 -0
- data/spec/lib/frenchy/model_spec.rb +213 -0
- data/spec/lib/frenchy/request_spec.rb +22 -0
- data/spec/lib/frenchy/resource_spec.rb +105 -0
- data/spec/lib/frenchy/veneer_spec.rb +19 -0
- data/spec/lib/frenchy_spec.rb +18 -0
- data/spec/spec_helper.rb +21 -0
- metadata +62 -23
data/lib/frenchy/model.rb
CHANGED
@@ -1,20 +1,21 @@
|
|
1
1
|
module Frenchy
|
2
2
|
module Model
|
3
3
|
def self.included(base)
|
4
|
-
base.
|
5
|
-
cattr_accessor :fields, :defaults
|
4
|
+
base.extend(ClassMethods)
|
6
5
|
|
6
|
+
base.class_eval do
|
7
7
|
self.fields = {}
|
8
8
|
self.defaults = {}
|
9
9
|
end
|
10
10
|
|
11
|
-
base.extend(ClassMethods)
|
12
11
|
end
|
13
12
|
|
14
13
|
# Create a new instance of this model with the given attributes
|
15
14
|
def initialize(attrs={})
|
15
|
+
attrs.stringify_keys!
|
16
|
+
|
16
17
|
self.class.defaults.merge((attrs || {}).reject {|k,v| v.nil? }).each do |k,v|
|
17
|
-
if self.class.fields[k
|
18
|
+
if self.class.fields[k]
|
18
19
|
send("#{k}=", v)
|
19
20
|
end
|
20
21
|
end
|
@@ -22,7 +23,7 @@ module Frenchy
|
|
22
23
|
|
23
24
|
# Return a hash of field name as string and value pairs
|
24
25
|
def attributes
|
25
|
-
Hash[self.class.fields.map {|k,_| [k
|
26
|
+
Hash[self.class.fields.map {|k,_| [k, send(k)]}]
|
26
27
|
end
|
27
28
|
|
28
29
|
# Return a string representing the value of the model instance
|
@@ -38,21 +39,17 @@ module Frenchy
|
|
38
39
|
|
39
40
|
protected
|
40
41
|
|
41
|
-
def set(name, value
|
42
|
+
def set(name, value)
|
42
43
|
instance_variable_set("@#{name}", value)
|
43
44
|
end
|
44
45
|
|
45
46
|
module ClassMethods
|
46
|
-
# Create a new instance of the model from a hash
|
47
|
-
def from_hash(hash)
|
48
|
-
new(hash)
|
49
|
-
end
|
50
47
|
|
51
|
-
#
|
52
|
-
def
|
53
|
-
|
54
|
-
|
55
|
-
end
|
48
|
+
# Class accessors
|
49
|
+
def fields; @fields; end
|
50
|
+
def defaults; @defaults; end
|
51
|
+
def fields=(value); @fields = value; end
|
52
|
+
def defaults=(value); @defaults = value; end
|
56
53
|
|
57
54
|
protected
|
58
55
|
|
@@ -65,8 +62,11 @@ module Frenchy
|
|
65
62
|
|
66
63
|
# Macro to add a field
|
67
64
|
def field(name, options={})
|
68
|
-
|
69
|
-
|
65
|
+
name = name.to_s
|
66
|
+
options.stringify_keys!
|
67
|
+
|
68
|
+
type = (options["type"] || "string").to_s
|
69
|
+
aliases = (options["aliases"] || [])
|
70
70
|
|
71
71
|
aliases.each do |a|
|
72
72
|
define_method("#{a}") do
|
@@ -75,54 +75,78 @@ module Frenchy
|
|
75
75
|
end
|
76
76
|
|
77
77
|
case type
|
78
|
-
when
|
78
|
+
when "string"
|
79
|
+
# Convert value to a String.
|
79
80
|
define_method("#{name}=") do |v|
|
80
|
-
set(name, v
|
81
|
+
set(name, String(v))
|
81
82
|
end
|
82
|
-
|
83
|
+
|
84
|
+
when "integer"
|
85
|
+
# Convert value to an Integer.
|
83
86
|
define_method("#{name}=") do |v|
|
84
|
-
set(name, Integer(v)
|
87
|
+
set(name, Integer(v))
|
85
88
|
end
|
86
|
-
|
89
|
+
|
90
|
+
when "float"
|
91
|
+
# Convert value to a Float.
|
87
92
|
define_method("#{name}=") do |v|
|
88
|
-
set(name, Float(v)
|
93
|
+
set(name, Float(v))
|
89
94
|
end
|
90
|
-
|
95
|
+
|
96
|
+
when "bool"
|
97
|
+
# Accept truthy values as true.
|
91
98
|
define_method("#{name}=") do |v|
|
92
|
-
set(name, ["true", 1, true].include?(v)
|
99
|
+
set(name, ["true", "1", 1, true].include?(v))
|
93
100
|
end
|
101
|
+
|
102
|
+
# Alias a predicate method.
|
94
103
|
define_method("#{name}?") do
|
95
104
|
send(name)
|
96
105
|
end
|
97
|
-
|
106
|
+
|
107
|
+
when "time"
|
108
|
+
# Convert value to a Time or DateTime. Numbers are treated as unix timestamps,
|
109
|
+
# other values are parsed with DateTime.parse.
|
98
110
|
define_method("#{name}=") do |v|
|
99
111
|
if v.is_a?(Fixnum)
|
100
|
-
set(name, Time.at(v).to_datetime
|
112
|
+
set(name, Time.at(v).to_datetime)
|
101
113
|
else
|
102
|
-
set(name, DateTime.parse(v)
|
114
|
+
set(name, DateTime.parse(v))
|
103
115
|
end
|
104
116
|
end
|
105
|
-
|
106
|
-
|
117
|
+
|
118
|
+
when "array"
|
119
|
+
# Arrays always have a default of []
|
120
|
+
options["default"] ||= []
|
121
|
+
|
122
|
+
# Convert value to an Array.
|
107
123
|
define_method("#{name}=") do |v|
|
108
|
-
set(name, Array(v)
|
124
|
+
set(name, Array(v))
|
109
125
|
end
|
110
|
-
|
111
|
-
|
126
|
+
|
127
|
+
when "hash"
|
128
|
+
# Hashes always have a default of {}
|
129
|
+
options["default"] ||= {}
|
130
|
+
|
131
|
+
# Convert value to a Hash
|
112
132
|
define_method("#{name}=") do |v|
|
113
|
-
set(name, Hash[v]
|
133
|
+
set(name, Hash[v])
|
114
134
|
end
|
135
|
+
|
115
136
|
else
|
116
|
-
|
117
|
-
|
118
|
-
klass = options[
|
137
|
+
# Unknown types have their type constantized and initialized with the value. This
|
138
|
+
# allows us to support things like other Frenchy::Model classes, ActiveRecord models, etc.
|
139
|
+
klass = (options["class_name"] || type.camelize).constantize
|
119
140
|
|
120
|
-
|
121
|
-
|
141
|
+
# Fields with many values have a default of [] (unless previously set above)
|
142
|
+
if options["many"]
|
143
|
+
options["default"] ||= []
|
122
144
|
end
|
123
145
|
|
146
|
+
# Convert value using the constantized class. Fields with many values are mapped to a
|
147
|
+
# Frenchy::Collection containing mapped values.
|
124
148
|
define_method("#{name}=") do |v|
|
125
|
-
if options[
|
149
|
+
if options["many"]
|
126
150
|
set(name, Frenchy::Collection.new(Array(v).map {|vv| klass.new(vv)}))
|
127
151
|
else
|
128
152
|
if v.is_a?(Hash)
|
@@ -134,13 +158,16 @@ module Frenchy
|
|
134
158
|
end
|
135
159
|
end
|
136
160
|
|
137
|
-
|
161
|
+
# Store a reference to the field
|
162
|
+
self.fields[name] = options
|
138
163
|
|
139
|
-
if
|
140
|
-
|
164
|
+
# Store a default value if present
|
165
|
+
if options["default"]
|
166
|
+
self.defaults[name] = options["default"]
|
141
167
|
end
|
142
168
|
|
143
|
-
|
169
|
+
# Create an accessor for the field
|
170
|
+
attr_reader name
|
144
171
|
end
|
145
172
|
end
|
146
173
|
end
|
data/lib/frenchy/request.rb
CHANGED
@@ -1,20 +1,21 @@
|
|
1
|
-
|
2
|
-
require "
|
3
|
-
|
1
|
+
begin
|
2
|
+
require "active_support"
|
3
|
+
rescue LoadError
|
4
|
+
end
|
4
5
|
|
5
6
|
module Frenchy
|
6
7
|
class Request
|
7
|
-
|
8
|
-
def initialize(service, method, path, params={}, options={})
|
9
|
-
params.stringify_keys!
|
8
|
+
attr_accessor :service, :method, :path, :params, :extras
|
10
9
|
|
10
|
+
# Create a new request with given parameters
|
11
|
+
def initialize(service, method, path, params={}, extras={})
|
11
12
|
path = path.dup
|
12
13
|
path.scan(/(:[a-z0-9_+]+)/).flatten.uniq.each do |pat|
|
13
14
|
k = pat.sub(":", "")
|
14
15
|
begin
|
15
16
|
v = params.fetch(pat.sub(":", "")).to_s
|
16
17
|
rescue
|
17
|
-
raise Frenchy::
|
18
|
+
raise Frenchy::Error, "The required parameter '#{k}' was not specified."
|
18
19
|
end
|
19
20
|
|
20
21
|
params.delete(k)
|
@@ -25,14 +26,22 @@ module Frenchy
|
|
25
26
|
@method = method
|
26
27
|
@path = path
|
27
28
|
@params = params
|
28
|
-
@
|
29
|
+
@extras = extras
|
29
30
|
end
|
30
31
|
|
31
32
|
# Issue the request and return the value
|
32
33
|
def value
|
33
|
-
|
34
|
-
|
35
|
-
|
34
|
+
Frenchy.find_service(@service).send(@method, @path, @params)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Requests are instrumented if ActiveSupport is available.
|
38
|
+
if defined?(ActiveSupport::Notifications)
|
39
|
+
alias_method :value_without_instrumentation, :value
|
40
|
+
|
41
|
+
def value
|
42
|
+
ActiveSupport::Notifications.instrument("request.frenchy", {service: @service, method: @method, path: @path, params: @params}.merge(@extras)) do
|
43
|
+
value_without_instrumentation
|
44
|
+
end
|
36
45
|
end
|
37
46
|
end
|
38
47
|
end
|
data/lib/frenchy/resource.rb
CHANGED
@@ -1,6 +1,3 @@
|
|
1
|
-
require "frenchy"
|
2
|
-
require "frenchy/request"
|
3
|
-
|
4
1
|
module Frenchy
|
5
2
|
module Resource
|
6
3
|
def self.included(base)
|
@@ -10,83 +7,89 @@ module Frenchy
|
|
10
7
|
module ClassMethods
|
11
8
|
# Find record(s) using the default endpoint and flexible input
|
12
9
|
def find(params={})
|
13
|
-
params = {id
|
14
|
-
find_with_endpoint(
|
10
|
+
params = {"id" => params.to_s} if [Fixnum, String].any? {|c| params.is_a? c }
|
11
|
+
find_with_endpoint("default", params)
|
15
12
|
end
|
16
13
|
|
17
14
|
# Find a single record using the "one" (or "default") endpoint and an id
|
18
15
|
def find_one(id, params={})
|
19
|
-
find_with_endpoint([
|
16
|
+
find_with_endpoint(["one", "default"], {"id" => id}.merge(params))
|
20
17
|
end
|
21
18
|
|
22
19
|
# Find multiple record using the "many" (or "default") endpoint and an array of ids
|
23
20
|
def find_many(ids, params={})
|
24
|
-
find_with_endpoint([
|
21
|
+
find_with_endpoint(["many", "default"], {"ids" => ids.join(",")}.merge(params))
|
25
22
|
end
|
26
23
|
|
27
24
|
# Call with a specific endpoint and params
|
28
25
|
def find_with_endpoint(endpoints, params={})
|
26
|
+
params.stringify_keys!
|
29
27
|
name, endpoint = resolve_endpoints(endpoints)
|
30
|
-
method =
|
31
|
-
|
28
|
+
method = endpoint["method"] || "get"
|
29
|
+
extras = {"model" => self.name, "endpoint" => name}
|
32
30
|
|
33
|
-
response = Frenchy::Request.new(@service, method, endpoint[
|
34
|
-
digest_response(response)
|
31
|
+
response = Frenchy::Request.new(@service, method, endpoint["path"], params, extras).value
|
32
|
+
digest_response(response, endpoint)
|
35
33
|
end
|
36
34
|
|
37
35
|
# Call with arbitrary method and path
|
38
36
|
def find_with_path(method, path, params={})
|
39
|
-
|
40
|
-
|
41
|
-
|
37
|
+
params.stringify_keys!
|
38
|
+
extras = {"model" => self.name, "endpoint" => "path"}
|
39
|
+
response = Frenchy::Request.new(@service, method.to_s, path.to_s, params, extras).value
|
40
|
+
digest_response(response, endpoint)
|
42
41
|
end
|
43
42
|
|
44
43
|
private
|
45
44
|
|
46
45
|
# Converts a response into model data
|
47
|
-
def digest_response(response)
|
46
|
+
def digest_response(response, endpoint)
|
47
|
+
if endpoint["nesting"]
|
48
|
+
Array(endpoint["nesting"]).map(&:to_s).each do |key|
|
49
|
+
response = response.fetch(key)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
48
53
|
if response.is_a?(Array)
|
49
|
-
Frenchy::Collection.new(Array(response).map {|v|
|
54
|
+
Frenchy::Collection.new(Array(response).map {|v| new(v) })
|
50
55
|
else
|
51
|
-
|
56
|
+
new(response)
|
52
57
|
end
|
53
58
|
end
|
54
59
|
|
55
60
|
# Choose the first available endpoint
|
56
61
|
def resolve_endpoints(endpoints)
|
57
|
-
Array(endpoints).map(&:
|
58
|
-
if ep = @endpoints[
|
59
|
-
return
|
62
|
+
Array(endpoints).map(&:to_s).each do |s|
|
63
|
+
if ep = @endpoints[s]
|
64
|
+
return s, ep
|
60
65
|
end
|
61
66
|
end
|
62
67
|
|
63
|
-
raise(Frenchy::
|
68
|
+
raise(Frenchy::Error, "Resource does not contain any endpoints: #{Array(endpoints).join(", ")}")
|
64
69
|
end
|
65
70
|
|
66
71
|
# Macro to set the location pattern for this request
|
67
72
|
def resource(options={})
|
68
|
-
options.
|
73
|
+
options.stringify_keys!
|
69
74
|
|
70
|
-
@service = options.delete(
|
75
|
+
@service = options.delete("service").to_s || raise(Frenchy::Error, "Resource must specify a service")
|
71
76
|
|
72
|
-
if endpoints = options.delete(
|
77
|
+
if endpoints = options.delete("endpoints")
|
73
78
|
@endpoints = validate_endpoints(endpoints)
|
74
|
-
elsif endpoint = options.delete(
|
75
|
-
@endpoints = validate_endpoints({default
|
79
|
+
elsif endpoint = options.delete("endpoint")
|
80
|
+
@endpoints = validate_endpoints({"default" => endpoint})
|
76
81
|
else
|
77
|
-
|
82
|
+
@endpoints = {}
|
78
83
|
end
|
79
|
-
|
80
|
-
@many = options.delete(:many) || false
|
81
84
|
end
|
82
85
|
|
83
86
|
def validate_endpoints(endpoints={})
|
84
|
-
endpoints.
|
87
|
+
endpoints.stringify_keys!
|
85
88
|
|
86
89
|
Hash[endpoints.map do |k,v|
|
87
|
-
v.
|
88
|
-
raise(Frenchy::
|
89
|
-
[k,v]
|
90
|
+
v.stringify_keys!
|
91
|
+
raise(Frenchy::Error, "Endpoint #{k} does not specify a path") unless v["path"]
|
92
|
+
[k, v]
|
90
93
|
end]
|
91
94
|
end
|
92
95
|
end
|
data/lib/frenchy/veneer.rb
CHANGED
@@ -1,29 +1,33 @@
|
|
1
|
-
|
2
|
-
require "active_model
|
1
|
+
begin
|
2
|
+
require "active_model"
|
3
|
+
rescue LoadError
|
4
|
+
end
|
3
5
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
6
|
+
if defined?(ActiveModel)
|
7
|
+
module Frenchy
|
8
|
+
# Veneer provides a friendly face on unfriendly models, allowing your Frenchy
|
9
|
+
# models to appear as though they were of another class.
|
10
|
+
module Veneer
|
11
|
+
def self.included(base)
|
12
|
+
if defined?(ActiveModel)
|
13
|
+
base.extend(ClassMethods)
|
14
|
+
end
|
11
15
|
end
|
12
|
-
end
|
13
16
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
module ClassMethods
|
18
|
+
# Macro to establish a veneer for a given model
|
19
|
+
def veneer(options={})
|
20
|
+
options.stringify_keys!
|
21
|
+
@model = options.delete("model") || raise(Frenchy::Error, "Veneer must specify a model")
|
22
|
+
extend ActiveModel::Naming
|
20
23
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
+
class_eval do
|
25
|
+
def self.model_name
|
26
|
+
ActiveModel::Name.new(self, nil, @model.to_s.camelize)
|
27
|
+
end
|
24
28
|
end
|
25
29
|
end
|
26
30
|
end
|
27
31
|
end
|
28
32
|
end
|
29
|
-
end
|
33
|
+
end
|
data/lib/frenchy/version.rb
CHANGED
@@ -0,0 +1,63 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Frenchy::Client do
|
4
|
+
describe "#initialize" do
|
5
|
+
it "uses expected defaults" do
|
6
|
+
client = Frenchy::Client.new
|
7
|
+
expect(client.host).to eql("http://127.0.0.1:8080")
|
8
|
+
expect(client.timeout).to eql(30)
|
9
|
+
expect(client.retries).to eql(0)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "accepts an options hash" do
|
13
|
+
client = Frenchy::Client.new({"host" => "http://127.0.0.1:1234", "timeout" => 15, "retries" => 3})
|
14
|
+
expect(client.host).to eql("http://127.0.0.1:1234")
|
15
|
+
expect(client.timeout).to eql(15)
|
16
|
+
expect(client.retries).to eql(3)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "accepts an options hash (symbols)" do
|
20
|
+
client = Frenchy::Client.new(host: "http://127.0.0.1:1234", timeout: 15, retries: 3)
|
21
|
+
expect(client.host).to eql("http://127.0.0.1:1234")
|
22
|
+
expect(client.timeout).to eql(15)
|
23
|
+
expect(client.retries).to eql(3)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
["patch", "post", "put", "delete"].each do |method|
|
28
|
+
describe "##{method}" do
|
29
|
+
it "returns a successful response containing the request json data" do
|
30
|
+
client = Frenchy::Client.new("host" => "http://httpbin.org")
|
31
|
+
data = {"data" => "abcd", "number" => 1, "nested" => {"more" => "data"}}
|
32
|
+
expect = JSON.generate(data)
|
33
|
+
result = client.send(method, "/#{method}", data)
|
34
|
+
expect(result).to be_an_instance_of(Hash)
|
35
|
+
expect(result["data"]).to eql(expect)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "#perform" do
|
41
|
+
it "returns a hash for successful json response" do
|
42
|
+
client = Frenchy::Client.new("host" => "http://httpbin.org")
|
43
|
+
result = client.get("/ip", {})
|
44
|
+
expect(result).to be_an_instance_of(Hash)
|
45
|
+
expect(result.keys).to eql(["origin"])
|
46
|
+
end
|
47
|
+
|
48
|
+
it "raises an invalid response error for non-json response" do
|
49
|
+
client = Frenchy::Client.new("host" => "http://httpbin.org")
|
50
|
+
expect{client.get("/html", {})}.to raise_error(Frenchy::InvalidResponse)
|
51
|
+
end
|
52
|
+
|
53
|
+
it "raises a not found error for 404 responses" do
|
54
|
+
client = Frenchy::Client.new("host" => "http://httpbin.org")
|
55
|
+
expect{client.get("/status/404", {})}.to raise_error(Frenchy::NotFound)
|
56
|
+
end
|
57
|
+
|
58
|
+
it "raises a service unavailable error for 500+ responses" do
|
59
|
+
client = Frenchy::Client.new("host" => "http://httpbin.org")
|
60
|
+
expect{client.get("/status/500", {})}.to raise_error(Frenchy::ServiceUnavailable)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|