Skip to content

Commit 3b9d3e5

Browse files
committed
Merge pull request #343 from alphagov/fix-encoding-errors-with-constituency-api
Refactor constituency api to eliminate encoding errors
2 parents ebc67fb + a676702 commit 3b9d3e5

6 files changed

Lines changed: 128 additions & 74 deletions

File tree

app/controllers/local_petitions_controller.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def postcode?
2222
end
2323

2424
def find_constituency
25-
@constituency = ConstituencyApi::Client.constituency(@postcode)
26-
rescue ConstituencyApi::Error => e
27-
Rails.logger.error("Failed to fetch constituency - #{e}")
25+
@constituency = ConstituencyApi.constituency(@postcode)
2826
end
2927

3028
def constituency?

app/lib/constituency_api.rb

Lines changed: 88 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
require 'faraday'
2+
require 'nokogiri'
13
require 'postcode_sanitizer'
24

35
module ConstituencyApi
4-
class Error < RuntimeError; end
5-
66
class Constituency
77
attr_reader :id, :name, :mp
88

@@ -18,73 +18,121 @@ def ==(other)
1818

1919
class Mp
2020
attr_reader :id, :name, :start_date
21-
URL = "http://www.parliament.uk/biographies/commons"
21+
URL = "http://www.parliament.uk/biographies/commons/%{name}/%{id}"
2222

2323
def initialize(id, name, start_date)
2424
@id, @name, @start_date = id, name, start_date.to_date
2525
end
2626

2727
def url
28-
"#{URL}/#{name.parameterize}/#{id}"
28+
URL % { name: name.parameterize, id: id }
2929
end
3030

3131
def ==(other)
32-
other.is_a?(self.class) && id == other.id && name == other.name && start_date && other.start_date
32+
other.is_a?(self.class) && id == other.id && name == other.name && start_date == other.start_date
3333
end
3434
alias_method :eql?, :==
3535
end
3636

3737
class Client
38-
include Faraday
39-
URL = 'http://data.parliament.uk/membersdataplatform/services/mnis/Constituencies'
38+
API_HOST = 'http://data.parliament.uk'
39+
API_ENDPOINT = '/membersdataplatform/services/mnis/Constituencies/%{postcode}/'
4040
TIMEOUT = 5
4141

42-
def self.constituency(postcode)
43-
constituencies(postcode).first
42+
def call(postcode)
43+
faraday.get(path(postcode)) do |request|
44+
request.options[:timeout] = TIMEOUT
45+
request.options[:open_timeout] = TIMEOUT
46+
end
4447
end
4548

46-
def self.constituencies(postcode)
47-
response = call_api(postcode)
48-
parse_constituencies(response)
49+
private
50+
51+
def faraday
52+
Faraday.new(API_HOST) do |f|
53+
f.response :follow_redirects
54+
f.response :raise_error
55+
f.adapter Faraday.default_adapter
56+
end
4957
end
5058

51-
def self.parse_constituencies(response)
52-
return [] unless response["Constituencies"]
53-
constituencies = response["Constituencies"]["Constituency"]
54-
Array.wrap(constituencies).map { |c| Constituency.new(c["Constituency_Id"], c["Name"], last_mp(c)) }
59+
def path(postcode)
60+
API_ENDPOINT % { postcode: escape_path(postcode) }
5561
end
5662

57-
def self.call_api(postcode)
58-
sanitized_postcode = PostcodeSanitizer.call(postcode)
59-
response = Faraday.new(URL).get("#{sanitized_postcode}/") do |req|
60-
req.options[:timeout] = TIMEOUT
61-
req.options[:open_timeout] = TIMEOUT
62-
end
63-
unless response.status == 200
64-
raise Error.new("Unexpected response from API:"\
65-
"status #{response.status}"\
66-
"body #{response.body}"\
67-
"request #{URL}/#{sanitized_postcode}/")
63+
def escape_path(value)
64+
Rack::Utils.escape_path(value)
65+
end
66+
end
67+
68+
class Query
69+
CONSTITUENCIES = '//Constituencies/Constituency'
70+
CONSTITUENCY_ID = './Constituency_Id'
71+
CONSTITUENCY_NAME = './Name'
72+
73+
CURRENT_MP = './RepresentingMembers/RepresentingMember[1]'
74+
MP_ID = './Member_Id'
75+
MP_NAME = './Member'
76+
MP_DATE = './StartDate'
77+
78+
def initialize(postcode)
79+
@postcode = postcode
80+
end
81+
82+
def fetch
83+
response = client.call(postcode)
84+
85+
if response.success?
86+
parse(response.body)
87+
else
88+
[]
6889
end
69-
Hash.from_xml(response.body)
70-
rescue Faraday::Error::TimeoutError
71-
raise Error.new("Timeout after #{TIMEOUT} seconds")
90+
rescue Faraday::Error::ResourceNotFound => e
91+
return []
7292
rescue Faraday::Error => e
73-
raise Error.new("Network error - #{e}")
93+
Appsignal.send_exception(e) if defined?(Appsignal)
94+
return []
7495
end
7596

