amsi 1.0.0

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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/.ci +125 -0
  3. data/.gitignore +2 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.txt +20 -0
  6. data/CODEOWNERS +1 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +319 -0
  10. data/Rakefile +6 -0
  11. data/amsi.gemspec +32 -0
  12. data/bin/console +15 -0
  13. data/bin/setup +8 -0
  14. data/config/multi_xml.rb +4 -0
  15. data/lib/amsi/attribute_parser/base.rb +23 -0
  16. data/lib/amsi/attribute_parser/boolean.rb +25 -0
  17. data/lib/amsi/attribute_parser/date.rb +28 -0
  18. data/lib/amsi/attribute_parser/date_time.rb +24 -0
  19. data/lib/amsi/attribute_parser/decimal.rb +15 -0
  20. data/lib/amsi/attribute_parser/integer.rb +15 -0
  21. data/lib/amsi/attribute_parser/string.rb +13 -0
  22. data/lib/amsi/attribute_parser.rb +45 -0
  23. data/lib/amsi/document_parser/base.rb +52 -0
  24. data/lib/amsi/document_parser/get_moveins.rb +66 -0
  25. data/lib/amsi/document_parser/guest_card_result.rb +31 -0
  26. data/lib/amsi/document_parser/leases.rb +46 -0
  27. data/lib/amsi/document_parser/properties.rb +103 -0
  28. data/lib/amsi/error/bad_request.rb +18 -0
  29. data/lib/amsi/error/base.rb +9 -0
  30. data/lib/amsi/error/invalid_response.rb +9 -0
  31. data/lib/amsi/error/request_fault.rb +18 -0
  32. data/lib/amsi/error/request_timeout.rb +9 -0
  33. data/lib/amsi/error/unparsable_response.rb +11 -0
  34. data/lib/amsi/model/address.rb +18 -0
  35. data/lib/amsi/model/base/attribute.rb +63 -0
  36. data/lib/amsi/model/base/attribute_store.rb +37 -0
  37. data/lib/amsi/model/base.rb +64 -0
  38. data/lib/amsi/model/guest_card.rb +18 -0
  39. data/lib/amsi/model/guest_card_result.rb +26 -0
  40. data/lib/amsi/model/lease.rb +63 -0
  41. data/lib/amsi/model/leasing_agent.rb +21 -0
  42. data/lib/amsi/model/manager.rb +16 -0
  43. data/lib/amsi/model/marketing_source.rb +21 -0
  44. data/lib/amsi/model/occupant.rb +32 -0
  45. data/lib/amsi/model/property.rb +35 -0
  46. data/lib/amsi/model/unit_type.rb +38 -0
  47. data/lib/amsi/parameter/contact_type.rb +10 -0
  48. data/lib/amsi/parameter/prospect.rb +19 -0
  49. data/lib/amsi/request/add_guest_card.rb +93 -0
  50. data/lib/amsi/request/base.rb +106 -0
  51. data/lib/amsi/request/get_moveins_by_first_marketing_source.rb +57 -0
  52. data/lib/amsi/request/get_property_list.rb +49 -0
  53. data/lib/amsi/request/get_property_residents.rb +45 -0
  54. data/lib/amsi/request_section/add_guest_card.rb +105 -0
  55. data/lib/amsi/request_section/auth.rb +22 -0
  56. data/lib/amsi/request_section/moveins_filter.rb +42 -0
  57. data/lib/amsi/request_section/property_list_filter.rb +45 -0
  58. data/lib/amsi/request_section/property_resident_filter.rb +32 -0
  59. data/lib/amsi/utils/request_fetcher.rb +37 -0
  60. data/lib/amsi/utils/request_generator.rb +54 -0
  61. data/lib/amsi/utils/snowflake_event_tracker.rb +113 -0
  62. data/lib/amsi/validator/base.rb +95 -0
  63. data/lib/amsi/validator/prospect_event_validator.rb +20 -0
  64. data/lib/amsi/validator/request_errors.rb +61 -0
  65. data/lib/amsi/validator/request_fault.rb +57 -0
  66. data/lib/amsi/validator/resident_event_validator.rb +20 -0
  67. data/lib/amsi/validator.rb +7 -0
  68. data/lib/amsi/version.rb +3 -0
  69. data/lib/amsi.rb +31 -0
  70. metadata +265 -0
