Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
df007b7
test
dawei-nava Jun 2, 2023
c253ded
LG-9449: refresh token
dawei-nava Jun 6, 2023
144f2c5
LG-9449: WIP
dawei-nava Jun 9, 2023
d96e22d
LG-9449: WIP
dawei-nava Jun 9, 2023
1dfabbc
LG-9449: refactor
dawei-nava Jun 9, 2023
0c4e05c
LG-9449: format
dawei-nava Jun 9, 2023
ec69df9
LG-9449: cleanup and test
dawei-nava Jun 9, 2023
8535941
LG-9449: clean up.
dawei-nava Jun 9, 2023
73a8ee9
LG-9449: job config and test.
dawei-nava Jun 9, 2023
187bc92
LG-9449: remove test change
dawei-nava Jun 9, 2023
ef74c4b
LG-9449: lint and new event.
dawei-nava Jun 9, 2023
7857c2f
LG-9449: cleanup geocoder
dawei-nava Jun 9, 2023
29196e7
LG-9449: fix test mock
dawei-nava Jun 10, 2023
ef8c07c
LG-9449: minor refactor and cleanup.
dawei-nava Jun 12, 2023
4a1154a
LG-9449: test fixes.
dawei-nava Jun 12, 2023
6562861
LG-9449: address comment
dawei-nava Jun 12, 2023
7d4f6ae
LG-9449: address comment
dawei-nava Jun 13, 2023
a6a8caa
LG-9449: address comment
dawei-nava Jun 14, 2023
2d7e545
LG-9449: code climate
dawei-nava Jun 14, 2023
feb10fa
LG-9449: minor change.
dawei-nava Jun 14, 2023
69b8f4f
LG-9449: job refactor and comment work.
dawei-nava Jun 14, 2023
b980494
LG-9449: address comment on test.
dawei-nava Jun 14, 2023
18a931f
LG-9449: address comment, refactor and test.
dawei-nava Jun 16, 2023
6379866
LG-9449: address comment
dawei-nava Jun 16, 2023
fe2428e
LG-9449: address comment
dawei-nava Jun 16, 2023
d7024d6
LG-9449: address comment
dawei-nava Jun 16, 2023
5fb9933
LG-9449: address comment
dawei-nava Jun 16, 2023
d9ad542
LG-9449: lint issue.
dawei-nava Jun 16, 2023
b903397
LG-9449: exception type.
dawei-nava Jun 16, 2023
df19d25
LG-9449: address comments.
dawei-nava Jun 17, 2023
70cd2b7
LG-9449: customized error.
dawei-nava Jun 17, 2023
0f6d3a8
Merge branch 'main' of https://github.com/18F/identity-idp into dwang…
NavaTim Jun 20, 2023
d932d18
LG-9449: Remove unused suggest method and tests
NavaTim Jun 20, 2023
d7a98f7
LG-9449: Remove puts usage
NavaTim Jun 21, 2023
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
27 changes: 27 additions & 0 deletions app/jobs/arcgis_token_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class ArcgisTokenJob < ApplicationJob
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be more accurate to call this ArcgisRefreshTokenJob?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svalexander, will in effect it's refresh the token, but it does not check whether the token need to be refreshed and replace with a new one anyway.

queue_as :default

def perform
analytics.idv_arcgis_token_job_started
token_entry = token_keeper.retrieve_token
token_keeper.save_token(token_entry, token_entry.expires_at)
return true
ensure
analytics.idv_arcgis_token_job_completed
end

private

def token_keeper
ArcgisApi::TokenKeeper.new
end

def analytics
@analytics ||= Analytics.new(
user: AnonymousUser.new,
request: nil,
session: {},
sp: nil,
)
end
end
39 changes: 39 additions & 0 deletions app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,45 @@ def idv_arcgis_request_failure(
)
end

# Tracks if request to get auth token from ArcGIS fails
# @param [String] exception_class
# @param [String] exception_message
# @param [Boolean] response_body_present
# @param [Hash] response_body
# @param [Integer] response_status_code
def idv_arcgis_token_failure(
exception_class:,
exception_message:,
response_body_present:,
response_body:,
response_status_code:,
**extra
)
track_event(
'Request ArcGIS Token: request failed',
exception_class: exception_class,
exception_message: exception_message,
response_body_present: response_body_present,
response_body: response_body,
response_status_code: response_status_code,
**extra,
)
end

# Track when token job ended
def idv_arcgis_token_job_completed
track_event(
'ArcgisTokenJob: Completed',
)
end

# Track when token job started
def idv_arcgis_token_job_started
track_event(
'ArcgisTokenJob: Started',
)
end

