diff --git a/app/services/arcgis_api/geocoder.rb b/app/services/arcgis_api/geocoder.rb index dfc80c56a9c..0091924b401 100644 --- a/app/services/arcgis_api/geocoder.rb +++ b/app/services/arcgis_api/geocoder.rb @@ -1,5 +1,7 @@ module ArcgisApi class Geocoder + mattr_reader :token, :token_expires_at + Suggestion = Struct.new(:text, :magic_key, keyword_init: true) AddressCandidate = Struct.new( :address, :location, :street_address, :city, :state, :zip_code, @@ -23,14 +25,14 @@ class Geocoder # @param text [String] # @return [Array] Suggestions def suggest(text) - url = "#{root_url}/suggest" + url = "#{root_url}/servernh/rest/services/GSA/USA/GeocodeServer/suggest" params = { text: text, **COMMON_DEFAULT_PARAMETERS, } parse_suggestions( - faraday.get(url, params) do |req| + faraday.get(url, params, dynamic_headers) do |req| req.options.context = { service_name: 'arcgis_geocoder_suggest' } end.body, ) @@ -40,7 +42,7 @@ def suggest(text) # @param magic_key [String] a magic key value from a previous call to the #suggest method # @return [Array] AddressCandidates def find_address_candidates(magic_key) - url = "#{root_url}/findAddressCandidates" + url = "#{root_url}/servernh/rest/services/GSA/USA/GeocodeServer/findAddressCandidates" params = { magicKey: magic_key, outFields: 'StAddr,City,RegionAbbr,Postal', @@ -48,12 +50,25 @@ def find_address_candidates(magic_key) } parse_address_candidates( - faraday.get(url, params) do |req| + faraday.get(url, params, dynamic_headers) do |req| req.options.context = { service_name: 'arcgis_geocoder_find_address_candidates' } end.body, ) end + # Makes a request to retrieve a new token + # it expires after 1 hour + # @return [String] the token + def retrieve_token! + body = request_token + @@token_expires_at = Time.zone.at(body['expires']) + @@token = body['token'] + end + + def token_valid? + token.present? && token_expires_at.present? && token_expires_at.future? + end + private def root_url @@ -61,7 +76,7 @@ def root_url end def faraday - Faraday.new(headers: request_headers) do |conn| + Faraday.new do |conn| # Log request metrics conn.request :instrumentation, name: 'request_metric.faraday' @@ -74,10 +89,6 @@ def faraday end end - def request_headers - { 'Authorization' => "Bearer #{IdentityConfig.store.arcgis_api_key}" } - end - def parse_suggestions(response_body) handle_api_errors(response_body) @@ -97,9 +108,7 @@ def parse_address_candidates(response_body) address: candidate['address'], location: Location.new( longitude: candidate.dig('location', 'x'), - latitude: candidate.dig( - 'location', 'y' - ), + latitude: candidate.dig('location', 'y'), ), street_address: candidate.dig('attributes', 'StAddr'), city: candidate.dig('attributes', 'City'), @@ -121,5 +130,32 @@ def handle_api_errors(response_body) ) end end + + # Retrieve the short-lived API token (if needed) and then pass + # the headers to an arbitrary block of code as a Hash. + # + # Returns the same value returned by that block of code. + def dynamic_headers + retrieve_token! unless token_valid? + + { 'Authorization' => "Bearer #{token}" } + end + + # Makes HTTP request to authentication endpoint and + # returns the token and when it expires (1 hour). + # @return [Hash] API response + def request_token + url = "#{root_url}/portal/sharing/rest/generateToken" + body = { + username: IdentityConfig.store.arcgis_api_username, + password: IdentityConfig.store.arcgis_api_password, + referer: IdentityConfig.store.domain_name, + f: 'json', + } + + faraday.post(url, URI.encode_www_form(body)) do |req| + req.options.context = { service_name: 'usps_token' } + end.body + end end end diff --git a/config/application.yml.default b/config/application.yml.default index b2b3408a652..19a526b4c9f 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -41,8 +41,9 @@ acuant_get_results_timeout: 1.0 acuant_create_document_timeout: 1.0 add_email_link_valid_for_hours: 24 address_identity_proofing_supported_country_codes: '["AS", "GU", "MP", "PR", "US", "VI"]' -arcgis_api_root_url: 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer' -arcgis_api_key: '' +arcgis_api_root_url: 'https://gis.gsa.gov' +arcgis_api_username: '' +arcgis_api_password: '' asset_host: '' async_wait_timeout_seconds: 60 async_stale_job_timeout_seconds: 300 diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 4cd644ca915..cf347e2b792 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -106,7 +106,8 @@ def self.build_store(config_map) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) config.add(:arcgis_api_root_url, type: :string) - config.add(:arcgis_api_key, type: :string) + config.add(:arcgis_api_username, type: :string) + config.add(:arcgis_api_password, type: :string) config.add(:aws_http_retry_limit, type: :integer) config.add(:aws_http_retry_max_delay, type: :integer) config.add(:aws_http_timeout, type: :integer) diff --git a/spec/services/arcgis_api/geocoder_spec.rb b/spec/services/arcgis_api/geocoder_spec.rb index 9b90402563d..d80a7e5fc2e 100644 --- a/spec/services/arcgis_api/geocoder_spec.rb +++ b/spec/services/arcgis_api/geocoder_spec.rb @@ -6,6 +6,10 @@ let(:subject) { ArcgisApi::Geocoder.new } describe '#suggest' do + before(:each) do + stub_generate_token_response + end + it 'returns suggestions' do stub_request_suggestions @@ -35,6 +39,10 @@ end describe '#find_address_candidates' do + before(:each) do + stub_generate_token_response + end + it 'returns candidates from magic_key' do stub_request_candidates_response @@ -71,4 +79,66 @@ end end end + + describe '#retrieve_token!' do + it 'sets token and token_expires_at' do + stub_generate_token_response + subject.retrieve_token! + + expect(subject.token).to be_present + expect(subject.token_expires_at).to be_present + end + + it 'calls the endpoint with the expected params' do + stub_generate_token_response + root_url = 'http://my.root.url' + username = 'test username' + password = 'test password' + + allow(IdentityConfig.store).to receive(:arcgis_api_root_url). + and_return(root_url) + allow(IdentityConfig.store).to receive(:arcgis_api_username). + and_return(username) + allow(IdentityConfig.store).to receive(:arcgis_api_password). + and_return(password) + + subject.retrieve_token! + + expect(WebMock).to have_requested(:post, "#{root_url}/portal/sharing/rest/generateToken"). + with( + body: 'username=test+username&password=test+password&referer=www.example.com&f=json', + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Faraday v1.8.0', + }, + ) + end + + it 'reuses the cached token on subsequent requests' do + stub_generate_token_response + stub_request_suggestions + stub_request_suggestions + stub_request_suggestions + + subject.suggest('1') + subject.suggest('100') + subject.suggest('100 Main') + end + + it 'implicitly refreshes the token when expired' do + stub_generate_token_response(1.hour.from_now.to_i) + stub_request_suggestions + subject.suggest('100 Main') + expect(subject.token_valid?).to be(true) + + travel 2.hours + expect(subject.token_valid?).to be(false) + + stub_generate_token_response + stub_request_suggestions + subject.suggest('100 Main') + expect(subject.token_valid?).to be(true) + end + end end diff --git a/spec/support/arcgis_api_helper.rb b/spec/support/arcgis_api_helper.rb index 44859656c7a..af139a2f424 100644 --- a/spec/support/arcgis_api_helper.rb +++ b/spec/support/arcgis_api_helper.rb @@ -39,4 +39,28 @@ def stub_request_candidates_error headers: { content_type: 'application/json;charset=UTF-8' } ) end + + def stub_generate_token_response(expires_at = 1.hour.from_now.to_i) + stub_request(:post, %r{/generateToken}).to_return( + status: 200, body: { + token: 'abc123', + expires: expires_at, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } + ) + end + + def stub_invalid_token_response + stub_request(:get, %r{/suggest}).to_return( + status: 200, body: { + error: { + code: 498, + message: 'Invalid Token', + details: [], + }, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } + ) + end end