diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index 2b9e81b61..2c78a0283 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,244 +1,244 @@ module Puppet::Network::HTTP end require 'puppet/network/http/api/v1' require 'puppet/network/rest_authorization' require 'puppet/network/rights' require 'resolv' module Puppet::Network::HTTP::Handler include Puppet::Network::HTTP::API::V1 include Puppet::Network::RestAuthorization attr_reader :server, :handler # Retrieve the accept header from the http request. def accept_header(request) raise NotImplementedError end # Retrieve the Content-Type header from the http request. def content_type_header(request) raise NotImplementedError end # Which format to use when serializing our response or interpreting the request. # IF the client provided a Content-Type use this, otherwise use the Accept header # and just pick the first value. def format_to_use(request) unless header = accept_header(request) raise ArgumentError, "An Accept header must be provided to pick the right format" end format = nil header.split(/,\s*/).each do |name| next unless format = Puppet::Network::FormatHandler.format(name) next unless format.suitable? return format end raise "No specified acceptable formats (#{header}) are functional on this machine" end def request_format(request) if header = content_type_header(request) header.gsub!(/\s*;.*$/,'') # strip any charset format = Puppet::Network::FormatHandler.mime(header) raise "Client sent a mime-type (#{header}) that doesn't correspond to a format we support" if format.nil? return format.name.to_s if format.suitable? end raise "No Content-Type header was received, it isn't possible to unserialize the request" end def format_to_mime(format) format.is_a?(Puppet::Network::Format) ? format.mime : format end def initialize_for_puppet(server) @server = server end # handle an HTTP request def process(request, response) indirection, method, key, params = uri2indirection(http_method(request), path(request), params(request)) check_authorization(indirection, method, key, params) send("do_#{method}", indirection, key, params, request, response) rescue SystemExit,NoMemoryError raise rescue Exception => e return do_exception(response, e) end # Set the response up, with the body and status. def set_response(response, body, status = 200) raise NotImplementedError end # Set the specified format as the content type of the response. def set_content_type(response, format) raise NotImplementedError end def do_exception(response, exception, status=400) if exception.is_a?(Puppet::Network::AuthorizationError) # make sure we return the correct status code # for authorization issues status = 403 if status == 400 end if exception.is_a?(Exception) puts exception.backtrace if Puppet[:trace] Puppet.err(exception) end set_content_type(response, "text/plain") set_response(response, exception.to_s, status) end def model(indirection_name) raise ArgumentError, "Could not find indirection '#{indirection_name}'" unless indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) indirection.model end # Execute our find. def do_find(indirection_name, key, params, request, response) unless result = model(indirection_name).indirection.find(key, params) Puppet.info("Could not find #{indirection_name} for '#{key}'") return do_exception(response, "Could not find #{indirection_name} #{key}", 404) end # The encoding of the result must include the format to use, # and it needs to be used for both the rendering and as # the content type. format = format_to_use(request) set_content_type(response, format) if result.respond_to?(:render) set_response(response, result.render(format)) else set_response(response, result) end end # Execute our head. - def do_head(indirection_request, request, response) - unless indirection_request.model.head(indirection_request.key, indirection_request.to_hash) - Puppet.info("Could not find #{indirection_request.indirection_name} for '#{indirection_request.key}'") - return do_exception(response, "Could not find #{indirection_request.indirection_name} #{indirection_request.key}", 404) + def do_head(indirection_name, key, params, request, response) + unless self.model(indirection_name).indirection.head(key, params) + Puppet.info("Could not find #{indirection_name} for '#{key}'") + return do_exception(response, "Could not find #{indirection_name} #{key}", 404) end # No need to set a response because no response is expected from a # HEAD request. All we need to do is not die. end # Execute our search. def do_search(indirection_name, key, params, request, response) model = self.model(indirection_name) result = model.indirection.search(key, params) if result.nil? return do_exception(response, "Could not find instances in #{indirection_name} with '#{key}'", 404) end format = format_to_use(request) set_content_type(response, format) set_response(response, model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection_name, key, params, request, response) result = model(indirection_name).indirection.destroy(key, params) return_yaml_response(response, result) end # Execute our save. def do_save(indirection_name, key, params, request, response) data = body(request).to_s raise ArgumentError, "No data to save" if !data or data.empty? format = request_format(request) obj = model(indirection_name).convert_from(format, data) result = model(indirection_name).indirection.save(obj, key) return_yaml_response(response, result) end # resolve node name from peer's ip address # this is used when the request is unauthenticated def resolve_node(result) begin return Resolv.getname(result[:ip]) rescue => detail Puppet.err "Could not resolve #{result[:ip]}: #{detail}" end result[:ip] end private def return_yaml_response(response, body) set_content_type(response, Puppet::Network::FormatHandler.format("yaml")) set_response(response, body.to_yaml) end def get?(request) http_method(request) == 'GET' end def put?(request) http_method(request) == 'PUT' end def delete?(request) http_method(request) == 'DELETE' end # methods to be overridden by the including web server class def http_method(request) raise NotImplementedError end def path(request) raise NotImplementedError end def request_key(request) raise NotImplementedError end def body(request) raise NotImplementedError end def params(request) raise NotImplementedError end def decode_params(params) params.inject({}) do |result, ary| param, value = ary next result if param.nil? || param.empty? param = param.to_sym # These shouldn't be allowed to be set by clients # in the query string, for security reasons. next result if param == :node next result if param == :ip value = CGI.unescape(value) if value =~ /^---/ value = YAML.load(value) else value = true if value == "true" value = false if value == "false" value = Integer(value) if value =~ /^\d+$/ value = value.to_f if value =~ /^\d+\.\d+$/ end result[param] = value result end end end diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb index 83969c504..c709d82fe 100755 --- a/spec/unit/network/http/handler_spec.rb +++ b/spec/unit/network/http/handler_spec.rb @@ -1,471 +1,467 @@ #!/usr/bin/env rspec require 'spec_helper' require 'puppet/network/http/handler' require 'puppet/network/rest_authorization' class HttpHandled include Puppet::Network::HTTP::Handler end describe Puppet::Network::HTTP::Handler do before do @handler = HttpHandled.new end it "should include the v1 REST API" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::HTTP::API::V1) end it "should include the Rest Authorization system" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::RestAuthorization) end it "should have a method for initializing" do @handler.should respond_to(:initialize_for_puppet) end describe "when initializing" do it "should fail when no server type has been provided" do lambda { @handler.initialize_for_puppet }.should raise_error(ArgumentError) end it "should set server type" do @handler.initialize_for_puppet("foo") @handler.server.should == "foo" end end it "should be able to process requests" do @handler.should respond_to(:process) end describe "when processing a request" do before do @request = stub('http request') @request.stubs(:[]).returns "foo" @response = stub('http response') @model_class = stub('indirected model class') @indirection = stub('indirection') @model_class.stubs(:indirection).returns(@indirection) @result = stub 'result', :render => "mytext" @handler.stubs(:check_authorization) stub_server_interface end # Stub out the interface we require our including classes to # implement. def stub_server_interface @handler.stubs(:accept_header ).returns "format_one,format_two" @handler.stubs(:content_type_header).returns "text/yaml" @handler.stubs(:set_content_type ).returns "my_result" @handler.stubs(:set_response ).returns "my_result" @handler.stubs(:path ).returns "/my_handler/my_result" @handler.stubs(:http_method ).returns("GET") @handler.stubs(:params ).returns({}) @handler.stubs(:content_type ).returns("text/plain") end it "should create an indirection request from the path, parameters, and http method" do @handler.expects(:path).with(@request).returns "mypath" @handler.expects(:http_method).with(@request).returns "mymethod" @handler.expects(:params).with(@request).returns "myparams" @handler.expects(:uri2indirection).with("mymethod", "mypath", "myparams").returns stub("request", :method => :find) @handler.stubs(:do_find) @handler.process(@request, @response) end it "should call the 'do' method and delegate authorization to the RestAuthorization layer" do @handler.expects(:uri2indirection).returns(["facts", :mymethod, "key", {:node => "name"}]) @handler.expects(:do_mymethod).with("facts", "key", {:node => "name"}, @request, @response) @handler.expects(:check_authorization).with("facts", :mymethod, "key", {:node => "name"}) @handler.process(@request, @response) end it "should return 403 if the request is not authorized" do @handler.expects(:uri2indirection).returns(["facts", :mymethod, "key", {:node => "name"}]) @handler.expects(:do_mymethod).never @handler.expects(:check_authorization).with("facts", :mymethod, "key", {:node => "name"}).raises(Puppet::Network::AuthorizationError.new("forbidden")) @handler.expects(:set_response).with { |response, body, status| status == 403 } @handler.process(@request, @response) end it "should serialize a controller exception when an exception is thrown while finding the model instance" do @handler.expects(:uri2indirection).returns(["facts", :find, "key", {:node => "name"}]) @handler.expects(:do_find).raises(ArgumentError, "The exception") @handler.expects(:set_response).with { |response, body, status| body == "The exception" and status == 400 } @handler.process(@request, @response) end it "should set the format to text/plain when serializing an exception" do @handler.expects(:set_content_type).with(@response, "text/plain") @handler.do_exception(@response, "A test", 404) end it "should raise an error if the request is formatted in an unknown format" do @handler.stubs(:content_type_header).returns "unknown format" lambda { @handler.request_format(@request) }.should raise_error end it "should still find the correct format if content type contains charset information" do @handler.stubs(:content_type_header).returns "text/plain; charset=UTF-8" @handler.request_format(@request).should == "s" end describe "when finding a model instance" do before do @indirection.stubs(:find).returns @result Puppet::Indirector::Indirection.expects(:instance).with(:my_handler).returns( stub "indirection", :model => @model_class ) @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model class" do @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should use the escaped request key" do @indirection.expects(:find).with do |key, args| key == "my_result" end.returns @result @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should use a common method for determining the request parameters" do @indirection.expects(:find).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_find("my_handler", "my_result", {:foo => :baz, :bar => :xyzzy}, @request, @response) end it "should set the content type to the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should fail if no accept header is provided" do @handler.expects(:accept_header).with(@request).returns nil lambda { @handler.do_find("my_handler", "my_result", {}, @request, @response) }.should raise_error(ArgumentError) end it "should fail if the accept header does not contain a valid format" do @handler.expects(:accept_header).with(@request).returns "" lambda { @handler.do_find("my_handler", "my_result", {}, @request, @response) }.should raise_error(RuntimeError) end it "should not use an unsuitable format" do @handler.expects(:accept_header).with(@request).returns "foo,bar" foo = mock 'foo', :suitable? => false bar = mock 'bar', :suitable? => true Puppet::Network::FormatHandler.expects(:format).with("foo").returns foo Puppet::Network::FormatHandler.expects(:format).with("bar").returns bar @handler.expects(:set_content_type).with(@response, bar) # the suitable one @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should render the result using the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @result.expects(:render).with(@oneformat) @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should pass the result through without rendering it if the result is a string" do @indirection.stubs(:find).returns "foo" @handler.expects(:set_response).with(@response, "foo") @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should use the default status when a model find call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should return a serialized object when a model find call succeeds" do @model_instance = stub('model instance') @model_instance.expects(:render).returns "my_rendered_object" @handler.expects(:set_response).with { |response, body, status| body == "my_rendered_object" } @indirection.stubs(:find).returns(@model_instance) @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should return a 404 when no model instance can be found" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @indirection.stubs(:find).returns(nil) @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should write a log message when no model instance can be found" do @model_class.stubs(:name).returns "my name" @indirection.stubs(:find).returns(nil) Puppet.expects(:info).with("Could not find my_handler for 'my_result'") @handler.do_find("my_handler", "my_result", {}, @request, @response) end it "should serialize the result in with the appropriate format" do @model_instance = stub('model instance') @handler.expects(:format_to_use).returns(@oneformat) @model_instance.expects(:render).with(@oneformat).returns "my_rendered_object" @indirection.stubs(:find).returns(@model_instance) @handler.do_find("my_handler", "my_result", {}, @request, @response) end end describe "when performing head operation" do before do - @irequest = stub 'indirection_request', :method => :head, :indirection_name => "my_handler", :to_hash => {}, :key => "my_result", :model => @model_class + @handler.stubs(:model).with("my_handler").returns(stub 'model', :indirection => @model_class) + @handler.stubs(:http_method).with(@request).returns("HEAD") + @handler.stubs(:path).with(@request).returns("/production/my_handler/my_result") + @handler.stubs(:params).with(@request).returns({}) @model_class.stubs(:head).returns true end - it "should use the indirection request to find the model class" do - @irequest.expects(:model).returns @model_class - - @handler.do_head(@irequest, @request, @response) - end - it "should use the escaped request key" do @model_class.expects(:head).with do |key, args| key == "my_result" end.returns true - @handler.do_head(@irequest, @request, @response) + @handler.process(@request, @response) end it "should not generate a response when a model head call succeeds" do @handler.expects(:set_response).never - @handler.do_head(@irequest, @request, @response) + @handler.process(@request, @response) end it "should return a 404 when the model head call returns false" do - @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:head).returns(false) - @handler.do_head(@irequest, @request, @response) + @handler.process(@request, @response) end end describe "when searching for model instances" do before do Puppet::Indirector::Indirection.expects(:instance).with(:my_handler).returns( stub "indirection", :model => @model_class ) @result1 = mock 'result1' @result2 = mock 'results' @result = [@result1, @result2] @model_class.stubs(:render_multiple).returns "my rendered instances" @indirection.stubs(:search).returns(@result) @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model" do @handler.do_search("my_handler", "my_result", {}, @request, @response) end it "should use a common method for determining the request parameters" do @indirection.expects(:search).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_search("my_handler", "my_result", {:foo => :baz, :bar => :xyzzy}, @request, @response) end it "should use the default status when a model search call succeeds" do @indirection.stubs(:search).returns(@result) @handler.do_search("my_handler", "my_result", {}, @request, @response) end it "should set the content type to the first format returned by the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_search("my_handler", "my_result", {}, @request, @response) end it "should return a list of serialized objects when a model search call succeeds" do @handler.expects(:accept_header).with(@request).returns "one,two" @indirection.stubs(:search).returns(@result) @model_class.expects(:render_multiple).with(@oneformat, @result).returns "my rendered instances" @handler.expects(:set_response).with { |response, data| data == "my rendered instances" } @handler.do_search("my_handler", "my_result", {}, @request, @response) end it "should return [] when searching returns an empty array" do @handler.expects(:accept_header).with(@request).returns "one,two" @indirection.stubs(:search).returns([]) @model_class.expects(:render_multiple).with(@oneformat, []).returns "[]" @handler.expects(:set_response).with { |response, data| data == "[]" } @handler.do_search("my_handler", "my_result", {}, @request, @response) end it "should return a 404 when searching returns nil" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @indirection.stubs(:search).returns(nil) @handler.do_search("my_handler", "my_result", {}, @request, @response) end end describe "when destroying a model instance" do before do Puppet::Indirector::Indirection.expects(:instance).with(:my_handler).returns( stub "indirection", :model => @model_class ) @result = stub 'result', :render => "the result" @indirection.stubs(:destroy).returns @result end it "should use the indirection request to find the model" do @handler.do_destroy("my_handler", "my_result", {}, @request, @response) end it "should use the escaped request key to destroy the instance in the model" do @indirection.expects(:destroy).with do |key, args| key == "foo bar" end @handler.do_destroy("my_handler", "foo bar", {}, @request, @response) end it "should use a common method for determining the request parameters" do @indirection.expects(:destroy).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end @handler.do_destroy("my_handler", "my_result", {:foo => :baz, :bar => :xyzzy}, @request, @response) end it "should use the default status code a model destroy call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_destroy("my_handler", "my_result", {}, @request, @response) end it "should return a yaml-encoded result when a model destroy call succeeds" do @result = stub 'result', :to_yaml => "the result" @indirection.expects(:destroy).returns(@result) @handler.expects(:set_response).with { |response, body, status| body == "the result" } @handler.do_destroy("my_handler", "my_result", {}, @request, @response) end end describe "when saving a model instance" do before do Puppet::Indirector::Indirection.stubs(:instance).with(:my_handler).returns( stub "indirection", :model => @model_class ) @handler.stubs(:body).returns('my stuff') @handler.stubs(:content_type_header).returns("text/yaml") @result = stub 'result', :render => "the result" @model_instance = stub('indirected model instance') @model_class.stubs(:convert_from).returns(@model_instance) @indirection.stubs(:save) @format = stub 'format', :suitable? => true, :name => "format", :mime => "text/format" Puppet::Network::FormatHandler.stubs(:format).returns @format @yamlformat = stub 'yaml', :suitable? => true, :name => "yaml", :mime => "text/yaml" Puppet::Network::FormatHandler.stubs(:format).with("yaml").returns @yamlformat end it "should use the indirection request to find the model" do @handler.do_save("my_handler", "my_result", {}, @request, @response) end it "should use the 'body' hook to retrieve the body of the request" do @handler.expects(:body).returns "my body" @model_class.expects(:convert_from).with { |format, body| body == "my body" }.returns @model_instance @handler.do_save("my_handler", "my_result", {}, @request, @response) end it "should fail to save model if data is not specified" do @handler.stubs(:body).returns('') lambda { @handler.do_save("my_handler", "my_result", {}, @request, @response) }.should raise_error(ArgumentError) end it "should use a common method for determining the request parameters" do @indirection.expects(:save).with(@model_instance, 'key').once @handler.do_save("my_handler", "key", {}, @request, @response) end it "should use the default status when a model save call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_save("my_handler", "my_result", {}, @request, @response) end it "should return the yaml-serialized result when a model save call succeeds" do @indirection.stubs(:save).returns(@model_instance) @model_instance.expects(:to_yaml).returns('foo') @handler.do_save("my_handler", "my_result", {}, @request, @response) end it "should set the content to yaml" do @handler.expects(:set_content_type).with(@response, @yamlformat) @handler.do_save("my_handler", "my_result", {}, @request, @response) end it "should use the content-type header to know the body format" do @handler.expects(:content_type_header).returns("text/format") Puppet::Network::FormatHandler.stubs(:mime).with("text/format").returns @format @model_class.expects(:convert_from).with { |format, body| format == "format" }.returns @model_instance @handler.do_save("my_handler", "my_result", {}, @request, @response) end end end describe "when resolving node" do it "should use a look-up from the ip address" do Resolv.expects(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4") end it "should return the look-up result" do Resolv.stubs(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4").should == "host.domain.com" end it "should return the ip address if resolving fails" do Resolv.stubs(:getname).with("1.2.3.4").raises(RuntimeError, "no such host") @handler.resolve_node(:ip => "1.2.3.4").should == "1.2.3.4" end end end