diff options
| -rw-r--r-- | Gemfile | 1 | ||||
| -rw-r--r-- | Gemfile.lock | 4 | ||||
| -rw-r--r-- | app/controllers/imports_controller.rb | 10 | ||||
| -rw-r--r-- | app/helpers/imports_helper.rb | 22 | ||||
| -rw-r--r-- | app/models/import.rb | 43 | ||||
| -rw-r--r-- | app/models/import_service.rb | 10 | ||||
| -rw-r--r-- | app/views/imports/_import.erb | 2 | ||||
| -rw-r--r-- | app/views/imports/_imports.html.erb | 4 | ||||
| -rw-r--r-- | lib/iev_api.rb | 23 | ||||
| -rw-r--r-- | lib/iev_api/client.rb | 105 | ||||
| -rw-r--r-- | lib/iev_api/configuration.rb | 59 | ||||
| -rw-r--r-- | lib/iev_api/middleware/custom_parser.rb | 37 | ||||
| -rw-r--r-- | lib/iev_api/middleware/raise_response_error.rb | 13 | ||||
| -rw-r--r-- | lib/iev_api/middleware/raise_server_error.rb | 18 | ||||
| -rw-r--r-- | lib/ievkit.rb | 33 | ||||
| -rw-r--r-- | lib/ievkit/arguments.rb | 14 | ||||
| -rw-r--r-- | lib/ievkit/authentication.rb | 70 | ||||
| -rw-r--r-- | lib/ievkit/client.rb | 307 | ||||
| -rw-r--r-- | lib/ievkit/client/jobs.rb | 49 | ||||
| -rw-r--r-- | lib/ievkit/configurable.rb | 85 | ||||
| -rw-r--r-- | lib/ievkit/default.rb | 149 | ||||
| -rw-r--r-- | lib/ievkit/error.rb | 241 | ||||
| -rw-r--r-- | lib/ievkit/response/raise_error.rb | 28 | ||||
| -rw-r--r-- | lib/ievkit/version.rb | 17 | ||||
| -rw-r--r-- | spec/models/import_service_spec.rb | 3 |
25 files changed, 1043 insertions, 304 deletions
@@ -39,6 +39,7 @@ gem 'spring', group: :development gem "sitemap_generator" # API Rest +gem 'sawyer' gem 'faraday', '~> 0.9.1' gem 'faraday_middleware', '~> 0.9.1' gem 'kleisli' diff --git a/Gemfile.lock b/Gemfile.lock index 5e5f6874c..b17fc91d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -384,6 +384,9 @@ GEM sass (~> 3.2.2) sprockets (~> 2.8, < 3.0) sprockets-rails (~> 2.0) + sawyer (0.6.0) + addressable (~> 2.3.5) + faraday (~> 0.8, < 0.10) sdoc (0.4.1) json (~> 1.7, >= 1.7.7) rdoc (~> 4.0) @@ -531,6 +534,7 @@ DEPENDENCIES rspec-rails (~> 3.1.0) rubyzip (~> 1.1.6) sass-rails (~> 4.0.3) + sawyer sdoc (~> 0.4.0) simple_form (~> 3.1.0) sitemap_generator diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 2af6a57d6..927c188e2 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -17,7 +17,7 @@ class ImportsController < ChouetteController index! do build_breadcrumb :index end - rescue IevApi::IevError => error + rescue Ievkit::Error => error logger.error("Iev failure : #{error.message}") flash[:error] = t('iev.failure') redirect_to referential_path(@referential) @@ -29,7 +29,7 @@ class ImportsController < ChouetteController show! do build_breadcrumb :show end - rescue IevApi::IevError => error + rescue Ievkit::Error => error logger.error("Iev failure : #{error.message}") flash[:error] = t('iev.failure') redirect_to referential_path(@referential) @@ -41,7 +41,7 @@ class ImportsController < ChouetteController new! do puts "OK" end - rescue IevApi::IevError => error + rescue Ievkit::Error => error logger.error("Iev failure : #{error.message}") flash[:error] = t('iev.failure') redirect_to referential_path(@referential) @@ -53,7 +53,7 @@ class ImportsController < ChouetteController create! do puts "OK" end - rescue IevApi::IevError => error + rescue Ievkit::Error => error logger.error("Iev failure : #{error.message}") flash[:error] = t('iev.failure') redirect_to referential_path(@referential) @@ -66,7 +66,7 @@ class ImportsController < ChouetteController import_service.delete(@import.id) redirect_to referential_imports_path(@referential) end - rescue IevApi::IevError => error + rescue Ievkit::Error => error logger.error("Iev failure : #{error.message}") flash[:error] = t('iev.failure') redirect_to referential_path(@referential) diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 772533a8e..498d03bf9 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -28,17 +28,17 @@ module ImportsHelper def import_progress_bar_tag(import) - if import.canceled? || import.aborted? - div_class = "progress-bar progress-bar-danger" - elsif import.scheduled? - div_class = "progress-bar progress-bar-info" - elsif import.created? - div_class = "progress-bar progress-bar-info" - elsif import.terminated? - div_class = "progress-bar progress-bar-success" - else - div_class = "" - end + # if import.canceled? || import.aborted? + # div_class = "progress-bar progress-bar-danger" + # elsif import.scheduled? + # div_class = "progress-bar progress-bar-info" + # elsif import.created? + # div_class = "progress-bar progress-bar-info" + # elsif import.terminated? + # div_class = "progress-bar progress-bar-success" + # else + div_class = "" + # end content_tag :div, :class => "progress" do content_tag :div, :class => div_class, role: "progressbar", :'aria-valuenow' => "#{import.percentage_progress}", :'aria-valuemin' => "0", :'aria-valuemax' => "100", :style => "width: #{import.percentage_progress}%;" do diff --git a/app/models/import.rb b/app/models/import.rb index 93388a6f5..2e1c86394 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -6,18 +6,16 @@ class Import # enumerize :status, in: %w{created scheduled terminated canceled aborted}, default: "created", predicates: true # enumerize :format, in: %w{neptune netex gtfs}, default: "neptune", predicates: true - attr_reader :datas, :links, :headers, :errors + attr_reader :datas - def initialize( response ) - @datas = response[:datas] - @headers = response[:headers] - @links = response[:links] + def initialize( response ) + @datas = response # @status = @datas.status.downcase if @datas.status? # @format = @datas.type.downcase if @datas.type? end def report - report_path = links[:report] + report_path = datas.links[:report] if report_path response = IevApi.request(:get, compliance_check_path, params) ImportReport.new(response) @@ -27,7 +25,7 @@ class Import end def compliance_check - compliance_check_path = links[:validation] + compliance_check_path = datas.links[:validation] if compliance_check_path response = IevApi.request(:get, compliance_check_path, params) ComplianceCheck.new(response) @@ -37,7 +35,7 @@ class Import end def delete - delete_path = links[:delete] + delete_path = datas.links[:delete] if delete_path IevApi.request(:delete, delete_path, params) else @@ -46,7 +44,7 @@ class Import end def cancel - cancel_path = links[:cancel] + cancel_path = datas.links[:cancel] if cancel_path IevApi.request(:delete, cancel_path, params) else @@ -55,7 +53,7 @@ class Import end def id - @datas.id + datas.id end def status @@ -67,7 +65,7 @@ class Import end def filename - @datas.filename + datas.filename end def filename_extension @@ -85,43 +83,40 @@ class Import end def referential_name - @datas.referential + datas.referential end def name - @datas.parameters.name - end - - def user_name? - @datas.parameters? && @datas.parameters.user_name? + datas.action_parameters.name end def user_name - @datas.parameters.user_name if user_name? + + datas.action_parameters.user_name end def no_save - @datas.parameters.no_save + datas.action_parameters.no_save end def filename - @datas.filename + datas.filename end def created_at? - @datas.created? + datas.created? end def created_at - Time.at(@datas.created.to_i / 1000) if created_at? + Time.at(datas.created.to_i / 1000) if created_at? end def updated_at? - @datas.updated? + datas.updated? end def updated_at - Time.at(@datas.updated.to_i / 1000) if updated_at? + Time.at(datas.updated.to_i / 1000) if updated_at? end end diff --git a/app/models/import_service.rb b/app/models/import_service.rb index 567cd9e3e..2e3c1012b 100644 --- a/app/models/import_service.rb +++ b/app/models/import_service.rb @@ -8,14 +8,16 @@ class ImportService # Find an import whith his id def find(id) - Import.new( IevApi.scheduled_job(referential.slug, id, { :action => "importer" }) ) + Import.new( Ievkit.scheduled_job(referential.slug, id, { :action => "importer" }) ) end # Find all imports def all - IevApi.jobs(referential.slug, { :action => "importer" }).map do |import_hash| - Import.new( import_hash ) + [].tap do |jobs| + Ievkit.jobs(referential.slug, { :action => "importer" }).each do |job| + jobs << Import.new( job ) + end end end - + end diff --git a/app/views/imports/_import.erb b/app/views/imports/_import.erb index 8c3da0320..cbed386f5 100644 --- a/app/views/imports/_import.erb +++ b/app/views/imports/_import.erb @@ -20,7 +20,7 @@ <div class="panel-footer"> <%= import_progress_bar_tag(import) %> <div class="history"> - <%= l import.created_at, :format => "%d/%m/%Y %H:%M" %> | <%= import.user_name %> + <%= import.created_at %> | <%= import.user_name %> </div> </div> </div> diff --git a/app/views/imports/_imports.html.erb b/app/views/imports/_imports.html.erb index 51d1c02b2..d4c7e0b9e 100644 --- a/app/views/imports/_imports.html.erb +++ b/app/views/imports/_imports.html.erb @@ -1,8 +1,8 @@ <div class="page_info"> <span class="search"> <%= t("will_paginate.page_entries_info.search") %></span> <%= page_entries_info @imports %> </div> -<div class="imports paginated_content"> - <%= paginated_content @imports %> +<div class="imports paginated_content"> + <%= paginated_content @imports, "imports/import" %> </div> <div class="pagination"> <%= will_paginate @imports, :container => false, renderer: RemoteBootstrapPaginationLinkRenderer %> diff --git a/lib/iev_api.rb b/lib/iev_api.rb deleted file mode 100644 index 77a10d1ac..000000000 --- a/lib/iev_api.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'iev_api/configuration' - -module IevApi - extend Configuration - - class IevError < StandardError; end - - def self.client(options={}) - IevApi::Client.new(options) - end - - # Delegate to Instapaper::Client - def self.method_missing(method, *args, &block) - return super unless client.respond_to?(method) - client.send(method, *args, &block) - end - - def self.respond_to?(method, include_private = false) - client.respond_to?(method, include_private) || super(method, include_private) - end -end - -require 'iev_api/client' diff --git a/lib/iev_api/client.rb b/lib/iev_api/client.rb deleted file mode 100644 index 1994b1027..000000000 --- a/lib/iev_api/client.rb +++ /dev/null @@ -1,105 +0,0 @@ -module IevApi - class Client - - PER_PAGE = 12 - #PARALLEL_WORKERS = 10 - ACTIONS = %w{ importer exporter validator } - IMPORT_FORMAT = %w{ neptune netex gtfs } - EXPORT_FORMAT = %w{ neptune netex gtfs hub kml } - - attr_accessor *IevApi::Configuration::VALID_OPTIONS_KEYS - - def initialize(options={}) - attrs = IevApi.options.merge(options) - IevApi::Configuration::VALID_OPTIONS_KEYS.each do |key| - send("#{key}=", attrs[key]) - end - end - - def url_for(endpoint, *args) - path = case endpoint.to_s - when 'jobs' then jobs_path(args) - when 'scheduled_job' then scheduled_job_path(args) - when 'terminated_job' then terminated_job_path(args) - else raise ArgumentError.new("Unrecognized path: #{path}") - end - - [account_path, path.split('.').first].join('') - end - - def jobs(referential_id, options = {}) - results = request(:get, jobs_path(referential_id), options) - end - - def jobs_path(referential_id) - "referentials/#{referential_id}/jobs" - end - - def scheduled_job(referential_id, job_id, options = {}) - results = request(:get, scheduled_job_path(referential_id, job_id), options) - end - - def scheduled_job_path(referential_id, job_id) - "referentials/#{referential_id}/scheduled_jobs/#{job_id}" - end - - def terminated_job(referential_id, job_id, options = {}) - results = request(:get, terminated_job_path(referential_id, job_id), options) - end - - def terminated_job_path(referential_id, job_id) - "referentials/#{referential_id}/terminated_jobs/#{job_id}" - end - - def account_path - "#{protocol}://#{Rails.application.config.iev_url}" - end - - def protocol - @secure ? "https" : "http" - end - - # Perform an HTTP request - def request(method, path, params = {}, options = {}) - - response = connection(options).run_request(method, nil, nil, nil) do |request| - case method - when :delete, :get - request.url(path, params) - when :post, :put - request.url(path) - request.body = params unless params.empty? - end - end - - response.body - end - - def connection(options={}) - default_options = { - :headers => { - :accept => 'application/json', - :user_agent => user_agent, - }, - :ssl => {:verify => false}, - :url => account_path, - } - - @connection ||= Faraday.new(default_options.deep_merge(options)) do |builder| - middleware.each { |mw| builder.use *mw } - - builder.adapter adapter - end - - # cache_dir = File.join(ENV['TMPDIR'] || '/tmp', 'cache') - - # @connection.response :caching do - # ActiveSupport::Cache::FileStore.new cache_dir, :namespace => 'iev', - # :expires_in => 3600 # one hour - # end - - @connection - end - - end -end diff --git a/lib/iev_api/configuration.rb b/lib/iev_api/configuration.rb deleted file mode 100644 index b445da795..000000000 --- a/lib/iev_api/configuration.rb +++ /dev/null @@ -1,59 +0,0 @@ -module IevApi - module Configuration - VALID_OPTIONS_KEYS = [ - :account, - :auth_token, - :secure, - :connection_options, - :adapter, - :user_agent, - :middleware] - - attr_accessor *VALID_OPTIONS_KEYS - - DEFAULT_ADAPTER = :net_http - DEFAULT_USER_AGENT = "IEV Ruby Gem Api" - DEFAULT_CONNECTION_OPTIONS = {} - DEFAULT_MIDDLEWARE = [ - Faraday::Request::UrlEncoded, - IevApi::Middleware::RaiseResponseError, - Faraday::Request::Multipart, - FaradayMiddleware::Mashify, - #FaradayMiddleware::Caching, - FaradayMiddleware::FollowRedirects, - FaradayMiddleware::ParseJson, - IevApi::Middleware::RaiseServerError, - IevApi::Middleware::CustomParser - ] - - def self.extended(base) - base.reset - end - - def configure(options={}) - @account = options[:account] if options.has_key?(:account) - @auth_token = options[:auth_token] if options.has_key?(:auth_token) - @secure = options[:secure] if options.has_key?(:secure) - @middleware = options[:middleware] if options.has_key?(:middleware) - yield self if block_given? - self - end - - def options - options = {} - VALID_OPTIONS_KEYS.each{|k| options[k] = send(k)} - options - end - - def reset - @account = nil - @auth_token = nil - @secure = false - @adapter = DEFAULT_ADAPTER - @user_agent = DEFAULT_USER_AGENT - @connection_options = DEFAULT_CONNECTION_OPTIONS - @middleware = DEFAULT_MIDDLEWARE - end - - end -end diff --git a/lib/iev_api/middleware/custom_parser.rb b/lib/iev_api/middleware/custom_parser.rb deleted file mode 100644 index 82cf0d563..000000000 --- a/lib/iev_api/middleware/custom_parser.rb +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -# Expects responses like: -# -# { -# "result": { "id": 1, "name": "Tobias Fünke" }, -# "errors": [] -# } -# -require 'faraday' - -module IevApi - module Middleware - class CustomParser < Faraday::Response::Middleware - def on_complete(env) - body = env[:body] - env[:body] = { - datas: body, - headers: env[:response_headers], - links: process_links(env[:response_headers]) - } - end - - # Finds link relations from 'Link' response header - def process_links(headers) - puts headers.inspect - links = ( headers["Link"] || "" ).split(', ').map do |link| - href, name = link.match(/<(.*?)>; rel="(\w+)"/).captures - - [name.to_sym, href] - end - puts links.inspect - Hash[*links.flatten] - end - - end - end -end diff --git a/lib/iev_api/middleware/raise_response_error.rb b/lib/iev_api/middleware/raise_response_error.rb deleted file mode 100644 index e299e3410..000000000 --- a/lib/iev_api/middleware/raise_response_error.rb +++ /dev/null @@ -1,13 +0,0 @@ -require 'faraday' - -module IevApi - module Middleware - class RaiseResponseError < Faraday::Response::Middleware - - def on_complete(env) - raise IevError.new('No results found.') if env[:body].nil? - end - - end - end -end diff --git a/lib/iev_api/middleware/raise_server_error.rb b/lib/iev_api/middleware/raise_server_error.rb deleted file mode 100644 index cb6f96f98..000000000 --- a/lib/iev_api/middleware/raise_server_error.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'faraday' - -module IevApi - module Middleware - class RaiseServerError < Faraday::Response::Middleware - - def on_complete(env) - case env[:status].to_i - when 403 - raise IevError.new('SSL should be enabled - use AirbrakeAPI.secure = true in configuration') - when 404 - raise IevError.new('No resource found') - end - end - - end - end -end diff --git a/lib/ievkit.rb b/lib/ievkit.rb new file mode 100644 index 000000000..7e47e9d84 --- /dev/null +++ b/lib/ievkit.rb @@ -0,0 +1,33 @@ +require 'ievkit/client' +require 'ievkit/default' + +# Ruby toolkit for the GitHub API +module Ievkit + + class << self + include Ievkit::Configurable + + # API client based on configured options {Configurable} + # + # @return [Ievkit::Client] API wrapper + def client + @client = Ievkit::Client.new(options) unless defined?(@client) && @client.same_options?(options) + @client + end + + # @private + def respond_to_missing?(method_name, include_private=false); client.respond_to?(method_name, include_private); end if RUBY_VERSION >= "1.9" + # @private + def respond_to?(method_name, include_private=false); client.respond_to?(method_name, include_private) || super; end if RUBY_VERSION < "1.9" + + private + + def method_missing(method_name, *args, &block) + return super unless client.respond_to?(method_name) + client.send(method_name, *args, &block) + end + + end +end + +Ievkit.setup diff --git a/lib/ievkit/arguments.rb b/lib/ievkit/arguments.rb new file mode 100644 index 000000000..7f512eb25 --- /dev/null +++ b/lib/ievkit/arguments.rb @@ -0,0 +1,14 @@ +module Ievkit + + # Extracts options from method arguments + # @private + class Arguments < Array + attr_reader :options + + def initialize(args) + @options = args.last.is_a?(::Hash) ? args.pop : {} + super(args) + end + + end +end diff --git a/lib/ievkit/authentication.rb b/lib/ievkit/authentication.rb new file mode 100644 index 000000000..666fcc8d8 --- /dev/null +++ b/lib/ievkit/authentication.rb @@ -0,0 +1,70 @@ +module Ievkit + + # Authentication methods for {Octokit::Client} + module Authentication + + # Indicates if the client was supplied Basic Auth + # username and password + # + # @return [Boolean] + def basic_authenticated? + !!(@login && @password) + end + + # Indicates if the client was supplied an OAuth + # access token + # + # @return [Boolean] + def token_authenticated? + !!@access_token + end + + # Indicates if the client was supplied an OAuth + # access token or Basic Auth username and password + # + # @return [Boolean] + def user_authenticated? + basic_authenticated? || token_authenticated? + end + + # Indicates if the client has OAuth Application + # client_id and secret credentials to make anonymous + # requests at a higher rate limit + # + # @return Boolean + def application_authenticated? + !!application_authentication + end + + private + + def application_authentication + if @client_id && @client_secret + { + :client_id => @client_id, + :client_secret => @client_secret + } + end + end + + def login_from_netrc + return unless netrc? + + require 'netrc' + info = Netrc.read netrc_file + netrc_host = URI.parse(api_endpoint).host + creds = info[netrc_host] + if creds.nil? + # creds will be nil if there is no netrc for this end point + ievkit_warn "Error loading credentials from netrc file for #{api_endpoint}" + else + creds = creds.to_a + self.login = creds.shift + self.password = creds.shift + end + rescue LoadError + ievkit_warn "Please install netrc gem for .netrc support" + end + + end +end diff --git a/lib/ievkit/client.rb b/lib/ievkit/client.rb new file mode 100644 index 000000000..53eb61074 --- /dev/null +++ b/lib/ievkit/client.rb @@ -0,0 +1,307 @@ +require 'sawyer' +require 'ievkit/arguments' +require 'ievkit/configurable' +require 'ievkit/client/jobs' + +module Ievkit + + # Client for the Iev API + class Client + + include Ievkit::Configurable + include Ievkit::Authentication + include Ievkit::Client::Jobs + + # Header keys that can be passed in options hash to {#get},{#head} + CONVENIENCE_HEADERS = Set.new([:accept, :content_type]) + + def initialize(options = {}) + # Use options passed in, but fall back to module defaults + Ievkit::Configurable.keys.each do |key| + instance_variable_set(:"@#{key}", options[key] || Ievkit.instance_variable_get(:"@#{key}")) + end + end + + # Compares client options to a Hash of requested options + # + # @param opts [Hash] Options to compare with current client options + # @return [Boolean] + def same_options?(opts) + opts.hash == options.hash + end + + # Text representation of the client, masking tokens and passwords + # + # @return [String] + def inspect + inspected = super + + # mask password + inspected = inspected.gsub! @password, "*******" if @password + # Only show last 4 of token, secret + if @access_token + inspected = inspected.gsub! @access_token, "#{'*'*36}#{@access_token[36..-1]}" + end + if @client_secret + inspected = inspected.gsub! @client_secret, "#{'*'*36}#{@client_secret[36..-1]}" + end + + inspected + end + + # Make a HTTP GET request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def get(url, options = {}) + request :get, url, parse_query_and_convenience_headers(options) + end + + # Make a HTTP POST request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def post(url, options = {}) + request :post, url, options + end + + # Make a HTTP PUT request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def put(url, options = {}) + request :put, url, options + end + + # Make a HTTP PATCH request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Body and header params for request + # @return [Sawyer::Resource] + def patch(url, options = {}) + request :patch, url, options + end + + # Make a HTTP DELETE request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def delete(url, options = {}) + request :delete, url, options + end + + # Make a HTTP HEAD request + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @return [Sawyer::Resource] + def head(url, options = {}) + request :head, url, parse_query_and_convenience_headers(options) + end + + # Make one or more HTTP GET requests, optionally fetching + # the next page of results from URL in Link response header based + # on value in {#auto_paginate}. + # + # @param url [String] The path, relative to {#api_endpoint} + # @param options [Hash] Query and header params for request + # @param block [Block] Block to perform the data concatination of the + # multiple requests. The block is called with two parameters, the first + # contains the contents of the requests so far and the second parameter + # contains the latest response. + # @return [Sawyer::Resource] + def paginate(url, options = {}, &block) + opts = parse_query_and_convenience_headers(options.dup) + if @auto_paginate || @per_page + opts[:query][:per_page] ||= @per_page || (@auto_paginate ? 100 : nil) + end + + data = request(:get, url, opts) + + if @auto_paginate + while @last_response.rels[:next] && rate_limit.remaining > 0 + @last_response = @last_response.rels[:next].get + if block_given? + yield(data, @last_response) + else + data.concat(@last_response.data) if @last_response.data.is_a?(Array) + end + end + + end + + data + end + + # Hypermedia agent for the Iev API + # + # @return [Sawyer::Agent] + def agent + @agent ||= Sawyer::Agent.new(api_endpoint, sawyer_options) do |http| + http.headers[:accept] = default_media_type + http.headers[:content_type] = "application/json" + http.headers[:user_agent] = user_agent + + # Activate if authentication is needed + # + # if basic_authenticated? + # http.basic_auth(@login, @password) + # elsif token_authenticated? + # http.authorization 'token', @access_token + # elsif application_authenticated? + # http.params = http.params.merge application_authentication + # end + end + end + + # Fetch the root resource for the API + # + # @return [Sawyer::Resource] + def root + get "/" + end + + # Response for last HTTP request + # + # @return [Sawyer::Response] + def last_response + @last_response if defined? @last_response + end + + # Duplicate client using client_id and client_secret as + # Basic Authentication credentials. + # @example + # Ievkit.client_id = "foo" + # Ievkit.client_secret = "bar" + # + # # GET https://api.github.com/?client_id=foo&client_secret=bar + # Ievkit.get "/" + # + # Ievkit.client.as_app do |client| + # # GET https://foo:bar@api.github.com/ + # client.get "/" + # end + def as_app(key = client_id, secret = client_secret, &block) + if key.to_s.empty? || secret.to_s.empty? + raise ApplicationCredentialsRequired, "client_id and client_secret required" + end + app_client = self.dup + app_client.client_id = app_client.client_secret = nil + app_client.login = key + app_client.password = secret + + yield app_client if block_given? + end + + # Set username for authentication + # + # @param value [String] GitHub username + def login=(value) + reset_agent + @login = value + end + + # Set password for authentication + # + # @param value [String] GitHub password + def password=(value) + reset_agent + @password = value + end + + # Set OAuth access token for authentication + # + # @param value [String] 40 character GitHub OAuth access token + def access_token=(value) + reset_agent + @access_token = value + end + + # Set OAuth app client_id + # + # @param value [String] 20 character GitHub OAuth app client_id + def client_id=(value) + reset_agent + @client_id = value + end + + # Set OAuth app client_secret + # + # @param value [String] 40 character GitHub OAuth app client_secret + def client_secret=(value) + reset_agent + @client_secret = value + end + + # Wrapper around Kernel#warn to print warnings unless + # IEVKIT_SILENT is set to true. + # + # @return [nil] + def ievkit_warn(*message) + unless ENV['IEVKIT_SILENT'] + warn message + end + end + + private + + def reset_agent + @agent = nil + end + + def request(method, path, data, options = {}) + if data.is_a?(Hash) + options[:query] = data.delete(:query) || {} + options[:headers] = data.delete(:headers) || {} + if accept = data.delete(:accept) + options[:headers][:accept] = accept + end + end + + @last_response = response = agent.call(method, URI::Parser.new.escape(path.to_s), data, options) + response.data + end + + # Executes the request, checking if it was successful + # + # @return [Boolean] True on success, false otherwise + def boolean_from_response(method, path, options = {}) + request(method, path, options) + @last_response.status == 204 + rescue Ievkit::NotFound + false + end + + + def sawyer_options + opts = { + :links_parser => Sawyer::LinkParsers::Hal.new + } + conn_opts = @connection_options + conn_opts[:builder] = @middleware if @middleware + conn_opts[:proxy] = @proxy if @proxy + opts[:faraday] = Faraday.new(conn_opts) + + opts + end + + def parse_query_and_convenience_headers(options) + headers = options.fetch(:headers, {}) + CONVENIENCE_HEADERS.each do |h| + if header = options.delete(h) + headers[h] = header + end + end + query = options.delete(:query) + opts = {:query => options} + opts[:query].merge!(query) if query && query.is_a?(Hash) + opts[:headers] = headers unless headers.empty? + + opts + end + end +end diff --git a/lib/ievkit/client/jobs.rb b/lib/ievkit/client/jobs.rb new file mode 100644 index 000000000..a530b2527 --- /dev/null +++ b/lib/ievkit/client/jobs.rb @@ -0,0 +1,49 @@ +module Ievkit + class Client + module Jobs + + # List jobs for a referential + # + # @param referential [String] Data referential name. + # @return [Array<Sawyer::Resource>] A list of jobs + # @example Fetch all jobs for referential test + # client.jobs("test") + def jobs(referential, options = {}) + paginate "referentials/#{referential}/jobs", options + end + + # Get scheduled job + # + # @param referential [String] Data referential name. + # @param job_id [Integer] Id of the scheduled job. + # @return [Sawyer::Resource] Hash representing scheduled job. + # @example + # client.scheduled_job('test', 1451398) + def scheduled_job(referential, job_id, options = {}) + get "referentials/#{referential}/scheduled_jobs/#{job_id}", options + end + + # Get terminated job + # + # @param referential [String] Data referential name. + # @param job_id [Integer] Id of the terminated job. + # @return [Sawyer::Resource] Hash representing terminated job. + # @example + # client.terminated_job('test', 1451399) + def terminated_job(referential, job_id, options = {}) + get "referentials/#{referential}/terminated_jobs/#{job_id}", options + end + + # Create job + # + # @param referential [String] Data referential name. + # @return [Sawyer::Resource] Hash representing the new job. + # @example + # client.create_job("test",....) + def create_job(referential, options = {}) + post "jobs", options + end + + end + end +end diff --git a/lib/ievkit/configurable.rb b/lib/ievkit/configurable.rb new file mode 100644 index 000000000..fb1d9f787 --- /dev/null +++ b/lib/ievkit/configurable.rb @@ -0,0 +1,85 @@ +module Ievkit + + # Configuration options for {Client}, defaulting to values + # in {Default} + module Configurable + + attr_accessor :access_token, :auto_paginate, :client_id, + :client_secret, :default_media_type, :connection_options, + :middleware, :netrc, :netrc_file, + :per_page, :proxy, :user_agent + attr_writer :password, :web_endpoint, :api_endpoint, :login + + class << self + + # List of configurable keys for {Octokit::Client} + # @return [Array] of option keys + def keys + @keys ||= [ + :access_token, + :api_endpoint, + :auto_paginate, + :client_id, + :client_secret, + :connection_options, + :default_media_type, + :login, + :middleware, + :netrc, + :netrc_file, + :per_page, + :password, + :proxy, + :user_agent, + :web_endpoint + ] + end + end + + # Set configuration options using a block + def configure + yield self + end + + # Reset configuration options to default values + def reset! + Ievkit::Configurable.keys.each do |key| + instance_variable_set(:"@#{key}", Ievkit::Default.options[key]) + end + self + end + alias setup reset! + + def api_endpoint + File.join(@api_endpoint, "") + end + + # Base URL for generated web URLs + # + # @return [String] Default: https://github.com/ + def web_endpoint + File.join(@web_endpoint, "") + end + + def login + @login ||= begin + user.login if token_authenticated? + end + end + + def netrc? + !!@netrc + end + + private + + def options + Hash[Ievkit::Configurable.keys.map{|key| [key, instance_variable_get(:"@#{key}")]}] + end + + def fetch_client_id_and_secret(overrides = {}) + opts = options.merge(overrides) + opts.values_at :client_id, :client_secret + end + end +end diff --git a/lib/ievkit/default.rb b/lib/ievkit/default.rb new file mode 100644 index 000000000..af0df422f --- /dev/null +++ b/lib/ievkit/default.rb @@ -0,0 +1,149 @@ +require 'ievkit/response/raise_error' +require 'ievkit/version' + +module Ievkit + + # Default configuration options for {Client} + module Default + + # Default API endpoint + API_ENDPOINT = "http://localhost:8080/chouette_iev/".freeze + + # Default User Agent header string + USER_AGENT = "Ievkit Ruby Gem #{Ievkit::VERSION}".freeze + + # Default media type + MEDIA_TYPE = "" # "application/vnd.iev.v1.0+json".freeze + + # Default WEB endpoint + WEB_ENDPOINT = "http://localhost:3000".freeze + + # Default page sie + PER_PAGE = 12 + + # In Faraday 0.9, Faraday::Builder was renamed to Faraday::RackBuilder + RACK_BUILDER_CLASS = defined?(Faraday::RackBuilder) ? Faraday::RackBuilder : Faraday::Builder + + # Default Faraday middleware stack + MIDDLEWARE = RACK_BUILDER_CLASS.new do |builder| + builder.use Ievkit::Response::RaiseError + builder.use Faraday::Request::Multipart + builder.use FaradayMiddleware::FollowRedirects + builder.adapter Faraday.default_adapter + end + + class << self + + # Configuration options + # @return [Hash] + def options + Hash[Ievkit::Configurable.keys.map{|key| [key, send(key)]}] + end + + # Default access token from ENV + # @return [String] + def access_token + ENV['IEVKIT_ACCESS_TOKEN'] + end + + # Default API endpoint from ENV or {API_ENDPOINT} + # @return [String] + def api_endpoint + ENV['IEVKIT_API_ENDPOINT'] || API_ENDPOINT + end + + # Default pagination preference from ENV + # @return [String] + def auto_paginate + ENV['IEVKIT_AUTO_PAGINATE'] + end + + # Default OAuth app key from ENV + # @return [String] + def client_id + ENV['IEVKIT_CLIENT_ID'] + end + + # Default OAuth app secret from ENV + # @return [String] + def client_secret + ENV['IEVKIT_SECRET'] + end + + # Default options for Faraday::Connection + # @return [Hash] + def connection_options + { + :headers => { + :accept => default_media_type, + :user_agent => user_agent + } + } + end + + # Default media type from ENV or {MEDIA_TYPE} + # @return [String] + def default_media_type + ENV['IEVKIT_DEFAULT_MEDIA_TYPE'] || MEDIA_TYPE + end + + # Default Iev username for Basic Auth from ENV + # @return [String] + def login + ENV['IEVKIT_LOGIN'] + end + + # Default middleware stack for Faraday::Connection + # from {MIDDLEWARE} + # @return [String] + def middleware + MIDDLEWARE + end + + # Default Iev password for Basic Auth from ENV + # @return [String] + def password + ENV['IEVKIT_PASSWORD'] + end + + # Default pagination page size from ENV + # @return [Fixnum] Page size + def per_page + page_size = ENV['IEVKIT_PER_PAGE'] || PER_PAGE + + page_size.to_i if page_size + end + + # Default proxy server URI for Faraday connection from ENV + # @return [String] + def proxy + ENV['IEVKIT_PROXY'] + end + + # Default User-Agent header string from ENV or {USER_AGENT} + # @return [String] + def user_agent + ENV['IEVKIT_USER_AGENT'] || USER_AGENT + end + + # Default web endpoint from ENV or {WEB_ENDPOINT} + # @return [String] + def web_endpoint + ENV['IEVKIT_WEB_ENDPOINT'] || WEB_ENDPOINT + end + + # Default behavior for reading .netrc file + # @return [Boolean] + def netrc + ENV['IEVKIT_NETRC'] || false + end + + # Default path for .netrc file + # @return [String] + def netrc_file + ENV['IEVKIT_NETRC_FILE'] || File.join(ENV['HOME'].to_s, '.netrc') + end + + end + end +end diff --git a/lib/ievkit/error.rb b/lib/ievkit/error.rb new file mode 100644 index 000000000..593ed25a3 --- /dev/null +++ b/lib/ievkit/error.rb @@ -0,0 +1,241 @@ +module Ievkit + # Custom error class for rescuing from all GitHub errors + class Error < StandardError + + # Returns the appropriate Ievkit::Error subclass based + # on status and response message + # + # @param [Hash] response HTTP response + # @return [Ievkit::Error] + def self.from_response(response) + status = response[:status].to_i + body = response[:body].to_s + headers = response[:response_headers] + + if klass = case status + when 400 then Ievkit::BadRequest + when 401 then error_for_401(headers) + when 403 then error_for_403(body) + when 404 then Ievkit::NotFound + when 405 then Ievkit::MethodNotAllowed + when 406 then Ievkit::NotAcceptable + when 409 then Ievkit::Conflict + when 415 then Ievkit::UnsupportedMediaType + when 422 then Ievkit::UnprocessableEntity + when 400..499 then Ievkit::ClientError + when 500 then Ievkit::InternalServerError + when 501 then Ievkit::NotImplemented + when 502 then Ievkit::BadGateway + when 503 then Ievkit::ServiceUnavailable + when 500..599 then Ievkit::ServerError + end + klass.new(response) + end + end + + def initialize(response=nil) + @response = response + super(build_error_message) + end + + # Documentation URL returned by the API for some errors + # + # @return [String] + def documentation_url + data[:documentation_url] if data.is_a? Hash + end + + # Returns most appropriate error for 401 HTTP status code + # @private + def self.error_for_401(headers) + if Ievkit::OneTimePasswordRequired.required_header(headers) + Ievkit::OneTimePasswordRequired + else + Ievkit::Unauthorized + end + end + + # Returns most appropriate error for 403 HTTP status code + # @private + def self.error_for_403(body) + if body =~ /rate limit exceeded/i + Ievkit::TooManyRequests + elsif body =~ /login attempts exceeded/i + Ievkit::TooManyLoginAttempts + elsif body =~ /abuse/i + Ievkit::AbuseDetected + elsif body =~ /repository access blocked/i + Ievkit::RepositoryUnavailable + else + Ievkit::Forbidden + end + end + + # Array of validation errors + # @return [Array<Hash>] Error info + def errors + if data && data.is_a?(Hash) + data[:errors] || [] + else + [] + end + end + + private + + def data + @data ||= + if (body = @response[:body]) && !body.empty? + if body.is_a?(String) && + @response[:response_headers] && + @response[:response_headers][:content_type] =~ /json/ + + Sawyer::Agent.serializer.decode(body) + else + body + end + else + nil + end + end + + def response_message + case data + when Hash + data[:message] + when String + data + end + end + + def response_error + "Error: #{data[:error]}" if data.is_a?(Hash) && data[:error] + end + + def response_error_summary + return nil unless data.is_a?(Hash) && !Array(data[:errors]).empty? + + summary = "\nError summary:\n" + summary << data[:errors].map do |hash| + hash.map { |k,v| " #{k}: #{v}" } + end.join("\n") + + summary + end + + def build_error_message + return nil if @response.nil? + + message = "#{@response[:method].to_s.upcase} " + message << redact_url(@response[:url].to_s) + ": " + message << "#{@response[:status]} - " + message << "#{response_message}" unless response_message.nil? + message << "#{response_error}" unless response_error.nil? + message << "#{response_error_summary}" unless response_error_summary.nil? + message << " // See: #{documentation_url}" unless documentation_url.nil? + message + end + + def redact_url(url_string) + %w[client_secret access_token].each do |token| + url_string.gsub!(/#{token}=\S+/, "#{token}=(redacted)") if url_string.include? token + end + url_string + end + end + + # Raised on errors in the 400-499 range + class ClientError < Error; end + + # Raised when GitHub returns a 400 HTTP status code + class BadRequest < ClientError; end + + # Raised when GitHub returns a 401 HTTP status code + class Unauthorized < ClientError; end + + # Raised when GitHub returns a 401 HTTP status code + # and headers include "X-GitHub-OTP" + class OneTimePasswordRequired < ClientError + #@private + OTP_DELIVERY_PATTERN = /required; (\w+)/i + + #@private + def self.required_header(headers) + OTP_DELIVERY_PATTERN.match headers['X-GitHub-OTP'].to_s + end + + # Delivery method for the user's OTP + # + # @return [String] + def password_delivery + @password_delivery ||= delivery_method_from_header + end + + private + + def delivery_method_from_header + if match = self.class.required_header(@response[:response_headers]) + match[1] + end + end + end + + # Raised when GitHub returns a 403 HTTP status code + class Forbidden < ClientError; end + + # Raised when GitHub returns a 403 HTTP status code + # and body matches 'rate limit exceeded' + class TooManyRequests < Forbidden; end + + # Raised when GitHub returns a 403 HTTP status code + # and body matches 'login attempts exceeded' + class TooManyLoginAttempts < Forbidden; end + + # Raised when GitHub returns a 403 HTTP status code + # and body matches 'abuse' + class AbuseDetected < Forbidden; end + + # Raised when GitHub returns a 403 HTTP status code + # and body matches 'repository access blocked' + class RepositoryUnavailable < Forbidden; end + + # Raised when GitHub returns a 404 HTTP status code + class NotFound < ClientError; end + + # Raised when GitHub returns a 405 HTTP status code + class MethodNotAllowed < ClientError; end + + # Raised when GitHub returns a 406 HTTP status code + class NotAcceptable < ClientError; end + + # Raised when GitHub returns a 409 HTTP status code + class Conflict < ClientError; end + + # Raised when GitHub returns a 414 HTTP status code + class UnsupportedMediaType < ClientError; end + + # Raised when GitHub returns a 422 HTTP status code + class UnprocessableEntity < ClientError; end + + # Raised on errors in the 500-599 range + class ServerError < Error; end + + # Raised when GitHub returns a 500 HTTP status code + class InternalServerError < ServerError; end + + # Raised when GitHub returns a 501 HTTP status code + class NotImplemented < ServerError; end + + # Raised when GitHub returns a 502 HTTP status code + class BadGateway < ServerError; end + + # Raised when GitHub returns a 503 HTTP status code + class ServiceUnavailable < ServerError; end + + # Raised when client fails to provide valid Content-Type + class MissingContentType < ArgumentError; end + + # Raised when a method requires an application client_id + # and secret but none is provided + class ApplicationCredentialsRequired < StandardError; end +end diff --git a/lib/ievkit/response/raise_error.rb b/lib/ievkit/response/raise_error.rb new file mode 100644 index 000000000..9c466249e --- /dev/null +++ b/lib/ievkit/response/raise_error.rb @@ -0,0 +1,28 @@ +require 'faraday' +require 'ievkit/error' + +module Ievkit + # Faraday response middleware + module Response + + # This class raises an Ievkit-flavored exception based + # HTTP status codes returned by the API + class RaiseError < Faraday::Response::Middleware + + private + + def on_complete(response) + if error = Ievkit::Error.from_response(response) + raise error + end + + # Big horrible hack to fix + body = response[:body] + if body["jobs"].present? + response[:body] = body.gsub("{\"jobs\":", "").chomp("}") + end + + end + end + end +end diff --git a/lib/ievkit/version.rb b/lib/ievkit/version.rb new file mode 100644 index 000000000..b2767952e --- /dev/null +++ b/lib/ievkit/version.rb @@ -0,0 +1,17 @@ +module Ievkit + # Current major release. + # @return [Integer] + MAJOR = 0 + + # Current minor release. + # @return [Integer] + MINOR = 1 + + # Current patch level. + # @return [Integer] + PATCH = 0 + + # Full release version. + # @return [String] + VERSION = [MAJOR, MINOR, PATCH].join('.').freeze +end diff --git a/spec/models/import_service_spec.rb b/spec/models/import_service_spec.rb index 8e7c38188..c1f3161a0 100644 --- a/spec/models/import_service_spec.rb +++ b/spec/models/import_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Import, :type => :model do +describe ImportService, :type => :model do let(:referential) { create(:referential, :slug => "test") } @@ -10,7 +10,6 @@ describe Import, :type => :model do it "should build an import with a scheduled job" do import = subject.find(1) - expect(import).to eq(nil) end it "should build an import with a terminated job" do |
