harvest-ruby-v2 0.2.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.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/harvest/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'harvest-ruby-v2'
7
+ spec.version = Harvest::VERSION
8
+ spec.authors = ['Craig Davis']
9
+ spec.email = ['craig.davis@rackspace.com']
10
+
11
+ spec.summary = 'Fluent API Harvest Client API v2 '
12
+ spec.description = 'Harvest API for v2 written in Fluent API style'
13
+ spec.homepage = 'https://github.com/blade2005/harvest-ruby/wiki'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
16
+
17
+ # spec.metadata['allowed_push_host'] = 'TODO: Set to 'http://mygemserver.com''
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/blade2005/harvest-ruby'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/blade2005/harvest-ruby/blob/master/CHANGELOG.rst'
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
+ end
28
+ # spec.bindir = 'exe'
29
+ # spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ['lib']
31
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'date'
5
+ require 'rest-client'
6
+
7
+ require 'harvest/version'
8
+ require 'harvest/resourcefactory'
9
+ require 'harvest/httpclient'
10
+ require 'harvest/exceptions'
11
+ require 'harvest/finders'
12
+ require 'harvest/discovers'
13
+ require 'harvest/creates'
14
+
15
+ # Conform to naming pattern of Finder, Discover, Creators.
16
+ # @param key [Symbol] symbol of state
17
+ def to_class_name(key)
18
+ key.to_s.split('_').map(&:capitalize).join.to_sym
19
+ end
20
+
21
+ def merge_state(state, meth, args)
22
+ state.merge(
23
+ meth => args.first ? !args.first.nil? : [],
24
+ active: meth
25
+ )
26
+ end
27
+
28
+ # Harvest
29
+ module Harvest
30
+ # Harvest client interface
31
+ class Client
32
+ attr_reader :active_user, :client, :time_entries, :factory, :state
33
+
34
+ # @param config [Struct::Config] Configuration Struct which provides attributes
35
+ # @param state [Hash] State of the Client for FluentAPI
36
+ def initialize(config, state: { filtered: {} })
37
+ @config = config
38
+ @client = Harvest::HTTP::Api.new(**@config)
39
+ @factory = Harvest::ResourceFactory.new
40
+ @state = state
41
+ @active_user = @factory.user(@client.api_call(@client.api_caller('/users/me')))
42
+ @admin_api = if @active_user.is_admin
43
+ config.admin_api
44
+ else
45
+ false
46
+ end
47
+ end
48
+
49
+ def allowed?(meth)
50
+ %i[
51
+ projects
52
+ project_tasks
53
+ time_entry
54
+ tasks
55
+ ].include?(meth)
56
+ end
57
+
58
+ # @param meth [Symbol]
59
+ # @return [Boolean]
60
+ def respond_to_missing?(meth)
61
+ allowed?(meth)
62
+ end
63
+
64
+ # @param meth [Symbol]
65
+ # @param *args [Array] arguments passed to method.
66
+ def method_missing(meth, *args)
67
+ if allowed?(meth)
68
+ Harvest::Client.new(
69
+ @config,
70
+ state: merge_state(@state, meth, args)
71
+ )
72
+ else
73
+
74
+ super
75
+ end
76
+ rescue NoMethodError
77
+ # binding.pry
78
+ raise Harvest::Exceptions::BadState, "#{meth} is an invalid state change."
79
+ end
80
+
81
+ # Find single instance of resource
82
+ def find(id)
83
+ @state[@state[:active]] = Harvest::Finders.const_get(
84
+ to_class_name(@state[:active])
85
+ ).new.find(@factory, @client, id)
86
+ self
87
+ end
88
+
89
+ # Discover resources
90
+ def discover(**params)
91
+ @state[@state[:active]] = Harvest::Discovers.const_get(
92
+ to_class_name(@state[:active])
93
+ ).new.discover(
94
+ @admin_api, @client, @factory, active_user, @state, params
95
+ )
96
+ self
97
+ end
98
+
99
+ # Select a subset of all items depending on state
100
+ def select(&block)
101
+ @state[:filtered][@state[:active]] = @state[@state[:active]].select(&block)
102
+ self
103
+ end
104
+
105
+ # Create an instance of object based on state
106
+ def create(**kwargs)
107
+ @state[@state[:active]] = Harvest::Create.const_get(
108
+ to_class_name(@state[:active])
109
+ ).new.create(
110
+ @factory, @client, active_user, @state, kwargs
111
+ )
112
+ self
113
+ end
114
+ end
115
+ end
File without changes
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harvest
4
+ # @param domain [String] Harvest domain ex: https://company.harvestapp.com
5
+ # @param account_id [Integer] Harvest Account id
6
+ # @param personal_token [String] Harvest Personal token
7
+ # @param admin_api [Boolean] Certain API Requests will fail if you are not
8
+ # an admin in Harvest. This helps set that
9
+ # functionality to limit broken interfaces
10
+ Struct.new(
11
+ 'Config',
12
+ :domain,
13
+ :account_id,
14
+ :personal_token,
15
+ keyword_init: true
16
+ ) do
17
+ def admin_api=(value: false)
18
+ @admin_api ||= value
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Some API calls will return Project others ProjectAssignment.
4
+ def true_project(project)
5
+ return project.project if project.respond_to?(:project)
6
+
7
+ project
8
+ end
9
+
10
+ module Harvest
11
+ module Create
12
+ class TimeEntry
13
+ def create(factory, client, active_user, state, kwargs)
14
+ @state = state
15
+ @active_user = active_user
16
+ begin
17
+ factory.time_entry(
18
+ client.api_call(
19
+ client.api_caller(
20
+ 'time_entries',
21
+ http_method: 'post',
22
+ payload: time_entry_payload(kwargs).to_json,
23
+ headers: { content_type: 'application/json' }
24
+ )
25
+ )
26
+ )
27
+ rescue RestClient::UnprocessableEntity => e
28
+ puts "Harvest Error from Create Time Entry: #{JSON.parse(e.response.body)['message']}"
29
+ raise
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # @api private
36
+ def time_entry_payload(kwargs)
37
+ possible_keys = %i[spent_date notes external_reference user_id]
38
+ payload = kwargs.map { |k, v| [k, v] if possible_keys.include?(k) }.to_h
39
+ payload[:user_id] ||= @active_user.id
40
+ payload[:task_id] = @state[:filtered][:project_tasks][0].task.id
41
+ payload[:project_id] = true_project(@state[:filtered][:projects][0]).id
42
+ payload
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harvest
4
+ module Discovers
5
+ class Projects
6
+ def discover(admin_api, client, factory, active_user, _state, _params)
7
+ @client = client
8
+ @factory = factory
9
+ @active_user = active_user
10
+ admin_api ? admin_projects : project_assignments
11
+ end
12
+
13
+ private
14
+
15
+ # @api private
16
+ # All Projects
17
+ def admin_projects
18
+ @client
19
+ .api_call(
20
+ @client.api_caller('projects')
21
+ )['projects']
22
+ .map { |project| @factory.project(project) }
23
+ end
24
+
25
+ # @api private
26
+ # Projects assigned to the specified user_id
27
+ def project_assignments(user_id: @active_user.id)
28
+ @client
29
+ .api_call(
30
+ @client.api_caller(
31
+ "users/#{user_id}/project_assignments"
32
+ )
33
+ )['project_assignments']
34
+ .map do |project|
35
+ @factory.project_assignment(project)
36
+ end
37
+ end
38
+ end
39
+
40
+ class TimeEntry
41
+ def discover(_admin_api, client, factory, _active_user, _state, params)
42
+ paginator = client.paginator
43
+ paginator.path = 'time_entries'
44
+ paginator.data_key = 'time_entries'
45
+ paginator.param = params
46
+ client.pagination(paginator).map do |time_entry|
47
+ factory.time_entry(time_entry)
48
+ end
49
+ end
50
+ end
51
+
52
+ class ProjectTasks
53
+ def discover(_admin_api, _client, _factory, _active_user, state, _params)
54
+ state[:filtered][:projects][0].task_assignments
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harvest
4
+ module Exceptions
5
+ class Error < StandardError; end
6
+ class BadState < Error; end
7
+ class ProjectError < Error; end
8
+ class TooManyProjects < ProjectError; end
9
+ class NoProjectsFound < ProjectError; end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harvest
4
+ module Finders
5
+ class Projects
6
+ def find(factory, client, id)
7
+ [factory.project(client.api_call(client.api_caller("projects/#{id}")))]
8
+ end
9
+ end
10
+
11
+ class TimeEntry
12
+ def find(factory, client, id)
13
+ [factory.time_entry(client.api_call(client.api_caller("time_entry/#{id}")))]
14
+ end
15
+ end
16
+ end
17
+ end
File without changes
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harvest
4
+ module HTTP
5
+ # lower level class which create the Client for making api calls
6
+ class Client
7
+ def initialize(state: {})
8
+ @state = state
9
+ end
10
+
11
+ attr_reader :state
12
+
13
+ def method_missing(meth, *args)
14
+ if allowed?(meth)
15
+ Client.new(
16
+ state: @state.merge(meth => args.first)
17
+ )
18
+ else
19
+ super
20
+ end
21
+ end
22
+
23
+ def allowed?(meth)
24
+ %i[
25
+ domain
26
+ headers
27
+ client
28
+ ].include?(meth)
29
+ end
30
+
31
+ def respond_to_missing?(*)
32
+ super
33
+ end
34
+
35
+ def headers(personal_token, account_id)
36
+ Client.new(
37
+ state: @state.merge(
38
+ {
39
+ headers: {
40
+ 'User-Agent' => 'harvest-ruby API Client',
41
+ 'Authorization' => "Bearer #{personal_token}",
42
+ 'Harvest-Account-ID' => account_id
43
+ }
44
+ }
45
+ )
46
+ )
47
+ end
48
+
49
+ def client
50
+ RestClient::Resource.new(
51
+ "#{@state[:domain].chomp('/')}/api/v2",
52
+ headers: @state[:headers]
53
+ )
54
+ end
55
+ end
56
+ # Make
57
+ class Api
58
+ def initialize(domain:, account_id:, personal_token:)
59
+ @domain = domain
60
+ @account_id = account_id
61
+ @personal_token = personal_token
62
+ end
63
+
64
+ def client
65
+ Harvest::HTTP::Client
66
+ .new
67
+ .domain(@domain)
68
+ .headers(@personal_token, @account_id)
69
+ .client
70
+ end
71
+
72
+ # Make a api call to an endpoint.
73
+ # @api public
74
+ # @param struct [Struct::ApiCall]
75
+ def api_call(struct)
76
+ JSON.parse(
77
+ send(struct.http_method.to_sym, struct).tap do
78
+ require 'pry'
79
+ # binding.pry
80
+ end
81
+ )
82
+ end
83
+
84
+ # Pagination through request
85
+ # @api public
86
+ # @param struct [Struct::Pagination]
87
+ def pagination(struct)
88
+ struct.param[:page] = struct.page_count
89
+ page = api_call(struct.to_api_call)
90
+ struct.rows.concat(page[struct.data_key])
91
+
92
+ return struct.rows if struct.page_count >= page['total_pages']
93
+
94
+ struct.page_count += 1
95
+ pagination(struct)
96
+ end
97
+
98
+ # Create Paginaation struct message to pass to pagination call
99
+ def paginator(http_method: 'get', page_count: 1, param: {}, entries: [], headers: {})
100
+ Struct::Pagination.new(
101
+ {
102
+ http_method: http_method,
103
+ param: param,
104
+ rows: entries,
105
+ page_count: page_count,
106
+ headers: headers
107
+ }
108
+ )
109
+ end
110
+
111
+ def api_caller(path, http_method: 'get', param: {}, payload: nil, headers: {})
112
+ Struct::ApiCall.new(
113
+ {
114
+ path: path,
115
+ http_method: http_method.to_sym,
116
+ param: param,
117
+ payload: payload,
118
+ headers: headers
119
+ }
120
+ )
121
+ end
122
+
123
+ private
124
+
125
+ def get(struct)
126
+ client[struct.path].get(struct.headers)
127
+ end
128
+
129
+ def post(struct)
130
+ client[struct.path].post(struct.payload, struct.headers)
131
+ end
132
+ end
133
+
134
+ Struct.new(
135
+ 'ApiCall',
136
+ :path,
137
+ :http_method,
138
+ :param,
139
+ :payload,
140
+ :headers,
141
+ keyword_init: true
142
+ ) do
143
+ def param(params)
144
+ headers['params'] = params
145
+ end
146
+ end
147
+
148
+ Struct.new(
149
+ 'Pagination',
150
+ :path,
151
+ :data_key,
152
+ :http_method,
153
+ :page_count,
154
+ :param,
155
+ :payload,
156
+ :rows,
157
+ :headers,
158
+ keyword_init: true
159
+ ) do
160
+ def to_api_call
161
+ Struct::ApiCall.new(
162
+ {
163
+ path: path,
164
+ http_method: http_method,
165
+ param: param,
166
+ payload: payload,
167
+ headers: headers
168
+ }
169
+ )
170
+ end
171
+
172
+ def increment_page
173
+ self.page_count += 1
174
+ self
175
+ end
176
+ end
177
+ end
178
+ end