# @param [String] step the step that the user was on when they clicked cancel
# @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user confirmed their choice to cancel going through IDV
Expand Down
22 changes: 22 additions & 0 deletions app/services/arcgis_api/connection_factory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module ArcgisApi
# Stateless and thread-safe factory object that create a new Faraday::Connection object.
class ConnectionFactory
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this class? If there a reason we couldn't reuse geocoder.faraday?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To easily inject a mock one that stub request on connection for testing purpose, also customize faraday middleware behaviours.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW there is test support that allows you to simulate HTTP interactions, making mocks of HTTP libraries like Faraday unnecessary (see the ArcgisApiHelper). That said I like the separation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@allthesignals , good point, this was to mock on faraday level, webmock covers much more. It was adapted to the mock geocoder used in feature test. Let me c whether I can handle it better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ambivalent to the existence of this class. There are two effective usages of this (excluding suggest which is not currently used).

# @param [String|URI] url
# @options [Hash] Faraday connection options
# @return Faraday::Connection
def connection(url = nil, options = {})
conn_options = options ? options.dup : {}
Faraday.new(url, conn_options) do |conn|
# Log request metrics
conn.request :instrumentation, name: 'request_metric.faraday'
conn.options.timeout = IdentityConfig.store.arcgis_api_request_timeout_seconds
# Parse JSON responses
conn.response :json, content_type: 'application/json'
# Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses
# Note: The order of this matters for parsing the error response body.
conn.response :raise_error
yield conn if block_given?
end
end
end
end
85 changes: 7 additions & 78 deletions app/services/arcgis_api/geocoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ class Geocoder
keyword_init: true
)
Location = Struct.new(:latitude, :longitude, keyword_init: true)
API_TOKEN_HOST = URI(IdentityConfig.store.arcgis_api_generate_token_url).host
API_TOKEN_CACHE_KEY = "arcgis_api_token:#{API_TOKEN_HOST}"

# These are option URL params that tend to apply to multiple endpoints
# https://developers.arcgis.com/rest/geocode/api-reference/geocoding-find-address-candidates.htm#ESRI_SECTION2_38613C3FCB12462CAADD55B2905140BF
Expand Down Expand Up @@ -41,24 +39,10 @@ class Geocoder
:SingleLine, # Unvalidated address-like text string used to search for geocoded addresses
]

# Makes an HTTP request to quickly find potential address matches. Each match that is found
# will include an associated magic_key value which can later be used to get more details about
# the address using the #find_address_candidates method
# Requests text input and will only match possible addresses
# A maximum of 5 suggestions are included in the suggestions array.
# @param text [String]
# @return [Array<Suggestion>] Suggestions
def suggest(text)
params = {
text: text,
**COMMON_DEFAULT_PARAMETERS,
}
private attr_accessor :token_keeper

parse_suggestions(
faraday.get(IdentityConfig.store.arcgis_api_suggest_url, params, dynamic_headers) do |req|
req.options.context = { service_name: 'arcgis_geocoder_suggest' }
end.body,
)
def initialize(token_keeper: TokenKeeper.new)
@token_keeper = token_keeper
end

# Makes HTTP request to find a full address record using a magic key or single text line
Expand All @@ -83,68 +67,34 @@ def find_address_candidates(**options)
}

