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