Skip to content

Commit a1b3bb9

Browse files
committed
Add Geocoding services
1 parent 1adf85b commit a1b3bb9

6 files changed

Lines changed: 518 additions & 2 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
katalyst-google-apis (1.2.3)
4+
katalyst-google-apis (1.3.0)
55
activesupport
66
aws-sdk-core
77
curb
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
require "curb"
4+
5+
module Katalyst
6+
module GoogleApis
7+
module Geocoding
8+
# Use Google Maps Geocoding API to find a location from lat/lng.
9+
class ReverseService
10+
attr_reader :response, :result, :error
11+
12+
def self.scope
13+
"https://www.googleapis.com/auth/maps-platform.geocode.location"
14+
end
15+
16+
def self.call(latlng:, credentials: Katalyst::GoogleApis.credentials(scope:))
17+
new(credentials:).call(latlng:)
18+
end
19+
20+
def initialize(credentials:)
21+
@credentials = credentials
22+
end
23+
24+
def call(latlng:)
25+
@latlng = latlng
26+
27+
@response = Curl.get(url) do |http|
28+
http.headers["Content-Type"] = "application/json; UTF-8"
29+
@credentials.apply!(http.headers)
30+
end
31+
32+
if %r{^application/json}.match?(@response.content_type)
33+
@result = JSON.parse(response.body, symbolize_names: true)
34+
else
35+
raise GoogleApis::Error.new(
36+
code: response.response_code,
37+
status: Rack::Utils::HTTP_STATUS_CODES[response.response_code],
38+
message: "Unexpected HTTP response received (#{response.response_code}, #{@response.content_type})",
39+
)
40+
end
41+
42+
if result[:error].present?
43+
api_error = result.fetch(:error)
44+
45+
raise GoogleApis::Error.new(
46+
code: api_error.fetch(:code, response.response_code),
47+
status: api_error.fetch(:status, Rack::Utils::HTTP_STATUS_CODES[response.response_code]),
48+
message: api_error.fetch(:message, "Unexpected API error"),
49+
details: api_error.fetch(:details, nil),
50+
)
51+
end
52+
53+
self
54+
rescue StandardError => e
55+
@error = e
56+
raise
57+
ensure
58+
report_error
59+
end
60+
61+
def locations
62+
@result&.fetch(:results, nil)
63+
end
64+
65+
def first_location
66+
locations&.first
67+
end
68+
69+
def formatted_address
70+
first_location&.dig(:formattedAddress)
71+
end
72+
73+
def latlng
74+
location = first_location&.dig(:location)
75+
return if location.blank?
76+
77+
latitude = location[:latitude]
78+
longitude = location[:longitude]
79+
return if latitude.nil? || longitude.nil?
80+
81+
[latitude, longitude].join(",")
82+
end
83+
84+
def inspect
85+
"#<#{self.class.name} result: #{@result.inspect} error: #{@error.inspect}>"
86+
end
87+
88+
private
89+
90+
def url
91+
"https://geocode.googleapis.com/v4beta/geocode/location/#{@latlng}"
92+
end
93+
94+
def report_error
95+
return if error.nil?
96+
97+
if defined?(Sentry)
98+
Sentry.add_breadcrumb(sentry_breadcrumb(error))
99+
else
100+
Rails.logger.error(error)
101+
end
102+
end
103+
104+
def sentry_breadcrumb(error)
105+
Sentry::Breadcrumb.new(
106+
type: "http",
107+
category: "geocode",
108+
data: {
109+
url:,
110+
method: "GET",
111+
status_code: error.try(:code),
112+
reason: error.message,
113+
},
114+
)
115+
end
116+
end
117+
end
118+
end
119+
end
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# frozen_string_literal: true
2+
3+
require "curb"
4+
5+
module Katalyst
6+
module GoogleApis
7+
module Geocoding
8+
# Use Google Maps Geocoding API to find a location from an address.
9+
class SearchService
10+
attr_reader :response, :result, :error
11+
12+
def self.scope
13+
"https://www.googleapis.com/auth/maps-platform.geocode.address"
14+
end
15+
16+
def self.call(address:, bounds:, credentials: Katalyst::GoogleApis.credentials(scope:))
17+
new(credentials:).call(address:, bounds:)
18+
end
19+
20+
def initialize(credentials:)
21+
@credentials = credentials
22+
end
23+
24+
def call(address:, bounds:)
25+
@address = address
26+
@bounds = bounds
27+
28+
@response = Curl.get(url, **params) do |http|
29+
http.headers["Content-Type"] = "application/json; UTF-8"
30+
@credentials.apply!(http.headers)
31+
end
32+
33+
if %r{^application/json}.match?(@response.content_type)
34+
@result = JSON.parse(response.body, symbolize_names: true)
35+
else
36+
raise GoogleApis::Error.new(
37+
code: response.response_code,
38+
status: Rack::Utils::HTTP_STATUS_CODES[response.response_code],
39+
message: "Unexpected HTTP response received (#{response.response_code}, #{@response.content_type})",
40+
)
41+
end
42+
43+
if result[:error].present?
44+
api_error = result.fetch(:error)
45+
46+
raise GoogleApis::Error.new(
47+
code: api_error.fetch(:code, response.response_code),
48+
status: api_error.fetch(:status, Rack::Utils::HTTP_STATUS_CODES[response.response_code]),
49+
message: api_error.fetch(:message, "Unexpected API error"),
50+
details: api_error.fetch(:details, nil),
51+
)
52+
end
53+
54+
self
55+
rescue StandardError => e
56+
@error = e
57+
raise
58+
ensure
59+
report_error
60+
end
61+
62+
def locations
63+
@result&.fetch(:results, nil)
64+
end
65+
66+
def first_location
67+
locations&.first
68+
end
69+
70+
def formatted_address
71+
first_location&.dig(:formattedAddress)
72+
end
73+
74+
def latlng
75+
location = first_location&.dig(:location)
76+
return if location.blank?
77+
78+
latitude = location[:latitude]
79+
longitude = location[:longitude]
80+
return if latitude.nil? || longitude.nil?
81+
82+
[latitude, longitude].join(",")
83+
end
84+
85+
def inspect
86+
"#<#{self.class.name} result: #{@result.inspect} error: #{@error.inspect}>"
87+
end
88+
89+
private
90+
91+
def url
92+
"https://geocode.googleapis.com/v4beta/geocode/address/#{URI.encode_uri_component(@address)}"
93+
end
94+
95+
def params
96+
low, high = @bounds.split("|")
97+
98+
low_lat, low_lng = low.split(",")
99+
high_lat, high_lng = high.split(",")
100+
101+
{
102+
"locationBias.rectangle.low.latitude" => low_lat,
103+
"locationBias.rectangle.low.longitude" => low_lng,
104+
"locationBias.rectangle.high.latitude" => high_lat,
105+
"locationBias.rectangle.high.longitude" => high_lng,
106+
}
107+
end
108+
109+
def report_error
110+
return if error.nil?
111+
112+
if defined?(Sentry)
113+
Sentry.add_breadcrumb(sentry_breadcrumb(error))
114+
else
115+
Rails.logger.error(error)
116+
end
117+
end
118+
119+
def sentry_breadcrumb(error)
120+
Sentry::Breadcrumb.new(
121+
type: "http",
122+
category: "geocode",
123+
data: {
124+
url:,
125+
method: "GET",
126+
status_code: error.try(:code),
127+
reason: error.message,
128+
},
129+
)
130+
end
131+
end
132+
end
133+
end
134+
end

