timestamp_api 0.2.0 → 0.3.0

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: 6a870a3e355cac05312491e1f1de0868959a58cb
4
- data.tar.gz: 030bdb7e4306a523c303f66121b003661b7ea38a
3
+ metadata.gz: 1a9b651bd6a1f81bb14b9edc23be1403ee37a4d3
4
+ data.tar.gz: 50238b9ee2ab12eb51b8fd4e5f82657c3cfb0385
5
5
  SHA512:
6
- metadata.gz: b84b0632fd788418b36bc86a1398326973cc64f9676a875579285e35345aa2523f4789ddc75d427fe34b74e08866f2c762e355556e2f17b35ea831fe1a8c165f
7
- data.tar.gz: d63292c7170080dc6b3b72b6a5183db06fe0bbb986610d22a9bf11769a1adfbf6cb4ec7043c53d97060685389114c0ab6ddee2a8ec9ef6d1396f77b25cb68286
6
+ metadata.gz: 5a72476235ef88e59f68f3e8d56a0d34c657581cf2f0cd4e2bbe626db7ec8d4193f89b5e90d45a14b7a299f326d431c93d204cd0d9ac4bfc9cd2ed974a46caa3
7
+ data.tar.gz: f6b28f91da78b36c7d8240fc1c37c5d16b21c2fac7242d4397fdcdb09cb87e0bf5773bcc60b4f46aa865333c337b7305ef3114277f72dce03f37942013e1d4c2
data/README.md CHANGED
@@ -32,18 +32,72 @@ Configure your Timestamp API key by setting environment variable `TIMESTAMP_API_
32
32
  TimestampAPi.api_key = "YOUR_TIMESTAMP_API_KEY"
33
33
  ```
34
34
 
35
+ #### Projects
36
+
35
37
  List all projects:
36
38
  ```ruby
37
- projects = TimestampAPI::Project.all
38
- projects.map(&:name) # => ["A project", "Another project", "One more project"]
39
+ TimestampAPI::Project.all
39
40
  ```
40
41
 
41
42
  Find a given project:
42
43
  ```ruby
43
- project = TimestampAPI::Project.find(123456)
44
+ project = TimestampAPI::Project.find(123)
45
+
46
+ project.name # => "My awesome project"
47
+ ```
48
+
49
+ #### Clients
50
+
51
+ List all clients:
52
+ ```ruby
53
+ TimestampAPI::Client.all
54
+ ```
55
+
56
+ Find a given client:
57
+ ```ruby
58
+ client = TimestampAPI::Client.find(123)
59
+
60
+ client.name # => "My beloved customer"
61
+ ```
62
+
63
+ Find the client of a project
64
+ ```ruby
65
+ project = TimestampAPI::Project.find(123)
66
+
44
67
  project.client.name # => "My beloved customer"
45
68
  ```
46
69
 
70
+ ## Models
71
+
72
+ The objects are represented by model classes (that inherits from `TimestampAPI::Model`):
73
+ ```ruby
74
+ project = TimestampAPI::Project.find(123456)
75
+
76
+ project.class # => TimestampAPI::Project
77
+ project.is_a? TimestampAPI::Model # => true
78
+ ```
79
+
80
+ Collections of objects are represented by `TimestampAPI::Collection` that inherits from `Array` (and implement the chainable `.where(conditions)` filter method described above). It means any `Array` method works on `TimestampAPI::Collection`:
81
+ ```ruby
82
+ projects = TimestampAPI::Project.all
83
+
84
+ projects.class # => TimestampAPI::Collection
85
+ projects.map(&:name) # => ["A project", "Another project", "One more project"]
86
+ ```
87
+
88
+ ## Filtering
89
+
90
+ You can filter any object collection using the handy `.where()` syntax:
91
+ ```ruby
92
+ projects = TimestampAPI::Project.all
93
+
94
+ projects.where(is_public: true) # => returns all public projects
95
+ projects.where(is_public: true, is_billable: true) # => returns all projects that are both public and billable
96
+ projects.where(is_public: true).where(is_billable: true) # => same as above: `where` is chainable \o/
97
+ ```
98
+
99
+ :information_source: This does not filter objects **before** the network call (like ActiveRecord does), it's only a more elegant way of calling `Array#select` on the `Collection`
100
+
47
101
  ### Low level API calls
