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.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +117 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.rst +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/EXAMPLE.conf +4 -0
- data/Gemfile +39 -0
- data/LICENSE.txt +21 -0
- data/Makefile +9 -0
- data/README.md +44 -0
- data/Rakefile +34 -0
- data/bin/console +53 -0
- data/bin/setup +8 -0
- data/harvest-ruby-v2.gemspec +31 -0
- data/lib/harvest.rb +115 -0
- data/lib/harvest/client.rb +0 -0
- data/lib/harvest/config.rb +21 -0
- data/lib/harvest/creates.rb +46 -0
- data/lib/harvest/discovers.rb +58 -0
- data/lib/harvest/exceptions.rb +11 -0
- data/lib/harvest/finders.rb +17 -0
- data/lib/harvest/http/client.rb +0 -0
- data/lib/harvest/httpclient.rb +178 -0
- data/lib/harvest/resourcefactory.rb +220 -0
- data/lib/harvest/resources.rb +15 -0
- data/lib/harvest/resources/client.rb +34 -0
- data/lib/harvest/resources/company.rb +62 -0
- data/lib/harvest/resources/estimates.rb +173 -0
- data/lib/harvest/resources/expenses.rb +90 -0
- data/lib/harvest/resources/invoices.rb +262 -0
- data/lib/harvest/resources/message.rb +16 -0
- data/lib/harvest/resources/project.rb +84 -0
- data/lib/harvest/resources/project_assignment.rb +46 -0
- data/lib/harvest/resources/task.rb +35 -0
- data/lib/harvest/resources/task_assignment.rb +38 -0
- data/lib/harvest/resources/timeentry.rb +105 -0
- data/lib/harvest/resources/user.rb +75 -0
- data/lib/harvest/resources/user_assignment.rb +43 -0
- data/lib/harvest/version.rb +5 -0
- metadata +86 -0
@@ -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
|
data/lib/harvest.rb
ADDED
@@ -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
|