usps-imis-api 1.0.0.pre.rc.5 → 1.0.0.pre.rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/Gemfile.lock +36 -30
  4. data/Readme.md +149 -33
  5. data/lib/usps/imis/api.rb +32 -39
  6. data/lib/usps/imis/business_object.rb +97 -47
  7. data/lib/usps/imis/config.rb +14 -3
  8. data/lib/usps/imis/data.rb +72 -0
  9. data/lib/usps/imis/error.rb +53 -0
  10. data/lib/usps/imis/errors/api_error.rb +11 -0
  11. data/lib/usps/imis/errors/config_error.rb +11 -0
  12. data/lib/usps/imis/errors/locked_id_error.rb +15 -0
  13. data/lib/usps/imis/errors/mapper_error.rb +29 -0
  14. data/lib/usps/imis/errors/not_found_error.rb +11 -0
  15. data/lib/usps/imis/errors/panel_unimplemented_error.rb +34 -0
  16. data/lib/usps/imis/{error → errors}/response_error.rb +5 -8
  17. data/lib/usps/imis/errors/unexpected_property_type_error.rb +31 -0
  18. data/lib/usps/imis/mapper.rb +51 -20
  19. data/lib/usps/imis/mocks/business_object.rb +47 -0
  20. data/lib/usps/imis/mocks.rb +11 -0
  21. data/lib/usps/imis/panels/base_panel.rb +154 -0
  22. data/lib/usps/imis/{panel → panels}/education.rb +2 -2
  23. data/lib/usps/imis/{panel → panels}/vsc.rb +2 -2
  24. data/lib/usps/imis/panels.rb +25 -0
  25. data/lib/usps/imis/properties.rb +50 -0
  26. data/lib/usps/imis/query.rb +94 -0
  27. data/lib/usps/imis/requests.rb +27 -3
  28. data/lib/usps/imis/version.rb +1 -1
  29. data/lib/usps/imis.rb +20 -15
  30. data/spec/lib/usps/imis/api_spec.rb +26 -13
  31. data/spec/lib/usps/imis/business_object_spec.rb +70 -33
  32. data/spec/lib/usps/imis/config_spec.rb +2 -2
  33. data/spec/lib/usps/imis/data_spec.rb +66 -0
  34. data/spec/lib/usps/imis/{error/api_error_spec.rb → error_spec.rb} +1 -1
  35. data/spec/lib/usps/imis/{error → errors}/response_error_spec.rb +4 -4
  36. data/spec/lib/usps/imis/mapper_spec.rb +27 -3
  37. data/spec/lib/usps/imis/mocks/business_object_spec.rb +65 -0
  38. data/spec/lib/usps/imis/panels/base_panel_spec.rb +33 -0
  39. data/spec/lib/usps/imis/panels/education_spec.rb +70 -0
  40. data/spec/lib/usps/imis/{panel → panels}/vsc_spec.rb +6 -7
  41. data/spec/lib/usps/imis/properties_spec.rb +19 -0
  42. data/spec/spec_helper.rb +2 -0
  43. data/usps-imis-api.gemspec +1 -1
  44. metadata +28 -16
  45. data/lib/ext/hash.rb +0 -10
  46. data/lib/usps/imis/error/api_error.rb +0 -44
  47. data/lib/usps/imis/error/mapper_error.rb +0 -11
  48. data/lib/usps/imis/panel/base_panel.rb +0 -101
  49. data/lib/usps/imis/panel/panel_properties.rb +0 -52
  50. data/spec/lib/usps/imis/panel/base_panel_spec.rb +0 -32
  51. data/spec/lib/usps/imis/panel/education_spec.rb +0 -55
  52. data/spec/lib/usps/imis/panel/panel_properties_spec.rb +0 -19
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'requests'
4
+ require_relative 'data'
5
+
3
6
  module Usps
4
7
  module Imis
5
8
  # DEV
@@ -18,112 +21,137 @@ module Usps
18
21
  #
19
22
  attr_reader :business_object_name
20
23
 
21
- # Override ID param of the URL (e.g. used for Panels)
24
+ # Ordinal to build override ID param of the URL (e.g. used for Panels)
22
25
  #
23
- attr_reader :url_id
26
+ attr_reader :ordinal
24
27
 
25
28
  # A new instance of +BusinessObject+
26
29
  #
27
- def initialize(api, business_object_name, url_id: nil)
30
+ def initialize(api, business_object_name, ordinal: nil)
28
31
  @api = api
29
32
  @business_object_name = business_object_name
