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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cfc53ad6ae8399ce20c1088d4d8af731e38c3ebd
4
- data.tar.gz: da03a49221e8e7c5aff1509fe8805788c1349670
3
+ metadata.gz: 896b7777033ce445d2e9bd558356457c5ff7b1f8
4
+ data.tar.gz: 35332a7b5006236a816ba18d060fa9fbb34a4789
5
5
  SHA512:
6
- metadata.gz: ed5d78ec4022471cf35a7eac83ca2c91f154c168e72eb039aeeb4e4ef82e8e9512318492e433cb61656e1f80792a0e27987ab9c5ba9a139f965936d53ec994d9
7
- data.tar.gz: c0d155f5886853a2f4a8204d8a37c778903ecf395be03fc2e08989cb946b990091a2ebe66eff024d8ff0ceb80317126769fc6fe7edc641a7cbef871f4698d59a
6
+ metadata.gz: 096edcd0e10872db18fda625db3fc1690fc05d22feb06b323fed80e3b599c1f12f8396d1d4ae473ce5c53bf63e859b23d92953b1ae3a397df00cea71088a41ac
7
+ data.tar.gz: 38c76d3f9714798bc3582de8fd64d95d56c5175ad660f0f5d7ae74e752c8873d39147ea6cb5073cfd57ce77fb437cd83bcb4b709665086d4884649c6ff3a9920
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --format documentation
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
4
+ - 2.1.2
data/Guardfile ADDED
@@ -0,0 +1,6 @@
1
+ guard :rspec, cmd: "bundle exec rspec" do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
4
+ watch("spec/spec_helper.rb") { "spec" }
5
+ end
6
+
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Frenchy
2
2
 
3
- Frenchy is a thing for turning HTTP JSON API endpoints into Rails-ish model objects. It deals with making requests, converting responses, type conversion, struct nesting, model decorating and instrumentation.
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, you should register them in an initializer:
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", many: true },
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, type: "integer"
47
- field :name, type: "string"
48
- field :win_rate, type: "float"
49
- field :free_agent, type: "bool"
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, type: "nickname", many: true
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, type: "string"
62
- field :insulting, type: "bool"
63
+ field :name, type: "string"
64
+ field :insulting, type: "bool"
63
65
  end
64
66
 
65
67
  # GET /v1/players/1
data/Rakefile CHANGED
@@ -1 +1,8 @@
1
+ #!/usr/bin/env rake
2
+
1
3
  require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: "spec"
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{Frenchy's got the goods}
12
- spec.summary = %q{Frenchy's got the goods}
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
- class Error < ::StandardError; end
12
- class NotFound < Error; end
13
- class ServerError < Error; end
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
- if @services.nil?
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
@@ -1,88 +1,116 @@
1
- require "frenchy"
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.symbolize_keys!
10
+ options.stringify_keys!
10
11
 
11
- @host = options.delete(:host) || "http://127.0.0.1:8080"
12
- @timeout = options.delete(:timeout) || 60
13
- @retries = options.delete(:retries) || 0
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
- error = nil
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(:get, path, params)
24
- rescue Frenchy::ServerError, Frenchy::InvalidResponse => error
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 error
34
+ raise err
31
35
  end
32
36
 
33
37
  # Issue a non-retryable request with the given path and query parameters
34
- def patch(path, params); perform(:patch, path, params); end
35
- def post(path, params); perform(:post, path, params); end
36
- def put(path, params); perform(:put, path, params); end
37
- def delete(path, params); perform(:delete, path, params); end
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
- url = "#{@host}#{path}"
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" => "application/json",
51
+ "Accept" => "application/json",
53
52
  }
54
53
 
55
- body = nil
54
+ # Set the URI path
55
+ uri.path = path
56
56
 
57
- case method
58
- when :patch, :post, :put
59
- headers["Content-Type"] = "application/json"
60
- body = JSON.generate(params)
61
- params = nil
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
- response = begin
65
- HTTP.accept(:json).send(method, url, headers: headers, params: params, body: body).response
66
- rescue => exception
67
- raise Frenchy::ServerError, {request: request, error: exception}
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
- case response.code
71
- when 200, 400
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(response.body)
74
- rescue => e
75
- raise Frenchy::InvalidResponse, {request: request, error: exception, status: response.status, body: response.body}
103
+ JSON.parse(resp.body)
104
+ rescue => ex
105
+ raise Frenchy::InvalidResponse.new(ex, reqinfo, resp)
76
106
  end
77
107
  when 404
78
- body = JSON.parse(response.body) rescue response.body
79
- raise Frenchy::NotFound, {request: request, status: response.status, body: body}
108
+ # Explicitly handle not found errors
109
+ raise Frenchy::NotFound.new(nil, reqinfo, resp)
80
110
  else
81
- body = JSON.parse(response.body) rescue response.body
82
- raise Frenchy::ServerError, {request: request, status: response.status, body: body}
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
@@ -10,7 +10,7 @@ module Frenchy
10
10
  def decorate(options={})
11
11
  return self if none?
12
12
 
13
- decorator_class.decorate_collection(self)
13
+ decorator_class.decorate_collection(self, options)
14
14
  end
15
15
 
16
16
  # Compatbility for associations in draper
@@ -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
- require "active_support/concern"
2
- require "active_support/log_subscriber"
3
-
4
- module Frenchy
5
- module Instrumentation
6
- class LogSubscriber < ActiveSupport::LogSubscriber
7
- def start_processing(event)
8
- Thread.current[:frenchy_runtime] = 0.0
9
- end
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
- def request(event)
12
- Thread.current[:frenchy_runtime] ||= 0.0
13
- Thread.current[:frenchy_runtime] += event.duration
14
- if logger.debug?
15
- name = "%s (%.2fms)" % [event.payload[:service].capitalize, event.duration]
16
- output = " #{color(name, YELLOW, true)} #{event.payload[:method].to_s.upcase} #{event.payload[:path]}"
17
- if event.payload[:params].any?
18
- output += "?"
19
- output += event.payload[:params].map {|k,v| "#{k}=#{v}" }.join("&")
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
- def self.runtime
26
- Thread.current[:frenchy_runtime] || 0.0
28
+ def self.runtime
29
+ Thread.current[:frenchy_runtime] || 0.0
30
+ end
27
31
  end
28
- end
29
32
 
30
- module ControllerRuntime
31
- extend ActiveSupport::Concern
33
+ module ControllerRuntime
34
+ extend ActiveSupport::Concern
32
35
 
33
- protected
36
+ protected
34
37
 
35
- def append_info_to_payload(payload)
36
- super
37
- payload[:frenchy_runtime] = Frenchy::Instrumentation::LogSubscriber.runtime
38
- end
38
+ def append_info_to_payload(payload)
39
+ super
40
+ payload[:frenchy_runtime] = Frenchy::Instrumentation::LogSubscriber.runtime
41
+ end
39
42
 
40
- module ClassMethods
41
- def log_process_action(payload)
42
- messages = super
43
- if runtime = payload[:frenchy_runtime]
44
- messages << "Frenchy: %.1fms" % runtime.to_f
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
- include Frenchy::Instrumentation::ControllerRuntime
59
+ ActiveSupport.on_load(:action_controller) do
60
+ include Frenchy::Instrumentation::ControllerRuntime
61
+ end
58
62
  end