diff --git a/Gemfile b/Gemfile index 7949e731..6a6a638b 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source 'https://rubygems.org' gemspec group :test do + gem 'builder' gem 'rubocop', '~> 0.52.1', require: false gem 'awesome_print', require: 'ap' end diff --git a/lib/fhir_client.rb b/lib/fhir_client.rb index 9f8e7127..d84bb73e 100644 --- a/lib/fhir_client.rb +++ b/lib/fhir_client.rb @@ -23,3 +23,7 @@ Dir.glob(File.join(root, 'fhir_client', 'model', '**', '*.rb')).each do |file| require file end + +Dir.glob(File.join(root, 'fhir_client', 'client', '**', '*.rb')).each do |file| + require file +end diff --git a/lib/fhir_client/client.rb b/lib/fhir_client/client.rb index c6a0a8cd..e465f8b0 100644 --- a/lib/fhir_client/client.rb +++ b/lib/fhir_client/client.rb @@ -416,317 +416,52 @@ def clean_headers(headers) FHIR::ResourceAddress.convert_symbol_headers(headers) end - def scrubbed_response_headers(result) - result.each_key do |k| - v = result[k] - result[k] = v[0] if v.is_a? Array - end - end - def get(path, headers = {}) - url = Addressable::URI.parse(build_url(path)).to_s - FHIR.logger.info "GETTING: #{url}" - headers = clean_headers(headers) unless headers.empty? - if @use_oauth2_auth - # @client.refresh! - begin - response = @client.get(url, headers: headers) - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "GET - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response if e.response - end - req = { - method: :get, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: nil - } - res = { - code: response.status.to_s, - headers: response.headers, - body: response.body - } - if url.end_with?('/metadata') - FHIR.logger.debug "GET - Request: #{req}, Response: [too large]" - else - FHIR.logger.debug "GET - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - end - @reply = FHIR::ClientReply.new(req, res, self) - else - headers.merge!(@security_headers) if @use_basic_auth - begin - response = @client.get(url, headers) - rescue RestClient::SSLCertificateNotVerified => sslerr - FHIR.logger.error "SSL Error: #{url}" - req = { - method: :get, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: nil - } - res = { - code: nil, - headers: nil, - body: sslerr.message - } - @reply = FHIR::ClientReply.new(req, res, self) - return @reply - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "GET - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response - end - if url.end_with?('/metadata') - FHIR.logger.debug "GET - Request: #{response.request.to_json}, Response: [too large]" - else - FHIR.logger.debug "GET - Request: #{response.request.to_json}, Response: #{response.body.force_encoding('UTF-8')}" - end - response.request.args[:path] = response.request.args[:url].gsub(@base_service_url, '') - headers = response.headers.each_with_object({}) { |(k, v), h| h[k.to_s.tr('_', '-')] = v.to_s; h } - res = { - code: response.code, - headers: scrubbed_response_headers(headers), - body: response.body - } - - @reply = FHIR::ClientReply.new(response.request.args, res, self) - end + request :get, path, headers end def post(path, resource, headers) - url = URI(build_url(path)).to_s - FHIR.logger.info "POSTING: #{url}" - headers = clean_headers(headers) - payload = request_payload(resource, headers) if resource - if @use_oauth2_auth - # @client.refresh! - begin - response = @client.post(url, headers: headers, body: payload) - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "POST - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response if e.response - end - req = { - method: :post, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: payload - } - res = { - code: response.status.to_s, - headers: response.headers, - body: response.body - } - FHIR.logger.debug "POST - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - @reply = FHIR::ClientReply.new(req, res, self) - else - headers.merge!(@security_headers) if @use_basic_auth - @client.post(url, payload, headers) do |resp, request, result| - FHIR.logger.debug "POST - Request: #{request.to_json}\nResponse:\nResponse Headers: #{scrubbed_response_headers(result.each_key {})} \nResponse Body: #{resp.force_encoding('UTF-8')}" - request.args[:path] = url.gsub(@base_service_url, '') - res = { - code: result.code, - headers: scrubbed_response_headers(result.each_key {}), - body: resp - } - @reply = FHIR::ClientReply.new(request.args, res, self) - end - end + request :post, path, headers, resource end def put(path, resource, headers) - url = URI(build_url(path)).to_s - FHIR.logger.info "PUTTING: #{url}" - headers = clean_headers(headers) - payload = request_payload(resource, headers) if resource - if @use_oauth2_auth - # @client.refresh! - begin - response = @client.put(url, headers: headers, body: payload) - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "PUT - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response if e.response - end - req = { - method: :put, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: payload - } - res = { - code: response.status.to_s, - headers: response.headers, - body: response.body - } - FHIR.logger.debug "PUT - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - @reply = FHIR::ClientReply.new(req, res, self) - else - headers.merge!(@security_headers) if @use_basic_auth - @client.put(url, payload, headers) do |resp, request, result| - FHIR.logger.debug "PUT - Request: #{request.to_json}, Response: #{resp.force_encoding('UTF-8')}" - request.args[:path] = url.gsub(@base_service_url, '') - res = { - code: result.code, - headers: scrubbed_response_headers(result.each_key {}), - body: resp - } - @reply = FHIR::ClientReply.new(request.args, res, self) - end - end + request :put, path, headers, resource end - def patch(path, patchset, headers) - url = URI(build_url(path)).to_s - FHIR.logger.info "PATCHING: #{url}" - headers = clean_headers(headers) - payload = request_patch_payload(patchset, headers['Content-Type']) - if @use_oauth2_auth - # @client.refresh! - begin - response = @client.patch(url, headers: headers, body: payload) - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "PATCH - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response if e.response - end - req = { - method: :patch, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: payload - } - res = { - code: response.status.to_s, - headers: response.headers, - body: response.body - } - FHIR.logger.debug "PATCH - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - @reply = FHIR::ClientReply.new(req, res, self) - else - headers.merge!(@security_headers) if @use_basic_auth - begin - @client.patch(url, payload, headers) do |resp, request, result| - FHIR.logger.debug "PATCH - Request: #{request.to_json}, Response: #{resp.force_encoding('UTF-8')}" - request.args[:path] = url.gsub(@base_service_url, '') - res = { - code: result.code, - headers: scrubbed_response_headers(result.each_key {}), - body: resp - } - @reply = FHIR::ClientReply.new(request.args, res, self) - end - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "PATCH - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - req = { - method: :patch, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: payload - } - res = { - body: e.message - } - FHIR.logger.debug "PATCH - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - FHIR.logger.error "PATCH Error: #{e.message}" - @reply = FHIR::ClientReply.new(req, res, self) - end - end + def patch(path, resource, headers) + request :patch, path, headers, resource end - def delete(path, headers) - url = URI(build_url(path)).to_s - FHIR.logger.info "DELETING: #{url}" - headers = clean_headers(headers) - if @use_oauth2_auth - # @client.refresh! - begin - response = @client.delete(url, headers: headers) - rescue => e - if !e.respond_to?(:response) || e.response.nil? - # Re-raise the client error if there's no response. Otherwise, logging - # and other things break below! - FHIR.logger.error "DELETE - Request: #{url} failed! No response from server: #{e}" - raise # Re-raise the same error we caught. - end - response = e.response if e.response - end - req = { - method: :delete, - url: url, - path: url.gsub(@base_service_url, ''), - headers: headers, - payload: nil - } - res = { - code: response.status.to_s, - headers: response.headers, - body: response.body - } - FHIR.logger.debug "DELETE - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" - @reply = FHIR::ClientReply.new(req, res, self) - else - headers.merge!(@security_headers) if @use_basic_auth - @client.delete(url, headers) do |resp, request, result| - FHIR.logger.debug "DELETE - Request: #{request.to_json}, Response: #{resp.force_encoding('UTF-8')}" - request.args[:path] = url.gsub(@base_service_url, '') - res = { - code: result.code, - headers: scrubbed_response_headers(result.each_key {}), - body: resp - } - @reply = FHIR::ClientReply.new(request.args, res, self) - end - end + def delete(path, headers = {}) + request :delete, path, headers end def head(path, headers) - headers.merge!(@security_headers) unless @security_headers.blank? - url = URI(build_url(path)).to_s - FHIR.logger.info "HEADING: #{url}" - RestClient.head(url, headers) do |response, request, result| - FHIR.logger.debug "HEAD - Request: #{request.to_json}, Response: #{response.force_encoding('UTF-8')}" - request.args[:path] = url.gsub(@base_service_url, '') - res = { - code: result.code, - headers: scrubbed_response_headers(result.each_key {}), - body: response - } - @reply = FHIR::ClientReply.new(request.args, res, self) + request :head, path, headers + end + + def request(action, path, headers, resource = nil) + # Grab the name of the class + # If + module_name = @client.class + module_name = @client if [Object, Module].include? module_name + resolver = FHIR::Client::RestProviders.const_get(module_name.to_s) + + url = Addressable::URI.parse(build_url(path)).to_s + FHIR.logger.info "#{action.to_s.upcase} #{url}" + + headers = clean_headers(headers) unless headers.empty? + case action + when :patch + payload = request_patch_payload(resource, headers['Content-Type']) + else + payload = request_payload(resource, headers) if resource end + @reply = resolver.request action, self, url, + base_service_url: @base_service_url, + credentials: @security_headers, + headers: headers, + body: payload end def build_url(path) diff --git a/lib/fhir_client/client/error.rb b/lib/fhir_client/client/error.rb new file mode 100644 index 00000000..8da037f6 --- /dev/null +++ b/lib/fhir_client/client/error.rb @@ -0,0 +1,15 @@ +module FHIR + class Client + class Error + class NotImplemented < StandardError ; end + # Create an exception for each HTTP code + HTTP_CODE = {} + + Net::HTTPResponse::CODE_TO_OBJ.transform_values{|v| v.to_s.split('::').last}.each do |code, name| + error_class = Class.new(StandardError) + const_set name, error_class + HTTP_CODE[code] = const_get(name) + end + end + end +end diff --git a/lib/fhir_client/client/rest_providers/oauth2_access_token.rb b/lib/fhir_client/client/rest_providers/oauth2_access_token.rb new file mode 100644 index 00000000..6ed7e8f3 --- /dev/null +++ b/lib/fhir_client/client/rest_providers/oauth2_access_token.rb @@ -0,0 +1,67 @@ +module FHIR + class Client + class RestProviders + class OAuth2 + class AccessToken + def self.refresh_token_for(fhir_client, **params) + return unless fhir_client.client.refresh_token + + if params[:force] || fhir_client.client.expired? + FHIR.logger.debug "OAuth2 token refresh invoked" + fhir_client.client = fhir_client.client.refresh! + end + end + + # All of the OAuth2 logic is common across all methods + def self.request(action, fhir_client, url, **params) + attempted_action ||= false + action_name = action.to_s.upcase + + refresh_token_for fhir_client + + begin + response = fhir_client.client.request action, url, **params.slice(:headers, :body) + status = response.status.to_s + raise ::FHIR::Client::Error::HTTP_CODE[status] if ['401'].include? status + rescue ::FHIR::Client::Error::HTTPUnauthorized + unless attempted_action + attempted_action = true + refresh_token_for fhir_client, force: true + retry + end + rescue => e + if !e.respond_to?(:response) || e.response.nil? + # Re-raise the client error if there's no response. Otherwise, logging + # and other things break below! + FHIR.logger.error "#{action_name} - Request: #{url} failed! No response from server: #{e}" + raise # Re-raise the same error we caught. + end + response = e.response if e.response + end + + req = { + method: action, + url: url, + path: url.gsub(params[:base_service_url], ''), + headers: params[:headers], + payload: params[:body] + } + res = { + code: response.status.to_s, + headers: response.headers, + body: response.body + } + + if url.end_with?('/metadata') + FHIR.logger.debug "#{action_name} - Request: #{req}, Response: [metadata, too large]" + else + FHIR.logger.debug "#{action_name} - Request: #{req}, Response: #{response.body.force_encoding('UTF-8')}" + end + + FHIR::ClientReply.new(req, res, fhir_client) + end + end + end + end + end +end diff --git a/lib/fhir_client/client/rest_providers/rest_client.rb b/lib/fhir_client/client/rest_providers/rest_client.rb new file mode 100644 index 00000000..83aeedce --- /dev/null +++ b/lib/fhir_client/client/rest_providers/rest_client.rb @@ -0,0 +1,171 @@ +module FHIR + class Client + class RestProviders + class RestClient + def self.scrubbed_response_headers(result) + result.each_key do |k| + v = result[k] + result[k] = v[0] if v.is_a? Array + end + end + + def self.request(action, fhir_client, url, **params) + params[:headers].merge! params[:credentials] + send action, fhir_client, url, **params + end + + def self.get(fhir_client, url, **params) + begin + response = fhir_client.client.get(url, params[:headers]) + rescue ::RestClient::SSLCertificateNotVerified => sslerr + return handle_ssl_error(sslerr, fhir_client, url, **params) + rescue => e + response = handle_response_error(e) + end + + response.request.args[:path] = response.request.args[:url].gsub(params[:base_service_url], '') + headers = response.headers.each_with_object({}) { |(k, v), h| h[k.to_s.tr('_', '-')] = v.to_s; h } + res = { + code: response.code, + headers: scrubbed_response_headers(headers), + body: response.body + } + debug_line url, response.request.to_json, response.body + + FHIR::ClientReply.new(response.request.args, res, fhir_client) + end + + def self.post(fhir_client, url, **params) + fhir_client.client.post(url, params[:body], params[:headers]) do |resp, request, result| + request.args[:path] = url.gsub(params[:base_service_url], '') + res = { + code: result.code, + headers: scrubbed_response_headers(result.each_key {}), + body: resp + } + debug_line url, request.to_json, [ + '', + "Response Headers: #{res[:headers]}", + "Response Body: #{res[:body]}" + ].join('\n') + + FHIR::ClientReply.new(request.args, res, fhir_client) + end + + end + + def self.put(fhir_client, url, **params) + fhir_client.client.put(url, params[:body], params[:headers]) do |resp, request, result| + request.args[:path] = url.gsub(params[:base_service_url], '') + res = { + code: result.code, + headers: scrubbed_response_headers(result.each_key {}), + body: resp + } + debug_line url, request.to_json, resp + + FHIR::ClientReply.new(request.args, res, fhir_client) + end + end + + def self.patch(fhir_client, url, **params) + begin + fhir_client.client.patch(url, params[:body], params[:headers]) do |resp, request, result| + request.args[:path] = url.gsub(params[:base_service_url], '') + res = { + code: result.code, + headers: scrubbed_response_headers(result.each_key {}), + body: resp + } + debug_line url, request.to_json, resp + FHIR::ClientReply.new(request.args, res, fhir_client) + end + rescue => e + handle_response_error(e) + + req = { + method: :patch, + url: url, + path: url.gsub(params[:base_service_url], ''), + headers: headers, + payload: payload + } + res = { + body: e.message + } + + debug_line url, req, response.body + error_line e.message + FHIR::ClientReply.new(req, res, fhir_client) + end + end + + def self.delete(fhir_client, url, **params) + fhir_client.client.delete(url, params[:headers]) do |resp, request, result| + request.args[:path] = url.gsub(params[:base_service_url], '') + res = { + code: result.code, + headers: scrubbed_response_headers(result.each_key {}), + body: resp + } + debug_line url, request.to_json, resp + FHIR::ClientReply.new(request.args, res, fhir_client) + end + end + + def self.head(fhir_client, url, **params) + fhir_client.client.head(url, params[:headers]) do |response, request, result| + debug_line url, req, response + request.args[:path] = url.gsub(params[:base_service_url], '') + res = { + code: result.code, + headers: scrubbed_response_headers(result.each_key {}), + body: response + } + FHIR::ClientReply.new(request.args, res, fhir_client) + end + end + + # Common debug line output + def self.debug_line(url, request, response) + action_name = caller_locations(1..1).first.label.upcase + + if url.end_with?('/metadata') + FHIR.logger.debug "#{action_name} - Request: #{request}, Response: [metadata, too large]" + else + FHIR.logger.debug "#{action_name} - Request: #{request}, Response: #{response.force_encoding('UTF-8')}" + end + end + + def self.error_line(message) + action_name = caller_locations(1..1).first.label.upcase + FHIR.logger.debug "#{action_name} Error: #{message}" + end + + def self.handle_ssl_error(sslerr, fhir_client, url, **params) + req = { + method: caller_locations(1..1).first.label.to_sym, + url: url, + path: url.gsub(params[:base_service_url], ''), + headers: params[:headers], + payload: params[:body] + } + res = { + body: sslerr.message + } + FHIR::ClientReply.new(req, res, fhir_client) + end + + def self.handle_response_error(e) + action_name = caller_locations(1..1).first.label.upcase.split(' ').last + + if !e.respond_to?(:response) || e.response.nil? + FHIR.logger.error "GET - Request: #{action_name} failed! No response from server: #{e}" + raise + end + e.response + end + end + end + end +end diff --git a/test/unit/basic_test.rb b/test/unit/basic_test.rb index 53567a43..6c100de3 100644 --- a/test/unit/basic_test.rb +++ b/test/unit/basic_test.rb @@ -15,7 +15,7 @@ def test_set_basic_auth_auth assert client.use_oauth2_auth == false assert client.use_basic_auth == true assert client.security_headers == {"Authorization"=>"Basic Y2xpZW50OnNlY3JldA==\n"} - assert client.client == RestClient + assert RestClient == client.client end def test_bearer_token_auth @@ -24,8 +24,7 @@ def test_bearer_token_auth assert client.use_oauth2_auth == false assert client.use_basic_auth == true assert client.security_headers == {"Authorization"=>"Bearer secret_token"} - assert client.client == RestClient - + assert RestClient == client.client end def test_oauth2_token_auth @@ -36,6 +35,7 @@ def test_oauth2_token_auth assert client.use_oauth2_auth == true assert client.use_basic_auth == false assert client.security_headers == {} + assert OAuth2::AccessToken === client.client assert client.client.client.site == "http://basic-test.com/fhir/" end @@ -48,6 +48,7 @@ def test_oauth2_token_auth_custom assert client.use_oauth2_auth == true assert client.use_basic_auth == false assert client.security_headers == {} + assert OAuth2::AccessToken === client.client assert client.client.client.site == "http://custom-test.com/fhir/" end @@ -56,32 +57,42 @@ def test_client_logs_without_response # This used to provide a NoMethodError: # undefined method `request' for nil:NilClass # on the line which logs the request/response, because Response was nil - format_headers = { format: :json} + format_headers = { format: :json } stubbed_path = 'Patient/1234' - [false, true].each do |use_auth| - client.use_oauth2_auth = use_auth + [false,true].each do |use_oauth| + if use_oauth + stub_request(:post, /token_path/).to_return(status: 200, body: '{"access_token" : "valid_token"}', headers: {'Content-Type' => 'application/json'}) + client.set_oauth2_auth("client", "secret", "authorize_path", "token_path") + timeouts = [Faraday::ConnectionFailed] + raises = Faraday::ConnectionFailed + else + client.set_basic_auth('client', 'secret') + timeouts = [RestClient::RequestTimeout, RestClient::Exceptions::OpenTimeout] + raises = SocketError + end + %i[get delete head].each do |method| stub = stub_request(method, /basic-test/).to_timeout - assert_raise(RestClient::RequestTimeout, RestClient::Exceptions::OpenTimeout) do + assert_raise(*timeouts) do client.send(method, stubbed_path, format_headers) assert_requested stub end stub = stub_request(method, /basic-test/).to_raise(SocketError) - assert_raise(SocketError) do + assert_raise(raises) do client.send(method, stubbed_path, format_headers) - assert_requested stub + assert_requested raises end end %i[post put patch].each do |method| stub = stub_request(method, /basic-test/).to_timeout - assert_raise(RestClient::RequestTimeout, RestClient::Exceptions::OpenTimeout) do + assert_raise(*timeouts) do client.send(method, stubbed_path, FHIR::Patient.new, format_headers) assert_requested stub end stub = stub_request(method, /basic-test/).to_raise(SocketError) - assert_raise(SocketError) do + assert_raise(raises) do client.send(method, stubbed_path, FHIR::Patient.new, format_headers) - assert_requested stub + assert_requested raises end end end