48
102
 
49
103
  The above methods are simple wrappers around the generic low-level-ish API request method `TimestampAPI.request` that take a HTTP `method` (verb) and a `path` (to be appended to preconfigured API endpoint URL):
@@ -52,12 +106,9 @@ TimestampAPI.request(:get, "/projects") # Same as TimestampAPI::Project.a
52
106
  TimestampAPI.request(:get, "/projects/123456") # Same as TimestampAPI::Project.find(123456)
53
107
  ```
54
108
 
55
- Response is provided as a [RecursiveOpenStruct](https://github.com/aetherknight/recursive-open-struct) (or as an `Array` of `RecursiveOpenStruct`), thus can be accessed by:
109
+ To output all network requests done, you can set verbosity on:
56
110
  ```ruby
57
- project = TimestampAPI.request(:get, "/projects/123456")
58
- project.id # => 123456
59
- project.name # => "Awesome project"
60
- project.client.name # => "My beloved customer"
111
+ TimestampAPI.verbose = true
61
112
  ```
62
113
 
63
114
  ## Reverse engineering
@@ -74,12 +125,14 @@ There's also a `bin/console` executable provided with this gem, if you want a RE
74
125
 
75
126
  ### What's implemented already ?
76
127
 
77
- * [x] Project#all
78
- * [x] Project#find
128
+ * [x] `Project#all`
129
+ * [x] `Project#find`
130
+ * [x] `belongs_to` relationships
79
131
 
80
132
  ### What's not implemented yet ?
81
133
 
82
- * [ ] _everything else_ :scream:
134
+ * [ ] _all other models_ :scream:
135
+ * [ ] `has_many` relationships
83
136
 
84
137
  ## Development
85
138
 
@@ -0,0 +1,10 @@
1
+ module TimestampAPI
2
+ class Collection < Array
3
+ def where(conditions)
4
+ raise TimestampAPI::InvalidWhereContitions unless conditions.is_a? Hash
5
+ conditions.each_with_object(self.dup) do |condition, acc|
6
+ acc.select! { |i| i.send(condition.first.to_sym) == condition.last }
7
+ end
8
+ end
9
+ end
10
+ end
@@ -5,9 +5,92 @@ module TimestampAPI
5
5
  end
6
6
  end
7
7
 
8
+ class InvalidAPIKey < StandardError
9
+ def message
10
+ "Configured API key is invalid."
11
+ end
12
+ end
13
+
8
14
  class InvalidServerResponse < StandardError
9
15
  def message
10
- "Server responded with invalid JSON. A possible cause is an invalid or revoked API key."
16
+ "Server responded with invalid JSON."
17
+ end
18
+ end
19
+
20
+ class InvalidModelData < StandardError
21
+ attr_reader :caller_class, :json_data
22
+
23
+ def initialize(caller_class, json_data)
24
+ @caller_class = caller_class
25
+ @json_data = json_data
26
+ end
27
+
28
+ def message
29
+ if json_data.is_a? Hash
30
+ "A `#{caller_class}` class was initialized with JSON data for a `#{json_data["object"] || "unknown"}` object."
31
+ else
32
+ "A `#{caller_class}` class was initialized with data which is not a `Hash` (it was a `#{json_data.class}`, actually)."
33
+ end
34
+ end
35
+ end
36
+
37
+ class UnknownModelData < StandardError
38
+ attr_reader :json_object
39
+
40
+ def initialize(json_object = nil)
41
+ @json_object = json_object
42
+ end
43
+
44
+ def message
45
+ if json_object
46
+ "JSON data with object type `#{json_object}` has no matching model implemented."
47
+ else
48
+ "JSON data doesn't have an `object` field or it's not valid JSON data."
49
+ end
50
+ end
51
+ end
52
+
53
+ class InvalidWhereContitions < StandardError
54
+ def message
55
+ "Conditions passed to `Collection#where` must be a hash."
56
+ end
57
+ end
58
+
59
+ class UnknownAssociation < StandardError
60
+ attr_reader :klass, :association_name
61
+
62
+ def initialize(klass, association_name)
63
+ @klass = klass
64
+ @association_name = association_name
65
+ end
66
+
67
+ def message
68
+ "Association `#{association_name}` could not be found on model `#{klass}`."
69
+ end
70
+ end
71
+
72
+ class ResourceNotFound < StandardError
73
+ attr_reader :klass, :id
74
+
75
+ def initialize(klass, id)
76
+ @klass = klass
77
+ @id = id
78
+ end
79
+
80
+ def message
81
+ "No `#{klass}` was found with id `#{id}` (API returned 404)"
82
+ end
83
+ end
84
+
85
+ class APIPathNotSet < StandardError
86
+ attr_reader :klass
87
+
88
+ def initialize(klass)
89
+ @klass = klass
90
+ end
91
+
92
+ def message
93
+ "API path not defined in `#{klass}` model. Use `api_path` to define it."
11
94
  end
