diff --git a/app/clients/client_error.rb b/app/clients/client_error.rb new file mode 100644 index 00000000..8677ab7d --- /dev/null +++ b/app/clients/client_error.rb @@ -0,0 +1,2 @@ +# A generic error that occurred during a client operation +class ClientError < StandardError; end diff --git a/app/clients/discovery_engine/control_client.rb b/app/clients/discovery_engine/control_client.rb new file mode 100644 index 00000000..3568824b --- /dev/null +++ b/app/clients/discovery_engine/control_client.rb @@ -0,0 +1,58 @@ +module DiscoveryEngine + # Client to synchronise `Control`s to Discovery Engine + class ControlClient + # Creates a corresponding resource for this control on Discovery Engine. + def create(control) + discovery_engine_client.create_control( + control: control.to_discovery_engine_control, + control_id: control.discovery_engine_id, + parent: control.parent, + ) + rescue Google::Cloud::Error => e + set_record_errors(control, e) + raise ClientError, "Could not create control: #{e.message}" + end + + # Updates the corresponding resource for this control on Discovery Engine. + def update(control) + discovery_engine_client.update_control(control: control.to_discovery_engine_control) + rescue Google::Cloud::Error => e + set_record_errors(control, e) + raise ClientError, "Could not update control: #{e.message}" + end + + # Deletes the corresponding resource for this control on Discovery Engine. + def delete(control) + discovery_engine_client.delete_control(name: control.name) + rescue Google::Cloud::Error => e + set_record_errors(control, e) + raise ClientError, "Could not delete control: #{e.message}" + end + + private + + attr_reader :control + + def set_record_errors(control, error) + # There is no way to extract structured error information from the Google API client, so we + # have to resort to regex matching to see if we can extract the cause of the error. + # + # In this case, we know that if the error message contains "filter syntax", the user probably + # made a mistake entering the filter expression and we can attach the error to that field. + # Otherwise, we consider it an unknown error and make sure to log it. + case error.message + when /filter syntax/i + control.action.errors.add(:filter_expression, error.details) + else + control.errors.add(:base, :remote_error) + + GovukError.notify(error) + Rails.logger.error(error.message) + end + end + + def discovery_engine_client + Google::Cloud::DiscoveryEngine.control_service(version: :v1) + end + end +end diff --git a/spec/clients/discovery_engine/control_client_spec.rb b/spec/clients/discovery_engine/control_client_spec.rb new file mode 100644 index 00000000..51e22b88 --- /dev/null +++ b/spec/clients/discovery_engine/control_client_spec.rb @@ -0,0 +1,101 @@ +RSpec.describe DiscoveryEngine::ControlClient, type: :client do + let(:control) { build(:control, id: 42) } + + let(:discovery_engine_client) do + instance_double( + Google::Cloud::DiscoveryEngine::V1::ControlService::Client, + create_control: true, + update_control: true, + delete_control: true, + ) + end + + before do + allow(Google::Cloud::DiscoveryEngine) + .to receive(:control_service).and_return(discovery_engine_client) + end + + describe "#create" do + it "creates the control on Discovery Engine" do + expect(discovery_engine_client).to receive(:create_control).with( + control: control.to_discovery_engine_control, + control_id: "search-admin-42", + parent: "[engine]", + ) + + subject.create(control) # rubocop:disable Rails/SaveBang (not an ActiveRecord model) + end + end + + describe "#update" do + it "updates the control on Discovery Engine" do + expect(discovery_engine_client).to receive(:update_control).with( + control: control.to_discovery_engine_control, + ) + + subject.update(control) # rubocop:disable Rails/SaveBang (not an ActiveRecord model) + end + + context "when the operation raises an arbitrary error" do + before do + allow(discovery_engine_client).to receive(:update_control).and_raise(Google::Cloud::Error) + end + + it "raises a ClientInternalError and adds a base validation error" do + expect { subject.update(control) }.to raise_error(ClientError) + + expect(control.errors).to be_of_kind(:base, :remote_error) + end + end + + context "when the operation raises an invalid argument error about filter expressions" do + before do + allow(discovery_engine_client) + .to receive(:update_control) + .and_raise(Google::Cloud::InvalidArgumentError, "The filter syntax is broken") + end + + it "raises a ClientInternalError and adds a field validation error on action" do + expect { subject.update(control) }.to raise_error(ClientError) + + expect(control.action.errors).to be_of_kind(:filter_expression, :invalid) + end + end + + context "when the operation raises an invalid argument error about anything else" do + before do + allow(discovery_engine_client) + .to receive(:update_control) + .and_raise(Google::Cloud::InvalidArgumentError, "The splines are unreticulated") + end + + it "raises a ClientInternalError and adds a base validation error" do + expect { subject.update(control) }.to raise_error(ClientError) + + expect(control.errors).to be_of_kind(:base, :remote_error) + end + end + end + + describe "#delete" do + it "deletes the control on Discovery Engine" do + expect(discovery_engine_client).to receive(:delete_control).with( + name: "[engine]/controls/search-admin-42", + ) + + subject.delete(control) + end + + context "when the operation raises an arbitrary error" do + before do + allow(discovery_engine_client).to receive(:delete_control).and_raise(Google::Cloud::Error) + end + + it "raises a ClientInternalError and adds a base validation error" do + expect { subject.delete(control) }.to raise_error(ClientError) + + expect(control.errors).to be_of_kind(:base, :remote_error) + end + end + end +end