diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbf4b5c..690f88e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Next Release (minor) - Adds Ruby 3.4 support +- Fixes error parsing + - Allows for alternative format of `errors` field + - Corrects available properties of an `EasyPostError` and `ApiError` (`code` and `field` removed from `EasyPostError`, `message` unfurled and explicitly added to `ApiError`) + - Removes unused `Error` model - Corrects the HTTP verb for updating a brand from `GET` to `PATCH` - Removes the deprecated `create_list` tracker endpoint function as it is no longer available via API diff --git a/Makefile b/Makefile index ec2320b1..2b0a19b2 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ docs: bundle exec rdoc lib -o docs --title "EasyPost Ruby Docs" ## install-styleguide - Import the style guides (Unix only) -install-styleguide: | update-examples-submodule +install-styleguide: | init-examples-submodule sh examples/symlink_directory_files.sh examples/style_guides/ruby . ## init-examples-submodule - Initialize the examples submodule diff --git a/lib/easypost/client.rb b/lib/easypost/client.rb index 522cf12e..496520b7 100644 --- a/lib/easypost/client.rb +++ b/lib/easypost/client.rb @@ -76,7 +76,7 @@ def initialize(api_key:, read_timeout: 60, open_timeout: 30, api_base: 'https:// # @param endpoint [String] URI path of the resource # @param params [Object] (nil) object to be used as the request parameters # @param api_version [String] the version of API to hit - # @raise [EasyPost::Error] if the response has a non-2xx status code + # @raise [EasyPost::Errors::EasyPostError] if the response has a non-2xx status code # @return [Hash] JSON object parsed from the response body def make_request( method, diff --git a/lib/easypost/connection.rb b/lib/easypost/connection.rb index 3dcb3c17..d9d6f8b5 100644 --- a/lib/easypost/connection.rb +++ b/lib/easypost/connection.rb @@ -7,7 +7,7 @@ # @param path [String] URI path of the resource # @param requested_api_key [String] ({EasyPost.api_key}) key set Authorization header. # @param body [String] (nil) body of the request - # @raise [EasyPost::Error] if the response has a non-2xx status code + # @raise [EasyPost::Errors::EasyPostError] if the response has a non-2xx status code # @return [Hash] JSON object parsed from the response body def call(method, path, api_key = nil, body = nil) raise EasyPost::Errors::MissingParameterError.new('api_key') if api_key.nil? diff --git a/lib/easypost/errors/api/api_error.rb b/lib/easypost/errors/api/api_error.rb index efa91a9f..f592ef60 100644 --- a/lib/easypost/errors/api/api_error.rb +++ b/lib/easypost/errors/api/api_error.rb @@ -4,10 +4,13 @@ require 'easypost/constants' class EasyPost::Errors::ApiError < EasyPost::Errors::EasyPostError - attr_reader :status_code, :code, :errors + attr_reader :message, :status_code, :code, :errors def initialize(message, status_code = nil, error_code = nil, sub_errors = nil) super message + message_list = [] + EasyPost::Errors::ApiError.collect_error_messages(message, message_list) + @message = message_list.join(', ') @status_code = status_code @code = error_code @errors = sub_errors @@ -46,12 +49,9 @@ def self.handle_api_error(response) # Try to parse the response body as JSON begin error_data = JSON.parse(response.body)['error'] - error_message = error_data['message'] error_type = error_data['code'] - errors = error_data['errors']&.map do |error| - EasyPost::Models::Error.from_api_error_response(error) - end + errors = error_data['errors'] rescue StandardError error_message = response.code.to_s error_type = EasyPost::Constants::API_ERROR_DETAILS_PARSING_ERROR diff --git a/lib/easypost/models.rb b/lib/easypost/models.rb index a5903111..ca9c4b10 100644 --- a/lib/easypost/models.rb +++ b/lib/easypost/models.rb @@ -14,7 +14,6 @@ module EasyPost::Models require_relative 'models/customs_info' require_relative 'models/customs_item' require_relative 'models/end_shipper' -require_relative 'models/error' require_relative 'models/event' require_relative 'models/insurance' require_relative 'models/order' diff --git a/lib/easypost/models/error.rb b/lib/easypost/models/error.rb deleted file mode 100644 index 62f1fc8a..00000000 --- a/lib/easypost/models/error.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# EasyPost Error object. -class EasyPost::Models::Error - attr_reader :code, :field, :message - - # Initialize a new EasyPost Error - def initialize(code, field = nil, message = nil) - @code = code - @field = field - @message = message - end - - # Create an EasyPost Error from an API error response. - def self.from_api_error_response(data) - code = data['code'] - field = data['field'] || nil - message = data['message'] || nil - EasyPost::Models::Error.new(code, field, message) - end -end diff --git a/spec/address_spec.rb b/spec/address_spec.rb index a20a3ef3..e7b81f03 100644 --- a/spec/address_spec.rb +++ b/spec/address_spec.rb @@ -28,7 +28,23 @@ address = client.address.create(address_data) expect(address).to be_an_instance_of(EasyPost::Models::Address) + + # Delivery verification assertions expect(address.verifications.delivery.success).to be false + # TODO: details is not deserializing correctly, related to the larger "double EasyPostObject" wrapping issue + # expect(address.verifications.delivery.details).to be_empty + expect(address.verifications.delivery.errors[0].code).to eq('E.ADDRESS.NOT_FOUND') + expect(address.verifications.delivery.errors[0].field).to eq('address') + expect(address.verifications.delivery.errors[0].suggestion).to be nil + expect(address.verifications.delivery.errors[0].message).to eq('Address not found') + + # Zip4 verification assertions + expect(address.verifications.zip4.success).to be false + expect(address.verifications.zip4.details).to be nil + expect(address.verifications.zip4.errors[0].code).to eq('E.ADDRESS.NOT_FOUND') + expect(address.verifications.zip4.errors[0].field).to eq('address') + expect(address.verifications.zip4.errors[0].suggestion).to be nil + expect(address.verifications.zip4.errors[0].message).to eq('Address not found') end it 'creates an address with verify_strict param' do diff --git a/spec/cassettes/errors/EasyPost_Errors_api_error_raised_when_API_returns_error.yml b/spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly.yml similarity index 60% rename from spec/cassettes/errors/EasyPost_Errors_api_error_raised_when_API_returns_error.yml rename to spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly.yml index 50ae4470..6cf2c845 100644 --- a/spec/cassettes/errors/EasyPost_Errors_api_error_raised_when_API_returns_error.yml +++ b/spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly.yml @@ -2,10 +2,10 @@ http_interactions: - request: method: post - uri: https://api.easypost.com/v2/addresses + uri: https://api.easypost.com/v2/shipments body: encoding: UTF-8 - string: "{}" + string: '{"shipment":{}}' headers: Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 @@ -19,8 +19,8 @@ http_interactions: Authorization: "" response: status: - code: 403 - message: Forbidden + code: 422 + message: Unprocessable Entity headers: X-Frame-Options: - SAMEORIGIN @@ -35,27 +35,33 @@ http_interactions: Referrer-Policy: - strict-origin-when-cross-origin X-Ep-Request-Uuid: - - d044fa7b66a7da85e799dcc00059a941 + - 764a527867b8f96ce2ba1cd90039a515 + Cache-Control: + - private, no-cache, no-store + Pragma: + - no-cache + Expires: + - '0' Content-Type: - application/json; charset=utf-8 X-Runtime: - - '0.010842' + - '0.018727' Transfer-Encoding: - chunked X-Node: - - bigweb36nuq + - bigweb54nuq X-Version-Label: - - easypost-202407291746-57ea285141-master + - easypost-202502212131-cc7bde76a5-master X-Backend: - easypost X-Proxied: - - extlb1nuq fa152d4755 - - intlb4nuq c0f5e722d1 + - extlb1nuq 99aac35317 + - intlb3nuq 51d74985a2 Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload body: encoding: UTF-8 - string: '{"error":{"code":"APIKEY.INACTIVE","message":"This api key is no longer - active. Please use a different api key or reactivate this key.","errors":[]}}' - recorded_at: Mon, 29 Jul 2024 18:08:05 GMT -recorded_with: VCR 6.1.0 + string: '{"error":{"code":"PARAMETER.REQUIRED","message":"Missing required parameter.","errors":[{"field":"shipment","message":"cannot + be blank"}]}}' + recorded_at: Fri, 21 Feb 2025 22:08:44 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly_when_returned_via_the_alternative_format.yml b/spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly_when_returned_via_the_alternative_format.yml new file mode 100644 index 00000000..4faeb341 --- /dev/null +++ b/spec/cassettes/errors/EasyPost_Errors_api_error_assigns_properties_of_an_error_correctly_when_returned_via_the_alternative_format.yml @@ -0,0 +1,68 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.easypost.com/v2/claims + body: + encoding: UTF-8 + string: '{"tracking_code":"123"}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: "" + Host: + - api.easypost.com + Content-Type: + - application/json + Authorization: "" + response: + status: + code: 404 + message: Not Found + headers: + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + X-Ep-Request-Uuid: + - 764a527967b8f96ce2ba1cdb0039a535 + Cache-Control: + - private, no-cache, no-store + Pragma: + - no-cache + Expires: + - '0' + Content-Type: + - application/json; charset=utf-8 + X-Runtime: + - '0.135492' + Transfer-Encoding: + - chunked + X-Node: + - bigweb40nuq + X-Version-Label: + - easypost-202502212131-cc7bde76a5-master + X-Backend: + - easypost + X-Proxied: + - extlb1nuq 99aac35317 + - intlb3nuq 51d74985a2 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + body: + encoding: UTF-8 + string: '{"error":{"code":"NOT_FOUND","errors":["No eligible insurance found + with provided tracking code."],"message":"The requested resource could not + be found."}}' + recorded_at: Fri, 21 Feb 2025 22:08:44 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/cassettes/errors/EasyPost_Errors_api_error_deserialize_HTTP_error_response_properly.yml b/spec/cassettes/errors/EasyPost_Errors_api_error_deserialize_HTTP_error_response_properly.yml deleted file mode 100644 index 723f3608..00000000 --- a/spec/cassettes/errors/EasyPost_Errors_api_error_deserialize_HTTP_error_response_properly.yml +++ /dev/null @@ -1,134 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https://api.easypost.com/v2/addresses - body: - encoding: UTF-8 - string: '{"street1":"invalid"}' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: "" - Host: - - api.easypost.com - Content-Type: - - application/json - Authorization: "" - response: - status: - code: 201 - message: Created - headers: - X-Frame-Options: - - SAMEORIGIN - X-Xss-Protection: - - 1; mode=block - X-Content-Type-Options: - - nosniff - X-Download-Options: - - noopen - X-Permitted-Cross-Domain-Policies: - - none - Referrer-Policy: - - strict-origin-when-cross-origin - X-Ep-Request-Uuid: - - d044fa7a66a7da86e799dcc10059a996 - Cache-Control: - - private, no-cache, no-store - Pragma: - - no-cache - Expires: - - '0' - Location: - - "/api/v2/addresses/adr_81160b934dd511efa8cdac1f6bc539aa" - Content-Type: - - application/json; charset=utf-8 - X-Runtime: - - '0.045206' - Transfer-Encoding: - - chunked - X-Node: - - bigweb35nuq - X-Version-Label: - - easypost-202407291746-57ea285141-master - X-Backend: - - easypost - X-Proxied: - - extlb1nuq fa152d4755 - - intlb4nuq c0f5e722d1 - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - body: - encoding: UTF-8 - string: '{"id":"adr_81160b934dd511efa8cdac1f6bc539aa","object":"Address","created_at":"2024-07-29T18:08:06+00:00","updated_at":"2024-07-29T18:08:06+00:00","name":null,"company":null,"street1":"invalid","street2":null,"city":null,"state":null,"zip":null,"country":"US","phone":"","email":"","mode":"test","carrier_facility":null,"residential":null,"federal_tax_id":null,"state_tax_id":null,"verifications":{}}' - recorded_at: Mon, 29 Jul 2024 18:08:05 GMT -- request: - method: get - uri: https://api.easypost.com/v2/addresses/adr_81160b934dd511efa8cdac1f6bc539aa/verify - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: "" - Host: - - api.easypost.com - Content-Type: - - application/json - Authorization: "" - response: - status: - code: 422 - message: Unprocessable Entity - headers: - X-Frame-Options: - - SAMEORIGIN - X-Xss-Protection: - - 1; mode=block - X-Content-Type-Options: - - nosniff - X-Download-Options: - - noopen - X-Permitted-Cross-Domain-Policies: - - none - Referrer-Policy: - - strict-origin-when-cross-origin - X-Ep-Request-Uuid: - - d044fa7766a7da86e799dcc20059aa05 - Cache-Control: - - private, no-cache, no-store - Pragma: - - no-cache - Expires: - - '0' - Content-Type: - - application/json; charset=utf-8 - X-Runtime: - - '0.056601' - Transfer-Encoding: - - chunked - X-Node: - - bigweb38nuq - X-Version-Label: - - easypost-202407291746-57ea285141-master - X-Backend: - - easypost - X-Proxied: - - extlb1nuq fa152d4755 - - intlb4nuq c0f5e722d1 - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - body: - encoding: UTF-8 - string: '{"error":{"code":"ADDRESS.VERIFY.FAILURE","message":"Unable to verify - address.","errors":[{"code":"E.ADDRESS.NOT_FOUND","field":"address","message":"Address - not found","suggestion":null},{"code":"E.HOUSE_NUMBER.MISSING","field":"street1","message":"House - number is missing","suggestion":null}]}}' - recorded_at: Mon, 29 Jul 2024 18:08:06 GMT -recorded_with: VCR 6.1.0 diff --git a/spec/cassettes/errors/EasyPost_Errors_api_error_pretty_prints_properly.yml b/spec/cassettes/errors/EasyPost_Errors_api_error_pretty_prints_properly.yml deleted file mode 100644 index 1ac4ba29..00000000 --- a/spec/cassettes/errors/EasyPost_Errors_api_error_pretty_prints_properly.yml +++ /dev/null @@ -1,134 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https://api.easypost.com/v2/addresses - body: - encoding: UTF-8 - string: '{"street1":"invalid"}' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: "" - Host: - - api.easypost.com - Content-Type: - - application/json - Authorization: "" - response: - status: - code: 201 - message: Created - headers: - X-Frame-Options: - - SAMEORIGIN - X-Xss-Protection: - - 1; mode=block - X-Content-Type-Options: - - nosniff - X-Download-Options: - - noopen - X-Permitted-Cross-Domain-Policies: - - none - Referrer-Policy: - - strict-origin-when-cross-origin - X-Ep-Request-Uuid: - - d044fa7666a7da86e799dcc30059aa70 - Cache-Control: - - private, no-cache, no-store - Pragma: - - no-cache - Expires: - - '0' - Location: - - "/api/v2/addresses/adr_819f214a4dd511efa916ac1f6bc539aa" - Content-Type: - - application/json; charset=utf-8 - X-Runtime: - - '0.046850' - Transfer-Encoding: - - chunked - X-Node: - - bigweb35nuq - X-Version-Label: - - easypost-202407291746-57ea285141-master - X-Backend: - - easypost - X-Proxied: - - extlb1nuq fa152d4755 - - intlb3nuq c0f5e722d1 - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - body: - encoding: UTF-8 - string: '{"id":"adr_819f214a4dd511efa916ac1f6bc539aa","object":"Address","created_at":"2024-07-29T18:08:06+00:00","updated_at":"2024-07-29T18:08:06+00:00","name":null,"company":null,"street1":"invalid","street2":null,"city":null,"state":null,"zip":null,"country":"US","phone":"","email":"","mode":"test","carrier_facility":null,"residential":null,"federal_tax_id":null,"state_tax_id":null,"verifications":{}}' - recorded_at: Mon, 29 Jul 2024 18:08:06 GMT -- request: - method: get - uri: https://api.easypost.com/v2/addresses/adr_819f214a4dd511efa916ac1f6bc539aa/verify - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: "" - Host: - - api.easypost.com - Content-Type: - - application/json - Authorization: "" - response: - status: - code: 422 - message: Unprocessable Entity - headers: - X-Frame-Options: - - SAMEORIGIN - X-Xss-Protection: - - 1; mode=block - X-Content-Type-Options: - - nosniff - X-Download-Options: - - noopen - X-Permitted-Cross-Domain-Policies: - - none - Referrer-Policy: - - strict-origin-when-cross-origin - X-Ep-Request-Uuid: - - d044fa7966a7da87e799dcdb0059aadf - Cache-Control: - - private, no-cache, no-store - Pragma: - - no-cache - Expires: - - '0' - Content-Type: - - application/json; charset=utf-8 - X-Runtime: - - '0.063640' - Transfer-Encoding: - - chunked - X-Node: - - bigweb42nuq - X-Version-Label: - - easypost-202407291746-57ea285141-master - X-Backend: - - easypost - X-Proxied: - - extlb1nuq fa152d4755 - - intlb4nuq c0f5e722d1 - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - body: - encoding: UTF-8 - string: '{"error":{"code":"ADDRESS.VERIFY.FAILURE","message":"Unable to verify - address.","errors":[{"code":"E.ADDRESS.NOT_FOUND","field":"address","message":"Address - not found","suggestion":null},{"code":"E.HOUSE_NUMBER.MISSING","field":"street1","message":"House - number is missing","suggestion":null}]}}' - recorded_at: Mon, 29 Jul 2024 18:08:07 GMT -recorded_with: VCR 6.1.0 diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index c62308ff..e1c914d3 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -4,40 +4,57 @@ describe EasyPost::Errors do let(:client) { EasyPost::Client.new(api_key: ENV['EASYPOST_TEST_API_KEY']) } - let(:fake_client) { EasyPost::Client.new(api_key: 'not_a_real_key') } describe 'api error' do - it 'raised when API returns error' do - expect { - fake_client.address.create({}) - }.to raise_error(EasyPost::Errors::ApiError) + it 'assigns properties of an error correctly' do + client.shipment.create({}) + rescue EasyPost::Errors::ApiError => e + expect(e.status_code).to eq(422) + expect(e.code).to eq('PARAMETER.REQUIRED') + expect(e.message).to eq('Missing required parameter.') + expect(e.errors.first).to eq({ 'field' => 'shipment', 'message' => 'cannot be blank' }) end - it 'deserialize HTTP error response properly' do - # bad request - address = client.address.create(street1: 'invalid') - client.address.verify(address.id) - rescue EasyPost::Errors::InvalidRequestError => e - # should construct an InvalidRequestError object correctly - expect(e.message).to eq('Unable to verify address.') - expect(e.code).to eq('ADDRESS.VERIFY.FAILURE') - expect(e.errors).to be_a(Array) - expect(e.errors).not_to be_empty - first_error = e.errors.first - expect(first_error).to be_a(EasyPost::Models::Error) - expect(first_error.field).not_to be_nil - expect(first_error.code).not_to be_nil - expect(first_error.message).not_to be_nil + it 'assigns properties of an error correctly when returned via the alternative format' do + claim_data = { tracking_code: '123' } # Intentionally pass a bad tracking code + + begin + client.claim.create(claim_data) + rescue EasyPost::Errors::ApiError => e + expect(e.status_code).to eq(404) + expect(e.code).to eq('NOT_FOUND') + expect(e.message).to eq('The requested resource could not be found.') + expect(e.errors.first).to eq('No eligible insurance found with provided tracking code.') + end + end + + it 'concatenates error messages that are a list' do + error = EasyPost::Errors::ApiError.new(message: %w[Error1 Error2]) + + expect(error.message).to eq('Error1, Error2') end - it 'pretty prints properly' do - # bad request - address = client.address.create(street1: 'invalid') - client.address.verify(address.id) - rescue EasyPost::Errors::InvalidRequestError => e - expect(e.pretty_print).to be_a(String) - expect(e.pretty_print).not_to be_empty - expect(e.pretty_print).to include('Unable to verify address.') + it 'concatenates error messages that are a dict' do + message_data = { 'errors' => ['Bad format 1', 'Bad format 2'] } + error = EasyPost::Errors::ApiError.new(message: message_data) + + expect(error.message).to eq('Bad format 1, Bad format 2') + end + + it 'concatenates error messages that have an invalid format' do + message_data = { + 'errors' => ['Bad format 1', 'Bad format 2'], + 'bad_data' => [ + { + 'first_message' => 'Bad format 3', + 'second_message' => 'Bad format 4', + 'third_message' => 'Bad format 5', + }, + ], + } + error = EasyPost::Errors::ApiError.new(message: message_data) + + expect(error.message).to eq('Bad format 1, Bad format 2, Bad format 3, Bad format 4, Bad format 5') end end end