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