30
- @url_id = url_id
33
+ @ordinal = ordinal
31
34
  end
32
35
 
33
36
  # Get a business object for the current member
34
37
  #
35
- # @return [Hash] Response data from the API
38
+ # If +fields+ is provided, will return only those field values
36
39
  #
37
- def get
38
- request = Net::HTTP::Get.new(uri)
39
- result = submit(uri, authorize(request))
40
- JSON.parse(result.body)
41
- end
40
+ # @param fields [String] Field names to return
41
+ #
42
+ # @return [Usps::Imis::Data, Array<Usps::Imis::Data>] Response data from the API
43
+ #
44
+ def get(*fields) = fields.any? ? get_fields(*fields) : raw_object
45
+ alias read get
42
46
 
43
47
  # Get a single named field from a business object for the current member
44
48
  #
45
- # @param name [String] Field name to return
49
+ # @param field [String] Field name to return
46
50
  #
47
- # @return [Hash] Response data from the API
51
+ # @return Response data field value from the API
48
52
  #
49
- def get_field(name)
50
- values = get['Properties']['$values']
51
- value = values.find { |hash| hash['Name'] == name }['Value']
53
+ def get_field(field) = raw_object[field]
54
+ alias fetch get_field
55
+ alias [] get_field
52
56
 
53
- value.is_a?(String) ? value : value['$value']
57
+ # Get named fields from a business object for the current member
58
+ #
59
+ # @param names [Array<String>] Field names to return
60
+ #
61
+ # @return [Array] Response data from the API
62
+ #
63
+ def get_fields(*fields)
64
+ values = raw_object
65
+ fields.map { values[it] }
54
66
  end
67
+ alias fetch_all get_fields
68
+
69
+ # Update a single named field on a business object for the current member
70
+ #
71
+ # @param field [String] Name of the field
72
+ # @param value Value of the field
73
+ #
74
+ # @return [Usps::Imis::Data] Response data from the API
75
+ #
76
+ def put_field(field, value) = put(filter_fields(field => value))
77
+ alias []= put_field
55
78
 
56
79
  # Update only specific fields on a business object for the current member
57
80
  #
58
81
  # @param fields [Hash] Conforms to pattern +{ field_key => value }+
59
82
  #
60
- # @return [Hash] Response data from the API
83
+ # @return [Usps::Imis::Data] Response data from the API
61
84
  #
62
- def put_fields(fields)
63
- updated = filter_fields(fields)
64
- put(updated)
65
- end
85
+ def put_fields(fields) = put(filter_fields(fields))
86
+ alias patch put_fields
66
87
 
67
88
  # Update a business object for the current member
68
89
  #
90
+ # Any properties not included will be left unmodified
91
+ #
69
92
  # @param body [Hash] Full raw API object data
70
93
  #
71
- # @return [Hash] Response data from the API
94
+ # @return [Usps::Imis::Data] Response data from the API
72
95
  #
73
- def put(body)
74
- request = Net::HTTP::Put.new(uri)
75
- request.body = JSON.dump(body)
76
- result = submit(uri, authorize(request))
77
- JSON.parse(result.body)
78
- end
96
+ def put(body) = put_object(Net::HTTP::Put.new(uri), body)
97
+ alias update put
79
98
 
80
99
  # Create a business object for the current member
81
100
  #
82
101
  # @param body [Hash] Full raw API object data
83
102
  #
84
- # @return [Hash] Response data from the API
103
+ # @return [Usps::Imis::Data] Response data from the API
85
104
  #
86
- def post(body)
87
- request = Net::HTTP::Post.new(uri)
88
- request.body = JSON.dump(body)
89
- result = submit(uri, authorize(request))
90
- JSON.parse(result.body)
91
- end
105
+ def post(body) = put_object(Net::HTTP::Post.new(uri(id: '')), body)
106
+ alias create post
92
107
 
93
108
  # Remove a business object for the current member
94
109
  #
95
- # @return [String] Error response body from the API, or empty string on success
110
+ # @return [true] Only on success response (i.e. blank string from the API)
96
111
  #
97
- def delete
98
- request = Net::HTTP::Delete.new(uri)
99
- result = submit(uri, authorize(request))
100
- result.body
101
- end
112
+ def delete = submit(uri, authorize(Net::HTTP::Delete.new(uri))).body == '' # rubocop:disable Naming/PredicateMethod
113
+ alias destroy delete
114
+
115
+ # Ruby 3.5 instance variable filter
116
+ #
117
+ def instance_variables_to_inspect = instance_variables - %i[@api]
102
118
 