76-
def self.last_mp(constituency_hash)
77-
mps = parse_mps(constituency_hash)
78-
mps.select(&:start_date).sort_by(&:start_date).last
97+
private
98+
99+
def client
100+
@client ||= Client.new
79101
end
80102

81-
def self.parse_mps(response)
82-
return [] unless response["RepresentingMembers"]
83-
mps = response["RepresentingMembers"]["RepresentingMember"]
84-
Array.wrap(mps).map { |m| Mp.new(m["Member_Id"], m["Member"], m["StartDate"]) }
103+
def postcode
104+
PostcodeSanitizer.call(@postcode)
85105
end
86106

87-
private_class_method :parse_constituencies, :call_api, :parse_mps, :last_mp
107+
def parse(body)
108+
xml = Nokogiri::XML(body)
109+
110+
xml.xpath(CONSTITUENCIES).map do |node|
111+
id = node.xpath(CONSTITUENCY_ID).text
112+
name = node.xpath(CONSTITUENCY_NAME).text
113+
114+
if mp = node.at_xpath(CURRENT_MP)
115+
Constituency.new(id, name, parse_mp(mp))
116+
else
117+
Constituency.new(id, name)
118+
end
119+
end
120+
end
121+
122+
def parse_mp(node)
123+
id = node.xpath(MP_ID).text
124+
name = node.xpath(MP_NAME).text
125+
date = node.xpath(MP_DATE).text
126+
127+
Mp.new(id, name, date)
128+
end
129+
end
130+
131+
def self.constituency(postcode)
132+
constituencies(postcode).first
88133
end
89-
end
90134

135+
def self.constituencies(postcode)
136+
Query.new(postcode).fetch
137+
end
138+
end

app/models/signature.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,7 @@ def invalid_unsubscribe_token?
130130
end
131131

132132
def constituency
133-
@constituency ||= ConstituencyApi::Client.constituencies(self.postcode).first
134-
rescue ConstituencyApi::Error => e
135-
Rails.logger.error("Failed to fetch constituency - #{e}")
136-
nil
133+
@constituency ||= ConstituencyApi.constituency(postcode)
137134
end
138135

139136
def set_constituency_id

spec/controllers/local_petitions_controller_spec.rb

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

6969
shared_examples_for 'a local petitions controller that does not try to lookup a constituency' do
7070
it 'does not communicate with the API' do
71-
expect(ConstituencyApi::Client).not_to receive(:constituency)
71+
expect(ConstituencyApi).not_to receive(:constituency)
7272
get :index, params
7373
end
7474

spec/lib/constituency_api_spec.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
let(:mp) { ConstituencyApi::Mp.new("1536", "Emily Thornberry MP", Date.new(2015, 5, 7)) }
1515

1616
it "returns the URL for the mp" do
17-
expect(mp.url).to eq "#{ConstituencyApi::Mp::URL}/emily-thornberry-mp/1536"
17+
expect(mp.url).to eq "http://www.parliament.uk/biographies/commons/emily-thornberry-mp/1536"
1818
end
1919
end
2020
end
2121

2222
describe "query methods" do
23-
let(:api) { ConstituencyApi::Client }
23+
let(:api) { ConstituencyApi }
2424
let(:url) { "http://data.parliament.uk" }
2525
let(:endpoint) { "#{url}/membersdataplatform/services/mnis/Constituencies" }
2626

@@ -162,8 +162,8 @@ def constituency(id, name, mp = nil, &block)
162162
stub_request(:get, /.*data.parliament.uk.*/).to_timeout
163163
end
164164

165-
it "raises a ConstituencyApi::Error" do
166-
expect{ api.constituencies("N1") }.to raise_error(ConstituencyApi::Error)
165+
it "returns an empty array" do
166+
expect(api.constituencies("N1")).to eq([])
167167
end
168168
end
169169

@@ -172,8 +172,8 @@ def constituency(id, name, mp = nil, &block)
172172
stub_request(:get, /.*data.parliament.uk.*/).to_raise(Faraday::Error::ConnectionFailed)
173173
end
174174

175-
it "raises a ConstituencyApi::Error" do
176-
expect{ api.constituencies("N1") }.to raise_error(ConstituencyApi::Error)
175+
it "returns an empty array" do
176+
expect(api.constituencies("N1")).to eq([])
177177
end
178178
end
179179

