frenchy 0.0.9 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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.class_eval do
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.to_sym]
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.to_s, send(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, options={})
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
- # Create a new instance of the model from JSON
52
- def from_json(json)
53
- hash = JSON.parse(json)
54
- from_hash(hash)
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
- type = (options[:type] || :string).to_sym
69
- aliases = (options[:aliases] || [])
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 :string
78
+ when "string"
79
+ # Convert value to a String.
79
80
  define_method("#{name}=") do |v|
80
- set(name, v.to_s, options)
81
+ set(name, String(v))
81
82
  end
82
- when :integer
83
+
84
+ when "integer"
85
+ # Convert value to an Integer.
83
86
  define_method("#{name}=") do |v|
84
- set(name, Integer(v), options)
87
+ set(name, Integer(v))
85
88
  end
86
- when :float
89
+
90
+ when "float"
91
+ # Convert value to a Float.
87
92
  define_method("#{name}=") do |v|
88
- set(name, Float(v), options)
93
+ set(name, Float(v))
89
94
  end
90
- when :bool
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), options)
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
- when :time
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, options)
112
+ set(name, Time.at(v).to_datetime)
101
113
  else
102
- set(name, DateTime.parse(v), options)
114
+ set(name, DateTime.parse(v))
103
115
  end
104
116
  end
105
- when :array
106
- options[:default] ||= []
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), options)
124
+ set(name, Array(v))
109
125
  end
110
- when :hash
111
- options[:default] ||= {}
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], options)
133
+ set(name, Hash[v])
114
134
  end
135
+
115
136
  else
116
- options[:class_name] ||= type.to_s.camelize
117
- options[:many] = (name.to_s.singularize != name.to_s) unless options.key?(:many)
118
- klass = options[:class_name].constantize
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
- if options[:many]
121
- options[:default] ||= []
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[:many]
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
- self.fields[name.to_sym] = options
161
+ # Store a reference to the field
162
+ self.fields[name] = options
138
163
 
139
- if options[:default]
140
- self.defaults[name.to_sym] = options[:default]
164
+ # Store a default value if present
165
+ if options["default"]
166
+ self.defaults[name] = options["default"]
141
167
  end
142
168
 
143
- attr_reader name.to_sym
169
+ # Create an accessor for the field
170
+ attr_reader name
144
171
  end
145
172
  end
146
173
  end
@@ -1,20 +1,21 @@
1
- require "frenchy"
2
- require "frenchy/client"
3
- require "active_support/notifications"
1
+ begin
2
+ require "active_support"
3
+ rescue LoadError
4
+ end
4
5
 
5
6
  module Frenchy
6
7
  class Request
7
- # Create a new request with given parameters
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::InvalidRequest, "The required parameter '#{k}' was not specified."
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
- @options = options
29
+ @extras = extras
29
30
  end
30
31
 
31
32
  # Issue the request and return the value
32
33
  def value
33
- ActiveSupport::Notifications.instrument("request.frenchy", {service: @service, method: @method, path: @path, params: @params}.merge(@options)) do
34
- client = Frenchy.find_service(@service)
35
- client.send(@method, @path, @params)
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
@@ -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: params.to_s} if [Fixnum, String].any? {|c| params.is_a? c }
14
- find_with_endpoint(:default, params)
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([:one, :default], {id: id}.merge(params))
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([:many, :default], {ids: ids.join(",")}.merge(params))
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 = (endpoint[:method] || :get).to_sym
31
- options = {model: self.name.underscore, endpoint: name.to_s}
28
+ method = endpoint["method"] || "get"
29
+ extras = {"model" => self.name, "endpoint" => name}
32
30
 
33
- response = Frenchy::Request.new(@service, method, endpoint[:path], params, options).value
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
- options = {model: self.name.underscore, endpoint: "path"}
40
- response = Frenchy::Request.new(@service, method.to_sym, path, params, options).value
41
- digest_response(response)
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| from_hash(v) })
54
+ Frenchy::Collection.new(Array(response).map {|v| new(v) })
50
55
  else
51
- from_hash(response)
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(&:to_sym).each do |sym|
58
- if ep = @endpoints[sym]
59
- return sym, ep
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::ConfigurationError, "Resource does not contain any endpoints: #{endpoints.join(", ")}")
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.symbolize_keys!
73
+ options.stringify_keys!
69
74
 
70
- @service = options.delete(:service) || raise(Frenchy::ConfigurationError, "Resource must specify a service")
75
+ @service = options.delete("service").to_s || raise(Frenchy::Error, "Resource must specify a service")
71
76
 
72
- if endpoints = options.delete(:endpoints)
77
+ if endpoints = options.delete("endpoints")
73
78
  @endpoints = validate_endpoints(endpoints)
74
- elsif endpoint = options.delete(:endpoint)
75
- @endpoints = validate_endpoints({default: endpoint})
79
+ elsif endpoint = options.delete("endpoint")
80
+ @endpoints = validate_endpoints({"default" => endpoint})
76
81
  else
77
- raise(Frenchy::ConfigurationError, "Resource must specify one or more endpoint")
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.symbolize_keys!
87
+ endpoints.stringify_keys!
85
88
 
86
89
  Hash[endpoints.map do |k,v|
87
- v.symbolize_keys!
88
- raise(Frenchy::ConfigurationError, "Endpoint #{k} does not specify a path") unless v[:path]
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
@@ -1,29 +1,33 @@
1
- require "frenchy"
2
- require "active_model/naming"
1
+ begin
2
+ require "active_model"
3
+ rescue LoadError
4
+ end
3
5
 
4
- module Frenchy
5
- # Veneer provides a friendly face on unfriendly models, allowing your Frenchy
6
- # models to appear as though they were of another class.
7
- module Veneer
8
- def self.included(base)
9
- if defined?(ActiveModel)
10
- base.extend(ClassMethods)
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
- module ClassMethods
15
- # Macro to establish a veneer for a given model
16
- def veneer(options={})
17
- options.symbolize_keys!
18
- @model = options.delete(:model) || raise(Frenchy::ConfigurationError, "Veneer must specify a model")
19
- extend ActiveModel::Naming
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
- class_eval do
22
- def self.model_name
23
- ActiveModel::Name.new(self, nil, @model.to_s.camelize)
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
@@ -1,3 +1,3 @@
1
1
  module Frenchy
2
- VERSION = "0.0.9"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -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