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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 896b7777033ce445d2e9bd558356457c5ff7b1f8
|
4
|
+
data.tar.gz: 35332a7b5006236a816ba18d060fa9fbb34a4789
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 096edcd0e10872db18fda625db3fc1690fc05d22feb06b323fed80e3b599c1f12f8396d1d4ae473ce5c53bf63e859b23d92953b1ae3a397df00cea71088a41ac
|
7
|
+
data.tar.gz: 38c76d3f9714798bc3582de8fd64d95d56c5175ad660f0f5d7ae74e752c8873d39147ea6cb5073cfd57ce77fb437cd83bcb4b709665086d4884649c6ff3a9920
|
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Guardfile
ADDED
data/README.md
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
# Frenchy
|
2
2
|
|
3
|
-
|
3
|
+
[](https://rubygems.org/gems/frenchy) [](https://travis-ci.org/jcoene/frenchy) [](https://coveralls.io/r/jcoene/frenchy)
|
4
|
+
|
5
|
+
Frenchy is an opinionated modeling framework for consuming HTTP+JSON API endpoints as ActiveModel-like objects. It deals with making requests, converting responses, type conversion, struct nesting, model decorating and instrumentation. Frenchy is used in production at [Dotabuff](http://dotabuff.com) serving millions of requests per day.
|
4
6
|
|
5
7
|
## Installation
|
6
8
|
|
@@ -10,11 +12,11 @@ Add this line to your application's Gemfile:
|
|
10
12
|
|
11
13
|
And then execute:
|
12
14
|
|
13
|
-
$ bundle
|
15
|
+
$ bundle install
|
14
16
|
|
15
17
|
## Usage
|
16
18
|
|
17
|
-
Frenchy supports multiple back-end services
|
19
|
+
Frenchy supports multiple back-end services. If you're using Rails, register them in an initializer:
|
18
20
|
|
19
21
|
```ruby
|
20
22
|
# config/initializer/frenchy.rb
|
@@ -34,7 +36,7 @@ class Player
|
|
34
36
|
# Declare which service the model belongs to and specify your named API endpoints
|
35
37
|
resource service: "dodgeball", endpoints: {
|
36
38
|
one: { path: "/v1/players/:id" },
|
37
|
-
many: { path: "/v1/players",
|
39
|
+
many: { path: "/v1/players", many: true },
|
38
40
|
team: { path: "/v1/teams/:team_id/players", many: true }
|
39
41
|
}
|
40
42
|
|
@@ -43,23 +45,23 @@ class Player
|
|
43
45
|
|
44
46
|
# Define fields which create named attributes and deal with typecasting.
|
45
47
|
# Valid built-in types: string, integer, float, bool, time, array, hash
|
46
|
-
field :id,
|
47
|
-
field :name,
|
48
|
-
field :win_rate,
|
49
|
-
field :free_agent,
|
48
|
+
field :id, type: "integer"
|
49
|
+
field :name, type: "string"
|
50
|
+
field :win_rate, type: "float"
|
51
|
+
field :free_agent, type: "bool"
|
50
52
|
|
51
53
|
# You can also supply types of any class that can be instantiated by sending
|
52
54
|
# a hash of attributes to the "new" class method. If you specify the "many"
|
53
55
|
# option, we'll expect that the server returns an array and will properly treat
|
54
56
|
# the response as a collection.
|
55
|
-
field :nicknames,
|
57
|
+
field :nicknames, type: "nickname", many: true
|
56
58
|
end
|
57
59
|
|
58
60
|
class Nickname
|
59
61
|
include Frenchy::Model
|
60
62
|
|
61
|
-
field :name,
|
62
|
-
field :insulting,
|
63
|
+
field :name, type: "string"
|
64
|
+
field :insulting, type: "bool"
|
63
65
|
end
|
64
66
|
|
65
67
|
# GET /v1/players/1
|
data/Rakefile
CHANGED
data/frenchie.gemspec
CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
|
|
8
8
|
spec.version = Frenchy::VERSION
|
9
9
|
spec.authors = ["Jason Coene"]
|
10
10
|
spec.email = ["jcoene@gmail.com"]
|
11
|
-
spec.description = %q{
|
12
|
-
spec.summary = %q{
|
11
|
+
spec.description = %q{Opinionated JSON API modeling framework for Ruby.}
|
12
|
+
spec.summary = %q{Opinionated JSON API modeling framework for Ruby.}
|
13
13
|
spec.homepage = "https://github.com/jcoene/frenchy"
|
14
14
|
spec.license = "MIT"
|
15
15
|
|
@@ -18,12 +18,13 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
-
spec.add_dependency "activemodel"
|
22
|
-
spec.add_dependency "activesupport"
|
23
|
-
spec.add_dependency "http"
|
24
21
|
spec.add_dependency "json"
|
25
22
|
|
26
23
|
spec.add_development_dependency "bundler", "~> 1.3"
|
24
|
+
spec.add_development_dependency "guard-rspec"
|
27
25
|
spec.add_development_dependency "rake"
|
28
26
|
spec.add_development_dependency "rspec"
|
27
|
+
spec.add_development_dependency "coveralls"
|
28
|
+
spec.add_development_dependency "activesupport", ">= 3.0"
|
29
|
+
spec.add_development_dependency "activemodel", ">= 3.0"
|
29
30
|
end
|
data/lib/frenchy.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
|
+
require "frenchy/core_ext"
|
2
|
+
|
1
3
|
require "frenchy/client"
|
2
4
|
require "frenchy/collection"
|
5
|
+
require "frenchy/error"
|
3
6
|
require "frenchy/instrumentation"
|
4
7
|
require "frenchy/model"
|
5
8
|
require "frenchy/request"
|
@@ -8,23 +11,15 @@ require "frenchy/veneer"
|
|
8
11
|
require "frenchy/version"
|
9
12
|
|
10
13
|
module Frenchy
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
class InvalidResponse < Error; end
|
15
|
-
class InvalidRequest < Error; end
|
16
|
-
class ConfigurationError < Error; end
|
14
|
+
class_eval do
|
15
|
+
@services = {}
|
16
|
+
end
|
17
17
|
|
18
18
|
def self.register_service(name, options={})
|
19
|
-
@services
|
20
|
-
@services[name.to_sym] = Frenchy::Client.new(options)
|
19
|
+
@services[name.to_s] = Frenchy::Client.new(options)
|
21
20
|
end
|
22
21
|
|
23
22
|
def self.find_service(name)
|
24
|
-
|
25
|
-
raise(Frenchy::ConfigurationError, "No services have been configured")
|
26
|
-
end
|
27
|
-
|
28
|
-
@services[name.to_sym] || raise(Frenchy::ConfigurationError, "No service '#{name}' registered")
|
23
|
+
@services[name.to_s] || raise(Frenchy::Error, "No service '#{name}' registered")
|
29
24
|
end
|
30
25
|
end
|
data/lib/frenchy/client.rb
CHANGED
@@ -1,88 +1,116 @@
|
|
1
|
-
require "
|
2
|
-
require "http"
|
1
|
+
require "net/http"
|
3
2
|
require "json"
|
4
3
|
|
5
4
|
module Frenchy
|
6
5
|
class Client
|
6
|
+
attr_accessor :host, :timeout, :retries
|
7
|
+
|
7
8
|
# Create a new client instance
|
8
9
|
def initialize(options={})
|
9
|
-
options.
|
10
|
+
options.stringify_keys!
|
10
11
|
|
11
|
-
@host = options.delete(
|
12
|
-
@timeout = options.delete(
|
13
|
-
@retries = options.delete(
|
12
|
+
@host = options.delete("host") || "http://127.0.0.1:8080"
|
13
|
+
@timeout = options.delete("timeout") || 30
|
14
|
+
@retries = options.delete("retries") || 0
|
15
|
+
@backoff_delay = options.delete("backoff_delay") || 1.0
|
14
16
|
end
|
15
17
|
|
16
|
-
# Issue a get request with the given path and query parameters
|
18
|
+
# Issue a get request with the given path and query parameters. Get
|
19
|
+
# requests can be retried.
|
17
20
|
def get(path, params)
|
18
21
|
try = 0
|
19
|
-
|
22
|
+
err = nil
|
20
23
|
|
21
24
|
while try <= @retries
|
25
|
+
sleep (@backoff_delay * (try*try)) if try > 0
|
26
|
+
|
22
27
|
begin
|
23
|
-
return perform(
|
24
|
-
rescue Frenchy::
|
25
|
-
sleep (0.35 * (try*try))
|
28
|
+
return perform("GET", path, params)
|
29
|
+
rescue Frenchy::Error => err
|
26
30
|
try += 1
|
27
31
|
end
|
28
32
|
end
|
29
33
|
|
30
|
-
raise
|
34
|
+
raise err
|
31
35
|
end
|
32
36
|
|
33
37
|
# Issue a non-retryable request with the given path and query parameters
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
+
["PATCH", "POST", "PUT", "DELETE"].each do |method|
|
39
|
+
define_method(method.downcase) do |path, params|
|
40
|
+
perform(method, path, params)
|
41
|
+
end
|
42
|
+
end
|
38
43
|
|
39
44
|
private
|
40
45
|
|
41
46
|
def perform(method, path, params)
|
42
|
-
|
43
|
-
|
44
|
-
request = {
|
45
|
-
method: method.to_s.upcase,
|
46
|
-
url: url,
|
47
|
-
params: params
|
48
|
-
}
|
49
|
-
|
47
|
+
uri = URI(@host)
|
48
|
+
body = nil
|
50
49
|
headers = {
|
51
50
|
"User-Agent" => "Frenchy/#{Frenchy::VERSION}",
|
52
|
-
"Accept"
|
51
|
+
"Accept" => "application/json",
|
53
52
|
}
|
54
53
|
|
55
|
-
|
54
|
+
# Set the URI path
|
55
|
+
uri.path = path
|
56
56
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
57
|
+
# Set request parameters
|
58
|
+
if params.any?
|
59
|
+
case method
|
60
|
+
when "GET"
|
61
|
+
# Get method uses params as query string
|
62
|
+
uri.query = URI.encode_www_form(params)
|
63
|
+
else
|
64
|
+
# Other methods post a JSON body
|
65
|
+
headers["Content-Type"] = "application/json"
|
66
|
+
body = JSON.generate(params)
|
67
|
+
end
|
62
68
|
end
|
63
69
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
70
|
+
# Create a new HTTP connection
|
71
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
72
|
+
http.use_ssl = true if uri.scheme == "https"
|
73
|
+
http.read_timeout = @timeout
|
74
|
+
http.open_timeout = @timeout
|
75
|
+
|
76
|
+
# Create a new HTTP request
|
77
|
+
req = Net::HTTPGenericRequest.new(
|
78
|
+
method.to_s.upcase, # method
|
79
|
+
body != nil, # request has body?
|
80
|
+
true, # response has body?
|
81
|
+
uri.request_uri, # request uri
|
82
|
+
headers, # request headers
|
83
|
+
)
|
84
|
+
|
85
|
+
# Set the request body if present
|
86
|
+
req.body = body if body != nil
|
87
|
+
|
88
|
+
# Create a request info string for inspection
|
89
|
+
reqinfo = "#{method} #{uri.to_s}"
|
90
|
+
|
91
|
+
# Perform the request
|
92
|
+
begin
|
93
|
+
resp = http.request(req)
|
94
|
+
rescue => ex
|
95
|
+
raise Frenchy::ServerError.new(ex, reqinfo, nil)
|
68
96
|
end
|
69
97
|
|
70
|
-
|
71
|
-
|
98
|
+
# Return based on response
|
99
|
+
case resp.code.to_i
|
100
|
+
when 200...399
|
101
|
+
# Positive responses are expected to return JSON
|
72
102
|
begin
|
73
|
-
JSON.parse(
|
74
|
-
rescue =>
|
75
|
-
raise Frenchy::InvalidResponse,
|
103
|
+
JSON.parse(resp.body)
|
104
|
+
rescue => ex
|
105
|
+
raise Frenchy::InvalidResponse.new(ex, reqinfo, resp)
|
76
106
|
end
|
77
107
|
when 404
|
78
|
-
|
79
|
-
raise Frenchy::NotFound,
|
108
|
+
# Explicitly handle not found errors
|
109
|
+
raise Frenchy::NotFound.new(nil, reqinfo, resp)
|
80
110
|
else
|
81
|
-
|
82
|
-
raise Frenchy::
|
111
|
+
# All other responses are treated as a server error
|
112
|
+
raise Frenchy::ServiceUnavailable.new(nil, reqinfo, resp)
|
83
113
|
end
|
84
114
|
end
|
85
|
-
|
86
|
-
public
|
87
115
|
end
|
88
116
|
end
|
data/lib/frenchy/collection.rb
CHANGED
@@ -0,0 +1,34 @@
|
|
1
|
+
class Hash
|
2
|
+
def stringify_keys!
|
3
|
+
keys.each do |key|
|
4
|
+
self[key.to_s] = delete(key)
|
5
|
+
end
|
6
|
+
self
|
7
|
+
end unless {}.respond_to?(:stringify_keys!)
|
8
|
+
end
|
9
|
+
|
10
|
+
class String
|
11
|
+
def camelize
|
12
|
+
dup.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
|
13
|
+
end unless "".respond_to?(:camelize)
|
14
|
+
|
15
|
+
def constantize
|
16
|
+
names = self.split('::')
|
17
|
+
names.shift if names.empty? || names.first.empty?
|
18
|
+
|
19
|
+
constant = Object
|
20
|
+
names.each do |name|
|
21
|
+
constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
|
22
|
+
end
|
23
|
+
|
24
|
+
constant
|
25
|
+
end unless "".respond_to?(:constantize)
|
26
|
+
|
27
|
+
def underscore
|
28
|
+
dup.gsub(/::/, '/').
|
29
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
30
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
31
|
+
tr("-", "_").
|
32
|
+
downcase
|
33
|
+
end unless "".respond_to?(:underscore)
|
34
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Frenchy
|
2
|
+
class Error < ::StandardError; end
|
3
|
+
|
4
|
+
class RequestError < Error
|
5
|
+
attr_reader :message, :request, :response
|
6
|
+
|
7
|
+
def initialize(message=nil, request=nil, response=nil)
|
8
|
+
@request, @response = request, response
|
9
|
+
|
10
|
+
if message
|
11
|
+
@message = message.respond_to?(:message) ? message.message : message
|
12
|
+
elsif response.respond_to?(:code)
|
13
|
+
@message = "The server responded with status #{response.code}"
|
14
|
+
@message += "\n\n#{response.body.to_s}" if response.body.to_s != ""
|
15
|
+
else
|
16
|
+
@message = "An unknown error has occured"
|
17
|
+
end
|
18
|
+
|
19
|
+
@message += "\n\n#{request}" if request
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
@message
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class NotFound < RequestError; end
|
28
|
+
class InvalidResponse < RequestError; end
|
29
|
+
class ServerError < RequestError; end
|
30
|
+
class ServiceUnavailable < ServerError; end
|
31
|
+
end
|
@@ -1,58 +1,62 @@
|
|
1
|
-
|
2
|
-
require "active_support
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
1
|
+
begin
|
2
|
+
require "active_support"
|
3
|
+
rescue LoadError
|
4
|
+
end
|
5
|
+
|
6
|
+
if defined?(ActiveSupport)
|
7
|
+
module Frenchy
|
8
|
+
module Instrumentation
|
9
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
10
|
+
def start_processing(event)
|
11
|
+
Thread.current[:frenchy_runtime] = 0.0
|
12
|
+
end
|
10
13
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
14
|
+
def request(event)
|
15
|
+
Thread.current[:frenchy_runtime] ||= 0.0
|
16
|
+
Thread.current[:frenchy_runtime] += event.duration
|
17
|
+
if logger.debug?
|
18
|
+
name = "%s (%.2fms)" % [event.payload[:service].capitalize, event.duration]
|
19
|
+
output = " #{color(name, YELLOW, true)} #{event.payload[:method].to_s.upcase} #{event.payload[:path]}"
|
20
|
+
if event.payload[:params].any?
|
21
|
+
output += "?"
|
22
|
+
output += event.payload[:params].map {|k,v| "#{k}=#{v}" }.join("&")
|
23
|
+
end
|
24
|
+
debug output
|
20
25
|
end
|
21
|
-
debug output
|
22
26
|
end
|
23
|
-
end
|
24
27
|
|
25
|
-
|
26
|
-
|
28
|
+
def self.runtime
|
29
|
+
Thread.current[:frenchy_runtime] || 0.0
|
30
|
+
end
|
27
31
|
end
|
28
|
-
end
|
29
32
|
|
30
|
-
|
31
|
-
|
33
|
+
module ControllerRuntime
|
34
|
+
extend ActiveSupport::Concern
|
32
35
|
|
33
|
-
|
36
|
+
protected
|
34
37
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
38
|
+
def append_info_to_payload(payload)
|
39
|
+
super
|
40
|
+
payload[:frenchy_runtime] = Frenchy::Instrumentation::LogSubscriber.runtime
|
41
|
+
end
|
39
42
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
43
|
+
module ClassMethods
|
44
|
+
def log_process_action(payload)
|
45
|
+
messages = super
|
46
|
+
if runtime = payload[:frenchy_runtime]
|
47
|
+
messages << "Frenchy: %.1fms" % runtime.to_f
|
48
|
+
end
|
49
|
+
messages
|
45
50
|
end
|
46
|
-
messages
|
47
51
|
end
|
48
52
|
end
|
49
53
|
end
|
50
54
|
end
|
51
|
-
end
|
52
55
|
|
53
|
-
Frenchy::Instrumentation::LogSubscriber.attach_to(:action_controller)
|
54
|
-
Frenchy::Instrumentation::LogSubscriber.attach_to(:frenchy)
|
56
|
+
Frenchy::Instrumentation::LogSubscriber.attach_to(:action_controller)
|
57
|
+
Frenchy::Instrumentation::LogSubscriber.attach_to(:frenchy)
|
55
58
|
|
56
|
-
ActiveSupport.on_load(:action_controller) do
|
57
|
-
|
59
|
+
ActiveSupport.on_load(:action_controller) do
|
60
|
+
include Frenchy::Instrumentation::ControllerRuntime
|
61
|
+
end
|
58
62
|
end
|