api-model 0.0.2 → 0.0.3

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