# # Copyright (c) 2014 RightScale Inc # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. require ::File.expand_path('../../config/init', __FILE__) require 'rack/chunked' require 'set' require 'stringio' require 'uri' module RightDevelop::Testing::Server::MightApi module App class Base MUTEX = ::Mutex.new # semaphore for critical sections MAX_REDIRECTS = 10 # 500 after so many redirects # Rack (and Skeletor) apps and some known AWS apps only accept dash and # not underscore so ensure the default settings reflect the 80-20 rule. DEFAULT_PROXY_SETTINGS = RightSupport::Data::Mash.new( header: RightSupport::Data::Mash.new( case: :capitalize, separator: :dash ).freeze ).freeze # exceptions class MightError < StandardError; end class MissingRoute < MightError; end attr_reader :config, :logger, :state_file_path # @param [Hash] options for initializer # @option options [String] :config for sevice # (default = MightAPI Config singleton) # @option options [String] :logger for sevice # (default = MightAPI logger singleton) # @option options [String] :state_file_name relative to fixtures # directory or nil for no perisisted state. def initialize(options = {}) @config = options[:config] || ::RightDevelop::Testing::Server::MightApi::Config @logger = options[:logger] || ::RightDevelop::Testing::Server::MightApi.logger @state_file_path = options[:state_file_name] ? ::File.join(@config.fixtures_dir, options[:state_file_name]) : nil end def self.interrupted? !!@interrupted end def self.interrupted=(value) @interrupted = value end def self.app_threads @app_threads ||= ::Set.new end def call(env) # HACK: chain trap interrupt on first call to app because the trap chain # does not exist, in rack terms, until just before app is run. # unforunately due to poor design of rack, this object gets no # calls from rack other than calls to handle requests (i.e. a call to # do run/shutdown would be nice). # # the downside here is that if the server never receives any request # then the trap chain is never setup so temporary files cannot be # cleaned-up on shutdown, etc. admin mode has a workaround whereby it is # able to clean-up any temporary files immediately after reading its # config and whenever the administered configuration changes. raise ::Interrupt if self.class.interrupted? MUTEX.synchronize do entrapment unless Base.trapped? self.class.app_threads << ::Thread.current end env['rack.logger'] ||= logger # read body from stream. request = ::Rack::Request.new(env) body = request.body.read # proxy any headers from env starting with HTTP_ headers = env.inject({}) do |r, (k,v)| # note that HTTP_HOST refers to this proxy server instead of the # proxied target server. in the case of AWS authentication, it is # necessary to pass the value through unmodified or else AWS auth # fails. if k.start_with?('HTTP_') r[k[5..-1]] = v end r end # special cases. ['ACCEPT', 'CONTENT_TYPE', 'CONTENT_LENGTH', 'USER_AGENT'].each do |key| headers[key] = env[key] unless env[key].to_s.empty? end # prepare and call handler # # note that verb supposed to already be .to_s.upcase but we want to # ensure that we agree on that. verb = request.request_method.to_s.upcase uri = ::URI.parse(request.url) logger.info("#{verb} #{uri}") result = handle_request(env, verb, uri, headers, body) logger.info(result.first.to_s) result rescue MissingRoute => e message = "#{e.class} #{e.message}" logger.error(message) if config.routes.empty? logger.error("No routes configured.") else logger.error("The following routes are configured:") config.routes.keys.each do |prefix| logger.error(" #{prefix}...") end end # not a 404 because this is a proxy/stub service and 40x might appear to # have come from a proxied request/response whereas 500 is never an # expected response. internal_server_error(message) rescue ::RightDevelop::Testing::Client::Rest::Request::Playback::PeerResetConnectionError => e # FIX: have only implemented socket close for webrick; not sure if this # is needed for other rack implementations. if socket = ::Thread.current[:WEBrickSocket] # closing the socket causes 'peer reset connection' on client side and # also prevents any response coming back on connection. socket.close end message = e.message trace = [e.class.name] + (e.backtrace || []) logger.info(message) internal_server_error(message) rescue ::RightDevelop::Testing::Recording::Metadata::PlaybackError => e # response has not been recorded, etc. message = e.message trace = [e.class.name] + (e.backtrace || []) logger.error(message) logger.debug(trace.join("\n")) internal_server_error(message) rescue ::Interrupt # setting interrupted=true may or may not be redundant, depending on # visibility of interrupted flag to all outstanding app threads. # the problem is that we are not allowed to synchronize a mutex inside # of a trap context. self.class.interrupted = true internal_server_error('interrupt') rescue ::Exception => e message = "Unhandled exception: #{e.class} #{e.message}" trace = e.backtrace || [] if logger logger.error(message) logger.debug(trace.join("\n")) else env['rack.errors'].puts(message) env['rack.errors'].puts(trace.join("\n")) end internal_server_error(message) ensure unless self.class.interrupted? MUTEX.synchronize do self.class.app_threads.delete(::Thread.current) end end end # Handler. # # @param [Hash] env from rack # @param [String] verb as one of ['GET', 'POST', etc.] # @param [URI] uri parsed from full url # @param [Hash] headers for proxy call with any non-proxy data omitted # @param [String] body streamed from payload or empty # # @return [TrueClass] always true def handle_request(env, verb, uri, headers, body) raise ::NotImplementedError, 'Must be overridden' end # Removes state and/or fixtures for current mode (overridable). def cleanup # the state file, if any, is always temporary. if @state_file_path && ::File.file?(@state_file_path) ::File.unlink(@state_file_path) end # remove any directories listed as temporary by config. (config.cleanup_dirs || []).each do |dir| ::FileUtils.rm_rf(dir) if ::File.directory?(dir) end true end protected # Makes a proxied API request using the given request class. # # @param [Class] request_class for API call # @param [String] verb as one of ['GET', 'POST', etc.] # @param [URI] uri parsed from full url # @param [Hash] headers for proxy call with any non-proxy data omitted # @param [String] body streamed from payload or empty # @param [Integer] throttle for playback or nil # # @return [Array] rack-style tuple of [code, headers, [body]] def proxy(request_class, verb, uri, headers, body, throttle = nil) # check routes. unless route = find_route(uri) raise MissingRoute, "No route configured for #{uri.path.inspect}" end route_path, route_data = route response = nil max_redirects = MAX_REDIRECTS while response.nil? do request_proxy = nil begin proxied_url = ::File.join(route_data[:url], uri.path) unless uri.query.to_s.empty? proxied_url << '?' << uri.query end proxied_headers = proxy_headers(headers, route_data) request_options = { fixtures_dir: config.fixtures_dir, logger: logger, route_path: route_path, route_data: route_data, state_file_path: state_file_path, method: verb.downcase.to_sym, url: proxied_url, headers: proxied_headers, payload: body } request_options[:throttle] = throttle if throttle request_proxy = request_class.new(request_options) # log normalized data for obfuscation. logger.debug("normalized request headers = #{request_proxy.request_metadata.headers.inspect}") logger.debug("normalized request body:\n" << request_proxy.request_metadata.body) request_proxy.execute do |rest_response, rest_request, net_http_response, &block| # headers. response_headers = normalize_rack_response_headers(net_http_response.to_hash) # eliminate headers that interfere with response via proxy. %w( status content-encoding ).each { |key| response_headers.delete(key) } case response_code = Integer(rest_response.code) when 301, 302, 307 raise RestClient::Exceptions::EXCEPTIONS_MAP[response_code].new(rest_response, response_code) else # special handling for chunked body. if response_headers['transfer-encoding'] == 'chunked' response_body = ::Rack::Chunked::Body.new([rest_response.body]) else response_body = [rest_response.body] end response = [response_code, response_headers, response_body] end end # log normalized data for obfuscation. logger.debug("normalized response headers = #{request_proxy.response_metadata.headers.inspect}") logger.debug("normalized response body:\n" << request_proxy.response_metadata.body.to_s) rescue RestClient::RequestTimeout net_http_response = request_proxy.handle_timeout response_code = Integer(net_http_response.code) response_headers = normalize_rack_response_headers(net_http_response.to_hash) response_body = [net_http_response.body] response = [response_code, response_headers, response_body] rescue RestClient::Exception => e case e.http_code when 301, 302, 307 max_redirects -= 1 raise MightError.new('Exceeded max redirects') if max_redirects < 0 if location = e.response.headers[:location] redirect_uri = ::URI.parse(location) redirect_uri.path = '' redirect_uri.query = nil logger.debug("#{e.message} from #{route_data[:url]} to #{redirect_uri}") route_data[:url] = redirect_uri.to_s # move to end of FIFO queue for retry. request_proxy.forget_outstanding_request else logger.debug("#{e.message} was missing expected location header.") raise end else raise end ensure # remove from FIFO queue in case of any unhandled error. request_proxy.forget_outstanding_request if request_proxy end end response end # @param [URI] uri path to find # # @return [Array] pair of [prefix, data] or nil def find_route(uri) # ensure path is slash-terminated only for matching purposes. find_path = uri.path find_path += '/' unless find_path.end_with?('/') logger.debug "Route URI path to match = #{find_path.inspect}" config.routes.find do |prefix, data| matched = find_path.start_with?(prefix) logger.debug "Tried = #{prefix.inspect}, matched = #{matched}" matched end end # Sets the header style using configuration of the proxied service. # # @param [Hash] headers for proxy # @param [Hash] route_data containing header configuration, if any # # @return [Mash] proxied headers def proxy_headers(headers, route_data) proxied = nil if proxy_data = route_data[:proxy] || DEFAULT_PROXY_SETTINGS if header_data = proxy_data[:header] to_separator = (header_data[:separator] == :underscore) ? '_' : '-' from_separator = (to_separator == '-') ? '_' : '-' proxied = headers.inject(RightSupport::Data::Mash.new) do |h, (k, v)| k = k.to_s case header_data[:case] when nil k = k.gsub(from_separator, to_separator) when :lower k = k.downcase.gsub(from_separator, to_separator) when :upper k = k.upcase.gsub(from_separator, to_separator) when :capitalize k = k.split(/-|_/).map { |word| word.capitalize }.join(to_separator) else raise ::ArgumentError, "Unexpected header case: #{route_data.inspect}" end h[k] = v h end end end proxied || RightSupport::Data::Mash.new(headers) end # rack has a convention of newline-delimited header multi-values. # # HACK: changes underscore to dash to defeat RestClient::AbstractResponse # line 27 (on client side) from failing to parse cookies array; it # incorrectly calls .inject on the stringized form instead of using the # raw array form or parsing the cookies into a hash, but only if the raw # name is 'set_cookie' ('set-cookie' is okay). # # even wierder, on line 78 it assumes the raw name is 'set-cookie' and # that works out for us here. # # @param [Hash] headers to normalize # # @return [Hash] normalized headers def normalize_rack_response_headers(headers) result = headers.inject({}) do |h, (k, v)| h[k.to_s.gsub('_', '-').downcase] = v.join("\n") h end # a proxy server must always instruct the client close the connection by # specification because a live socket cannot be proxied from client to # the real server. this also works around a lame warning in ruby 1.9.3 # webbrick code (fixed in 2.1.0+) saying: # Could not determine content-length of response body. # Set content-length of the response or set Response#chunked = true # in the case of 204 empty response, which is incorrect. result['connection'] = 'close' result end # @return [Array] rack-style response for 500 def internal_server_error(message) formal = < 'text/plain', 'Content-Length' => ::Rack::Utils.bytesize(formal).to_s }, [formal] ] end # @return [Trueclass|FalseClass] true if trap chain has been setup def self.trapped?; !!@trapped; end # @param [Trueclass|FalseClass] value of trapped # @return [Trueclass|FalseClass] true if trap chain has been setup def self.trapped=(value); @trapped = value; end # sets-up the trap chain, overriding the trap handler that rack setup just # before running the server. the rack trap is called after our own trap # handler is invoked. def entrapment # setup trap chain once. Base.trapped = true app = self # 'get' previous trap by replacing any existing trap with 'IGNORE' previous_trap = trap(:INT, 'IGNORE') # substitute our trap and chain it to previous by explicitly invoking # the previous trap. ruby makes this somewhat difficult and rack then # makes it even harder. trap(:INT) do begin # loggers may have closed file handles in a trap so disconnect any # loggers from multiplexer before continuing. even when they do not # raise exceptions they still appear to log nothing at this point # (not sure about syslog, definitely not file or console). if app.logger.respond_to?(:targets) # HACK: it is bad that Multiplexer#targets exposes its internal # array in a manner that allows us to clear it. it would be better # if to have a Multiplexer#reset method we could call instead. # to ensure that cleaning continues to work, check the result # afterward. note that we tried iterating targets and calling the # Multiplexer#remove method but that had no effect. app.logger.targets.clear fail 'Unexpected targets' unless app.logger.targets.empty? app.logger.warn('cannot log traps') # no exception raised end # interrupt any running app threads to resolve outstanding requests. # # note that Mutex#synchronize is not allowed inside a trap context. # # FIX: duplicating the set is slightly unsafe but not sure how else # to deal with data protected by critical section in a trap. we also # have logic in ensure block to avoid modifying set on interrupt. app.class.interrupted = true app_threads = app.class.app_threads.dup app_threads.each do |app_thread| if app_thread.alive? app_thread.raise(::Interrupt) app_thread.join end end # cleanup fixtures, if requested. app.cleanup if previous_trap && previous_trap.respond_to?(:call) previous_trap.call else exit end rescue ::Exception => e # loggers are unreliable so write any rescued error home. msg = ([e.class, e.message] + (e.backtrace || [])).join("\n") dir = ::ENV['HOME'] || ::Dir.pwd path = ::File.join(dir, 'might_api_rescued_error.txt') ::File.open(path, 'w') { |f| f.puts msg } exit 1 end end true end end end end