diff options
author | Lamont Granquist <454857+lamont-granquist@users.noreply.github.com> | 2022-04-01 16:22:12 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-04-01 16:22:12 -0700 |
commit | c24fadc57b4b293f764b5212463273bb00411296 (patch) | |
tree | a601ff58372dc64e8d7121ae81a4e1c2b0c51159 | |
parent | cfce94e193621898a903aab147b3edd1d1df2ec0 (diff) | |
parent | cc459fa4e699f068ae3ab889d141cde469eab79a (diff) | |
download | chef-c24fadc57b4b293f764b5212463273bb00411296.tar.gz |
Merge pull request #12755 from chef/lcg/rest_resource
-rw-r--r-- | cspell.json | 8 | ||||
-rw-r--r-- | lib/chef/dsl/rest_resource.rb | 70 | ||||
-rw-r--r-- | lib/chef/exceptions.rb | 8 | ||||
-rw-r--r-- | lib/chef/resource.rb | 16 | ||||
-rw-r--r-- | lib/chef/resource/_rest_resource.rb | 386 | ||||
-rw-r--r-- | spec/unit/resource/rest_resource_spec.rb | 381 |
6 files changed, 864 insertions, 5 deletions
diff --git a/cspell.json b/cspell.json index 767e66fffd..e171833e86 100644 --- a/cspell.json +++ b/cspell.json @@ -1497,7 +1497,13 @@ "ESRCH", "domainuser", "rfind", - "gempath" + "gempath", + "recusively", + "postprocess", + "metaparameter", + "igroups", + "errorhandler", + "IPACK" ], // flagWords - list of words to be always considered incorrect // This is useful for offensive words and common spelling errors. diff --git a/lib/chef/dsl/rest_resource.rb b/lib/chef/dsl/rest_resource.rb new file mode 100644 index 0000000000..96eba24eac --- /dev/null +++ b/lib/chef/dsl/rest_resource.rb @@ -0,0 +1,70 @@ +# +# Copyright:: Copyright 2008-2016, Chef, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "chef/constants" unless defined?(NOT_PASSED) + +class Chef + module DSL + module RestResource + def rest_property_map(rest_property_map = NOT_PASSED) + if rest_property_map != NOT_PASSED + rest_property_map = rest_property_map.to_h { |k| [k.to_sym, k] } if rest_property_map.is_a? Array + + @rest_property_map = rest_property_map + end + @rest_property_map + end + + # URL to collection + def rest_api_collection(rest_api_collection = NOT_PASSED) + @rest_api_collection = rest_api_collection if rest_api_collection != NOT_PASSED + @rest_api_collection + end + + # RFC6570-Templated URL to document + def rest_api_document(rest_api_document = NOT_PASSED, first_element_only: false) + if rest_api_document != NOT_PASSED + @rest_api_document = rest_api_document + @rest_api_document_first_element_only = first_element_only + end + @rest_api_document + end + + # Explicit REST document identity mapping + def rest_identity_map(rest_identity_map = NOT_PASSED) + @rest_identity_map = rest_identity_map if rest_identity_map != NOT_PASSED + @rest_identity_map + end + + # Mark up properties for POST only, not PATCH/PUT + def rest_post_only_properties(rest_post_only_properties = NOT_PASSED) + if rest_post_only_properties != NOT_PASSED + @rest_post_only_properties = Array(rest_post_only_properties).map(&:to_sym) + end + @rest_post_only_properties || [] + end + + def rest_api_document_first_element_only(rest_api_document_first_element_only = NOT_PASSED) + if rest_api_document_first_element_only != NOT_PASSED + @rest_api_document_first_element_only = rest_api_document_first_element_only + end + @rest_api_document_first_element_only + end + + end + end +end diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index ffdbdcbaba..c60b7fc888 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -561,5 +561,13 @@ class Chef super "before subscription from #{notification.resource} resource cannot be setup to #{notification.notifying_resource} resource, which has already fired while in unified mode" end end + + class RestError < RuntimeError; end + + class RestTargetError < RestError; end + + class RestTimeout < RestError; end + + class RestOperationFailed < RestError; end end end diff --git a/lib/chef/resource.rb b/lib/chef/resource.rb index 7f65f28fdd..d6c5fe7cdf 100644 --- a/lib/chef/resource.rb +++ b/lib/chef/resource.rb @@ -1498,10 +1498,18 @@ class Chef # @param partial [String] the code fragment to eval against the class # def self.use(partial) - dirname = ::File.dirname(partial) - basename = ::File.basename(partial, ".rb") - basename = basename[1..] if basename.start_with?("_") - class_eval IO.read(::File.expand_path("#{dirname}/_#{basename}.rb", ::File.dirname(caller_locations.first.path))) + if partial =~ /^core::(.*)/ + partial = $1 + dirname = ::File.dirname(partial) + basename = ::File.basename(partial, ".rb") + basename = basename[1..] if basename.start_with?("_") + class_eval IO.read(::File.expand_path("resource/#{dirname}/_#{basename}.rb", __dir__)) + else + dirname = ::File.dirname(partial) + basename = ::File.basename(partial, ".rb") + basename = basename[1..] if basename.start_with?("_") + class_eval IO.read(::File.expand_path("#{dirname}/_#{basename}.rb", ::File.dirname(caller_locations.first.path))) + end end # The cookbook in which this Resource was defined (if any). diff --git a/lib/chef/resource/_rest_resource.rb b/lib/chef/resource/_rest_resource.rb new file mode 100644 index 0000000000..f14e586eb2 --- /dev/null +++ b/lib/chef/resource/_rest_resource.rb @@ -0,0 +1,386 @@ +# +# Copyright:: Copyright 2008-2016, Chef, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "rest-client" unless defined?(RestClient) +require "jmespath" unless defined?(JMESPath) +require "chef/dsl/rest_resource" unless defined?(Chef::DSL::RestResource) + +extend Chef::DSL::RestResource + +action_class do + def load_current_resource + @current_resource = new_resource.class.new(new_resource.name) + + required_properties.each do |name| + requested = new_resource.send(name) + current_resource.send(name, requested) + end + + return @current_resource if rest_get_all.data.empty? + + resource_data = rest_get.data rescue nil + return @current_resource if resource_data.nil? || resource_data.empty? + + @resource_exists = true + + # Map JSON contents to defined properties + current_resource.class.rest_property_map.each do |property, match_instruction| + property_value = json_to_property(match_instruction, property, resource_data) + current_resource.send(property, property_value) unless property_value.nil? + end + + current_resource + end +end + +action :configure do + if resource_exists? + converge_if_changed do + data = {} + + new_resource.class.rest_property_map.each do |property, match_instruction| + # Skip "creation-only" properties on modifications + next if new_resource.class.rest_post_only_properties.include?(property) + + deep_merge! data, property_to_json(property, match_instruction) + end + + deep_compact!(data) + + rest_patch(data) + end + else + converge_by "creating resource" do + data = {} + + new_resource.class.rest_property_map.each do |property, match_instruction| + deep_merge! data, property_to_json(property, match_instruction) + end + + deep_compact!(data) + + rest_post(data) + end + end +end + +action :delete do + if resource_exists? + converge_by "deleting resource" do + rest_delete + end + else + logger.debug format("REST resource %<name>s of type %<type>s does not exist. Skipping.", + type: new_resource.name, name: id_property) + end +end + +action_class do + # Override this for postprocessing device-specifics (paging, data conversion) + def rest_postprocess(response) + response + end + + # Override this for error handling of device-specifics (readable error messages) + def rest_errorhandler(error_obj) + error_obj + end + + private + + def resource_exists? + @resource_exists + end + + def required_properties + current_resource.class.properties.select { |_, v| v.required? }.except(:name).keys + end + + # Return changed value or nil for delta current->new + def changed_value(property) + new_value = new_resource.send(property) + return new_value if current_resource.nil? + + current_value = current_resource.send(property) + + return current_value if required_properties.include? property + + new_value == current_value ? nil : new_value + end + + def id_property + current_resource.class.identity_attr + end + + # Map properties to their current values + def property_map + map = {} + + current_resource.class.state_properties.each do |property| + name = property.options[:name] + + map[name] = current_resource.send(name) + end + + map[id_property] = current_resource.send(id_property) + + map + end + + # Map part of a JSON (Hash) to resource property via JMESPath or user-supplied function + def json_to_property(match_instruction, property, resource_data) + case match_instruction + when String + JMESPath.search(match_instruction, resource_data) + when Symbol + function = "#{property}_from_json".to_sym + raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function) + + send(function, resource_data) || {} + else + raise TypeError, "Did not expect match type #{match_instruction.class}" + end + end + + # Map resource contents into a JSON (Hash) via JMESPath-like syntax or user-supplied function + def property_to_json(property, match_instruction) + case match_instruction + when String + bury(match_instruction, changed_value(property)) + when Symbol + function = "#{property}_to_json".to_sym + raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function) + + value = new_resource.send(property) + changed_value(property).nil? ? {} : send(function, value) + else + raise TypeError, "Did not expect match type #{match_instruction.class}" + end + end + + def rest_url_collection + current_resource.class.rest_api_collection + end + + # Resource document URL after RFC 6570 template evaluation via properties substitution + def rest_url_document + template = ::Addressable::Template.new(current_resource.class.rest_api_document) + template.expand(property_map).to_s + end + + # Convenience method for conditional requires + def conditionally_require_on_setting(property, dependent_properties) + dependent_properties = Array(dependent_properties) + + requirements.assert(:configure) do |a| + a.assertion do + # Needs to be set and truthy to require dependent properties + if new_resource.send(property) + dependent_properties.all? { |dep_prop| new_resource.property_is_set?(dep_prop) } + else + true + end + end + + message = format("Setting property :%<property>s requires properties :%<properties>s to be set as well on resource %<resource_name>s", + property: property, + properties: dependent_properties.join(", :"), + resource_name: current_resource.to_s) + + a.failure_message message + end + end + + # Generic REST helpers + + def rest_get_all + response = api_connection.get(rest_url_collection) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_get + response = api_connection.get(rest_url_document) + + response = rest_postprocess(response) + + first_only = current_resource.class.rest_api_document_first_element_only + first_only && response.is_a?(Array) ? response.first : response + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_post(data) + data.merge! rest_identity_values + + response = api_connection.post(rest_url_collection, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_put(data) + data.merge! rest_identity_values + + response = api_connection.put(rest_url_collection, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_patch(data) + response = api_connection.patch(rest_url_document, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_delete + response = api_connection.delete(rest_url_document) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + # REST parameter mapping + + # Return number of parameters needed to identify a resource (pre- and post-creation) + def rest_arity + rest_identity_map.keys.count + end + + # Return mapping of template placeholders to property value of identity parameters + def rest_identity_values + data = {} + + rest_identity_map.each do |rfc_template, property| + property_value = new_resource.send(property) + data.merge! bury(rfc_template, property_value) + end + + data + end + + def rest_identity_map + rest_identity_explicit || rest_identity_implicit + end + + # Accept direct mapping like { "svm.name" => :name } for specifying the x-ary identity of a resource + def rest_identity_explicit + current_resource.class.rest_identity_map + end + + # Parse document URL for RFC 6570 templates and map them to resource properties. + # + # Examples: + # Query based: "/api/protocols/san/igroups?name={name}&svm.name={svm}": { "name" => :name, "svm.name" => :svm } + # Path based: "/api/v1/{address}": { "address" => :address } + # + def rest_identity_implicit + template_url = current_resource.class.rest_api_document + + rfc_template = ::Addressable::Template.new(template_url) + rfc_template_vars = rfc_template.variables + + # Shortcut for 0-ary resources + return {} if rfc_template_vars.empty? + + if query_based_selection? + uri_query = URI.parse(template_url).query + + if CGI.parse(uri_query).values.any?(&:empty?) + raise "Need explicit identity mapping, as URL does not contain query parameters for all templates" + end + + path_variables = CGI.parse(uri_query).keys + elsif path_based_selection? + path_variables = rfc_template_vars + else + # There is also + raise "Unknown type of resource selection. Document URL does not seem to be path- or query-based?" + end + + identity_map = {} + path_variables.each_with_index do |v, i| + next if rfc_template_vars[i].nil? # Not mapped to property, assume metaparameter + + identity_map[v] = rfc_template_vars[i].to_sym + end + + identity_map + end + + def query_based_selection? + template_url = current_resource.class.rest_api_document + + # Will throw exception on presence of RFC 6570 templates + URI.parse(template_url) + true + rescue URI::InvalidURIError => _e + false + end + + def path_based_selection? + !query_based_selection? + end + + def api_connection + Chef.run_context.transport.connection + end + + # Remove all empty keys (recusively) from Hash. + # @see https://stackoverflow.com/questions/56457020/#answer-56458673 + def deep_compact!(hsh) + raise TypeError unless hsh.is_a? Hash + + hsh.each do |_, v| + deep_compact!(v) if v.is_a? Hash + end.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + end + + # Deep merge two hashes + # @see https://stackoverflow.com/questions/41109599#answer-41109737 + def deep_merge!(hsh1, hsh2) + raise TypeError unless hsh1.is_a?(Hash) && hsh2.is_a?(Hash) + + hsh1.merge!(hsh2) { |_, v1, v2| deep_merge!(v1, v2) } + end + + # Create nested hashes from JMESPath syntax. + def bury(path, value) + raise TypeError unless path.is_a?(String) + + arr = path.split(".") + ret = {} + + if arr.count == 1 + ret[arr.first] = value + + ret + else + partial_path = arr[0..-2].join(".") + + bury(partial_path, bury(arr.last, value)) + end + end +end diff --git a/spec/unit/resource/rest_resource_spec.rb b/spec/unit/resource/rest_resource_spec.rb new file mode 100644 index 0000000000..43cb63d8de --- /dev/null +++ b/spec/unit/resource/rest_resource_spec.rb @@ -0,0 +1,381 @@ +# +# Copyright:: Copyright 2008-2016, Chef, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" +require "train" +require "train-rest" + +class RestResourceByQuery < Chef::Resource + use "core::rest_resource" + + provides :rest_resource_by_query, target_mode: true + + property :address, String, required: true + property :prefix, Integer, required: true + property :gateway, String + + rest_api_collection "/api/v1/addresses" + rest_api_document "/api/v1/address/?ip={address}" + rest_property_map({ + address: "address", + prefix: "prefix", + gateway: "gateway", + }) +end + +class RestResourceByPath < RestResourceByQuery + provides :rest_resource_by_path, target_mode: true + + rest_api_document "/api/v1/address/{address}" +end + +describe "rest_resource using query-based addressing" do + let(:train) { + Train.create( + "rest", { + endpoint: "https://api.example.com/api/v1/", + debug_rest: true, + logger: Chef::Log, + } + ).connection + } + + let(:run_context) do + cookbook_collection = Chef::CookbookCollection.new([]) + node = Chef::Node.new + node.name "node1" + events = Chef::EventDispatch::Dispatcher.new + Chef::RunContext.new(node, cookbook_collection, events) + end + + let(:resource) do + RestResourceByQuery.new("set_address", run_context).tap do |resource| + resource.address = "192.0.2.1" + resource.prefix = 24 + resource.action :configure + end + end + + let(:provider) do + resource.provider_for_action(:configure).tap do |provider| + provider.current_resource = resource # for some stubby tests that don't call LCR + allow(provider).to receive(:api_connection).and_return(train) + end + end + + before(:each) do + allow(Chef::Provider).to receive(:new).and_return(provider) + end + + it "should include :configure action" do + expect(provider).to respond_to(:action_configure) + end + + it "should include :delete action" do + expect(provider).to respond_to(:action_delete) + end + + it "should include :nothing action" do + expect(provider).to respond_to(:action_nothing) + end + + it "sets the default action as :configure" do + expect(resource.action).to eql([:configure]) + end + + it "supports :configure action" do + expect { resource.action :configure }.not_to raise_error + end + + it "supports :delete action" do + expect { resource.action :delete }.not_to raise_error + end + + it "should mixin RestResourceDSL" do + expect(resource.class.ancestors).to include(Chef::DSL::RestResource) + end + + describe "#rest_postprocess" do + before do + provider.singleton_class.send(:public, :rest_postprocess) + end + it "should have a default rest_postprocess implementation" do + expect(provider).to respond_to(:rest_postprocess) + end + + it "should have a non-mutating rest_postprocess implementation" do + response = "{ data: nil }" + + expect(provider.rest_postprocess(response.dup)).to eq(response) + end + end + + describe "#rest_errorhandler" do + before do + provider.singleton_class.send(:public, :rest_errorhandler) + end + + it "should have a default rest_errorhandler implementation" do + expect(provider).to respond_to(:rest_errorhandler) + end + + it "should have a non-mutating rest_errorhandler implementation" do + error_obj = StandardError.new + + expect(provider.rest_errorhandler(error_obj.dup)).to eq(error_obj) + end + end + + describe "#required_properties" do + before do + provider.singleton_class.send(:public, :required_properties) + end + + it "should include required properties only" do + expect(provider.required_properties).to contain_exactly(:address, :prefix) + end + end + + describe "#property_map" do + before do + provider.singleton_class.send(:public, :property_map) + end + + it "should map resource properties to values properly" do + expect(provider.property_map).to eq({ + address: "192.0.2.1", + prefix: 24, + gateway: nil, + name: "set_address", + }) + end + end + + describe "#rest_url_collection" do + before do + provider.singleton_class.send(:public, :rest_url_collection) + end + + it "should return collection URLs properly" do + expect(provider.rest_url_collection).to eq("/api/v1/addresses") + end + end + + describe "#rest_url_document" do + before do + provider.singleton_class.send(:public, :rest_url_document) + end + + it "should apply URI templates to document URLs using query syntax properly" do + expect(provider.rest_url_document).to eq("/api/v1/address/?ip=192.0.2.1") + end + end + + # TODO: Test with path-style URLs + describe "#rest_identity_implicit" do + before do + provider.singleton_class.send(:public, :rest_identity_implicit) + end + + it "should return implicit identity properties properly" do + expect(provider.rest_identity_implicit).to eq({ "ip" => :address }) + end + end + + describe "#rest_identity_values" do + before do + provider.singleton_class.send(:public, :rest_identity_values) + end + + it "should return implicit identity properties and values properly" do + expect(provider.rest_identity_values).to eq({ "ip" => "192.0.2.1" }) + end + end + + # TODO: changed_value + # TODO: load_current_value + + # this might be a functional test, but it runs on any O/S so I leave it here + describe "when managing a resource" do + before { WebMock.disable_net_connect! } + let(:addresses_exists) { JSON.generate([{ "address": "192.0.2.1" }]) } + let(:addresses_other) { JSON.generate([{ "address": "172.16.32.85" }]) } + let(:address_exists) { JSON.generate({ "address": "192.0.2.1", "prefix": 24, "gateway": "192.0.2.1" }) } + let(:prefix_wrong) { JSON.generate({ "address": "192.0.2.1", "prefix": 25, "gateway": "192.0.2.1" }) } + + it "should be idempotent" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_exists, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: address_exists, headers: { "Content-Type" => "application/json" }) + resource.run_action(:configure) + expect(resource.updated_by_last_action?).to be false + end + + it "should PATCH if a property is incorrect" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_exists, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: prefix_wrong, headers: { "Content-Type" => "application/json" }) + stub_request(:patch, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .with( + body: "{\"address\":\"192.0.2.1\",\"prefix\":25}", + headers: { + "Accept" => "application/json", + "Content-Type" => "application/json", + } + ) + .to_return(status: 200, body: address_exists, headers: { "Content-Type" => "application/json" }) + resource.run_action(:configure) + expect(resource.updated_by_last_action?).to be true + end + + it "should POST if there's no resources at all" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: "[]", headers: { "Content-Type" => "application/json" }) + stub_request(:post, "https://api.example.com/api/v1/addresses") + .with( + body: "{\"address\":\"192.0.2.1\",\"prefix\":24,\"ip\":\"192.0.2.1\"}" + ) + .to_return(status: 200, body: address_exists, headers: { "Content-Type" => "application/json" }) + resource.run_action(:configure) + expect(resource.updated_by_last_action?).to be true + end + + it "should POST if the specific resource does not exist" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_other, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 404, body: "", headers: {}) + stub_request(:post, "https://api.example.com/api/v1/addresses") + .with( + body: "{\"address\":\"192.0.2.1\",\"prefix\":24,\"ip\":\"192.0.2.1\"}" + ) + .to_return(status: 200, body: address_exists, headers: { "Content-Type" => "application/json" }) + resource.run_action(:configure) + expect(resource.updated_by_last_action?).to be true + end + + it "should be idempotent if the resouces needs deleting and there are no resources at all" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: "[]", headers: { "Content-Type" => "application/json" }) + resource.run_action(:delete) + expect(resource.updated_by_last_action?).to be false + end + + it "should be idempotent if the resource doesn't exist" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_other, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 404, body: "", headers: {}) + resource.run_action(:delete) + expect(resource.updated_by_last_action?).to be false + end + + it "should DELETE the resource if it exists and matches" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_exists, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: address_exists, headers: { "Content-Type" => "application/json" }) + stub_request(:delete, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: "", headers: {}) + resource.run_action(:delete) + expect(resource.updated_by_last_action?).to be true + end + + it "should DELETE the resource if it exists and doesn't match" do + stub_request(:get, "https://api.example.com/api/v1/addresses") + .to_return(status: 200, body: addresses_exists, headers: { "Content-Type" => "application/json" }) + stub_request(:get, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: prefix_wrong, headers: { "Content-Type" => "application/json" }) + stub_request(:delete, "https://api.example.com/api/v1/address/?ip=192.0.2.1") + .to_return(status: 200, body: "", headers: {}) + resource.run_action(:delete) + expect(resource.updated_by_last_action?).to be true + end + end +end + +describe "rest_resource using path-based addressing" do + let(:train) { + Train.create( + "rest", { + endpoint: "https://api.example.com/api/v1/", + debug_rest: true, + logger: Chef::Log, + } + ).connection + } + + let(:run_context) do + cookbook_collection = Chef::CookbookCollection.new([]) + node = Chef::Node.new + node.name "node1" + events = Chef::EventDispatch::Dispatcher.new + Chef::RunContext.new(node, cookbook_collection, events) + end + + let(:resource) do + RestResourceByPath.new("set_address", run_context).tap do |resource| + resource.address = "192.0.2.1" + resource.prefix = 24 + resource.action :configure + end + end + + let(:provider) do + resource.provider_for_action(:configure).tap do |provider| + provider.current_resource = resource # for some stubby tests that don't call LCR + allow(provider).to receive(:api_connection).and_return(train) + end + end + + before(:each) do + allow(Chef::Provider).to receive(:new).and_return(provider) + end + + describe "#rest_url_document" do + before do + provider.singleton_class.send(:public, :rest_url_document) + end + + it "should apply URI templates to document URLs using path syntax properly" do + expect(provider.rest_url_document).to eq("/api/v1/address/192.0.2.1") + end + end + + describe "#rest_identity_implicit" do + before do + provider.singleton_class.send(:public, :rest_identity_implicit) + end + + it "should return implicit identity properties properly" do + expect(provider.rest_identity_implicit).to eq({ "address" => :address }) + end + end + + describe "#rest_identity_values" do + before do + provider.singleton_class.send(:public, :rest_identity_values) + end + + it "should return implicit identity properties and values properly" do + expect(provider.rest_identity_values).to eq({ "address" => "192.0.2.1" }) + end + end + +end |