Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions app/services/arcgis_api/geocoder.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,14 +25,14 @@ class Geocoder
# @param text [String]
# @return [Array<Suggestion>] 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,
)
Expand All @@ -40,28 +42,41 @@ def suggest(text)
# @param magic_key [String] a magic key value from a previous call to the #suggest method
# @return [Array<AddressCandidate>] 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',
**COMMON_DEFAULT_PARAMETERS,
}

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
IdentityConfig.store.arcgis_api_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'

Expand All @@ -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)

Expand All @@ -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'),
Expand All @@ -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
5 changes: 3 additions & 2 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions spec/services/arcgis_api/geocoder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions spec/support/arcgis_api_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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