harvest-ruby-v2 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|