harvest-ruby-v2 0.2.0

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