12
95
  end
13
96
  end
@@ -0,0 +1,31 @@
1
+ module TimestampAPI
2
+ class Model
3
+ attr_reader :json_data
4
+
5
+ include Hooks
6
+ define_hooks :after_initialize, :after_inherited
7
+
8
+ include Utils
9
+ include ModelAttributes
10
+ include ModelRelations
11
+ include ModelDefaultAPIMethods
12
+
13
+ def self.inherited(subclass)
14
+ ModelRegistry.register(subclass)
15
+ run_hook :after_inherited, subclass
16
+ end
17
+
18
+ def initialize(json_data)
19
+ @json_data = json_data
20
+ validate_init_data!
21
+ run_hook :after_initialize
22
+ end
23
+
24
+ private
25
+
26
+ def validate_init_data!
27
+ class_basename = self.class.name.split("::").last
28
+ raise InvalidModelData.new(class_basename, json_data) unless json_data.is_a?(Hash) && json_data["object"] == class_basename.downcase
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ module TimestampAPI
2
+ module ModelAttributes
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ base.class_eval do
6
+ after_initialize do
7
+ self.class.class_variable_get(:@@attributes).each do |attribute|
8
+ instance_variable_set(:"@#{attribute}", json_data[camelize(attribute)])
9
+ end
10
+ end
11
+
12
+ after_inherited do |subclass|
13
+ subclass.class_variable_set(:@@attributes, [])
14
+ end
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ def has_attributes(*attributes)
20
+ # Add those attributes to the list of attributes for this class
21
+ self.class_variable_set(:@@attributes, self.class_variable_get(:@@attributes) + attributes)
22
+ # Define getters for those attributes
23
+ self.send(:attr_accessor, *attributes)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ module TimestampAPI
2
+ module ModelDefaultAPIMethods
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ base.class_eval do
6
+ after_inherited do |subclass|
7
+ subclass.class_variable_set(:@@api_path, nil)
8
+ end
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def api_path(path = nil)
14
+ path.nil? ? self.class_variable_get(:@@api_path) : self.class_variable_set(:@@api_path, path)
15
+ end
16
+
17
+ def all
18
+ raise APIPathNotSet.new(self) if api_path.nil?
19
+ TimestampAPI.request(:get, api_path)
20
+ end
21
+
22
+ def find(id)
23
+ return nil if id.nil?
24
+ raise APIPathNotSet.new(self) if api_path.nil?
25
+ TimestampAPI.request(:get, "#{api_path}/#{id}")
26
+ rescue RestClient::ResourceNotFound
27
+ raise ResourceNotFound.new(self, id)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ module TimestampAPI
2
+ class ModelRegistry
3
+ class << self
4
+
5
+ @@registry = {}
6
+
7
+ def register(klass)
8
+ @@registry[registry_key(klass)] = klass unless klass.name.nil?
9
+ end
10
+
11
+ def registry
12
+ @@registry
13
+ end
14
+
15
+ def model_for(json_data)
16
+ raise UnknownModelData.new unless json_data.is_a? Hash
17
+ registry[json_data["object"]] || raise(UnknownModelData.new(json_data["object"]))
18
+ end
19
+
20
+ private
21
+
22
+ def registry_key(klass)
23
+ klass.name.split("::").last.gsub(/(.)([A-Z])/, '\1_\2').downcase
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ module TimestampAPI
2
+ module ModelRelations
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ base.class_eval do
6
+ after_initialize do
7
+ self.class.class_variable_get(:@@belongs_to).each do |association_name|
8
+ instance_variable_set(:"@_#{association_name}_id", json_data[camelize(association_name)]["id"]) if json_data.has_key? camelize(association_name)
9
+ end
10
+ end
11
+
12
+ after_inherited do |subclass|
13
+ subclass.class_variable_set(:@@belongs_to, [])
14
+ end
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ def belongs_to(association_name)
20
+ # Add this association to the list of associations for this class
21
+ self.class_variable_set(:@@belongs_to, self.class_variable_get(:@@belongs_to) + [association_name])
22
+ # Define a memoizing getter for this association
23
+ define_method(association_name) do
24
+ return instance_variable_get(:"@#{association_name}") unless instance_variable_get(:"@#{association_name}").nil?
25
+ unknown_association_error = UnknownAssociation.new(self, association_name)
26
+ associationship_id = instance_variable_get(:"@_#{association_name}_id")
27
+ association_class = ModelRegistry.registry[association_name.to_s] || raise(unknown_association_error)
28
+ instance_variable_set(:"@#{association_name}", association_class.find(associationship_id))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,7 @@
1
+ module TimestampAPI
2
+ class Client < Model
3
+ api_path "/clients"
4
+
5
+ has_attributes :id, :created_at, :updated_at, :account_id, :name, :code, :is_archived, :is_account_default
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module TimestampAPI
2
+ class Project < Model
3
+ api_path "/projects"
4
+
5
+ has_attributes :id, :created_at, :updated_at, :account_id, :name, :code, :color, :initiation_date,
6
+ :target_completion_date, :is_archived, :is_billable, :is_public, :is_approvable
7
+
8
+ belongs_to :client
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ module TimestampAPI
2
+ module Utils
3
+ def camelize(symbol)
4
+ symbol.to_s.split('_').each_with_index.map{ |chunk, idx| idx == 0 ? chunk : chunk.capitalize }.join
5
+ end
6
+ end
7
+ end
@@ -1,3 +1,3 @@
1
1
  module TimestampAPI
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/timestamp_api.rb CHANGED
@@ -1,41 +1,63 @@
1
1
  require "rest-client"