103
119
  private
104
120
 
105
121
  def token = api.token
106
122
  def token_expiration = api.token_expiration
107
123
 
124
+ def logger = Imis.logger('BusinessObject')
125
+
108
126
  # Construct a business object API endpoint address
109
127
  #
110
- def uri
111
- id_for_url = url_id ? CGI.escape(url_id) : api.imis_id
112
- full_path = "#{API_PATH}/#{business_object_name}/#{id_for_url}"
128
+ def uri(id: nil)
129
+ full_path = "#{API_PATH}/#{business_object_name}/#{id_for_uri(id)}"
113
130
  URI(File.join(Imis.configuration.hostname, full_path))
114
131
  end
115
132
 
133
+ # Select the correct ID to use in the URI
134
+ #
135
+ def id_for_uri(id = nil)
136
+ return CGI.escape(id) unless id.nil?
137
+ return CGI.escape("~#{api.imis_id}|#{ordinal}") if ordinal
138
+
139
+ api.imis_id.to_s
140
+ end
141
+
116
142
  # Manually assemble the matching data structure, with fields in the correct order
117
143
  #
118
144
  def filter_fields(fields)
119
145
  existing = get
120
146
 
121
147
  JSON.parse(JSON.dump(existing)).tap do |updated|
122
- # The first property is always the iMIS ID again
123
- updated['Properties']['$values'] = [existing['Properties']['$values'][0]]
148
+ # Preserve the iMIS ID, as well as the Ordinal (if present)
149
+ updated['Properties']['$values'], properties =
150
+ existing.raw['Properties']['$values'].partition { %w[ID Ordinal].include?(it['Name']) }
124
151
 
125
152
  # Iterate through all existing fields
126
- existing['Properties']['$values'].each do |value|
153
+ properties.each do |value|
154
+ # Skip unmodified fields
127
155
  next unless fields.keys.include?(value['Name'])
128
156
 
129
157
  # Strings are not wrapped in the type definition structure
@@ -139,6 +167,28 @@ module Usps
139
167
  end
140
168
  end
141
169
  end
170
+
171
+ # Get a raw object response from the API
172
+ #
173
+ # Useful for stubbing data in tests
174
+ #
175
+ def raw_object
176
+ request = Net::HTTP::Get.new(uri)
177
+ result = submit(uri, authorize(request))
178
+ result = Data.from_json(result.body)
179
+ JSON.pretty_generate(result).split("\n").each { logger.debug " -> #{it}" }
180
+ result
181
+ end
182
+
183
+ # Upload an object to the API
184
+ #
185
+ def put_object(request, body)
186
+ request.body = JSON.dump(body)
187
+ result = submit(uri, authorize(request))
188
+ result = Data.from_json(result.body)
189
+ JSON.pretty_generate(result).split("\n").each { logger.debug " -> #{it}" }
190
+ result
191
+ end
142
192
  end
143
193
  end
144
194
  end
@@ -9,21 +9,28 @@ module Usps
9
9
  IMIS_ROOT_URL_DEV = 'https://abcdev.imiscloud.com'
10
10
 
11
11
  attr_accessor :imis_id_query_name, :username, :password
12
- attr_reader :environment
12
+ attr_reader :environment, :logger, :logger_level
13
13
 
14
14
  def initialize
15
15
  @environment = defined?(Rails) ? Rails.env : ActiveSupport::StringInquirer.new('development')
16
16
  @imis_id_query_name = ENV.fetch('IMIS_ID_QUERY_NAME', nil)
17
17
  @username = ENV.fetch('IMIS_USERNAME', nil)
18
18
  @password = ENV.fetch('IMIS_PASSWORD', nil)
19
+ @logger = ActiveSupport::TaggedLogging.new(Logger.new($stdout, level: :info))
19
20
 
20
21
  yield self if block_given?
22
+
23
+ @logger_level = logger.class::SEV_LABEL[logger.level].downcase.to_sym
21
24
  end
22
25
 
23
26
  def environment=(env)
24
27
  @environment = ActiveSupport::StringInquirer.new(env.to_s)
25
28
  end
26
29
 
30
+ def logger=(logger)
31
+ @logger = ActiveSupport::TaggedLogging.new(logger)
32
+ end
33
+
27
34
  # Environment-specific API endpoint hostname
28
35
  #
29
36
  # @return The API hostname for the current environment
