diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index 38ecdb758..c3d689e93 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -1,95 +1,92 @@ require 'net/http' require 'uri' require 'puppet/network/http_pool' -require 'puppet/network/http/handler' +require 'puppet/network/http/api/v1' # Access objects via REST class Puppet::Indirector::REST < Puppet::Indirector::Terminus - include Puppet::Network::HTTP::Handler + include Puppet::Network::HTTP::API::V1 class << self attr_reader :server_setting, :port_setting end # Specify the setting that we should use to get the server name. def self.use_server_setting(setting) @server_setting = setting end def self.server return Puppet.settings[server_setting || :server] end # Specify the setting that we should use to get the port. def self.use_port_setting(setting) @port_setting = setting end def self.port return Puppet.settings[port_setting || :masterport].to_i end # Figure out the content type, turn that into a format, and use the format # to extract the body of the response. def deserialize(response, multiple = false) case response.code when "404" return nil when /^2/ unless response['content-type'] raise "No content type in http response; cannot parse" end # Convert the response to a deserialized object. if multiple model.convert_from_multiple(response['content-type'], response.body) else model.convert_from(response['content-type'], response.body) end else # Raise the http error if we didn't get a 'success' of some kind. message = "Server returned %s: %s" % [response.code, response.message] raise Net::HTTPError.new(message, response) end end # Provide appropriate headers. def headers {"Accept" => model.supported_formats.join(", ")} end def network(request) Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end def find(request) - p model - p indirection - p indirection.model deserialize network(request).get(indirection2uri(request), headers) end def search(request) unless result = deserialize(network(request).get(indirection2uri(request), headers), true) return [] end return result end def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? deserialize network(request).delete(indirection2uri(request), headers) end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? deserialize network(request).put(indirection2uri(request), request.instance.render, headers) end private def environment Puppet::Node::Environment.new end end diff --git a/lib/puppet/network/http/api.rb b/lib/puppet/network/http/api.rb new file mode 100644 index 000000000..8b1b747ac --- /dev/null +++ b/lib/puppet/network/http/api.rb @@ -0,0 +1,4 @@ +require 'puppet/network/http' + +class Puppet::Network::HTTP::API +end diff --git a/lib/puppet/network/http/api/v1.rb b/lib/puppet/network/http/api/v1.rb new file mode 100644 index 000000000..2ee1a815f --- /dev/null +++ b/lib/puppet/network/http/api/v1.rb @@ -0,0 +1,60 @@ +require 'puppet/network/http/api' + +module Puppet::Network::HTTP::API::V1 + # How we map http methods and the indirection name in the URI + # to an indirection method. + METHOD_MAP = { + "GET" => { + :plural => :search, + :singular => :find + }, + "PUT" => { + :singular => :save + }, + "DELETE" => { + :singular => :destroy + } + } + + def uri2indirection(http_method, uri, params) + environment, indirection, key = uri.split("/", 4)[1..-1] # the first field is always nil because of the leading slash + + raise ArgumentError, "The environment must be purely alphanumeric, not '%s'" % environment unless environment =~ /^\w+$/ + raise ArgumentError, "The indirection name must be purely alphanumeric, not '%s'" % indirection unless indirection =~ /^\w+$/ + + method = indirection_method(http_method, indirection) + + params[:environment] = environment + + raise ArgumentError, "No request key specified in %s" % uri if key == "" or key.nil? + + key = URI.unescape(key) + + Puppet::Indirector::Request.new(indirection, method, key, params) + end + + def indirection2uri(request) + indirection = request.method == :search ? request.indirection_name.to_s + "s" : request.indirection_name.to_s + "/#{request.environment.to_s}/#{indirection}/#{request.escaped_key}#{request.query_string}" + end + + def indirection_method(http_method, indirection) + unless METHOD_MAP[http_method] + raise ArgumentError, "No support for http method %s" % http_method + end + + unless method = METHOD_MAP[http_method][plurality(indirection)] + raise ArgumentError, "No support for plural %s operations" % http_method + end + + return method + end + + def plurality(indirection) + result = (indirection == handler.to_s + "s") ? :plural : :singular + + indirection.sub!(/s$/, '') if result + + result + end +end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index 610aa0a3f..76f07ed73 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,237 +1,184 @@ module Puppet::Network::HTTP end -module Puppet::Network::HTTP::Handler +require 'puppet/network/http/api/v1' - # How we map http methods and the indirection name in the URI - # to an indirection method. - METHOD_MAP = { - "GET" => { - :plural => :search, - :singular => :find - }, - "PUT" => { - :singular => :save - }, - "DELETE" => { - :singular => :destroy - } - } +module Puppet::Network::HTTP::Handler + include Puppet::Network::HTTP::API::V1 attr_reader :model, :server, :handler # Retrieve the accept header from the http request. def accept_header(request) raise NotImplementedError end # Which format to use when serializing our response. Just picks # the first value in the accept header, at this point. 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 name end raise "No specified acceptable formats (%s) are functional on this machine" % header end def initialize_for_puppet(args = {}) raise ArgumentError unless @server = args[:server] raise ArgumentError unless @handler = args[:handler] @model = find_model_for_handler(@handler) end # handle an HTTP request def process(request, response) indirection_request = uri2indirection(path(request), params(request), http_method(request)) send("do_%s" % indirection_request.method, indirection_request, request, response) rescue Exception => e + puts e.backtrace return do_exception(response, e) end - def uri2indirection(http_method, uri, params) - environment, indirection, key = uri.split("/", 4)[1..-1] # the first field is always nil because of the leading slash - - raise ArgumentError, "The environment must be purely alphanumeric, not '%s'" % environment unless environment =~ /^\w+$/ - raise ArgumentError, "The indirection name must be purely alphanumeric, not '%s'" % indirection unless indirection =~ /^\w+$/ - - method = indirection_method(http_method, indirection) - - params[:environment] = environment - - raise ArgumentError, "No request key specified in %s" % uri if key == "" or key.nil? - - key = URI.unescape(key) - - Puppet::Indirector::Request.new(indirection, method, key, params) - end - - def indirection2uri(request) - indirection = request.method == :search ? request.indirection_name.to_s + "s" : request.indirection_name.to_s - "/#{request.environment.to_s}/#{indirection}/#{request.escaped_key}#{request.query_string}" - end - - def indirection_method(http_method, indirection) - unless METHOD_MAP[http_method] - raise ArgumentError, "No support for http method %s" % http_method - end - - unless method = METHOD_MAP[http_method][plurality(indirection)] - raise ArgumentError, "No support for plural %s operations" % http_method - end - - return method - end - - def plurality(indirection) - result = (indirection == handler.to_s + "s") ? :plural : :singular - - indirection.sub!(/s$/, '') if result - - result - 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?(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 # Execute our find. def do_find(indirection_request, request, response) unless result = model.find(indirection_request.key, indirection_request.options) return do_exception(response, "Could not find %s %s" % [model.name, indirection_request.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) set_response(response, result.render(format)) end # Execute our search. def do_search(indirection_request, request, response) result = model.search(indirection_request.key, indirection_request.options) if result.nil? or (result.is_a?(Array) and result.empty?) return do_exception(response, "Could not find instances in %s with '%s'" % [model.name, indirection_request.options.inspect], 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_request, request, response) result = model.destroy(indirection_request.key, indirection_request.options) set_content_type(response, "yaml") set_response(response, result.to_yaml) end # Execute our save. def do_save(indirection_request, request, response) data = body(request).to_s raise ArgumentError, "No data to save" if !data or data.empty? format = format_to_use(request) obj = model.convert_from(format_to_use(request), data) result = save_object(indirection_request, obj) set_content_type(response, "yaml") set_response(response, result.to_yaml) end private # LAK:NOTE This has to be here for testing; it's a stub-point so # we keep infinite recursion from happening. def save_object(ind_request, object) object.save(ind_request.options) end def find_model_for_handler(handler) Puppet::Indirector::Indirection.model(handler) || raise(ArgumentError, "Cannot locate indirection [#{handler}].") 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 value = URI.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.to_sym] = value result end end end diff --git a/spec/unit/indirector/rest.rb b/spec/unit/indirector/rest.rb index 9305a7109..d135b8dba 100755 --- a/spec/unit/indirector/rest.rb +++ b/spec/unit/indirector/rest.rb @@ -1,363 +1,363 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../spec_helper' require 'puppet/indirector/rest' describe "a REST http call", :shared => true do it "should accept a path" do lambda { @search.send(@method, *@arguments) }.should_not raise_error(ArgumentError) end it "should require a path" do lambda { @searcher.send(@method) }.should raise_error(ArgumentError) end it "should return the results of deserializing the response to the request" do conn = mock 'connection' conn.stubs(:put).returns @response conn.stubs(:delete).returns @response conn.stubs(:get).returns @response Puppet::Network::HttpPool.stubs(:http_instance).returns conn @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.send(@method, *@arguments).should == 'myobject' end end describe Puppet::Indirector::REST do before do Puppet::Indirector::Terminus.stubs(:register_terminus_class) @model = stub('model', :supported_formats => %w{}, :convert_from => nil) @instance = stub('model instance') @indirection = stub('indirection', :name => :mystuff, :register_terminus_type => nil, :model => @model) Puppet::Indirector::Indirection.stubs(:instance).returns(@indirection) @rest_class = Class.new(Puppet::Indirector::REST) do def self.to_s "This::Is::A::Test::Class" end end @response = stub('mock response', :body => 'result', :code => "200") @response.stubs(:[]).with('content-type').returns "text/plain" @searcher = @rest_class.new @searcher.stubs(:model).returns @model end - it "should include the Http Handler module" do - Puppet::Indirector::REST.ancestors.should be_include(Puppet::Network::HTTP::Handler) + it "should include the v1 REST API module" do + Puppet::Indirector::REST.ancestors.should be_include(Puppet::Network::HTTP::API::V1) end it "should have a method for specifying what setting a subclass should use to retrieve its server" do @rest_class.should respond_to(:use_server_setting) end it "should use any specified setting to pick the server" do @rest_class.expects(:server_setting).returns :servset Puppet.settings.expects(:value).with(:servset).returns "myserver" @rest_class.server.should == "myserver" end it "should default to :server for the server setting" do @rest_class.expects(:server_setting).returns nil Puppet.settings.expects(:value).with(:server).returns "myserver" @rest_class.server.should == "myserver" end it "should have a method for specifying what setting a subclass should use to retrieve its port" do @rest_class.should respond_to(:use_port_setting) end it "should use any specified setting to pick the port" do @rest_class.expects(:port_setting).returns :servset Puppet.settings.expects(:value).with(:servset).returns "321" @rest_class.port.should == 321 end it "should default to :port for the port setting" do @rest_class.expects(:port_setting).returns nil Puppet.settings.expects(:value).with(:masterport).returns "543" @rest_class.port.should == 543 end describe "when deserializing responses" do it "should return nil if the response code is 404" do response = mock 'response' response.expects(:code).returns "404" @searcher.deserialize(response).should be_nil end it "should fail if the response code is not in the 200s" do @model.expects(:convert_from).never response = mock 'response' response.stubs(:code).returns "300" response.stubs(:message).returns "There was a problem" lambda { @searcher.deserialize(response) }.should raise_error(Net::HTTPError) end it "should return the results of converting from the format specified by the content-type header if the response code is in the 200s" do @model.expects(:convert_from).with("myformat", "mydata").returns "myobject" response = mock 'response' response.stubs(:[]).with("content-type").returns "myformat" response.stubs(:body).returns "mydata" response.stubs(:code).returns "200" @searcher.deserialize(response).should == "myobject" end it "should convert and return multiple instances if the return code is in the 200s and 'multiple' is specified" do @model.expects(:convert_from_multiple).with("myformat", "mydata").returns "myobjects" response = mock 'response' response.stubs(:[]).with("content-type").returns "myformat" response.stubs(:body).returns "mydata" response.stubs(:code).returns "200" @searcher.deserialize(response, true).should == "myobjects" end end describe "when creating an HTTP client" do before do Puppet.settings.stubs(:value).returns("rest_testing") end it "should use the class's server and port if the indirection request provides neither" do @request = stub 'request', :key => "foo", :server => nil, :port => nil @searcher.class.expects(:port).returns 321 @searcher.class.expects(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end it "should use the server from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => "myserver", :port => nil @searcher.class.stubs(:port).returns 321 Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end it "should use the port from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => nil, :port => 321 @searcher.class.stubs(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" @searcher.network(@request).should == "myconn" end end describe "when doing a find" do before :each do @connection = stub('mock http connection', :get => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection # Use a key with spaces, so we can test escaping @request = Puppet::Indirector::Request.new(:foo, :find, "foo bar") end it "should call the GET http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:get).returns @response @searcher.find(@request) end it "should deserialize and return the http response" do @connection.expects(:get).returns @response @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.find(@request).should == 'myobject' end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:get).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.find(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:get).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.find(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.find(@request).should equal(@instance) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.find(@request) }.should raise_error(ArgumentError) end end describe "when doing a search" do before :each do @connection = stub('mock http connection', :get => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @model.stubs(:convert_from_multiple) @request = Puppet::Indirector::Request.new(:foo, :search, "foo bar") end it "should call the GET http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:get).returns @response @searcher.search(@request) end it "should deserialize as multiple instances and return the http response" do @connection.expects(:get).returns @response @searcher.expects(:deserialize).with(@response, true).returns "myobject" @searcher.search(@request).should == 'myobject' end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/mys/uri" @connection.expects(:get).with { |path, args| path == "/mys/uri" }.returns(@response) @searcher.search(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:get).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.search(@request) end it "should return an empty array if serialization returns nil" do @model.stubs(:convert_from_multiple).returns nil @searcher.search(@request).should == [] end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.search(@request) }.should raise_error(ArgumentError) end end describe "when doing a destroy" do before :each do @connection = stub('mock http connection', :delete => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @request = Puppet::Indirector::Request.new(:foo, :destroy, "foo bar") end it "should call the DELETE http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:delete).returns @response @searcher.destroy(@request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do @request.stubs(:options).returns(:one => "two", :three => "four") lambda { @searcher.destroy(@request) }.should raise_error(ArgumentError) end it "should deserialize and return the http response" do @connection.expects(:delete).returns @response @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.destroy(@request).should == 'myobject' end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:delete).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.destroy(@request) end it "should not include the query string" do @connection.stubs(:delete).returns @response @searcher.destroy(@request) end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:delete).with { |path, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.destroy(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.destroy(@request).should equal(@instance) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.destroy(@request) }.should raise_error(ArgumentError) end end describe "when doing a save" do before :each do @connection = stub('mock http connection', :put => @response) @searcher.stubs(:network).returns(@connection) # neuter the network connection @instance = stub 'instance', :render => "mydata" @request = Puppet::Indirector::Request.new(:foo, :save, "foo bar") @request.instance = @instance end it "should call the PUT http method on a network connection" do @searcher.expects(:network).returns @connection @connection.expects(:put).returns @response @searcher.save(@request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do @request.stubs(:options).returns(:one => "two", :three => "four") lambda { @searcher.save(@request) }.should raise_error(ArgumentError) end it "should use the URI generated by the Handler module" do @searcher.expects(:indirection2uri).with(@request).returns "/my/uri" @connection.expects(:put).with { |path, args| path == "/my/uri" }.returns(@response) @searcher.save(@request) end it "should serialize the instance using the default format and pass the result as the body of the request" do @instance.expects(:render).returns "serial_instance" @connection.expects(:put).with { |path, data, args| data == "serial_instance" }.returns @response @searcher.save(@request) end it "should deserialize and return the http response" do @connection.expects(:put).returns @response @searcher.expects(:deserialize).with(@response).returns "myobject" @searcher.save(@request).should == 'myobject' end it "should provide an Accept header containing the list of supported formats joined with commas" do @connection.expects(:put).with { |path, data, args| args["Accept"] == "supported, formats" }.returns(@response) @searcher.model.expects(:supported_formats).returns %w{supported formats} @searcher.save(@request) end it "should deserialize and return the network response" do @searcher.expects(:deserialize).with(@response).returns @instance @searcher.save(@request).should equal(@instance) end it "should generate an error when result data deserializes fails" do @searcher.expects(:deserialize).raises(ArgumentError) lambda { @searcher.save(@request) }.should raise_error(ArgumentError) end end end diff --git a/spec/unit/network/http/api/v1.rb b/spec/unit/network/http/api/v1.rb new file mode 100644 index 000000000..fc284de82 --- /dev/null +++ b/spec/unit/network/http/api/v1.rb @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +Dir.chdir(File.dirname(__FILE__)) { (s = lambda { |f| File.exist?(f) ? require(f) : Dir.chdir("..") { s.call(f) } }).call("spec/spec_helper.rb") } + +require 'puppet/network/http/api/v1' + +class V1RestApiTester + include Puppet::Network::HTTP::API::V1 +end + +describe Puppet::Network::HTTP::API::V1 do + before do + @tester = V1RestApiTester.new + end + + it "should be able to convert a URI into a request" do + @tester.should respond_to(:uri2indirection) + end + + it "should be able to convert a request into a URI" do + @tester.should respond_to(:indirection2uri) + end + + describe "when converting a URI into a request" do + before do + @tester.stubs(:handler).returns "foo" + end + + it "should require the http method, the URI, and the query parameters" do + # Not a terribly useful test, but an important statement for the spec + lambda { @tester.uri2indirection("/foo") }.should raise_error(ArgumentError) + end + + it "should use the first field of the URI as the environment" do + @tester.uri2indirection("GET", "/env/foo/bar", {}).environment.should == Puppet::Node::Environment.new("env") + end + + it "should fail if the environment is not alphanumeric" do + lambda { @tester.uri2indirection("GET", "/env ness/foo/bar", {}) }.should raise_error(ArgumentError) + end + + it "should use the environment from the URI even if one is specified in the parameters" do + @tester.uri2indirection("GET", "/env/foo/bar", {:environment => "otherenv"}).environment.should == Puppet::Node::Environment.new("env") + end + + it "should use the second field of the URI as the indirection name" do + @tester.uri2indirection("GET", "/env/foo/bar", {}).indirection_name.should == :foo + end + + it "should fail if the indirection name is not alphanumeric" do + lambda { @tester.uri2indirection("GET", "/env/foo ness/bar", {}) }.should raise_error(ArgumentError) + end + + it "should use the remainder of the URI as the indirection key" do + @tester.uri2indirection("GET", "/env/foo/bar", {}).key.should == "bar" + end + + it "should support the indirection key being a /-separated file path" do + @tester.uri2indirection("GET", "/env/foo/bee/baz/bomb", {}).key.should == "bee/baz/bomb" + end + + it "should fail if no indirection key is specified" do + lambda { @tester.uri2indirection("GET", "/env/foo/", {}) }.should raise_error(ArgumentError) + lambda { @tester.uri2indirection("GET", "/env/foo", {}) }.should raise_error(ArgumentError) + end + + it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do + @tester.uri2indirection("GET", "/env/foo/bar", {}).method.should == :find + end + + it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do + @tester.uri2indirection("GET", "/env/foos/bar", {}).method.should == :search + end + + it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do + @tester.uri2indirection("DELETE", "/env/foo/bar", {}).method.should == :destroy + end + + it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do + @tester.uri2indirection("PUT", "/env/foo/bar", {}).method.should == :save + end + + it "should fail if an indirection method cannot be picked" do + lambda { @tester.uri2indirection("UPDATE", "/env/foo/bar", {}) }.should raise_error(ArgumentError) + end + + it "should URI unescape the indirection key" do + escaped = URI.escape("foo bar") + @tester.uri2indirection("GET", "/env/foo/#{escaped}", {}).key.should == "foo bar" + end + end + + describe "when converting a request into a URI" do + before do + @request = Puppet::Indirector::Request.new(:foo, :find, "with spaces", :foo => :bar, :environment => "myenv") + end + + it "should use the environment as the first field of the URI" do + @tester.indirection2uri(@request).split("/")[1].should == "myenv" + end + + it "should use the indirection as the second field of the URI" do + @tester.indirection2uri(@request).split("/")[2].should == "foo" + end + + it "should pluralize the indirection name if the method is 'search'" do + @request.stubs(:method).returns :search + @tester.indirection2uri(@request).split("/")[2].should == "foos" + end + + it "should use the escaped key as the remainder of the URI" do + escaped = URI.escape("with spaces") + @tester.indirection2uri(@request).split("/")[3].sub(/\?.+/, '').should == escaped + end + + it "should add the query string to the URI" do + @request.expects(:query_string).returns "?query" + @tester.indirection2uri(@request).should =~ /\?query$/ + end + end + +end diff --git a/spec/unit/network/http/handler.rb b/spec/unit/network/http/handler.rb index aa3e13df7..85149b642 100755 --- a/spec/unit/network/http/handler.rb +++ b/spec/unit/network/http/handler.rb @@ -1,461 +1,359 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../../spec_helper' require 'puppet/network/http/handler' class HttpHandled include Puppet::Network::HTTP::Handler end describe Puppet::Network::HTTP::Handler do before do @handler = HttpHandled.new end - - it "should be able to convert a URI into a request" do - @handler.should respond_to(:uri2indirection) - end - - it "should be able to convert a request into a URI" do - @handler.should respond_to(:indirection2uri) - end - - describe "when converting a URI into a request" do - before do - @handler.stubs(:handler).returns "foo" - end - - it "should require the http method, the URI, and the query parameters" do - # Not a terribly useful test, but an important statement for the spec - lambda { @handler.uri2indirection("/foo") }.should raise_error(ArgumentError) - end - - it "should use the first field of the URI as the environment" do - @handler.uri2indirection("GET", "/env/foo/bar", {}).environment.should == Puppet::Node::Environment.new("env") - end - - it "should fail if the environment is not alphanumeric" do - lambda { @handler.uri2indirection("GET", "/env ness/foo/bar", {}) }.should raise_error(ArgumentError) - end - - it "should use the environment from the URI even if one is specified in the parameters" do - @handler.uri2indirection("GET", "/env/foo/bar", {:environment => "otherenv"}).environment.should == Puppet::Node::Environment.new("env") - end - - it "should use the second field of the URI as the indirection name" do - @handler.uri2indirection("GET", "/env/foo/bar", {}).indirection_name.should == :foo - end - - it "should fail if the indirection name is not alphanumeric" do - lambda { @handler.uri2indirection("GET", "/env/foo ness/bar", {}) }.should raise_error(ArgumentError) - end - - it "should use the remainder of the URI as the indirection key" do - @handler.uri2indirection("GET", "/env/foo/bar", {}).key.should == "bar" - end - - it "should support the indirection key being a /-separated file path" do - @handler.uri2indirection("GET", "/env/foo/bee/baz/bomb", {}).key.should == "bee/baz/bomb" - end - - it "should fail if no indirection key is specified" do - lambda { @handler.uri2indirection("GET", "/env/foo/", {}) }.should raise_error(ArgumentError) - lambda { @handler.uri2indirection("GET", "/env/foo", {}) }.should raise_error(ArgumentError) - end - - it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do - @handler.uri2indirection("GET", "/env/foo/bar", {}).method.should == :find - end - - it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do - @handler.uri2indirection("GET", "/env/foos/bar", {}).method.should == :search - end - - it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do - @handler.uri2indirection("DELETE", "/env/foo/bar", {}).method.should == :destroy - end - - it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do - @handler.uri2indirection("PUT", "/env/foo/bar", {}).method.should == :save - end - - it "should fail if an indirection method cannot be picked" do - lambda { @handler.uri2indirection("UPDATE", "/env/foo/bar", {}) }.should raise_error(ArgumentError) - end - - it "should URI unescape the indirection key" do - escaped = URI.escape("foo bar") - @handler.uri2indirection("GET", "/env/foo/#{escaped}", {}).key.should == "foo bar" - end - end - - describe "when converting a request into a URI" do - before do - @request = Puppet::Indirector::Request.new(:foo, :find, "with spaces", :foo => :bar, :environment => "myenv") - end - - it "should use the environment as the first field of the URI" do - @handler.indirection2uri(@request).split("/")[1].should == "myenv" - end - - it "should use the indirection as the second field of the URI" do - @handler.indirection2uri(@request).split("/")[2].should == "foo" - end - - it "should pluralize the indirection name if the method is 'search'" do - @request.stubs(:method).returns :search - @handler.indirection2uri(@request).split("/")[2].should == "foos" - end - - it "should use the escaped key as the remainder of the URI" do - escaped = URI.escape("with spaces") - @handler.indirection2uri(@request).split("/")[3].sub(/\?.+/, '').should == escaped - end - - it "should add the query string to the URI" do - @request.expects(:query_string).returns "?query" - @handler.indirection2uri(@request).should =~ /\?query$/ - 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 have a method for initializing" do @handler.should respond_to(:initialize_for_puppet) end describe "when initializing" do before do Puppet::Indirector::Indirection.stubs(:model).returns "eh" end it "should fail when no server type has been provided" do lambda { @handler.initialize_for_puppet :handler => "foo" }.should raise_error(ArgumentError) end it "should fail when no handler has been provided" do lambda { @handler.initialize_for_puppet :server => "foo" }.should raise_error(ArgumentError) end it "should set the handler and server type" do @handler.initialize_for_puppet :server => "foo", :handler => "bar" @handler.server.should == "foo" @handler.handler.should == "bar" end it "should use the indirector to find the appropriate model" do Puppet::Indirector::Indirection.expects(:model).with("bar").returns "mymodel" @handler.initialize_for_puppet :server => "foo", :handler => "bar" @handler.model.should == "mymodel" 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') @result = stub 'result', :render => "mytext" @handler.stubs(:model).returns @model_class @handler.stubs(:handler).returns :my_handler 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(: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("mypath", "myparams", "mymethod").returns stub("request", :method => :find) @handler.stubs(:do_find) @handler.process(@request, @response) end it "should call the 'do' method associated with the indirection method" do request = stub 'request' @handler.expects(:uri2indirection).returns request request.expects(:method).returns "mymethod" @handler.expects(:do_mymethod).with(request, @request, @response) @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 stub("request", :method => :find) @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 describe "when finding a model instance" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :options => {}, :key => "my_result" @model_class.stubs(:find).returns @result @format = stub 'format', :suitable? => true Puppet::Network::FormatHandler.stubs(:format).returns @format end it "should use the escaped request key" do @model_class.expects(:find).with do |key, args| key == "my_result" end.returns @result @handler.do_find(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:options).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:find).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_find(@irequest, @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, "one") @handler.do_find(@irequest, @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(@irequest, @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(@irequest, @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(@irequest, @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("one") @handler.do_find(@irequest, @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(@irequest, @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" } @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @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 } @model_class.stubs(:find).returns(nil) @handler.do_find(@irequest, @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 "one" @model_instance.expects(:render).with("one").returns "my_rendered_object" @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @request, @response) end end describe "when searching for model instances" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :options => {}, :key => "key" @result1 = mock 'result1' @result2 = mock 'results' @result = [@result1, @result2] @model_class.stubs(:render_multiple).returns "my rendered instances" @model_class.stubs(:search).returns(@result) @format = stub 'format', :suitable? => true Puppet::Network::FormatHandler.stubs(:format).returns @format end it "should use a common method for determining the request parameters" do @irequest.stubs(:options).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:search).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_search(@irequest, @request, @response) end it "should use the default status when a model search call succeeds" do @model_class.stubs(:search).returns(@result) @handler.do_search(@irequest, @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, "one") @handler.do_search(@irequest, @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" @model_class.stubs(:search).returns(@result) @model_class.expects(:render_multiple).with("one", @result).returns "my rendered instances" @handler.expects(:set_response).with { |response, data| data == "my rendered instances" } @handler.do_search(@irequest, @request, @response) end it "should return a 404 when searching returns an empty array" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @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 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @request, @response) end end describe "when destroying a model instance" do before do @irequest = stub 'indirection_request', :method => :destroy, :indirection_name => "my_handler", :options => {}, :key => "key" @result = stub 'result', :render => "the result" @model_class.stubs(:destroy).returns @result end it "should use the escaped request key to destroy the instance in the model" do @irequest.expects(:key).returns "foo bar" @model_class.expects(:destroy).with do |key, args| key == "foo bar" end @handler.do_destroy(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:options).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:destroy).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end @handler.do_destroy(@irequest, @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(@irequest, @request, @response) end it "should return a yaml-encoded result when a model destroy call succeeds" do @result = stub 'result', :to_yaml => "the result" @model_class.expects(:destroy).returns(@result) @handler.expects(:set_response).with { |response, body, status| body == "the result" } @handler.do_destroy(@irequest, @request, @response) end end describe "when saving a model instance" do before do @irequest = stub 'indirection_request', :method => :save, :indirection_name => "my_handler", :options => {}, :key => "key" @handler.stubs(:body).returns('my stuff') @result = stub 'result', :render => "the result" @model_instance = stub('indirected model instance', :save => true) @model_class.stubs(:convert_from).returns(@model_instance) @format = stub 'format', :suitable? => true Puppet::Network::FormatHandler.stubs(:format).returns @format 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(@irequest, @request, @response) end it "should fail to save model if data is not specified" do @handler.stubs(:body).returns('') lambda { @handler.do_save(@irequest, @request, @response) }.should raise_error(ArgumentError) end it "should use a common method for determining the request parameters" do @irequest.stubs(:options).returns(:foo => :baz, :bar => :xyzzy) @model_instance.expects(:save).with do |args| args[:foo] == :baz and args[:bar] == :xyzzy end @handler.do_save(@irequest, @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(@irequest, @request, @response) end it "should return the yaml-serialized result when a model save call succeeds" do @model_instance.stubs(:save).returns(@model_instance) @model_instance.expects(:to_yaml).returns('foo') @handler.do_save(@irequest, @request, @response) end it "should set the content to yaml" do @handler.expects(:set_content_type).with(@response, "yaml") @handler.do_save(@irequest, @request, @response) end end end end