2
- require "recursive-open-struct"
2
+ require "colorize"
3
+ require "hooks"
3
4
 
4
5
  require "timestamp_api/version"
5
6
  require "timestamp_api/errors"
6
- require "timestamp_api/project"
7
+ require "timestamp_api/utils"
8
+ require "timestamp_api/model_registry"
9
+ require "timestamp_api/model_attributes"
10
+ require "timestamp_api/model_relations"
11
+ require "timestamp_api/model_default_api_methods"
12
+ require "timestamp_api/model"
13
+ require "timestamp_api/collection"
14
+ require "timestamp_api/models/project"
15
+ require "timestamp_api/models/client"
7
16
 
8
17
  module TimestampAPI
9
18
  @api_endpoint = "https://api.ontimestamp.com/api"
10
19
 
11
20
  class << self
12
- attr_accessor :api_endpoint, :api_key
21
+ attr_accessor :api_endpoint, :api_key, :verbose
13
22
  end
14
23
 
15
- def self.request(method, url)
16
- response = RestClient::Request.execute(request_options(method, url))
17
- objectify(JSON.parse(response))
24
+ def self.request(method, path, query_params = {})
25
+ output(method, path, query_params) if verbose
26
+ response = RestClient::Request.execute(request_options(method, path, query_params))
27
+ modelify(JSON.parse(response))
28
+ rescue RestClient::Forbidden
29
+ raise InvalidAPIKey
18
30
  rescue JSON::ParserError
19
31
  raise InvalidServerResponse
20
32
  end
21
33
 
22
- def self.request_options(method, url)
34
+ private
35
+
36
+ def self.request_options(method, path, query_params)
23
37
  {
24
38
  method: method,
25
- url: api_endpoint + url,
39
+ url: api_endpoint + path,
26
40
  headers: {
27
41
  "X-API-Key" => api_key || ENV["TIMESTAMP_API_KEY"] || raise(MissingAPIKey),
28
42
  :accept => :json,
29
- :user_agent => "TimestampAPI Ruby gem https://github.com/alpinelab/timestamp_api"
43
+ :user_agent => "TimestampAPI Ruby gem (https://github.com/alpinelab/timestamp_api)",
44
+ :params => query_params
30
45
  }
31
46
  }