@@ -0,0 +1,24 @@
1
+ require 'tzinfo'
2
+
3
+ require_relative 'base'
4
+
5
+ module Amsi
6
+ class AttributeParser
7
+ # Parse the response value of a date with time attribute.
8
+ class DateTime < Base
9
+ # AMSI time strings are assumed to be in Central time.
10
+ TIME_ZONE = TZInfo::Timezone.get('America/Chicago')
11
+ private_constant :TIME_ZONE
12
+
13
+ # @return [Date] the parsed attribute value
14
+ def parse
15
+ return if value == ''
16
+ date_time = ::DateTime.parse(value)
17
+ TIME_ZONE.local_to_utc(date_time) { |periods| periods.last }
18
+ rescue ArgumentError
19
+ raise Error::InvalidResponse,
20
+ "Invalid date/time response value: #{value}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ class AttributeParser
5
+ # Parse the response value of a decimal attribute
6
+ class Decimal < Base
7
+ # @return [Float] the parsed attribute value
8
+ def parse
9
+ Float(value)
10
+ rescue ArgumentError
11
+ raise Error::InvalidResponse, "Invalid decimal response value: #{value}"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ class AttributeParser
5
+ # Parse the response value of an integer attribute
6
+ class Integer < Base
7
+ # @return [Integer] the parsed attribute value
8
+ def parse
9
+ Integer(value, 10)
10
+ rescue ArgumentError
11
+ raise Error::InvalidResponse, "Invalid integer response value: #{value}"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ class AttributeParser
5
+ # Parse the response value of a string attribute
6
+ class String < Base
7
+ # @return [String] the parsed attribute value
8
+ def parse
9
+ value
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ require 'amsi/attribute_parser/boolean'
2
+ require 'amsi/attribute_parser/date'
3
+ require 'amsi/attribute_parser/date_time'
4
+ require 'amsi/attribute_parser/decimal'
5
+ require 'amsi/attribute_parser/integer'
6
+ require 'amsi/attribute_parser/string'
7
+
8
+ module Amsi
9
+ # Parse the string value from the XML response into the configured data type
10
+ class AttributeParser
11
+ # @param value [String] the response value from AMSI
12
+ # @param type [Symbol] the attribute's configured data type
13
+ def initialize(value:, type:)
14
+ @value = value
15
+ @type = type
16
+ end
17
+
18
+ # @return [Object] the parsed attribute value
19
+ def parse
20
+ return unless value
21
+ case type
22
+ when :boolean
23
+ AttributeParser::Boolean.new(value).parse
24
+ when :date
25
+ AttributeParser::Date.new(value).parse
26
+ when :date_time
27
+ AttributeParser::DateTime.new(value).parse
28
+ when :decimal
29
+ AttributeParser::Decimal.new(value).parse
30
+ when :integer
31
+ AttributeParser::Integer.new(value).parse
32
+ when :string
33
+ AttributeParser::String.new(value).parse
34
+ else
35
+ raise ArgumentError, "Invalid attribute type: #{type}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :value, :type
42
+ end
43
+
44
+ private_constant :AttributeParser
45
+ end
@@ -0,0 +1,52 @@
1
+ require 'multi_xml'
2
+ require 'amsi/validator/prospect_event_validator'
3
+ require 'amsi/validator/resident_event_validator'
4
+
5
+ module Amsi
6
+ module DocumentParser
7
+ # Base class for parsing AMSI responses. Subclasses must implement
8
+ # #parse_body and can optionally override #validator_classes to add more
9
+ # validation than just the default
10
+ class Base
11
+ def initialize(params = {})
12
+ @params = params
13
+ end
14
+
15
+ # @param xml [String] the XML response from the request
16
+ # @return [Object] the parsed object(s) from the response
17
+ # @raise [Amsi::Error::Base] if the response is invalid
18
+ def parse(xml)
19
+ begin
20
+ parsed_response = MultiXml.parse(xml)
21
+ rescue MultiXml::ParseError => e
22
+ raise Amsi::Error::UnparsableResponse.new(xml)
23
+ end
24
+
25
+ validate_response!(parsed_response)
26
+
27
+ parse_body(parsed_response['soap:Envelope']['soap:Body'])
28
+ end
29
+
30
+ private
31
+
32
+ def validate_response!(response_hash)
33
+ [*validator_classes].each do |klass|
34
+ klass.new(response_hash).validate!
35
+ end
36
+ end
37
+
38
+ # @param body [Hash<String, Object>] the body of the XML response parsed
39
+ # into a Hash
40
+ # @return [Object] the parsed object(s) from the response
41
+ # @raise [Amsi::Error::Base] if the response is invalid
42
+ def parse_body(body)
43
+ raise NotImplementedError,
44
+ "#{self.class.name} must implement #{__method__}"
45
+ end
46
+
47
+ def validator_classes
48
+ [Validator::RequestErrors, Validator::RequestFault, Validator::ResidentEventValidator, Validator::ProspectEventValidator]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,66 @@
1
+ require 'amsi/validator/request_errors'
2
+
3
+ require_relative 'base'
4
+
5
+ module Amsi
6
+ module DocumentParser
7
+ # Parse the GetMoveinsByFirstMarketingSource response
8
+ class GetMoveins < Base
9
+ private
10
+
11
+ # @param body [Hash<String, Object>] the body of the XML response parsed
12
+ # into a Hash
13
+ # @return [Array<Amsi::Model::Lease>] the leases contained in the
14
+ # response
15
+ # @raise [Amsi::Error::Base] if the response is invalid
16
+ def parse_body(body)
17
+ lease_hashes(body).map do |lease_hash|
18
+ lease = Model::Lease.new(lease_hash)
19
+ lease.occupants = occupants(lease_hash)
20
+ lease.guest_card = guest_card(lease_hash)
21
+ lease.matched_guest_cards = matched_guest_cards(lease_hash)
22
+
23
+ lease
24
+ end
25
+ end
26
+
27
+ def lease_hashes(body)
28
+ response = body['GetMoveinsByFirstMarketingSourceResponse']
29
+ escaped_result = response['GetMoveinsByFirstMarketingSourceResult']
30
+ parsed_result = MultiXml.parse(escaped_result)
31
+ leases = parsed_result['Leases']['Lease']
32
+
33
+ return [] if leases.nil?
34
+ leases.is_a?(Array) ? leases : [leases]
35
+ end
36
+
37
+ def occupants(lease_hash)
38
+ occupant_hashes(lease_hash).map do |occupant_hash|
39
+ Model::Occupant.new(occupant_hash)
40
+ end
41
+ end
42
+
43
+ def occupant_hashes(lease_hash)
44
+ occupants = lease_hash['Occupant']
45
+
46
+ return [] if occupants.nil?
47
+ occupants.is_a?(Array) ? occupants : [occupants]
48
+ end
49
+
50
+ def guest_card(lease_hash)
51
+ guest_card_hash = lease_hash['GuestCard']
52
+ Model::GuestCard.new(guest_card_hash) unless guest_card_hash.nil?
53
+ end
54
+
55
+ def matched_guest_cards(lease_hash)
56
+ matched_guest_card_hashes = lease_hash['MatchedGuestCard']
57
+
58
+ if matched_guest_card_hashes.is_a?(Hash)
59
+ [matched_guest_card_hashes]
60
+ else
61
+ [*matched_guest_card_hashes]
62
+ end.map { |h| Model::GuestCard.new(h) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ require 'amsi/validator/request_errors'
2
+ require 'amsi/validator/request_fault'
3
+ require 'amsi/error/bad_request'
4
+
5
+ require_relative 'base'
6
+
7
+ module Amsi
8
+ module DocumentParser
9
+ # Parse the AddGuestCard response
10
+ class GuestCardResult < Base
11
+ private
12
+
13
+ # @param body [Hash<String, Object>] the body of the XML response parsed
14
+ # into a Hash
15
+ # @return [Array<Amsi::Model::Lease>] the leases contained in the
16
+ # response
17
+ # @raise [Amsi::Error::Base] if the response is invalid
18
+ def parse_body(body)
19
+ response = body['AddGuestCardResponse']
20
+ escaped_result = response['AddGuestCardResult']
21
+ parsed_result = MultiXml.parse(escaped_result)['EDEX']
22
+ Model::GuestCardResult.new(
23
+ contact_seq_no: parsed_result['contactseqno'],
24
+ guest_card_id: parsed_result['guestcardid'],
25
+ property_id: parsed_result['propertyid'],
26
+ status: parsed_result['status']
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ require 'amsi/validator/request_errors'
2
+
3
+ require_relative 'base'
4
+
5
+ module Amsi
6
+ module DocumentParser
7
+ # Parse the GetPropertyResidents response
8
+ class Leases < Base
9
+ private
10
+
11
+ # @param body [Hash<String, Object>] the body of the XML response parsed
12
+ # into a Hash
13
+ # @return [Array<Amsi::Model::Lease>] the leases contained in the
14
+ # response
15
+ # @raise [Amsi::Error::Base] if the response is invalid
16
+ def parse_body(body)
17
+ lease_hashes(body).map do |lease_hash|
18
+ lease = Model::Lease.new(lease_hash)
19
+ lease.occupants = occupant_hashes(lease_hash).map do |occupant_hash|
20
+ Model::Occupant.new(occupant_hash)
21
+ end
22
+ Validator::ResidentEventValidator.new.send_event(lease, @params)
23
+ Validator::ProspectEventValidator.new.send_event(lease, @params)
24
+ lease
25
+ end
26
+ end
27
+
28
+ def lease_hashes(body)
29
+ response = body['GetPropertyResidentsResponse']
30
+ escaped_result = response['GetPropertyResidentsResult']
31
+ parsed_result = MultiXml.parse(escaped_result)
32
+ leases = parsed_result['PropertyResidents']['Lease']
33
+
34
+ return [] if leases.nil?
35
+ leases.is_a?(Array) ? leases : [leases]
36
+ end
37
+
38
+ def occupant_hashes(lease_hash)
39
+ occupants = lease_hash['Occupant']
40
+
41
+ return [] if occupants.nil?
42
+ occupants.is_a?(Array) ? occupants : [occupants]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,103 @@
1
+ require 'amsi/validator/request_errors'
2
+
3
+ require_relative 'base'
4
+
5
+ module Amsi
6
+ module DocumentParser
7
+ # Parse the GetPropertyList response
8
+ class Properties < Base
9
+ ADDRESS_ATTRS = %i[
10
+ Line1
11
+ Line2
12
+ Line3
13
+ Line4
14
+ City
15
+ State
16
+ ZipCode
17
+ Country
18
+ ].freeze
19
+ private_constant :ADDRESS_ATTRS
20
+
21
+ MANAGER_ATTR_MAP = {
22
+ 'MgrOffPhoneNo' => 'phone',
23
+ 'MgrFaxNo' => 'fax',
24
+ 'MgrSalutation' => 'salutation',
25
+ 'MgrFirstName' => 'first_name',
26
+ 'MgrMiName' => 'middle_name',
27
+ 'MgrLastName' => 'last_name'
28
+ }.freeze
29
+ private_constant :MANAGER_ATTR_MAP
30
+
31
+ private
32
+
33
+ # @param body [Hash<String, Object>] the body of the XML response parsed
34
+ # into a Hash
35
+ # @return [Array<Amsi::Model::Property>] the properties contained in
36
+ # the response
37
+ # @raise [Amsi::Error::Base] if the response is invalid
38
+ def parse_body(body)
39
+ property_hashes(body).map do |property_hash|
40
+ property = Model::Property.new(property_hash)
41
+ property.address = address(property_hash, 'Property')
42
+ property.remit_to_address = address(property_hash, 'RemitTo')
43
+ property.manager = manager(property_hash)
44
+ property.leasing_agents = leasing_agents(property_hash)
45
+ property.marketing_sources = marketing_sources(property_hash)
46
+ property.unit_types = unit_types(property_hash)
47
+ property
48
+ end
49
+ end
50
+
51
+ def address(property_hash, prefix)
52
+ address_attrs = ADDRESS_ATTRS.map.with_object({}) do |field, hash|
53
+ hash[field] = property_hash["#{prefix}Addr#{field}"]
54
+ end
55
+ Model::Address.new(address_attrs)
56
+ end
57
+
58
+ def manager(property_hash)
59
+ manager_attrs = MANAGER_ATTR_MAP.map.with_object({}) do |(node, attr), hash|
60
+ hash[attr] = property_hash[node]
61
+ end
62
+ Model::Manager.new(manager_attrs)
63
+ end
64
+
65
+ def leasing_agents(property_hash)
66
+ agent_hashes = property_hash['LeasingAgent']
67
+ return [] if agent_hashes.nil?
68
+ agent_hashes = [agent_hashes] unless agent_hashes.is_a?(Array)
69
+ agent_hashes.map do |agent_hash|
70
+ Model::LeasingAgent.new(agent_hash)
71
+ end
72
+ end
73
+
74
+ def marketing_sources(property_hash)
75
+ source_hashes = property_hash['MarketingSource']
76
+ return [] if source_hashes.nil?
77
+ source_hashes = [source_hashes] unless source_hashes.is_a?(Array)
78
+ source_hashes.map do |source_hash|
79
+ Model::MarketingSource.new(source_hash)
80
+ end
81
+ end
82
+
83
+ def unit_types(property_hash)
84
+ type_hashes = property_hash['UnitType']
85
+ return [] if type_hashes.nil?
86
+ type_hashes = [type_hashes] unless type_hashes.is_a?(Array)
87
+ type_hashes.map do |type_hash|
88
+ Model::UnitType.new(type_hash)
89
+ end
90
+ end
91
+
92
+ def property_hashes(body)
93
+ response = body['GetPropertyListResponse']
94
+ escaped_result = response['GetPropertyListResult']
95
+ parsed_result = MultiXml.parse(escaped_result)
96
+ properties = parsed_result['Properties']['Property']
97
+
98
+ return [] if properties.nil?
99
+ properties.is_a?(Array) ? properties : [properties]
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Error
5
+ # Raised when an AMSI request has an error node in the contents. This
6
+ # generally happens when one of the parameters in a section other than the
7
+ # auth section is invalid.
8
+ class BadRequest < Base
9
+ attr_reader :errors
10
+
11
+ # @param errors [Object] object must respond to message
12
+ def initialize(errors)
13
+ super(errors.map(&:message).join('; '))
14
+ @errors = errors
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ module Amsi
2
+ module Error
3
+ # A base class for all errors that originate from this gem
4
+ class Base < StandardError
5
+ end
6
+
7
+ private_constant :Base
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Error
5
+ # Raised when we cannot parse the response
6
+ class InvalidResponse < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Error
5
+ # Raised when a AMSI request has a fault node, which general appears
6
+ # when there is an issue with the auth section in the request (e.g.
7
+ # invalid password)
8
+ class RequestFault < Base
9
+ attr_reader :fault_code, :details
10
+
11
+ def initialize(message, fault_code, details = nil)
12
+ super(message)
13
+ @fault_code = fault_code
14
+ @details = details
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Error
5
+ # Raised when a AMSI request times out
6
+ class RequestTimeout < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Error
5
+ ##
6
+ # An Error that is raised when Amsi response cannot be parsed.
7
+ #
8
+ class UnparsableResponse < Base
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'base'
2
+
3
+ module Amsi
4
+ module Model
5
+ class Address < Base
6
+ string_attrs *%i[
7
+ line_1
8
+ line_2
9
+ line_3
10
+ line_4
11
+ city
12
+ state
13
+ zip_code
14
+ country
15
+ ]
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,63 @@
1
+ module Amsi
2
+ module Model
3
+ class Base
4
+ # Encapsulate a model attribute
5
+ class Attribute
6
+ attr_reader :prefix, :type, :name
7
+
8
+ def initialize(prefix:, type:, name:)
9
+ @prefix = prefix
10
+ @type = type
11
+ @name = name.to_s
12
+ end
13
+
14
+ # @return [String] the name of the method on the model used to access
15
+ # this attribute
16
+ def accessor_name
17
+ type == :boolean ? "#{name}?" : name
18
+ end
19
+
20
+ # There is some magic happening here to support more convenient
21
+ # attribute names. Since Model::Base.new can take parameters from a
22
+ # parsed XML document or from a human, we need to be able to look up
23
+ # the attribute based on either. The general mapping looks like:
24
+ #
25
+ # XML node | attribute name
26
+ # ---------------+--------------------
27
+ # SomeThing | some_thing
28
+ # something | some_thing
29
+ # SomeThingID | some_thing_id
30
+ # somethingID | some_thing_id
31
+ # foobarid | bar_id (if in Model::Foo)
32
+ # somethingbit | some_thing (if defined as a :boolean)
33
+ # somethingflag | some_thing (if defined as a :boolean)
34
+ #
35
+ # @param attr [String|Symbol] the name of the attribute or the XML node
36
+ # name from the AMSI response, e.g. :made_ready_date or
37
+ # "MadeReadyDate"
38
+ # @return [true|false] true iff the value passed in is for this
39
+ # Attribute
40
+ def matches?(attr)
41
+ possible_names.include?(attr.to_s.downcase)
42
+ end
43
+
44
+ private
45
+
46
+ def possible_names
47
+ squished_name = name.gsub(/_/, '')
48
+ prefixed_name = "#{prefix}#{squished_name}"
49
+ names = [name, squished_name, prefixed_name]
50
+ if name.end_with?('id')
51
+ big_id_names = [squished_name.gsub(/id$/, 'ID'),
52
+ prefixed_name.gsub(/id$/, 'ID')]
53
+ names.concat(big_id_names)
54
+ end
55
+ if type == :boolean
56
+ names.concat(["#{squished_name}flag", "#{squished_name}bit"])
57
+ end
58
+ names
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,37 @@
1
+ require_relative 'attribute'
2
+
3
+ module Amsi
4
+ module Model
5
+ class Base
6
+ # Hold attributes defined with the DSL in Models
7
+ class AttributeStore
8
+ def initialize(model_class)
9
+ @model_class = model_class
10
+ @attributes = []
11
+ end
12
+
13
+ # Add all attributes of the same type to the AttributeStore
14
+ def add(type, attrs)
15
+ attrs.map do |attr|
16
+ attribute = Attribute.new(prefix: prefix, type: type, name: attr)
17
+ @attributes << attribute
18
+ attribute
19
+ end
20
+ end
21
+
22
+ # Find a specific attribute with the given name
23
+ def find(attr)
24
+ attributes.detect { |attribute| attribute.matches?(attr) }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :attributes, :model_class
30
+
31
+ def prefix
32
+ ''
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,64 @@
1
+ require 'amsi/attribute_parser'
2
+ require_relative 'base/attribute_store'
3
+
4
+ module Amsi
5
+ module Model
6
+ # Base class for models, which are the return values from Requests
7
+ class Base
8
+ class << self
9
+ attr_accessor :attribute_store
10
+ end
11
+
12
+ # Create a DSL to define the models attributes and their data types. This
13
+ # bit of meta-programming will create a class method for each data type
14
+ # to define the model's attributes of the type, e.g.
15
+ #
16
+ # class LostAndFound < Base
17
+ # date_attr :found_date, :lost_date
18
+ # end
19
+ #
20
+ # When a new LostAndFound is initialized (from an AMSI response hash,
21
+ # that may have the keys 'founddate' and 'lostdate'), it will create
22
+ # attribute readers for #found_date and #lost_date that return Date
23
+ # instances.
24
+ #
25
+ # Boolean attributes will have the existential '?', e.g.
26
+ #
27
+ # class LostAndFound < Base
28
+ # boolean_attr :found
29
+ # end
30
+ #
31
+ # lost_and_found = LostAndFound.new('foundbit' => '1')
32
+ # lost_and_found.found?
33
+ # # => true
34
+ %i[
35
+ boolean date date_time decimal integer object string phone
36
+ ].each do |data_type|
37
+ define_singleton_method "#{data_type}_attrs" do |*attrs|
38
+ self.attribute_store ||= AttributeStore.new(self)
39
+ attribute_store.add(data_type, attrs).each do |attribute|
40
+ define_method attribute.accessor_name do
41
+ instance_variable_get("@#{attribute.name}")
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Initialize a new model with the response hash from AMSI. Attribute
48
+ # values are parsed into their configured data types.
49
+ #
50
+ # @param attrs [Hash<String, Object>] the response hash from AMSI.
51
+ # Attribute keys are case insensitive.
52
+ def initialize(attrs = {})
53
+ attrs.each do |attr, value|
54
+ attribute = self.class.attribute_store.find(attr)
55
+ next unless attribute
56
+ parser = AttributeParser.new(value: value, type: attribute.type)
57
+ instance_variable_set("@#{attribute.name}", parser.parse)
58
+ end
59
+ end
60
+ end
61
+
62
+ private_constant :Base
63
+ end
64
+ end