@@ -32,12 +39,16 @@ module Usps
32
39
  return IMIS_ROOT_URL_PROD if environment.production?
33
40
  return IMIS_ROOT_URL_DEV if environment.development?
34
41
 
35
- raise Error::ApiError, "Unexpected API environment: #{environment}"
42
+ raise Errors::ConfigError, "Unexpected API environment: #{environment}"
36
43
  end
37
44
 
38
45
  # Ruby 3.5 instance variable filter
39
46
  #
40
- def instance_variables_to_inspect = %i[@environment @imis_id_query_name @username]
47
+ def instance_variables_to_inspect = %i[@environment @imis_id_query_name @username @logger_level]
48
+
49
+ # Parameters to filter out of logging
50
+ #
51
+ def filtered_parameters = %i[password]
41
52
  end
42
53
  end
43
54
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+ require 'stringio'
5
+
6
+ module Usps
7
+ module Imis
8
+ # Convenience wrapper for accessing specific properties within an API data response
9
+ #
10
+ class Data < Hash
11
+ # Load raw API response JSON to access properties
12
+ #
13
+ # @param json [String] Raw API response JSON
14
+ #
15
+ def self.from_json(json) = self[JSON.parse(json)]
16
+
17
+ alias raw to_h
18
+
19
+ # The Business Object or Panel name
20
+ #
21
+ def entity = raw['EntityTypeName']
22
+
23
+ # Access the iMIS ID property
24
+ #
25
+ def imis_id = self['ID'].to_i
26
+ alias id imis_id
27
+
28
+ # Access the Ordinal identifier property (if present)
29
+ #
30
+ def ordinal = self['Ordinal']&.to_i
31
+
32
+ # Access an individual property value by name
33
+ #
34
+ def [](property_name)
35
+ property = raw['Properties']['$values'].find { it['Name'] == property_name }
36
+ return if property.nil?
37
+
38
+ value = property['Value']
39
+ value.is_a?(String) ? value : value['$value']
40
+ end
41
+
42
+ # Hash of all property names to values
43
+ #
44
+ # @param include_ids [Boolean] Whether to include the iMIS ID and Ordinal
45
+ #
46
+ def properties(include_ids: false)
47
+ raw['Properties']['$values']
48
+ .map { it['Name'] }
49
+ .select { include_ids || !%w[ID Ordinal].include?(it) }
50
+ .index_with { self[it] }
51
+ end
52
+
53
+ def inspect
54
+ stringio = StringIO.new
55
+ PP.pp(self, stringio)
56
+ stringio.string.delete("\n")
57
+ end
58
+
59
+ def pretty_print(pp)
60
+ data = { entity:, imis_id:, ordinal: }.compact
61
+
62
+ pp.group(1, "#<#{self.class}", '>') do
63
+ data.each do |key, value|
64
+ pp.breakable
65
+ pp.text "#{key}="
66
+ pp.pp value
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ # Base error class for all internal exceptions
6
+ #
7
+ class Error < StandardError
8
+ # Additional call-specific metadata to pass through to Bugsnag
9
+ #
10
+ attr_accessor :metadata
11
+
12
+ # A new instance of +ApiError+
13
+ #
14
+ # @param message [String] The base exception message
15
+ # @param metadata [Hash] Additional call-specific metadata to pass through to Bugsnag
16
+ #
17
+ def initialize(message, metadata = {})
18
+ super(message)
19
+ @metadata = metadata
20
+
21
+ Imis.logger.debug self
22
+ end
23
+
24
+ # Additional metadata to include in Bugsnag reports
25
+ #
26
+ # Can include fields at the top level, which will be shows on the custom tab
27
+ #
28
+ # Can include fields nested under a top-level key, which will be shown on a tab with the
29
+ # top-level key as its name
30
+ #
31
+ # @return [Hash]
32
+ #
33
+ def bugsnag_meta_data
34
+ metadata == {} ? {} : base_metadata
35
+ end
36
+
37
+ private
38
+
39
+ def base_metadata
40
+ { api: metadata }
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ require_relative 'errors/api_error'
47
+ require_relative 'errors/config_error'
48
+ require_relative 'errors/locked_id_error'
49
+ require_relative 'errors/mapper_error'
50
+ require_relative 'errors/not_found_error'
51
+ require_relative 'errors/response_error'
52
+ require_relative 'errors/panel_unimplemented_error'
53
+ require_relative 'errors/unexpected_property_type_error'
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Generic exception raised from within the gem
7
+ #
8
+ class ApiError < Error; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised for invalid configuration
7
+ #
8
+ class ConfigError < Error; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised when attempting to change the iMIS ID while it is locked
7
+ #
8
+ class LockedIdError < Error
9
+ def initialize = super(message)
10
+
11
+ def message = 'Cannot change iMIS ID while locked'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised by a +Mapper+
7
+ #
8
+ class MapperError < Error
9
+ # Create a new instance of +MapperError+
10
+ #
11
+ # @param metadata [Hash] Additional call-specific metadata to pass through to Bugsnag
12
+ #
13
+ def initialize(metadata = {})
14
+ @metadata = metadata
15
+ super(message, metadata)
16
+ end
17
+
18
+ # Exception message including the unrecognized field
19
+ #
20
+ def message
21
+ <<~MESSAGE.chomp
22
+ Mapper does not recognize field: "#{metadata[:field_key]}".
23
+ Please report what data you are attempting to work with to ITCom leadership.
24
+ MESSAGE
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised when the requested object cannot be found
7
+ #
8
+ class NotFoundError < Error; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised when a panel is missing required method definitions
7
+ #
8
+ class PanelUnimplementedError < Error
9
+ # Create a new instance of +PanelUnimplementedError+ from the class name and missing method
10
+ #
11
+ # @param class_name [String] Name of the Panel class that is missing a method definition
12
+ # @param method [String] Method definition that is not defined on the Panel
13
+ #
14
+ def self.from(class_name, method) = new(class_name, method)
15
+
16
+ # Create a new instance of +PanelUnimplementedError+
17
+ #
18
+ # @param class_name [String] Name of the Panel class that is missing a method definition
19
+ # @param method [String] Method definition that is not defined on the Panel
20
+ # @param metadata [Hash] Additional call-specific metadata to pass through to Bugsnag
21
+ #
22
+ def initialize(class_name, method, metadata = {})
23
+ @class_name = class_name
24
+ @method = method
25
+ super(message, metadata)
26
+ end
27
+
28
+ # Exception message including the undefined method
29
+ #
30
+ def message = "#{@class_name} must implement ##{@method}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Usps
4
4
  module Imis
