diff options
| -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 | 
