summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLamont Granquist <454857+lamont-granquist@users.noreply.github.com>2022-04-01 16:22:12 -0700
committerGitHub <noreply@github.com>2022-04-01 16:22:12 -0700
commitc24fadc57b4b293f764b5212463273bb00411296 (patch)
treea601ff58372dc64e8d7121ae81a4e1c2b0c51159
parentcfce94e193621898a903aab147b3edd1d1df2ec0 (diff)
parentcc459fa4e699f068ae3ab889d141cde469eab79a (diff)
downloadchef-c24fadc57b4b293f764b5212463273bb00411296.tar.gz
Merge pull request #12755 from chef/lcg/rest_resource
-rw-r--r--cspell.json8
-rw-r--r--lib/chef/dsl/rest_resource.rb70
-rw-r--r--lib/chef/exceptions.rb8
-rw-r--r--lib/chef/resource.rb16
-rw-r--r--lib/chef/resource/_rest_resource.rb386
-rw-r--r--spec/unit/resource/rest_resource_spec.rb381
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