5
- module Error
5
+ module Errors
6
6
  # Exception raised due to receiving an error response from the API
7
7
  #
8
- class ResponseError < ApiError
8
+ class ResponseError < Error
9
9
  # [Net::HTTPResponse] The response received from the API
10
10
  #
11
11
  attr_reader :response
@@ -18,17 +18,14 @@ module Usps
18
18
  #
19
19
  # @param response [Net::HTTPResponse] The response received from the API
20
20
  #
21
- def self.from(response)
22
- new(nil, response)
23
- end
21
+ def self.from(response) = new(response)
24
22
 
25
23
  # Create a new instance of +ResponseError+
26
24
  #
27
- # @param _message Ignored
28
25
  # @param response [Net::HTTPResponse] The response received from the API
29
26
  # @param metadata [Hash] Additional call-specific metadata to pass through to Bugsnag
30
27
  #
31
- def initialize(_message, response, metadata = {})
28
+ def initialize(response, metadata = {})
32
29
  @response = response
33
30
  super(message, metadata)
34
31
  end
@@ -43,7 +40,7 @@ module Usps
43
40
  # @return [Hash]
44
41
  #
45
42
  def bugsnag_meta_data
46
- base_metadata.tap { |m| m[:api].merge!(metadata) }
43
+ base_metadata.tap { it[:api].merge!(metadata) }
47
44
  end
48
45
 
49
46
  # Auto-formatted exception message, based on the provided API response
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps
4
+ module Imis
5
+ module Errors
6
+ # Exception raised when attempting to wrap an unexpected property type
7
+ #
8
+ class UnexpectedPropertyTypeError < Error
9
+ # Create a new instance of +UnexpectedPropertyTypeError+ from an unexpected value
10
+ #
11
+ # @param value Unexpected value
12
+ #
13
+ def self.from(value) = new(value)
14
+
15
+ # Create a new instance of +UnexpectedPropertyTypeError+
16
+ #
17
+ # @param value Unexpected value
18
+ # @param metadata [Hash] Additional call-specific metadata to pass through to Bugsnag
19
+ #
20
+ def initialize(value, metadata = {})
21
+ @value = value
22
+ super(message, metadata)
23
+ end
24
+
25
+ # Exception message including the undefined method
26
+ #
27
+ def message = "Unexpected property type: #{@value.inspect}"
28
+ end
29
+ end
30
+ end
31
+ end