usps-imis-api 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: af49f754c17bf49128b5e71d515f75066ab62cd56573a846b3fb50fa951324ef
4
+ data.tar.gz: b0df9cc67032d9d40a474fe119deb0c70d0374358425ef6817d91398698cfd92
5
+ SHA512:
6
+ metadata.gz: 8a26ceb0adc52557754fce7207a32f5e26eeb445c98f328d04d99dfb8c478daa47a124f5cae59d22661c302d33242ad67883d7fc0e4ae77ee77ee55dc5473177
7
+ data.tar.gz: da23f2bd903cf74d4b74e4d2b4324557662366e37cdb5acc299dc208231e64ed4c8f477649b2e2a53db2db03b9d51b4fa692507b49abfce5ea18f1156c7ca436
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ coverage/
2
+ tmp/
3
+ .env
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --order rand
data/.rubocop.yml ADDED
@@ -0,0 +1,85 @@
1
+ require:
2
+ - rubocop-rspec
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.0
6
+ Exclude:
7
+ - bin/**/*
8
+ - config/**/*
9
+ - db/**/*
10
+ - vendor/**/*
11
+ - tmp/**/*
12
+ NewCops: enable
13
+
14
+ Layout/FirstHashElementIndentation:
15
+ EnforcedStyle: consistent
16
+ Layout/AccessModifierIndentation:
17
+ EnforcedStyle: outdent
18
+ Layout/EmptyLinesAroundAccessModifier:
19
+ Enabled: true
20
+ Layout/ArrayAlignment:
21
+ Enabled: true
22
+ Layout/HashAlignment:
23
+ Enabled: true
24
+ Layout/EmptyLineAfterGuardClause:
25
+ Enabled: true
26
+ Layout/SpaceInsideBlockBraces:
27
+ EnforcedStyle: space
28
+ EnforcedStyleForEmptyBraces: no_space
29
+ Layout/SpaceInsideHashLiteralBraces:
30
+ EnforcedStyle: space
31
+ EnforcedStyleForEmptyBraces: no_space
32
+ Layout/SpaceInsideArrayLiteralBrackets:
33
+ EnforcedStyle: no_space
34
+
35
+ Lint/UnusedMethodArgument:
36
+ Enabled: true
37
+ Lint/UselessAssignment:
38
+ Enabled: true
39
+
40
+ Metrics/LineLength:
41
+ Max: 100
42
+ Metrics/MethodLength:
43
+ Enabled: true
44
+ Max: 15
45
+ Metrics/ClassLength:
46
+ Enabled: true
47
+ Max: 125
48
+ Metrics/ModuleLength:
49
+ Max: 125
50
+ Metrics/ParameterLists:
51
+ Enabled: true
52
+ Metrics/CyclomaticComplexity:
53
+ Enabled: true
54
+ Metrics/AbcSize:
55
+ Enabled: true
56
+ Max: 30
57
+
58
+ Naming/MemoizedInstanceVariableName:
59
+ Enabled: false
60
+ Naming/MethodParameterName:
61
+ Enabled: false
62
+
63
+ Style/Documentation:
64
+ Enabled: false
65
+ Style/FrozenStringLiteralComment:
66
+ Enabled: true
67
+ Style/NumericLiterals:
68
+ Enabled: true
69
+ Style/StringLiterals:
70
+ EnforcedStyle: single_quotes
71
+ Style/AndOr:
72
+ Enabled: true
73
+ Style/ClassCheck:
74
+ Enabled: true
75
+ Style/GuardClause:
76
+ Enabled: true
77
+ Style/OptionalBooleanParameter:
78
+ Enabled: false
79
+
80
+ Security/Eval:
81
+ Enabled: true
82
+ Security/JSONLoad:
83
+ Enabled: true
84
+ Security/YAMLLoad:
85
+ Enabled: true
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2.3
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ gem 'dotenv', '>= 3.1.4'
7
+ gem 'rspec', '>= 3.13.0'
8
+ gem 'rubocop', '>= 1.66.1'
9
+ gem 'rubocop-rspec', '>= 3.1.0'
10
+ gem 'simplecov', '>= 0.22.0'
data/Gemfile.lock ADDED
@@ -0,0 +1,71 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ usps-imis-api (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.2)
10
+ diff-lcs (1.5.1)
11
+ docile (1.4.1)
12
+ dotenv (3.1.4)
13
+ json (2.7.2)
14
+ language_server-protocol (3.17.0.3)
15
+ parallel (1.26.3)
16
+ parser (3.3.5.0)
17
+ ast (~> 2.4.1)
18
+ racc
19
+ racc (1.8.1)
20
+ rainbow (3.1.1)
21
+ regexp_parser (2.9.2)
22
+ rspec (3.13.0)
23
+ rspec-core (~> 3.13.0)
24
+ rspec-expectations (~> 3.13.0)
25
+ rspec-mocks (~> 3.13.0)
26
+ rspec-core (3.13.1)
27
+ rspec-support (~> 3.13.0)
28
+ rspec-expectations (3.13.3)
29
+ diff-lcs (>= 1.2.0, < 2.0)
30
+ rspec-support (~> 3.13.0)
31
+ rspec-mocks (3.13.2)
32
+ diff-lcs (>= 1.2.0, < 2.0)
33
+ rspec-support (~> 3.13.0)
34
+ rspec-support (3.13.1)
35
+ rubocop (1.66.1)
36
+ json (~> 2.3)
37
+ language_server-protocol (>= 3.17.0)
38
+ parallel (~> 1.10)
39
+ parser (>= 3.3.0.2)
40
+ rainbow (>= 2.2.2, < 4.0)
41
+ regexp_parser (>= 2.4, < 3.0)
42
+ rubocop-ast (>= 1.32.2, < 2.0)
43
+ ruby-progressbar (~> 1.7)
44
+ unicode-display_width (>= 2.4.0, < 3.0)
45
+ rubocop-ast (1.32.3)
46
+ parser (>= 3.3.1.0)
47
+ rubocop-rspec (3.1.0)
48
+ rubocop (~> 1.61)
49
+ ruby-progressbar (1.13.0)
50
+ simplecov (0.22.0)
51
+ docile (~> 1.1)
52
+ simplecov-html (~> 0.11)
53
+ simplecov_json_formatter (~> 0.1)
54
+ simplecov-html (0.13.1)
55
+ simplecov_json_formatter (0.1.4)
56
+ unicode-display_width (2.6.0)
57
+
58
+ PLATFORMS
59
+ arm64-darwin-23
60
+ ruby
61
+
62
+ DEPENDENCIES
63
+ dotenv (>= 3.1.4)
64
+ rspec (>= 3.13.0)
65
+ rubocop (>= 1.66.1)
66
+ rubocop-rspec (>= 3.1.0)
67
+ simplecov (>= 0.22.0)
68
+ usps-imis-api!
69
+
70
+ BUNDLED WITH
71
+ 2.5.6
data/lib/ext/hash.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Import a simplified version of to_query from Rails if not already defined
4
+ class Hash
5
+ def to_query
6
+ map do |key, value|
7
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
8
+ end.sort * '&'
9
+ end
10
+ end
data/lib/imis/api.rb ADDED
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imis
4
+ class Api
5
+ AUTHENTICATION_PATH = 'Token'
6
+ API_PATH = 'api'
7
+ QUERY_PATH = 'api/Query'
8
+
9
+ attr_reader :token, :token_expiration, :imis_id
10
+
11
+ def initialize(skip_authentication: false)
12
+ authenticate unless skip_authentication
13
+ end
14
+
15
+ # Manually set the current ID, if you already have it for a given member
16
+ #
17
+ def imis_id=(id)
18
+ @imis_id = id.to_s
19
+ end
20
+
21
+ # Convert a member's certificate number into an iMIS ID number
22
+ #
23
+ def imis_id_for(certificate)
24
+ result = query(Imis.configuration.imis_id_query_name, { certificate: certificate })
25
+ @imis_id = result['Items']['$values'][0]['ID']
26
+ end
27
+
28
+ # Get a business object for the current member
29
+ #
30
+ def get(business_object_name)
31
+ uri = uri_for(business_object_name)
32
+ request = Net::HTTP::Get.new(uri)
33
+ result = submit(uri, authorize(request))
34
+ JSON.parse(result.body)
35
+ end
36
+
37
+ # Update only specific fields on a business object for the current member
38
+ #
39
+ # fields - hash of shape: { field_name => new_value }
40
+ #
41
+ def put(business_object_name, fields)
42
+ uri = uri_for(business_object_name)
43
+ request = Net::HTTP::Put.new(uri)
44
+ updated = filter_fields(business_object_name, fields)
45
+ request.body = JSON.dump(updated)
46
+ result = submit(uri, authorize(request))
47
+ JSON.parse(result.body)
48
+ end
49
+
50
+ # Run an IQA Query
51
+ #
52
+ # query_name - the full path of the query in IQA, e.g. `$/_ABC/Fiander/iMIS_ID`
53
+ # query_params - hash of shape: { param_name => param_value }
54
+ #
55
+ def query(query_name, query_params = {})
56
+ query_params[:QueryName] = query_name
57
+ path = "#{QUERY_PATH}?#{query_params.to_query}"
58
+ uri = URI(File.join(imis_hostname, path))
59
+ request = Net::HTTP::Get.new(uri)
60
+ result = submit(uri, authorize(request))
61
+ JSON.parse(result.body)
62
+ end
63
+
64
+ def mapper
65
+ @mapper ||= Mapper.new(self)
66
+ end
67
+
68
+ private
69
+
70
+ def client(uri)
71
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
72
+ http.use_ssl = true
73
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
74
+ end
75
+ end
76
+
77
+ def imis_hostname
78
+ Imis.configuration.hostname
79
+ end
80
+
81
+ # Authorize a request prior to submitting
82
+ #
83
+ # If the current token is missing/expired, request a new one
84
+ #
85
+ def authorize(request)
86
+ authenticate if token_expiration < Time.now
87
+ request.tap { |r| r.add_field('Authorization', "Bearer #{token}") }
88
+ end
89
+
90
+ # Construct a business object API endpoint address
91
+ #
92
+ def uri_for(business_object_name)
93
+ URI(File.join(imis_hostname, "#{API_PATH}/#{business_object_name}/#{imis_id}"))
94
+ end
95
+
96
+ def submit(uri, request)
97
+ client(uri).request(request).tap do |result|
98
+ raise Error::Api.from(result) unless result.is_a?(Net::HTTPSuccess)
99
+ end
100
+ end
101
+
102
+ # Authenticate to the iMIS API, and store the access token and expiration time
103
+ #
104
+ def authenticate
105
+ uri = URI(File.join(imis_hostname, AUTHENTICATION_PATH))
106
+ req = Net::HTTP::Post.new(uri)
107
+ authentication_data = {
108
+ grant_type: 'password',
109
+ username: Imis.configuration.username,
110
+ password: Imis.configuration.password
111
+ }
112
+ req.body = URI.encode_www_form(authentication_data)
113
+ result = submit(uri, req)
114
+ json = JSON.parse(result.body)
115
+
116
+ @token = json['access_token']
117
+ @token_expiration = Time.parse(json['.expires'])
118
+ end
119
+
120
+ # Manually assemble the matching data structure, with fields in the correct order
121
+ #
122
+ def filter_fields(business_object_name, fields)
123
+ existing = get(business_object_name)
124
+
125
+ JSON.parse(JSON.dump(existing)).tap do |updated|
126
+ # The first property is always the iMIS ID again
127
+ updated['Properties']['$values'] = [existing['Properties']['$values'][0]]
128
+
129
+ # Iterate through all existing fields
130
+ existing['Properties']['$values'].each do |value|
131
+ next unless fields.keys.include?(value['Name'])
132
+
133
+ # Strings are not wrapped in the type definition structure
134
+ new_value = fields[value['Name']]
135
+ if new_value.is_a?(String)
136
+ value['Value'] = new_value
137
+ else
138
+ value['Value']['$value'] = new_value
139
+ end
140
+
141
+ # Add the completed field with the updated value
142
+ updated['Properties']['$values'] << value
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imis
4
+ class Config
5
+ IMIS_ROOT_URL_PROD = 'https://portal.americasboatingclub.org'
6
+ IMIS_ROOT_URL_DEV = 'https://abcdev.imiscloud.com'
7
+
8
+ attr_accessor :environment, :imis_id_query_name, :username, :password
9
+
10
+ def initialize
11
+ yield self if block_given?
12
+ end
13
+
14
+ def hostname
15
+ case environment.to_sym
16
+ when :production
17
+ IMIS_ROOT_URL_PROD
18
+ when :development
19
+ IMIS_ROOT_URL_DEV
20
+ else
21
+ raise "Unexpected API environment: #{environment}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imis
4
+ module Error
5
+ class Api < StandardError
6
+ attr_reader :response
7
+ attr_accessor :metadata
8
+
9
+ def self.from(response)
10
+ new('The iMIS API returned an error.', response)
11
+ end
12
+
13
+ def initialize(message, response, metadata = {})
14
+ super(message)
15
+ @metadata = metadata
16
+ @response = response
17
+ end
18
+
19
+ def bugsnag_meta_data
20
+ { api: { status: status, body: body }.merge!(metadata) }
21
+ end
22
+
23
+ private
24
+
25
+ def status
26
+ @status ||=
27
+ case response.code
28
+ when '400'
29
+ :bad_request
30
+ when '401'
31
+ :unauthorized # RequestVerificationToken invalid
32
+ when '404'
33
+ :not_found
34
+ when '422'
35
+ :unprocessable_entity # validation error
36
+ when /^50\d$/
37
+ :internal_server_error # error within iMIS
38
+ end
39
+ end
40
+
41
+ def response_body
42
+ @response_body ||= JSON.parse(response.body)
43
+ rescue StandardError
44
+ @response_body ||= response.body
45
+ end
46
+
47
+ def body
48
+ return response_body unless response_body.is_a?(Hash)
49
+
50
+ case response_body['error']
51
+ when 'invalid_grant'
52
+ response_body['error_description']
53
+ else
54
+ # Unknown error type: just use the raw response
55
+ response.body
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imis
4
+ module Error
5
+ class Mapper < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imis
4
+ class Mapper
5
+ FIELD_MAPPING = {
6
+ vessel_examiner: ['ABC_ASC_Individual_Demog', 'Vol_Vessel_Examiner'],
7
+ mm: ['ABC_ASC_Individual_Demog', 'TotMMS'],
8
+ mm_updated: ['ABC_ASC_Individual_Demog', 'MMS_Updated']
9
+ }.freeze
10
+
11
+ attr_reader :api
12
+
13
+ def initialize(api)
14
+ @api = api
15
+ end
16
+
17
+ # Update a member's data by a single field name
18
+ # Does not require knowing which business object / iMIS-specific field name to use
19
+ #
20
+ # Only available for previously-mapped fields
21
+ #
22
+ def update(field_name, value)
23
+ if FIELD_MAPPING.key?(field_name.to_sym)
24
+ business_object_name, field = FIELD_MAPPING[field_name.to_sym]
25
+ api.put(business_object_name, { field => value })
26
+ else
27
+ warn(
28
+ "Mapper does not know how to handle field \"#{field_name}\".\n\n" \
29
+ 'You can use api.put(business_object_name, { field_name => value }) ' \
30
+ "if you know the business object and iMIS-specific field name.\n\n"
31
+ ) unless ENV['TESTING']
32
+
33
+ raise(
34
+ Imis::Error::Mapper,
35
+ "Unrecognized field: \"#{field_name}\". " \
36
+ 'Please report what data you are attempting to work with to ITCom leadership.'
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core dependencies
4
+ require 'net/https'
5
+ require 'json'
6
+ require 'time'
7
+ require 'cgi'
8
+
9
+ # Extensions
10
+ require 'ext/hash' unless defined?(Rails)
11
+
12
+ # Internal requires
13
+ require 'imis/config'
14
+ require 'imis/error/api'
15
+ require 'imis/error/mapper'
16
+ require 'imis/api'
17
+ require 'imis/mapper'
18
+
19
+ module Imis
20
+ class << self
21
+ def configuration
22
+ @configuration ||= Imis::Config.new
23
+ end
24
+
25
+ def configure
26
+ yield(configuration) if block_given?
27
+ configuration
28
+ end
29
+
30
+ # def mock!(value = true)
31
+ # @mock = value
32
+ # end
33
+
34
+ # def mock
35
+ # @mock || false
36
+ # end
37
+ end
38
+ end
data/spec/api_spec.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Imis::Api do
6
+ let(:api) { described_class.new }
7
+
8
+ describe '#imis_id_for' do
9
+ it 'gets the iMIS ID' do
10
+ expect(api.imis_id_for('E231625')).to eq('31092')
11
+ end
12
+ end
13
+
14
+ describe '#put' do
15
+ before { api.imis_id = 31092 }
16
+
17
+ it 'sends an update' do
18
+ expect(api.put('ABC_ASC_Individual_Demog', { 'TotMMS' => 15 })).to be_a(Hash)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Imis::Mapper do
6
+ let(:api) { Imis::Api.new }
7
+
8
+ describe '#update' do
9
+ before { api.imis_id = 31092 }
10
+
11
+ it 'sends a mapped update' do
12
+ expect(api.mapper.update(:mm, 15)).to be_a(Hash)
13
+ end
14
+
15
+ it 'raises for unmapped updates' do
16
+ expect { api.mapper.update(:something, 'anything') }.to raise_error(
17
+ Imis::Error::Mapper,
18
+ 'Unrecognized field: "something". ' \
19
+ 'Please report what data you are attempting to work with to ITCom leadership.'
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ Bundler.setup
5
+ require 'simplecov'
6
+ SimpleCov.start do
7
+ add_filter '/spec'
8
+ end
9
+ # SimpleCov.minimum_coverage(100)
10
+
11
+ require 'dotenv/load'
12
+ require 'usps_imis_api'
13
+
14
+ ENV['TESTING'] = 'true'
15
+
16
+ RSpec.configure do |config|
17
+ config.before(:suite) do
18
+ Imis.configure do |imis_config|
19
+ imis_config.environment = :development
20
+ imis_config.imis_id_query_name = ENV.fetch('IMIS_ID_QUERY_NAME', '')
21
+
22
+ imis_config.username = ENV.fetch('IMIS_USERNAME', '')
23
+ imis_config.password = ENV.fetch('IMIS_PASSWORD', '')
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'usps-imis-api'
5
+ s.version = '0.1.0'
6
+ s.summary = 'iMIS API Wrapper'
7
+ s.description = 'A wrapper for the iMIS API.'
8
+ s.homepage = 'http://rubygems.org/gems/usps-imis-api'
9
+ s.authors = ['Julian Fiander']
10
+ s.email = 'jsfiander@gmail.com'
11
+ s.require_paths = %w[lib]
12
+ s.files = `git ls-files`.split("\n")
13
+ s.metadata['rubygems_mfa_required'] = 'true'
14
+
15
+ s.required_ruby_version = '>= 3.0'
16
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: usps-imis-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julian Fiander
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A wrapper for the iMIS API.
14
+ email: jsfiander@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".gitignore"
20
+ - ".rspec"
21
+ - ".rubocop.yml"
22
+ - ".ruby-version"
23
+ - Gemfile
24
+ - Gemfile.lock
25
+ - lib/ext/hash.rb
26
+ - lib/imis/api.rb
27
+ - lib/imis/config.rb
28
+ - lib/imis/error/api.rb
29
+ - lib/imis/error/mapper.rb
30
+ - lib/imis/mapper.rb
31
+ - lib/usps_imis_api.rb
32
+ - spec/api_spec.rb
33
+ - spec/mapper_spec.rb
34
+ - spec/spec_helper.rb
35
+ - usps-imis-api.gemspec
36
+ homepage: http://rubygems.org/gems/usps-imis-api
37
+ licenses: []
38
+ metadata:
39
+ rubygems_mfa_required: 'true'
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.5.6
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: iMIS API Wrapper
59
+ test_files: []