parse_address_candidates(
faraday.get(
IdentityConfig.store.arcgis_api_find_address_candidates_url, params,
dynamic_headers
connection.get(
IdentityConfig.store.arcgis_api_find_address_candidates_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] Auth token
def retrieve_token!
token, expires = request_token.fetch_values('token', 'expires')
expires_at = Time.zone.at(expires / 1000)
Rails.cache.write(API_TOKEN_CACHE_KEY, token, expires_at: expires_at)
# If using a redis cache we have to manually set the expires_at. This is because we aren't
# using a dedicated Redis cache and instead are just using our existing Redis server with
# mixed usage patterns. Without this cache entries don't expire.
# More at https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html
Rails.cache.try(:redis)&.expireat(API_TOKEN_CACHE_KEY, expires_at.to_i)
token
end

# Checks the cache for an unexpired token and returns that.
# If the cache has expired, retrieves a new token and returns it
# @return [String] Auth token
def token
Rails.cache.read(API_TOKEN_CACHE_KEY) || retrieve_token!
token_keeper&.token
end

private

def faraday
def connection
Faraday.new do |conn|
# Log request metrics
conn.request :instrumentation, name: 'request_metric.faraday'

conn.options.timeout = IdentityConfig.store.arcgis_api_request_timeout_seconds

# Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses
# Note: The order of this matters for parsing the error response body.
conn.response :raise_error

# Parse JSON responses
conn.response :json, content_type: 'application/json'

yield conn if block_given?
end
end

def parse_suggestions(response_body)
handle_api_errors(response_body)

response_body['suggestions'].map do |suggestion|
Suggestion.new(
text: suggestion['text'],
magic_key: suggestion['magicKey'],
)
end
end

def parse_address_candidates(response_body)
handle_api_errors(response_body)

Expand Down Expand Up @@ -180,7 +130,6 @@ def handle_api_errors(response_body)
response_status_code: error_code,
api_status_code: error_code,
)
Rails.cache.delete(API_TOKEN_CACHE_KEY) # this might only be needed for local testing
raise Faraday::ClientError.new(
RuntimeError.new(error_message),
{
Expand All @@ -199,26 +148,6 @@ def dynamic_headers
{ '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
body = {
username: IdentityConfig.store.arcgis_api_username,
password: IdentityConfig.store.arcgis_api_password,
referer: IdentityConfig.store.domain_name,
f: 'json',
}

faraday.post(
IdentityConfig.store.arcgis_api_generate_token_url, URI.encode_www_form(body)
) do |req|
req.options.context = { service_name: 'arcgis_token' }
end.body.tap do |body|
handle_api_errors(body)
end
end

def analytics(user: AnonymousUser.new)
Analytics.new(user: user, request: nil, session: {}, sp: nil)
end
Expand Down
59 changes: 51 additions & 8 deletions app/services/arcgis_api/mock/geocoder.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,56 @@
module ArcgisApi
module Mock
class TokenKeeper < ArcgisApi::TokenKeeper
def connection
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
stub_generate_token(stub)
end
super do |con|
con.adapter :test, stubs
end
end

private

def stub_generate_token(stub)
stub.post(IdentityConfig.store.arcgis_api_generate_token_url) do |env|
[
200,
{ 'Content-Type': 'application/json' },
{
token: '1234',
expires: (Time.zone.now + 1.minute).to_f * 1000,
ssl: true,
}.to_json,
]
end
end
end

class Geocoder < ArcgisApi::Geocoder
def faraday
super do |conn|
conn.adapter :test do |stub|
stub_generate_token(stub)
stub_suggestions(stub)
stub_address_candidates(stub)
end
# def faraday
# super do |conn|
# conn.adapter :test do |stub|
# stub_generate_token(stub)
# stub_suggestions(stub)
# stub_address_candidates(stub)
# end
# end
# end

def initialize
token_keeper = TokenKeeper.new
super(token_keeper: token_keeper)
end

def connection
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
stub_generate_token(stub)
stub_suggestions(stub)
stub_address_candidates(stub)
end
super do |con|
con.adapter :test, stubs
end
end

Expand All @@ -20,7 +63,7 @@ def stub_generate_token(stub)
{ 'Content-Type': 'application/json' },
{
token: '1234',
expires: 1234,
expires: (Time.zone.now + 30.seconds).to_f * 1000,
ssl: true,
}.to_json,
]
Expand Down
26 changes: 26 additions & 0 deletions app/services/arcgis_api/response_validation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module ArcgisApi
# Faraday middleware to raise exception when response status is 200 but with error in body
class ResponseValidation < Faraday::Middleware
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to keep the response logic closer to where the request is made?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mitchellhenke when a new connection is created on demand, this middleware is registered with the connection for response processing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, sorry, I understand how it works, but if this is specific to one API, could we pull it into the class that's making those requests rather than separating it across files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mitchellhenke , we use farady-retry for automatic retrying, if we handle this after response returned to client, I think it's too late to trigger retry with farady-retry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's possible to have similar logic for retries when making the request, and I think it'd be better to move it there instead of having a separate class.

Copy link
Copy Markdown
Contributor Author

@dawei-nava dawei-nava Jun 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mitchellhenke not exactly, for invalid response with 200 http status code, once the response is returned to caller, it's out of faraday middleware chain, and wont be able to utilize faraday-retry gem which works as a faraday middleware and we have to write our own retry mechanisms(bookkeeping, counting, making new requests the whole nine yard that's implemented by farady-retry).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mitchellhenke I think that using this as a separate middleware class is acceptable given the way ESRI itself handles this in its arcgis-rest-js library. That said, I think it could have somewhat better documentation.

def on_complete(env)
return unless env[:status] == 200 && env.body
body = env.body.is_a?(String) ? JSON.parse(env.body) : env.body
return unless body.fetch('error', false)
handle_api_errors(body)
end

def handle_api_errors(response_body)
# response_body is in this format:
# {"error"=>{"code"=>400, "message"=>"", "details"=>[""]}}
error_code = response_body.dig('error', 'code')
error_message = response_body.dig('error', 'message') || "Received error code #{error_code}"
# log an error
raise ArcgisApi::InvalidResponseError.new(
Faraday::Error.new(error_message),
{
status: error_code,
body: { details: response_body.dig('error', 'details')&.join(', ') },
},
)
end
end
end
Loading