api-model 0.0.2 → 0.0.3

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: 6e80eb51e30b3e5dacade8092adb4e6160224ab4
4
- data.tar.gz: 91c1d13d150318c136d49c16f19aaa8d491234c6
3
+ metadata.gz: 75b2ddb171371b895e8ecf655cfd69a56368738b
4
+ data.tar.gz: a0165241ef3855d10e044d9cdc8b821e3114a6a9
5
5
  SHA512:
6
- metadata.gz: f584b017ee477b3e5630c2cf73725f77cec863f1e42f522a548f01a1993e6e679f5d81d9c938df44e0ca934a5eed8cc8a037a6d8b94f7ac6aeb71b81c740a7f8
7
- data.tar.gz: 031a973ad6b657c11bf5ea819a4b6dcf54cacaf56aa9838118cf3cb450aa4750aae41f8cc546cd92c7fd33397380d341c491d2cc132e0719f25122a0649acc02
6
+ metadata.gz: c377a0408bcc44930c807b07ef4274bf15ab5ddb6db7e790c421e39c8ec4dcfeaf71496c3fd0d57188c0d6821b32646a5b8c96dafdf8e9166ff4e00e34510fb4
7
+ data.tar.gz: 480a76b37a1800abcbccc9da0365cda8a8332af301e5e997d0b281a451403e7339a7a93433dd9ea814ca4b6113aa26b5fe287b1a5e2a3b8ff1e6aa6937cbd8a8
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.0.0
5
+ script: bundle exec rspec spec
@@ -1,18 +1,19 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- api-model (0.0.1)
4
+ api-model (0.0.3)
5
5
  activemodel
6
6
  activesupport
7
+ hashie
7
8
  typhoeus
8
9
 
9
10
  GEM
10
11
  remote: https://rubygems.org/
11
12
  specs:
12
- activemodel (4.0.1)
13
- activesupport (= 4.0.1)
13
+ activemodel (4.0.2)
14
+ activesupport (= 4.0.2)
14
15
  builder (~> 3.1.0)
15
- activesupport (4.0.1)
16
+ activesupport (4.0.2)
16
17
  i18n (~> 0.6, >= 0.6.4)
17
18
  minitest (~> 4.2)
18
19
  multi_json (~> 1.3)
@@ -29,7 +30,8 @@ GEM
29
30
  ffi (>= 1.3.0)
30
31
  mime-types (~> 1.18)
31
32
  ffi (1.9.0)
32
- i18n (0.6.5)
33
+ hashie (2.0.5)
34
+ i18n (0.6.9)
33
35
  method_source (0.8.2)
34
36
  mime-types (1.25.1)
35
37
  minitest (4.7.5)
