frenchy 0.0.9 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/frenchy.png)](https://rubygems.org/gems/frenchy) [![Build Status](https://travis-ci.org/jcoene/frenchy.png)](https://travis-ci.org/jcoene/frenchy) [![Coverage Status](https://coveralls.io/repos/jcoene/frenchy/badge.png?branch=master)](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
|