timestamp_api 0.2.0 → 0.3.0

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