diff --git a/lib/seam.rb b/lib/seam.rb index ba15d9f..1cc6d74 100644 --- a/lib/seam.rb +++ b/lib/seam.rb @@ -8,6 +8,7 @@ require_relative "seam/client" require_relative "seam/base_client" require_relative "seam/base_resource" +require_relative "seam/errors" require_relative "seam/routes/resources/index" require_relative "seam/routes/clients/index" diff --git a/lib/seam/client.rb b/lib/seam/client.rb index c6b6473..e9ff532 100644 --- a/lib/seam/client.rb +++ b/lib/seam/client.rb @@ -10,7 +10,7 @@ class Client attr_accessor :wait_for_action_attempt, :defaults def initialize(api_key: nil, personal_access_token: nil, workspace_id: nil, endpoint: nil, - wait_for_action_attempt: false, debug: false) + wait_for_action_attempt: true, debug: false) options = SeamOptions.parse_options(api_key: api_key, personal_access_token: personal_access_token, workspace_id: workspace_id, endpoint: endpoint) @endpoint = options[:endpoint] @auth_headers = options[:auth_headers] diff --git a/lib/seam/errors.rb b/lib/seam/errors.rb new file mode 100644 index 0000000..3e90f5c --- /dev/null +++ b/lib/seam/errors.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Seam + module Errors + # HTTP + class SeamHttpApiError < StandardError + attr_reader :code, :status_code, :request_id, :data + + def initialize(error, status_code, request_id) + super(error[:message]) + @code = error[:type] + @status_code = status_code + @request_id = request_id + @data = error[:data] + end + end + + class SeamHttpUnauthorizedError < SeamHttpApiError + def initialize(request_id) + super({type: "unauthorized", message: "Unauthorized"}, 401, request_id) + end + end + + class SeamHttpInvalidInputError < SeamHttpApiError + attr_reader :validation_errors + + def initialize(error, status_code, request_id) + super(error, status_code, request_id) + @code = "invalid_input" + @validation_errors = error["validation_errors"] || {} + end + + def get_validation_error_messages(param_name) + @validation_errors.dig(param_name, "_errors") || [] + end + end + + # Action attempt + class SeamActionAttemptError < StandardError + attr_reader :action_attempt + + def initialize(message, action_attempt) + super(message) + @action_attempt = action_attempt + end + + def name + self.class.name + end + end + + class SeamActionAttemptFailedError < SeamActionAttemptError + attr_reader :code + + def initialize(action_attempt) + super(action_attempt.error.message, action_attempt) + @code = action_attempt.error.type + end + end + + class SeamActionAttemptTimeoutError < SeamActionAttemptError + def initialize(action_attempt, timeout) + message = "Timed out waiting for action attempt after #{timeout}s" + super(message, action_attempt) + end + end + end +end diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 160fbc9..2512736 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -6,16 +6,6 @@ module Seam class Request attr_reader :endpoint, :debug - class Error < StandardError - attr_reader :status, :response - - def initialize(message, status, response) - super(message) - @status = status - @response = response - end - end - def initialize(auth_headers:, endpoint:, debug: false) @auth_headers = auth_headers @endpoint = endpoint @@ -44,7 +34,7 @@ def handle_error_response(response, _method, _uri) status_code = response.status.code request_id = response.headers["seam-request-id"] - raise SeamHttpUnauthorizedError.new(request_id) if status_code == 401 + raise Errors::SeamHttpUnauthorizedError.new(request_id) if status_code == 401 error = response.parse["error"] || {} error_type = error["type"] || "unknown_error" @@ -57,12 +47,12 @@ def handle_error_response(response, _method, _uri) if error_type == "invalid_input" error_details["validation_errors"] = error["validation_errors"] - raise SeamHttpInvalidInputError.new( + raise Errors::SeamHttpInvalidInputError.new( error_details, status_code, request_id ) end - raise SeamHttpApiError.new(error_details, status_code, request_id) + raise Errors::SeamHttpApiError.new(error_details, status_code, request_id) end def build_url(uri) @@ -83,36 +73,4 @@ def user_agent "seam-ruby/#{Seam::VERSION}" end end - - class SeamHttpApiError < StandardError - attr_reader :code, :status_code, :request_id, :data - - def initialize(error, status_code, request_id) - super(error[:message]) - @code = error[:type] - @status_code = status_code - @request_id = request_id - @data = error[:data] - end - end - - class SeamHttpUnauthorizedError < SeamHttpApiError - def initialize(request_id) - super({type: "unauthorized", message: "Unauthorized"}, 401, request_id) - end - end - - class SeamHttpInvalidInputError < SeamHttpApiError - attr_reader :validation_errors - - def initialize(error, status_code, request_id) - super(error, status_code, request_id) - @code = "invalid_input" - @validation_errors = error["validation_errors"] || {} - end - - def get_validation_error_messages(param_name) - @validation_errors.dig(param_name, "_errors") || [] - end - end end diff --git a/lib/seam/utils/action_attempt_utils.rb b/lib/seam/utils/action_attempt_utils.rb index 202a918..fa3ab4f 100644 --- a/lib/seam/utils/action_attempt_utils.rb +++ b/lib/seam/utils/action_attempt_utils.rb @@ -14,20 +14,23 @@ def self.decide_and_wait(action_attempt, client, wait_for_action_attempt) end end - def self.wait_until_finished(action_attempt, client, timeout: 5.0, polling_interval: 0.5) + def self.wait_until_finished(action_attempt, client, timeout: nil, polling_interval: nil) + timeout = timeout.nil? ? 5.0 : timeout + polling_interval = polling_interval.nil? ? 0.5 : polling_interval + time_waiting = 0.0 while action_attempt.status == "pending" sleep(polling_interval) time_waiting += polling_interval - raise "Timed out waiting for action attempt to be finished" if time_waiting > timeout + raise Errors::SeamActionAttemptTimeoutError.new(action_attempt, timeout) if time_waiting > timeout action_attempt = update_action_attempt(action_attempt, client) - - raise "Action Attempt failed: #{action_attempt.error["message"]}" if action_attempt.status == "failed" end + raise Errors::SeamActionAttemptFailedError.new(action_attempt) if action_attempt.status == "error" + action_attempt end diff --git a/spec/request_spec.rb b/spec/request_spec.rb index b8cebaa..33cca67 100644 --- a/spec/request_spec.rb +++ b/spec/request_spec.rb @@ -22,7 +22,7 @@ it "parses the error" do expect { client.health }.to raise_error do |error| - expect(error).to be_a(Seam::SeamHttpApiError) + expect(error).to be_a(Seam::Errors::SeamHttpApiError) expect(error.message).to eq(message) expect(error.code).to eq(type) expect(error.request_id).to eq(request_id) @@ -48,7 +48,7 @@ it "parses the error" do expect { client.health }.to raise_error do |error| - expect(error).to be_a(Seam::SeamHttpApiError) + expect(error).to be_a(Seam::Errors::SeamHttpApiError) expect(error.message).to eq(message) expect(error.code).to eq(type) expect(error.request_id).to eq(request_id) diff --git a/spec/resources/action_attempt_spec.rb b/spec/resources/action_attempt_spec.rb index d30dee5..d58bc55 100644 --- a/spec/resources/action_attempt_spec.rb +++ b/spec/resources/action_attempt_spec.rb @@ -73,29 +73,6 @@ it "returns an updated ActionAttempt" do expect(result.status).to eq(finished_status) end - - context "when action attempt fails" do - let(:status) { "failed" } - let(:error_message) { "Something went wrong" } - - before do - stub_seam_request( - :post, - "/action_attempts/get", - {action_attempt: action_attempt_hash.merge(status: status, error: {"message" => error_message})} - ) - end - - it "raises an error" do - expect { described_class.wait_until_finished(action_attempt, client) }.to raise_error("Action Attempt failed: #{error_message}") - end - end - - context "when timeout is reached" do - it "raises a timeout error" do - expect { described_class.wait_until_finished(action_attempt, client, timeout: 0.1) }.to raise_error("Timed out waiting for action attempt to be finished") - end - end end describe ".update_action_attempt" do diff --git a/spec/seam_client/http_error_spec.rb b/spec/seam_client/http_error_spec.rb index 5d07be3..e8b8617 100644 --- a/spec/seam_client/http_error_spec.rb +++ b/spec/seam_client/http_error_spec.rb @@ -1,6 +1,6 @@ require "spec_helper" -RSpec.describe Seam::SeamHttpInvalidInputError do +RSpec.describe Seam::Errors::SeamHttpInvalidInputError do let(:api_key) { "seam_apikey1_token" } let(:client) { Seam::Client.new(api_key: api_key) } @@ -28,7 +28,7 @@ expect { client.devices.list(device_ids: 123) - }.to raise_error(Seam::SeamHttpInvalidInputError) do |error| + }.to raise_error(Seam::Errors::SeamHttpInvalidInputError) do |error| expect(error.code).to eq("invalid_input") expect(error.status_code).to eq(400) expect(error.get_validation_error_messages("device_ids")).to eq(["Expected array, received number"]) @@ -44,7 +44,7 @@ begin client.devices.list(device_ids: 123) - rescue Seam::SeamHttpInvalidInputError => error + rescue Seam::Errors::SeamHttpInvalidInputError => error expect(error.get_validation_error_messages("non_existent_field")).to eq([]) end end diff --git a/spec/seam_client/init_seam_spec.rb b/spec/seam_client/init_seam_spec.rb index bea8aed..e6c72e2 100644 --- a/spec/seam_client/init_seam_spec.rb +++ b/spec/seam_client/init_seam_spec.rb @@ -6,7 +6,7 @@ describe "#initialize" do it "initializes Seam with fixture" do expect(seam.lts_version).not_to be_nil - expect(seam.wait_for_action_attempt).to be_falsey + expect(seam.wait_for_action_attempt).to be true end end end diff --git a/spec/seam_client/request_spec.rb b/spec/seam_client/request_spec.rb index 9ef5803..5094254 100644 --- a/spec/seam_client/request_spec.rb +++ b/spec/seam_client/request_spec.rb @@ -12,7 +12,7 @@ end it "raises SeamHttpUnauthorizedError" do - expect { seam.devices.list }.to raise_error(Seam::SeamHttpUnauthorizedError) do |error| + expect { seam.devices.list }.to raise_error(Seam::Errors::SeamHttpUnauthorizedError) do |error| expect(error.message).to eq("Unauthorized") expect(error.request_id).to eq(request_id) end @@ -37,7 +37,7 @@ end it "raises SeamHttpInvalidInputError" do - expect { seam.devices.get(device_id: "invalid_device_id") }.to raise_error(Seam::SeamHttpInvalidInputError) do |error| + expect { seam.devices.get(device_id: "invalid_device_id") }.to raise_error(Seam::Errors::SeamHttpInvalidInputError) do |error| expect(error.message).to eq(error_message) expect(error.status_code).to eq(error_status) expect(error.request_id).to eq(request_id) @@ -64,7 +64,7 @@ end it "raises SeamHttpApiError with the correct details" do - expect { seam.devices.list }.to raise_error(Seam::SeamHttpApiError) do |error| + expect { seam.devices.list }.to raise_error(Seam::Errors::SeamHttpApiError) do |error| expect(error.message).to eq(error_message) expect(error.status_code).to eq(error_status) expect(error.request_id).to eq(request_id) diff --git a/spec/seam_client/wait_for_action_attepmt_spec.rb b/spec/seam_client/wait_for_action_attepmt_spec.rb new file mode 100644 index 0000000..59d0d0b --- /dev/null +++ b/spec/seam_client/wait_for_action_attepmt_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Seam::Client do + let(:api_key) { "seam_apikey1_token" } + let(:device_id) { "august_device_1" } + + describe "action attempt handling" do + let(:success_response) do + { + action_attempt: { + action_attempt_id: "1234", + status: "success" + } + } + end + + let(:pending_response) do + { + action_attempt: { + action_attempt_id: "1234", + status: "pending" + } + } + end + + let(:error_response) do + { + action_attempt: { + action_attempt_id: "1234", + status: "error", + error: {message: "Failed", type: "foo"} + } + } + end + + it "waits for action attempt when specified on method call" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", success_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id, wait_for_action_attempt: true) + expect(action_attempt.status).to eq("success") + end + + it "waits for action attempt by default" do + client = described_class.new(api_key: api_key) + + stub_seam_request(:post, "/locks/unlock_door", success_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("success") + end + + it "doesn't wait for action attempt when set to false in client initialization" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", pending_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("pending") + end + + it "can set class default with an object in client initialization" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: {timeout: 5}) + + stub_seam_request(:post, "/locks/unlock_door", success_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("success") + end + + it "returns successful action attempt" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", pending_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("pending") + + stub_seam_request(:post, "/action_attempts/get", success_response) + .with { |req| req.body.source == {action_attempt_id: "1234"}.to_json } + + successful_action_attempt = client.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id + ) + + expect(successful_action_attempt.status).to eq("success") + + resolved_action_attempt = client.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: true + ) + + expect(resolved_action_attempt.action_attempt_id).to eq(successful_action_attempt.action_attempt_id) + expect(resolved_action_attempt.status).to eq(successful_action_attempt.status) + end + + it "times out when waiting for action attempt" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", pending_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("pending") + + stub_seam_request(:post, "/action_attempts/get", pending_response) + .with { |req| req.body.source == {action_attempt_id: "1234"}.to_json } + + expect { + client.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: {timeout: 0.1} + ) + }.to raise_error(Seam::Errors::SeamActionAttemptTimeoutError) do |error| + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + expect(error.action_attempt.status).to eq(action_attempt.status) + end + end + + it "rejects when action attempt fails" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", pending_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("pending") + + stub_seam_request(:post, "/action_attempts/get", error_response) + .with { |req| req.body.source == {action_attempt_id: "1234"}.to_json } + + expect { + client.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: true + ) + }.to raise_error(Seam::Errors::SeamActionAttemptFailedError) do |error| + expect(error.message).to include("Failed") + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + expect(error.action_attempt.status).to eq("error") + expect(error.code).to eq("foo") + end + end + + it "times out if waiting for polling interval" do + client = described_class.new(api_key: api_key, wait_for_action_attempt: false) + + stub_seam_request(:post, "/locks/unlock_door", pending_response) + .with { |req| req.body.source == {device_id: device_id}.to_json } + + action_attempt = client.locks.unlock_door(device_id: device_id) + expect(action_attempt.status).to eq("pending") + + stub_seam_request(:post, "/action_attempts/get", pending_response) + .with { |req| req.body.source == {action_attempt_id: "1234"}.to_json } + + expect { + client.action_attempts.get( + action_attempt_id: action_attempt.action_attempt_id, + wait_for_action_attempt: {timeout: 0.5, polling_interval: 3} + ) + }.to raise_error(Seam::Errors::SeamActionAttemptTimeoutError) do |error| + expect(error.action_attempt.action_attempt_id).to eq(action_attempt.action_attempt_id) + expect(error.action_attempt.status).to eq(action_attempt.status) + end + end + end +end