diff options
| author | Zack Hobson | 2013-12-22 23:28:00 -0800 |
|---|---|---|
| committer | Zack Hobson | 2013-12-22 23:28:00 -0800 |
| commit | 11310cefb8206487337b8040f9ceb70e570a06f7 (patch) | |
| tree | f9b3624e4210616e48108ff650fa229c125d894d | |
| parent | d315a241dbd6c959085b26a413fc5e40683d63f0 (diff) | |
| parent | b8d0ef7022541999c2267fc7937f2a60e0da602c (diff) | |
| download | hcl-11310cefb8206487337b8040f9ceb70e570a06f7.tar.bz2 | |
Merge pull request #46 from zenhob/faraday
Switch to a Faraday-based JSON API client for Harvest.
| -rw-r--r-- | Gemfile | 1 | ||||
| -rw-r--r-- | Gemfile.lock | 16 | ||||
| -rw-r--r-- | hcl.gemspec | 3 | ||||
| -rw-r--r-- | lib/hcl.rb | 2 | ||||
| -rw-r--r-- | lib/hcl/app.rb | 10 | ||||
| -rw-r--r-- | lib/hcl/commands.rb | 3 | ||||
| -rw-r--r-- | lib/hcl/day_entry.rb | 22 | ||||
| -rw-r--r-- | lib/hcl/harvest_middleware.rb | 47 | ||||
| -rw-r--r-- | lib/hcl/net.rb | 48 | ||||
| -rw-r--r-- | lib/hcl/task.rb | 32 | ||||
| -rw-r--r-- | lib/hcl/timesheet_resource.rb | 88 | ||||
| -rw-r--r-- | test/app_test.rb | 6 | ||||
| -rw-r--r-- | test/day_entry_test.rb | 38 | ||||
| -rw-r--r-- | test/net_test.rb (renamed from test/timesheet_resource_test.rb) | 27 | ||||
| -rw-r--r-- | test/task_test.rb | 63 |
15 files changed, 173 insertions, 233 deletions
@@ -1,5 +1,6 @@ source "https://rubygems.org" gemspec +gem 'pry', group:['test','development'] # XXX this is dumb but it's crazy hard to get platform specfic deps into a gemspec gem 'rubysl-abbrev', platform:'rbx' gem 'rubysl-singleton', platform:'rbx' diff --git a/Gemfile.lock b/Gemfile.lock index 3e5e81e..5572f3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,20 +3,34 @@ PATH specs: hcl (0.4.9) chronic + faraday + faraday_middleware highline + multi_json trollop GEM remote: https://rubygems.org/ specs: chronic (0.10.2) + coderay (1.0.9) fakeweb (1.3.0) + faraday (0.8.8) + multipart-post (~> 1.2.0) + faraday_middleware (0.9.0) + faraday (>= 0.7.4, < 0.9) highline (1.6.20) metaclass (0.0.1) + method_source (0.8.2) minitest (4.7.5) mocha (0.14.0) metaclass (~> 0.0.1) multi_json (1.8.2) + multipart-post (1.2.0) + pry (0.9.12.2) + coderay (~> 1.0.5) + method_source (~> 0.8) + slop (~> 3.4) rake (10.1.0) rubinius-coverage (2.0.3) rubygems-tasks (0.2.4) @@ -28,6 +42,7 @@ GEM multi_json (~> 1.0) simplecov-html (~> 0.7.1) simplecov-html (0.7.1) + slop (3.4.7) trollop (2.0) yajl-ruby (1.1.0) yard (0.8.7.3) @@ -41,6 +56,7 @@ DEPENDENCIES hcl! minitest mocha + pry rake rubinius-coverage rubygems-tasks diff --git a/hcl.gemspec b/hcl.gemspec index ec2222b..4b769fc 100644 --- a/hcl.gemspec +++ b/hcl.gemspec @@ -18,6 +18,9 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'trollop' s.add_runtime_dependency 'chronic' s.add_runtime_dependency 'highline' + s.add_runtime_dependency 'faraday' + s.add_runtime_dependency 'faraday_middleware' + s.add_runtime_dependency 'multi_json' s.add_development_dependency 'rake' s.add_development_dependency 'rubygems-tasks' s.add_development_dependency 'mocha' @@ -1,10 +1,12 @@ module HCl autoload :VERSION, 'hcl/version' autoload :App, 'hcl/app' + autoload :Net, 'hcl/net' autoload :Commands, 'hcl/commands' autoload :TimesheetResource, 'hcl/timesheet_resource' autoload :Utility, 'hcl/utility' autoload :Project, 'hcl/project' autoload :Task, 'hcl/task' autoload :DayEntry, 'hcl/day_entry' + autoload :HarvestMiddleware, 'hcl/harvest_middleware' end diff --git a/lib/hcl/app.rb b/lib/hcl/app.rb index 1bceab8..1f641d5 100644 --- a/lib/hcl/app.rb +++ b/lib/hcl/app.rb @@ -62,15 +62,15 @@ module HCl rescue SocketError => e $stderr.puts "Connection failed. (#{e.message})" exit 1 - rescue TimesheetResource::ThrottleFailure => e + rescue HarvestMiddleware::ThrottleFailure => e $stderr.puts "Too many requests, retrying in #{e.retry_after+5} seconds..." sleep e.retry_after+5 run - rescue TimesheetResource::AuthFailure => e + rescue HarvestMiddleware::AuthFailure => e $stderr.puts "Unable to authenticate: #{e}" request_config run - rescue TimesheetResource::Failure => e + rescue HarvestMiddleware::Failure => e $stderr.puts "API failure: #{e}" exit 1 end @@ -140,7 +140,7 @@ EOM if has_security_command? load_password config end - TimesheetResource.configure config + Net.configure config else request_config end @@ -153,7 +153,7 @@ EOM config['password'] = ask("Password: ") { |q| q.echo = false }.to_s config['subdomain'] = ask("Subdomain: ").to_s config['ssl'] = /^y/.match(ask("Use SSL? (y/n): ").downcase) - TimesheetResource.configure config + Net.configure config write_config config end diff --git a/lib/hcl/commands.rb b/lib/hcl/commands.rb index e9fd847..98daff5 100644 --- a/lib/hcl/commands.rb +++ b/lib/hcl/commands.rb @@ -7,8 +7,7 @@ module HCl # Display a sanitized view of your auth credentials. def config - TimesheetResource.config_hash.merge(password:'***'). - map {|k,v| "#{k}: #{v}" }.join("\n") + Net.config_hash.merge(password:'***').map {|k,v| "#{k}: #{v}" }.join("\n") end def tasks project_code=nil diff --git a/lib/hcl/day_entry.rb b/lib/hcl/day_entry.rb index 6ef1708..645bb3d 100644 --- a/lib/hcl/day_entry.rb +++ b/lib/hcl/day_entry.rb @@ -1,5 +1,3 @@ -require 'rexml/document' - module HCl class DayEntry < TimesheetResource include Utility @@ -8,7 +6,9 @@ module HCl # defaults to today. def self.all date = nil url = date.nil? ? 'daily' : "daily/#{date.strftime '%j/%Y'}" - from_xml get(url) + doc = Net.get url + Task.cache_tasks_hash doc + doc[:day_entries].map {|e| new e} end def to_s @@ -19,19 +19,10 @@ module HCl @data[:task] end - def self.from_xml xml - doc = REXML::Document.new xml - raise Failure, "No root node in XML document: #{xml}" if doc.root.nil? - Task.cache_tasks doc - doc.root.elements.collect('//day_entry') do |day| - new xml_to_hash(day) - end - end - def cancel begin DayEntry.delete("daily/delete/#{id}") - rescue TimesheetResource::Failure + rescue HarvestMiddleware::Failure return false end true @@ -46,8 +37,7 @@ module HCl # If I don't include hours it gets reset. # This doens't appear to be the case for task and project. (self.notes << "\n#{new_notes}").lstrip! - DayEntry.post "daily/update/#{id}", - %{<request><notes>#{notes}</notes><hours>#{hours}</hours></request>} + Net.post "daily/update/#{id}", notes:notes, hours:hours end def self.with_timer date=nil @@ -73,7 +63,7 @@ module HCl end def toggle - DayEntry.get("daily/timer/#{id}") + Net.get("daily/timer/#{id}") self end diff --git a/lib/hcl/harvest_middleware.rb b/lib/hcl/harvest_middleware.rb new file mode 100644 index 0000000..4d742ed --- /dev/null +++ b/lib/hcl/harvest_middleware.rb @@ -0,0 +1,47 @@ +require 'faraday_middleware/response_middleware' +require 'multi_json' +require 'cgi' + +class HCl::HarvestMiddleware < FaradayMiddleware::ResponseMiddleware + class Failure < StandardError; end + class AuthFailure < StandardError; end + class ThrottleFailure < StandardError + attr_reader :retry_after + def initialize env + @retry_after = env[:response_headers]['retry-after'].to_i + super "Too many requests! Try again in #{@retry_after} seconds." + end + end + + def call(env) + @app.call(env).on_complete do |env| + case env[:status] + when 200..299 + begin + env[:body] = unescape(MultiJson.load(env[:body], symbolize_keys:true)) + rescue MultiJson::LoadError + env[:body] + end + when 300..399 + raise Failure, "Redirected! Perhaps your ssl configuration variable is set incorrectly?" + when 400..499 + raise AuthFailure, "Login failed." + when 503 + raise ThrottleFailure, env + else + raise Failure, "Unexpected response from the upstream API." + end + end + end + + def unescape obj + if obj.kind_of? Hash + obj.inject({}){|o,(k,v)| o[k] = unescape(v);o} + elsif obj.kind_of? Array + obj.inject([]){|o,v| o << unescape(v);o} + else + CGI.unescape_html(obj.to_s) + end + end + +end diff --git a/lib/hcl/net.rb b/lib/hcl/net.rb new file mode 100644 index 0000000..874240c --- /dev/null +++ b/lib/hcl/net.rb @@ -0,0 +1,48 @@ +require 'faraday_middleware' + +module HCl + module Net + class << self + # configuration accessors + CONFIG_VARS = [ :login, :password, :subdomain, :ssl ].freeze + CONFIG_VARS.each { |config_var| attr_accessor config_var } + + def config_hash + CONFIG_VARS.inject({}) {|c,k| c.update(k => send(k)) } + end + + def configure opts = nil + if opts + self.login = opts['login'] + self.password = opts['password'] + self.subdomain = opts['subdomain'] + self.ssl = opts['ssl'] + end + end + + def faraday + @faraday ||= Faraday.new( + "http#{ssl && 's'}://#{subdomain}.harvestapp.com" + ) do |f| + f.headers['Accept'] = 'application/json' + f.request :json + f.request :basic_auth, login, password + f.use HCl::HarvestMiddleware, content_type: /\bjson\b/ + f.adapter Faraday.default_adapter + end + end + + def get action + faraday.get(action).body + end + + def post action, data + faraday.post(action, data).body + end + + def delete action + faraday.delete(action).body + end + end + end +end diff --git a/lib/hcl/task.rb b/lib/hcl/task.rb index 79b9917..e1d5b58 100644 --- a/lib/hcl/task.rb +++ b/lib/hcl/task.rb @@ -3,18 +3,13 @@ require 'fileutils' module HCl class Task < TimesheetResource - def self.cache_tasks doc - tasks = [] - doc.root.elements.collect('projects/project') do |project_elem| - project = Project.new xml_to_hash(project_elem) - tasks.concat(project_elem.elements.collect('tasks/task') do |task| - new xml_to_hash(task).merge(:project => project) - end) - end + def self.cache_tasks_hash day_entry_hash + tasks = day_entry_hash[:projects]. + map { |p| p[:tasks].map {|t| new t.merge(project:Project.new(p)) } }.flatten.uniq unless tasks.empty? FileUtils.mkdir_p(cache_dir) File.open(cache_file, 'w') do |f| - f.write tasks.uniq.to_yaml + f.write tasks.to_yaml end end end @@ -55,16 +50,13 @@ module HCl def add opts notes = opts[:note] starting_time = opts[:starting_time] || 0 - days = DayEntry.from_xml Task.post("daily/add", <<-EOT) - <request> - <notes>#{notes}</notes> - <hours>#{starting_time}</hours> - <project_id type="integer">#{project.id}</project_id> - <task_id type="integer">#{id}</task_id> - <spent_at type="date">#{Date.today}</spent_at> - </request> - EOT - days.first + DayEntry.new Net.post("daily/add", { + notes: notes, + hours: starting_time, + project_id: project.id, + task_id: id, + spent_at: Date.today + }) end def start opts @@ -72,7 +64,7 @@ module HCl if day.running? day else - DayEntry.from_xml(Task.get("daily/timer/#{day.id}")).first + DayEntry.new Net.get("daily/timer/#{day.id}") end end end diff --git a/lib/hcl/timesheet_resource.rb b/lib/hcl/timesheet_resource.rb index 8fa7f5c..28e5378 100644 --- a/lib/hcl/timesheet_resource.rb +++ b/lib/hcl/timesheet_resource.rb @@ -1,90 +1,9 @@ -require 'net/http' -require 'net/https' -require 'cgi' - module HCl class TimesheetResource - class Failure < StandardError; end - class AuthFailure < StandardError; end - class ThrottleFailure < StandardError - attr_reader :retry_after - def initialize response - @retry_after = response.headers['Retry-After'].to_i - super "Too many requests! Try again in #{@retry_after} seconds." - end - end - - def self.configure opts = nil - if opts - self.login = opts['login'] - self.password = opts['password'] - self.subdomain = opts['subdomain'] - self.ssl = opts['ssl'] - end - end - - # configuration accessors - CONFIG_VARS = [ :login, :password, :subdomain, :ssl ].freeze - CONFIG_VARS.each do |config_var| - class_eval <<-EOC - def self.#{config_var}= arg - @@#{config_var} = arg - end - def self.#{config_var} - @@#{config_var} - end - EOC - end - - # @return [Hash] - def self.config_hash - CONFIG_VARS.inject({}) {|c,k| c.update(k => TimesheetResource.send(k)) } - end - def initialize params @data = params end - def self.get action - http_do Net::HTTP::Get, action - end - - def self.post action, data - http_do Net::HTTP::Post, action, data - end - - def self.delete action - http_do Net::HTTP::Delete, action - end - - def self.connect - Net::HTTP.new("#{subdomain}.harvestapp.com", (ssl ? 443 : 80)).tap do |https| - https.use_ssl = ssl - https.verify_mode = OpenSSL::SSL::VERIFY_NONE if ssl - end - end - - def self.http_do method_class, action, data = nil - https = connect - request = method_class.new "/#{action}" - request.basic_auth login, password - request.content_type = 'application/xml' - request['Accept'] = 'application/xml' - response = https.request request, data - case response - when Net::HTTPSuccess - response.body - when Net::HTTPFound - raise Failure, "Redirected! Perhaps your ssl configuration variable is set incorrectly?" - when Net::HTTPServiceUnavailable - raise ThrottleFailure, response - when Net::HTTPUnauthorized - raise AuthFailure, "Login failed." - else - raise Failure, "Unexpected response from the upstream API." - end - end - def id @data[:id] end @@ -96,12 +15,5 @@ module HCl def respond_to? method (@data && @data.key?(method.to_sym)) || super end - - def self.xml_to_hash elem - elem.elements.map { |e| e.name }.inject({}) do |a, f| - a[f.to_sym] = CGI.unescape_html(elem.elements[f].text || '') if elem.elements[f] - a - end - end end end diff --git a/test/app_test.rb b/test/app_test.rb index 68e3782..1fc1073 100644 --- a/test/app_test.rb +++ b/test/app_test.rb @@ -23,7 +23,7 @@ class AppTest < HCl::TestCase app = HCl::App.new throttled = states('throttled').starts_as(false) app.expects(:show). - raises(HCl::TimesheetResource::ThrottleFailure, stub(headers:{'Retry-After' => 42})). + raises(HCl::HarvestMiddleware::ThrottleFailure, {response_headers:{'retry-after' => 42}}). then(throttled.is(true)) app.expects(:sleep).with(47).when(throttled.is(true)) app.expects(:show).when(throttled.is(true)) @@ -48,7 +48,7 @@ class AppTest < HCl::TestCase def test_configure_on_auth_failure app = HCl::App.new configured = states('configured').starts_as(false) - app.expects(:show).raises(HCl::TimesheetResource::AuthFailure).when(configured.is(false)) + app.expects(:show).raises(HCl::HarvestMiddleware::AuthFailure).when(configured.is(false)) app.expects(:ask).returns('xxx').times(4).when(configured.is(false)) app.expects(:write_config).then(configured.is(true)) app.expects(:show).when(configured.is(true)) @@ -58,7 +58,7 @@ class AppTest < HCl::TestCase def test_api_failure app = HCl::App.new - app.expects(:show).raises(HCl::TimesheetResource::Failure) + app.expects(:show).raises(HCl::HarvestMiddleware::Failure) app.expects(:exit).with(1) app.process_args('show').run assert_match /API failure/i, error_output diff --git a/test/day_entry_test.rb b/test/day_entry_test.rb index 581b22b..3e8be24 100644 --- a/test/day_entry_test.rb +++ b/test/day_entry_test.rb @@ -9,7 +9,7 @@ class DayEntryTest < HCl::TestCase def test_cancel_failure entry = HCl::DayEntry.new(id:123) - HCl::DayEntry.expects(:delete).raises(HCl::TimesheetResource::Failure) + HCl::DayEntry.expects(:delete).raises(HCl::HarvestMiddleware::Failure) assert !entry.cancel end @@ -19,48 +19,16 @@ class DayEntryTest < HCl::TestCase assert_equal "Taco Town - Pizza Taco - Preparation (1:12)", entry.to_s end - def test_from_xml - entries = HCl::DayEntry.from_xml(<<-EOD) -<daily> - <for_day type="date">Wed, 18 Oct 2006</for_day> - <day_entries> - <day_entry> - <id type="integer">195168</id> - <client>Iridesco</client> - <project>Harvest</project> - <task>Backend Programming</task> - <hours type="float">2.06</hours> - <notes>Test api support</notes> - <timer_started_at type="datetime"> - Wed, 18 Oct 2006 09:53:06 -0000 - </timer_started_at> - <created_at type="datetime">Wed, 18 Oct 2006 09:53:06 -0000</created_at> - </day_entry> - </day_entries> -</daily> - EOD - assert_equal 1, entries.size - { - :project => 'Harvest', - :client => 'Iridesco', - :task => 'Backend Programming', - :notes => 'Test api support', - :hours => '2.06', - }.each do |method, value| - assert_equal value, entries.first.send(method) - end - end - def test_append_note entry = HCl::DayEntry.new(:id => '1', :notes => 'yourmom.', :hours => '1.0') - HCl::DayEntry.stubs(:post) + HCl::Net.stubs(:post) entry.append_note('hi world') assert_equal "yourmom.\nhi world", entry.notes end def test_append_note_to_empty entry = HCl::DayEntry.new(:id => '1', :notes => nil, :hours => '1.0') - HCl::DayEntry.stubs(:post) + HCl::Net.stubs(:post) entry.append_note('hi world') assert_equal 'hi world', entry.notes end diff --git a/test/timesheet_resource_test.rb b/test/net_test.rb index 4153429..c4e39e1 100644 --- a/test/timesheet_resource_test.rb +++ b/test/net_test.rb @@ -1,10 +1,10 @@ require 'test_helper' -class TimesheetResourceTest < HCl::TestCase +class NetTest < HCl::TestCase def setup FakeWeb.allow_net_connect = false - HCl::TimesheetResource.configure \ + HCl::Net.configure \ 'login' => 'bob', 'password' => 'secret', 'subdomain' => 'bobclock', @@ -12,27 +12,30 @@ class TimesheetResourceTest < HCl::TestCase end def test_configure - assert_equal 'bob', HCl::TimesheetResource.login - assert_equal 'secret', HCl::TimesheetResource.password - assert_equal 'bobclock', HCl::TimesheetResource.subdomain - assert_equal true, HCl::TimesheetResource.ssl + assert_equal 'bob', HCl::Net.login + assert_equal 'secret', HCl::Net.password + assert_equal 'bobclock', HCl::Net.subdomain + assert_equal true, HCl::Net.ssl end def test_http_get - FakeWeb.register_uri(:get, "https://bob:secret@bobclock.harvestapp.com/foo", :body => 'gotten!') - body = HCl::TimesheetResource.get 'foo' + FakeWeb.register_uri(:get, "https://bob:secret@bobclock.harvestapp.com/foo", + :body => 'gotten!'.inspect) + body = HCl::Net.get 'foo' assert_equal 'gotten!', body end def test_http_post - FakeWeb.register_uri(:post, "https://bob:secret@bobclock.harvestapp.com/foo", :body => 'posted!') - body = HCl::TimesheetResource.post 'foo', {pizza:'taco'} + FakeWeb.register_uri(:post, "https://bob:secret@bobclock.harvestapp.com/foo", + :body => 'posted!'.inspect) + body = HCl::Net.post 'foo', {pizza:'taco'} assert_equal 'posted!', body end def test_http_delete - FakeWeb.register_uri(:delete, "https://bob:secret@bobclock.harvestapp.com/foo", :body => 'wiped!') - body = HCl::TimesheetResource.delete 'foo' + FakeWeb.register_uri(:delete, "https://bob:secret@bobclock.harvestapp.com/foo", + :body => 'wiped!'.inspect) + body = HCl::Net.delete 'foo' assert_equal 'wiped!', body end end diff --git a/test/task_test.rb b/test/task_test.rb index f5c30b8..bbdce89 100644 --- a/test/task_test.rb +++ b/test/task_test.rb @@ -1,61 +1,20 @@ class TaskTest < HCl::TestCase - DAILY_ENTRY = %{<daily> - <for_day type="date">Wed, 18 Oct 2006</for_day> - <day_entries> - <day_entry> - <id type="integer">195168</id> - <client>Iridesco</client> - <project>Harvest</project> - <task>Backend Programming</task> - <hours type="float">2.06</hours> - <notes>Test api support</notes> - <timer_started_at type="datetime"> - Wed, 18 Oct 2006 09:53:06 -0000 - </timer_started_at> - <created_at type="datetime">Wed, 18 Oct 2006 09:53:06 -0000</created_at> - </day_entry> - </day_entries> - </daily>} - - def test_add_task - task = HCl::Task.new(id:456, project:HCl::Project.new(id:123)) - Date.expects(:today).returns('now') - HCl::Task.expects(:post).with('daily/add', <<-EOT).returns(DAILY_ENTRY) - <request> - <notes>hi world</notes> - <hours>0.5</hours> - <project_id type="integer">123</project_id> - <task_id type="integer">456</task_id> - <spent_at type="date">now</spent_at> - </request> - EOT - task.add note:'hi world', starting_time:0.5 - end def test_cache_file assert_equal "#{HCl::App::HCL_DIR}/cache/tasks.yml", HCl::Task.cache_file end - def test_cache_tasks - HCl::Task.cache_tasks(REXML::Document.new(<<-EOD)) -<daily> - <projects> - <project> - <name>Click and Type</name> - <code></code> - <id type="integer">3</id> - <client>AFS</client> - <tasks> - <task> - <name>Security support</name> - <id type="integer">14</id> - <billable type="boolean">true</billable> - </task> - </tasks> - </project> - </projects> -</daily> - EOD + def test_cache_tasks_hash + HCl::Task.cache_tasks_hash({ projects: [{ + name: "Click and Type", + id: 3, + client: "AFS", + tasks: [{ + name: "Security support", + id: 14, + billable: true + }] + }]}) assert_equal 1, HCl::Task.all.size assert_equal 'Security support', HCl::Task.all.first.name end |