data/README.md CHANGED
@@ -1 +1,4 @@
1
+ [![Code Climate](https://codeclimate.com/github/iZettle/api-model.png)](https://codeclimate.com/github/iZettle/api-model)
2
+ [![Build Status](https://travis-ci.org/iZettle/api-model.png?branch=master)](https://travis-ci.org/iZettle/api-model)
3
+
1
4
  Api Model README
@@ -2,7 +2,7 @@ $:.push File.expand_path("../lib", __FILE__)
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "api-model"
5
- s.version = "0.0.2"
5
+ s.version = "0.0.3"
6
6
  s.authors = ["Damien Timewell"]
7
7
  s.email = ["mail@damientimewell.com"]
8
8
  s.homepage = "https://github.com/iZettle/api-model"
@@ -13,6 +13,7 @@ Gem::Specification.new do |s|
13
13
  s.add_dependency 'activesupport'
14
14
  s.add_dependency 'activemodel'
15
15
  s.add_dependency 'typhoeus'
16
+ s.add_dependency 'hashie'
16
17
 
17
18
  s.add_development_dependency "rspec"
18
19
  s.add_development_dependency "pry"
@@ -2,42 +2,48 @@ require 'active_model'
2
2
  require 'active_support'
3
3
  require 'active_support/core_ext'
4
4
  require 'logger'
5
+ require 'hashie'
5
6
 
6
7
  require 'api_model/initializer'
7
8
  require 'api_model/http_request'
8
9
  require 'api_model/response'
10
+ require 'api_model/rest_methods'
11
+ require 'api_model/configuration'
9
12
 
10
13
  module ApiModel
11
-
12
- if defined? Rails
13
- Log = Rails.logger
14
- else
15
- Log = Logger.new STDOUT
14
+ Log = Logger.new STDOUT
15
+
16
+ class ResponseBuilderError < StandardError; end
17
+ class UnauthenticatedError < StandardError; end
18
+ class NotFoundError < StandardError; end
19
+
20
+ if defined?(Rails)
21
+ class Railtie < Rails::Railtie
22
+ initializer "api-model" do
23
+ ApiModel.send :remove_const, :Log
24
+ ApiModel::Log = Rails.logger
25
+ end
26
+ end
16
27
  end
17
28
 
18
- class Base
29
+ class Base < Hashie::Trash
19
30
  include ActiveModel::Conversion
20
31
  include ActiveModel::Validations
32
+ include ActiveModel::Serialization
21
33
  extend ActiveModel::Naming
22
34
  extend ActiveModel::Callbacks
23
35
 
24
- include ApiModel::Initializer
36
+ extend RestMethods
37
+ include ConfigurationMethods
25
38
 
26
- def self.api_host=(api_host)
27
- @api_host = api_host
39
+ # Overrides Hashie::Trash to catch errors from trying to set properties which have not been defined
40
+ # and defines it automatically
41
+ def property_exists?(property_name)
42
+ super property_name
43
+ rescue NoMethodError
44
+ Log.debug "Could not set #{property_name} on #{self.class.name}. Defining it now."
45
+ self.class.property property_name.to_sym
28
46
  end
29
-
30
- def self.api_host
31
- @api_host || ""
32
- end
33
-
34
- def self.get_json(path, options={})
35
- # TODO - tidy this up...
36
- builder = options.delete(:builder) || self
37
- options[:api_host] = api_host
38
-
39
- HttpRequest.run(options.merge(path: path)).build_objects builder
40
- end
41
-
42
47
  end
48
+
43
49
  end
@@ -0,0 +1,39 @@
1
+ module ApiModel
2
+ class Configuration
3
+ include Initializer
4
+
5
+ attr_accessor :host, :json_root, :headers, :raise_on_unauthenticated, :raise_on_not_found
6
+
7
+ def self.from_inherited_config(config)
8
+ new config.instance_values.reject {|k,v| v.blank? }
9
+ end
10
+
11
+ def headers
12
+ @headers ||= {}
13
+ @headers.reverse_merge "Content-Type" => "application/json; charset=utf-8", "Accept" => "application/json"
14
+ end
15
+ end
16
+
17
+ module ConfigurationMethods
18
+ extend ActiveSupport::Concern
19
+
20
+ module ClassMethods
21
+
22
+ def reset_api_configuration
23
+ @_api_config = nil
24
+ end
25
+
26
+ def api_model_configuration
27
+ @_api_config || superclass.api_model_configuration
28
+ rescue
29
+ @_api_config = Configuration.new
30
+ end
31
+
32
+ def api_config
33
+ @_api_config = Configuration.from_inherited_config api_model_configuration
34
+ yield @_api_config
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -2,15 +2,17 @@ module ApiModel
2
2
  class HttpRequest
3
3
  include ApiModel::Initializer
4
4
 
5
- attr_accessor :path, :method, :options, :api_call, :api_host
5
+ attr_accessor :path, :method, :options, :api_call, :builder, :config
6
6
 
7
- def self.run(options={})
8
- self.new(options).run
9
- end
7
+ after_initialize :set_default_options
8
+
9
+ define_model_callbacks :run
10
10
 
11
11
  def run
12
- self.api_call = Typhoeus.send(method, full_path, options)
13
- Response.new self.api_call
12
+ run_callbacks :run do
13
+ self.api_call = Typhoeus.send method, full_path, options
14
+ Response.new self, config
15
+ end
14
16
  end
15
17
 
16
18
  def method
@@ -23,7 +25,18 @@ module ApiModel
23
25
 
24
26
  def full_path
25
27
  return path if path =~ /^http/
26
- "#{api_host}#{path}"
28
+ "#{config.host}#{path}"
29
+ end
30
+
31
+ def request_method
32
+ api_call.request.original_options[:method]
33
+ end
34
+
35
+ private
36
+
37
+ def set_default_options
38
+ options[:headers] ||= {}
39
+ options[:headers].reverse_merge! config.headers if config.try(:headers)
27
40
  end
28
41
 
29
42
  end
@@ -1,9 +1,10 @@
1
1
  module ApiModel
2
2
  module Initializer
3
+ extend ActiveSupport::Concern
3
4
 
4
- def self.included(klass)
5
- klass.extend ActiveModel::Callbacks
6
- klass.define_model_callbacks :initialize
5
+ included do
6
+ extend ActiveModel::Callbacks
7
+ define_model_callbacks :initialize
7
8
  end
8
9
 
9
10
  def initialize(values={})
@@ -9,16 +9,20 @@ module ApiModel
9
9
 
10
10
  attr_accessor :http_response, :objects
11
11
 
12
- def initialize(http_response)
12
+ def initialize(http_response, config)
13
13
  @http_response = http_response
14
+ @_config = config || Configuration.new
14
15
  end
15
16
 
16
- # TODO - make json root configurable
17
- def build_objects(builder)
18
- if json_response_body.is_a? Array
19
- self.objects = json_response_body.collect{ |hash| build builder, hash }
20
- elsif json_response_body.is_a? Hash
21
- self.objects = self.build builder, json_response_body
17
+ def build_objects
18
+ raise UnauthenticatedError if @_config.raise_on_unauthenticated && http_response.api_call.response_code == 401
19
+ raise NotFoundError if @_config.raise_on_not_found && http_response.api_call.response_code == 404
20
+ return if json_response_body.nil?
21
+
22
+ if response_build_hash.is_a? Array
23
+ self.objects = response_build_hash.collect{ |hash| build http_response.builder, hash }
24
+ elsif response_build_hash.is_a? Hash
25
+ self.objects = self.build http_response.builder, response_build_hash
22
26
  end
23
27
 
24
28
  self
@@ -33,9 +37,9 @@ module ApiModel
33
37
  end
34
38
 
35
39
  def json_response_body
36
- JSON.parse http_response.body
40
+ @json_response_body ||= JSON.parse http_response.api_call.body
37
41
  rescue JSON::ParserError
38
- Log.info "Could not parse JSON response: #{http_response.body}"
42
+ Log.info "Could not parse JSON response: #{http_response.api_call.body}"
39
43
  return nil
40
44
  end
41
45
 
@@ -53,5 +57,26 @@ module ApiModel
53
57
  objects.send method_name, *args, &block
54
58
  end
55
59
 
60
+ private
61
+
62
+ # If the model config defines a json root, use it on the json_response_body
63
+ # to dig down in to the hash.
64
+ #
65
+ # The root for a deeply nested hash will come in as a string with key names split
66
+ # with a colon.
67
+ def response_build_hash
68
+ if @_config.json_root.present?
69
+ begin
70
+ @_config.json_root.split(".").inject(json_response_body) do |hash,key|
71
+ hash.fetch(key)
72
+ end
73
+ rescue
74
+ raise ResponseBuilderError, "Could not find key #{@_config.json_root} in:\n#{json_response_body}"
75
+ end
76
+ else
77
+ json_response_body
78
+ end
79
+ end
80
+
56
81
  end
57
82
  end
@@ -0,0 +1,21 @@
1
+ module ApiModel
2
+ module RestMethods
3
+
4
+ def get_json(path, params={}, options={})
5
+ call_api :get, path, options.merge(params: params)
6
+ end
7
+
8
+ def post_json(path, body=nil, options={})
9
+ body = body.to_json if body.is_a?(Hash)
10
+ call_api :post, path, options.merge(body: body)
11
+ end
12
+
13
+ def call_api(method, path, options={})
14
+ request = HttpRequest.new path: path, method: method, config: api_model_configuration
15
+ request.builder = options.delete(:builder) || self
16
+ request.options.merge! options
17
+ request.run.build_objects
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,130 @@
1
+ require 'spec_helper'
2
+ require 'support/mock_models/blog_post'
3
+ require 'support/mock_models/car'
4
+
5
+ describe ApiModel do
6
+
7
+ describe "sending different types of requests" do
8
+ before do
9
+ BlogPost.api_config do |config|
10
+ config.host = "http://api-model-specs.com"
11
+ end
12
+ end
13
+
14
+ it "should be possible to send a GET request" do
15
+ get_request = VCR.use_cassette('posts') { BlogPost.get_json "/single_post" }
16
+ get_request.http_response.request_method.should eq :get
17
+ end
18
+
19
+ it "should be possible to send a POST request" do
20
+ post_request = VCR.use_cassette('posts') { BlogPost.post_json "/posts" }
21
+ post_request.http_response.request_method.should eq :post
22
+ end
23
+
24
+ it 'should be possible to send a POST request with a hash as body' do
25
+ post_request = VCR.use_cassette('posts') { BlogPost.post_json "/create_with_json", name: "foobarbaz" }
26
+ post_request.http_response.api_call.request.options[:body].should eq "{\"name\":\"foobarbaz\"}"
27
+ end
28
+ end
29
+
30
+ describe "retrieving a single object" do
31
+ describe "with the default builder" do
32
+ let(:blog_post) do
33
+ VCR.use_cassette('posts') do
34
+ BlogPost.get_json "http://api-model-specs.com/single_post"
35
+ end
36
+ end
37
+
38
+ it "should run the request and objectify the response hash" do
39
+ blog_post.should be_a(BlogPost)
40
+ blog_post.name.should eq "foo"
41
+ end
42
+ end
43
+
44
+ describe "with a custom builder" do
45
+ let(:custom_built_blog_post) do
46
+ VCR.use_cassette('posts') do
47
+ BlogPost.get_json "http://api-model-specs.com/single_post", {}, builder: BlogPost::CustomBuilder.new
48
+ end
49
+ end
50
+
51
+ it "should be possible to use a custom builder class when objectifing" do
52
+ custom_built_blog_post.should be_a(BlogPost)
53
+ custom_built_blog_post.title.should eq "FOOBAR"
54
+ end
55
+ end
56
+ end
57
+
58
+ describe "using Hashie to build with properties" do
59
+ describe "with a single object response" do
60
+ let :car do
61
+ VCR.use_cassette('cars') { Car.get_json "http://cars.com/one_convertable" }
62
+ end
63
+
64
+ it 'should build the correct object' do
65
+ car.should be_a(Car)
66
+ end
67
+
68
+ it 'should correctly rename properties' do
69
+ car.number_of_doors.should eq 2
70
+ end
71
+
72
+ it 'should correctly transform properties' do
73
+ car.top_speed.should eq 600
74
+ end
75
+
76
+ it 'should let you define custom methods as normal' do
77
+ car.is_fast?.should be_true
78
+ end
79
+
80
+ it 'should let you respect default values for properties' do
81
+ car.name.should eq "Ferrari"
82
+ end
83
+ end
84
+
85
+ describe "with a collection of objects response" do
86
+ let :cars do
87
+ VCR.use_cassette('cars') { Car.get_json "http://cars.com/fast_ones" }
88
+ end
89
+
90
+ it 'should build an array of the correct objects' do
91
+ cars.should be_a(Array)
92
+ cars.collect { |car| car.should be_a(Car) }
93
+ end
94
+
95
+ it 'should correctly rename properties' do
96
+ cars.last.number_of_doors.should eq 4
97
+ end
98
+
99
+ it 'should correctly transform properties' do
100
+ cars.last.top_speed.should eq 300
101
+ end
102
+
103
+ it 'should let you define custom methods as normal' do
104
+ cars.last.is_fast?.should be_false
105
+ end
106
+
107
+ it 'should respect default values for properties, but also override them' do
108
+ cars.first.name.should eq "Ferrari"
109
+ cars.last.name.should eq "Ford"
110
+ end
111
+ end
112
+
113
+ describe "with a single object which has properties which are undefined" do
114
+ let :new_car do
115
+ VCR.use_cassette('cars') { Car.get_json "http://cars.com/new_model" }
116
+ end
117
+
118
+ it "should not raise an exception" do
119
+ expect {
120
+ new_car
121
+ }.to_not raise_error
122
+ end
123
+
124
+ it 'should define the missing property on the fly' do
125
+ new_car.shiney.should eq true
126
+ end
127
+ end
128
+ end
129
+
130
+ end