@@ -182,8 +182,8 @@ def constituency(id, name, mp = nil, &block)
182182
stub_request(:get, /.*data.parliament.uk.*/).to_raise(Faraday::Error::ResourceNotFound)
183183
end
184184

185-
it "raises a ConstituencyApi::Error" do
186-
expect{ api.constituencies("N1") }.to raise_error(ConstituencyApi::Error)
185+
it "returns an empty array" do
186+
expect(api.constituencies("N1")).to eq([])
187187
end
188188
end
189189

@@ -192,8 +192,8 @@ def constituency(id, name, mp = nil, &block)
192192
stub_request(:get, /.*data.parliament.uk.*/).to_return(status: 500, body: "<Constituencies/>")
193193
end
194194

195-
it "raises a ConstituencyApi::Error" do
196-
expect{ api.constituencies("N1") }.to raise_error(ConstituencyApi::Error)
195+
it "returns an empty array" do
196+
expect(api.constituencies("N1")).to eq([])
197197
end
198198
end
199199
end

spec/support/constituency_api_helpers.rb

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,48 @@ def stub_constituency(postcode, *constituency_params)
99
else
1010
ConstituencyApi::Constituency.new(*constituency_params)
1111
end
12-
allow(ConstituencyApi::Client).to receive(:constituencies).
13-
with(PostcodeSanitizer.call(postcode)).
14-
and_return([constituency])
12+
allow(ConstituencyApi).to receive(:constituencies).
13+
with(PostcodeSanitizer.call(postcode)).
14+
and_return([constituency])
1515
end
1616

1717
def stub_constituencies(partial_postcode, *constituencies)
18-
allow(ConstituencyApi::Client).to receive(:constituencies).
19-
with(PostcodeSanitizer.call(partial_postcode)).
20-
and_return(constituencies)
18+
allow(ConstituencyApi).to receive(:constituencies).
19+
with(PostcodeSanitizer.call(partial_postcode)).
20+
and_return(constituencies)
2121

2222
end
2323

2424
def stub_no_constituencies(postcode)
25-
allow(ConstituencyApi::Client).to receive(:constituencies).
26-
with(PostcodeSanitizer.call(postcode)).
27-
and_return([])
25+
allow(ConstituencyApi).to receive(:constituencies).
26+
with(PostcodeSanitizer.call(postcode)).
27+
and_return([])
2828
end
2929

3030
def stub_broken_api
31-
allow(ConstituencyApi::Client).to receive(:constituencies).
32-
and_raise(ConstituencyApi::Error)
31+
allow(ConstituencyApi).to receive(:constituencies).and_return([])
3332
end
3433
end
3534

3635
module NetworkLevel
37-
def api_url
38-
ConstituencyApi::Client::URL
36+
def api_host
37+
ConstituencyApi::Client::API_HOST
38+
end
39+
40+
def api_endpoint
41+
ConstituencyApi::Client::API_ENDPOINT
42+
end
43+
44+
def api_url(postcode)
45+
"#{api_host}#{api_endpoint}" % { postcode: sanitize_and_escape_postcode(postcode) }
46+
end
47+
48+
def sanitize_and_escape_postcode(postcode)
49+
Rack::Utils.escape_path(PostcodeSanitizer.call(postcode))
3950
end
4051

4152
def stub_constituency_from_file(postcode, filename)
42-
stub_request(:get, "#{ api_url }/#{PostcodeSanitizer.call(postcode)}/").to_return(status: 200, body: IO.read(filename))
53+
stub_request(:get, api_url(postcode)).to_return(status: 200, body: IO.read(filename))
4354
end
4455

4556
def stub_constituency(postcode, constituency_id, constituency_name, mp_id: '0001', mp_name: 'A. N. Other MP', mp_start_date: '2015-05-07T00:00:00')
@@ -61,15 +72,15 @@ def stub_constituency(postcode, constituency_id, constituency_name, mp_id: '0001
6172
</Constituencies>
6273
RESPONSE
6374

64-
stub_request(:get, "#{ api_url }/#{PostcodeSanitizer.call(postcode)}/").to_return(status: 200, body: api_response)
75+
stub_request(:get, api_url(postcode)).to_return(status: 200, body: api_response)
6576
end
6677

6778
def stub_no_constituencies(postcode)
6879
stub_constituency_from_file(postcode, Rails.root.join("spec", "fixtures", "constituency_api", "no_results.xml"))
6980
end
7081

7182
def stub_broken_api
72-
stub_request(:get, %r[#{ Regexp.escape(api_url) }/*]).to_return(status: 500)
83+
stub_request(:get, %r[#{api_host}/*]).to_return(status: 500)
7384
end
7485
end
7586
end

0 commit comments

Comments
 (0)