katalyst-google-apis.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Gem::Specification.new do |spec|
44
spec.name = "katalyst-google-apis"
5-
spec.version = "1.2.3"
5+
spec.version = "1.3.0"
66
spec.authors = ["Katalyst Interactive"]
77
spec.email = ["[email protected]"]
88

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Katalyst::GoogleApis::Geocoding::ReverseService do
6+
subject(:action) { described_class.call(latlng:, credentials:) }
7+
8+
let(:latlng) { "-34.9172454,138.62046709999998" }
9+
let(:credentials) { instance_double(Katalyst::GoogleApis::Credentials) }
10+
let(:response) do
11+
{ results: [
12+
{ place: "//places.googleapis.com/places/ChIJVVVxcUjJsGoRJZhZ-6cVzgg",
13+
placeId: "ChIJVVVxcUjJsGoRJZhZ-6cVzgg",
14+
location: { latitude: -34.9172326, longitude: 138.6204668 },
15+
granularity: "ROOFTOP",
16+
viewport:
17+
{ low: { latitude: -34.9185815802915, longitude: 138.6191178197085 },
18+
high: { latitude: -34.9158836197085, longitude: 138.62181578029148 } },
19+
formattedAddress: "64 North Terrace, Kent Town SA 5067, Australia",
20+
postalAddress:
21+
{ regionCode: "AU",
22+
languageCode: "en",
23+
postalCode: "5067",
24+
administrativeArea: "SA",
25+
locality: "Kent Town",
26+
addressLines: ["64 North Terrace"] },
27+
addressComponents:
28+
[{ longText: "64", shortText: "64", types: ["street_number"] },
29+
{ longText: "North Terrace",
30+
shortText: "North Terrace",
31+
types: ["route"],
32+
languageCode: "en" },
33+
{ longText: "Kent Town",
34+
shortText: "Kent Town",
35+
types: ["locality", "political"],
36+
languageCode: "en" },
37+
{ longText: "The City of Norwood Payneham and St Peters",
38+
shortText: "Norwood Payneham and St Peters",
39+
types: ["administrative_area_level_2", "political"],
40+
languageCode: "en" },
41+
{ longText: "South Australia",
42+
shortText: "SA",
43+
types: ["administrative_area_level_1", "political"],
44+
languageCode: "en" },
45+
{ longText: "Australia",
46+
shortText: "AU",
47+
types: ["country", "political"],
48+
languageCode: "en" },
49+
{ longText: "5067", shortText: "5067", types: ["postal_code"] }],
50+
types: ["establishment", "point_of_interest"],
51+
plusCode:
52+
{ globalCode: "4QQW3JMC+45",
53+
compoundCode: "3JMC+45 Kent Town SA, Australia" } },
54+
] }
55+
end
56+
57+
before do
58+
allow(credentials).to receive(:apply!)
59+
end
60+
61+
def stub_api_request(status: 200, content_type: "application/json", response: self.response)
62+
stub_request(:get, /geocode.googleapis.com/).to_return(
63+
status:,
64+
headers: { "Content-Type" => content_type },
65+
body: response.is_a?(String) ? response : response.to_json,
66+
)
67+
end
68+
69+
it "sends request to reverse geocoding with latlng" do
70+
stub_api_request
71+
72+
action
73+
74+
expect(a_request(:get, "https://geocode.googleapis.com/v4beta/geocode/location/-34.9172454,138.62046709999998"))
75+
.to have_been_made.once
76+
end
77+
78+
it "extracts and exposes location details", :aggregate_failures do
79+
stub_api_request
80+
81+
expect(action).to have_attributes(
82+
formatted_address: "64 North Terrace, Kent Town SA 5067, Australia",
83+
latlng: "-34.9172326,138.6204668",
84+
)
85+
expect(action.locations).to be_an(Array)
86+
expect(action.first_location).to include(formattedAddress: "64 North Terrace, Kent Town SA 5067, Australia")
87+
end
88+
89+
it "raises service errors returned by the API" do
90+
stub_api_request(status: 400, response: {
91+
error: {
92+
code: 400,
93+
message: "Invalid latlng format",
94+
status: "INVALID_ARGUMENT",
95+
},
96+
})
97+
98+
expect do
99+
action
100+
end.to raise_error(having_attributes(code: 400, status: "INVALID_ARGUMENT", message: /Invalid latlng/))
101+
end
102+
103+
it "returns nil location details when no geocoding results are returned" do
104+
stub_api_request(response: { results: [] })
105+
106+
expect(action).to have_attributes(locations: [], first_location: nil, formatted_address: nil, latlng: nil)
107+
end
108+
109+
it "raises on non-json responses" do
110+
stub_api_request(status: 500, content_type: "text/plain", response: "")
111+
112+
expect { action }.to raise_error(having_attributes(code: 500, message: /Unexpected HTTP response/))
113+
end
114+
115+
it "raises on invalid JSON response bodies" do
116+
stub_api_request(response: "{ invalid")
117+
118+
expect { action }.to raise_error(JSON::ParserError)
119+
end
120+
121+
it "raises network errors" do
122+
stub_request(:get, /geocode.googleapis.com/).to_raise(Curl::Err::TimeoutError.new)
123+
124+
expect { action }.to raise_error(Curl::Err::TimeoutError)
125+
end
126+
end

0 commit comments

Comments
 (0)