32
47
  end
33
48
 
34
- def self.objectify(json)
49
+ def self.modelify(json)
35
50
  case json
36
- when Array then json.map { |item| RecursiveOpenStruct.new(item, recurse_over_arrays: true) }
37
- when Hash then RecursiveOpenStruct.new(json, recurse_over_arrays: true)
38
- else json
51
+ when Array then Collection.new(json.map { |item| modelify(item) })
52
+ when Hash then ModelRegistry.model_for(json).new(json)
39
53
  end
40
54
  end
55
+
56
+ def self.output(method, path, query_params)
57
+ print "TimestampAPI ".colorize(:red)
58
+ print "#{method.upcase} ".colorize(:yellow)
59
+ full_path = path
60
+ full_path += "?#{query_params.each_with_object([]) { |p, acc| acc << "#{p[0]}=#{p[1]}" }.join("&")}" unless query_params.empty?
61
+ puts full_path.colorize(:yellow)
62
+ end
41
63
  end
@@ -19,12 +19,14 @@ Gem::Specification.new do |spec|
19
19
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
20
  spec.require_paths = ["lib"]
21
21
 
22
- spec.add_runtime_dependency "rest-client"
23
- spec.add_runtime_dependency "recursive-open-struct"
22
+ spec.add_runtime_dependency "rest-client", ">= 1.7.3"
23
+ spec.add_runtime_dependency "colorize"
24
+ spec.add_runtime_dependency "hooks"
24
25
 
25
26
  spec.add_development_dependency "bundler", "~> 1.9"
26
27
  spec.add_development_dependency "rake", "~> 10.0"
27
28
  spec.add_development_dependency "rspec"
28
29
  spec.add_development_dependency "pry"
29
30
  spec.add_development_dependency "awesome_print"
31
+ spec.add_development_dependency "webmock", "~> 1.22"
30
32
  end
metadata CHANGED
@@ -1,17 +1,31 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timestamp_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Baudino
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-01-24 00:00:00.000000000 Z
11
+ date: 2016-01-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.7.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.7.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: colorize
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - ">="
@@ -25,7 +39,7 @@ dependencies:
25
39
  - !ruby/object:Gem::Version
26
40
  version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
- name: recursive-open-struct
42
+ name: hooks
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - ">="
@@ -108,6 +122,20 @@ dependencies:
108
122
  - - ">="
109
123
  - !ruby/object:Gem::Version
110
124
  version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: webmock
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.22'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.22'
111
139
  description: 'Timestamp is "real-time project tracking for you and your clients" according
112
140
  to their website: https://www.timestamphq.com'
113
141
  email:
@@ -129,8 +157,16 @@ files:
129
157
  - bin/console
130
158
  - bin/setup
131
159
  - lib/timestamp_api.rb
160
+ - lib/timestamp_api/collection.rb
132
161
  - lib/timestamp_api/errors.rb
133
- - lib/timestamp_api/project.rb
162
+ - lib/timestamp_api/model.rb
163
+ - lib/timestamp_api/model_attributes.rb
164
+ - lib/timestamp_api/model_default_api_methods.rb
165
+ - lib/timestamp_api/model_registry.rb
166
+ - lib/timestamp_api/model_relations.rb
167
+ - lib/timestamp_api/models/client.rb
168
+ - lib/timestamp_api/models/project.rb
169
+ - lib/timestamp_api/utils.rb
134
170
  - lib/timestamp_api/version.rb
135
171
  - timestamp_api.gemspec
136
172
  homepage: https://github.com/alpinelab/timestamp_api
@@ -1,11 +0,0 @@
1
- module TimestampAPI
2
- class Project
3
- def self.all
4
- TimestampAPI.request(:get, "/projects")
5
- end
6
-
7
- def self.find(id)
8
- TimestampAPI.request(:get, "/projects/#{id}")
9
- end
10
- end
11
- end