london-bike-hire-cli 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +35 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +14 -0
  6. data/Gemfile +4 -0
  7. data/Guardfile +11 -0
  8. data/LICENSE +21 -0
  9. data/README.md +144 -0
  10. data/Rakefile +19 -0
  11. data/bin/lbh +5 -0
  12. data/boris-on-a-bike_med.jpg +0 -0
  13. data/lib/london_bike_hire_cli.rb +15 -0
  14. data/lib/london_bike_hire_cli/application.rb +71 -0
  15. data/lib/london_bike_hire_cli/basic_renderer.rb +38 -0
  16. data/lib/london_bike_hire_cli/controller.rb +82 -0
  17. data/lib/london_bike_hire_cli/feed_parser.rb +76 -0
  18. data/lib/london_bike_hire_cli/geocoding_adapter.rb +27 -0
  19. data/lib/london_bike_hire_cli/query_response.rb +22 -0
  20. data/lib/london_bike_hire_cli/spatial_search.rb +20 -0
  21. data/lib/london_bike_hire_cli/station.rb +16 -0
  22. data/lib/london_bike_hire_cli/station_adapter.rb +19 -0
  23. data/lib/london_bike_hire_cli/station_repository.rb +51 -0
  24. data/lib/london_bike_hire_cli/version.rb +3 -0
  25. data/london-bike-hire-cli.gemspec +37 -0
  26. data/spec/controller_spec.rb +186 -0
  27. data/spec/feed_parser_spec.rb +42 -0
  28. data/spec/fixtures/feed_xml.yml +822 -0
  29. data/spec/fixtures/n19ae_geocode.yml +114 -0
  30. data/spec/fixtures/no_results_geocode.yml +50 -0
  31. data/spec/geocoding_adapter_spec.rb +47 -0
  32. data/spec/spatial_search_spec.rb +27 -0
  33. data/spec/spec_helper.rb +83 -0
  34. data/spec/station_adapter_spec.rb +29 -0
  35. data/spec/station_respository_spec.rb +117 -0
  36. data/spec/station_spec.rb +28 -0
  37. data/spec/support/test_datasource.rb +24 -0
  38. data/spec/support/vcr.rb +7 -0
  39. data/test.rb +6 -0
  40. metadata +307 -0
@@ -0,0 +1,76 @@
1
+ require 'nokogiri'
2
+ require 'open-uri'
3
+
4
+ module LondonBikeHireCli
5
+ class FeedParser
6
+ TFL_FEED_URL = 'http://www.tfl.gov.uk/tfl/syndication/feeds/cycle-hire/livecyclehireupdates.xml'.freeze
7
+
8
+ def fetch
9
+ params = { 'User-Agent' => 'Mozilla/5.0 (Windows NT 5.1; rv:31.0) Gecko/20100101 Firefox/31.0' }
10
+ stations_doc = Nokogiri::XML(open(TFL_FEED_URL, params))
11
+
12
+ parse_xml(stations_doc)
13
+ end
14
+
15
+ private
16
+
17
+ attr_writer :stations
18
+
19
+ def parse_xml(xml_doc)
20
+ stations = {}
21
+
22
+ xml_doc.root.elements.each do |node|
23
+ station = parse_station(node)
24
+ station_id = station[:id].to_i
25
+ stations[station_id] = Station.new(station) unless station_id == 0
26
+ end
27
+ stations[:last_update] = parse_feed_time(xml_doc)
28
+
29
+ stations
30
+ end
31
+
32
+ def parse_feed_time(xml_doc)
33
+ Time.at xml_doc.root['lastUpdate'].to_i / 1000
34
+ end
35
+
36
+ # <stations lastUpdate="1407153481506" version="2.0">
37
+ # <station>
38
+ # <id>1</id>
39
+ # <name>River Street , Clerkenwell</name>
40
+ # <terminalName>001023</terminalName>
41
+ # <lat>51.52916347</lat>
42
+ # <long>-0.109970527</long>
43
+ # <installed>true</installed>
44
+ # <locked>false</locked>
45
+ # <installDate>1278947280000</installDate>
46
+ # <removalDate/>
47
+ # <temporary>false</temporary>
48
+ # <nbBikes>8</nbBikes>
49
+ # <nbEmptyDocks>11</nbEmptyDocks>
50
+ # <nbDocks>19</nbDocks>
51
+ # </station>
52
+ # ...
53
+ # </stations>
54
+
55
+ def parse_station(xml_node)
56
+ station = {}
57
+
58
+ xml_node.elements.each do |node|
59
+ station[:id] = node.text.to_i if node.node_name.eql? 'id'
60
+ station[:name] = node.text if node.node_name.eql? 'name'
61
+ station[:docks_free] = node.text.to_i if node.node_name.eql? 'nbEmptyDocks'
62
+ station[:docks_total] = node.text.to_i if node.node_name.eql? 'nbDocks'
63
+ station[:bikes] = node.text.to_i if node.node_name.eql? 'nbBikes'
64
+ station[:lat] = node.text.to_f if node.node_name.eql? 'lat'
65
+ station[:long] = node.text.to_f if node.node_name.eql? 'long'
66
+ station[:temporary] = parse_bool(node.text) if node.node_name.eql? 'temporary'
67
+ end
68
+
69
+ station
70
+ end
71
+
72
+ def parse_bool(value)
73
+ value == 'true'
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,27 @@
1
+ require 'geocoder'
2
+
3
+ module LondonBikeHireCli
4
+ class GeocodingAdapter
5
+ def geocode(search_term)
6
+ prepare_results Geocoder.search(search_term)
7
+ end
8
+
9
+ private
10
+
11
+ def prepare_results(results)
12
+ unless results && results.any?
13
+ return {}
14
+ end
15
+
16
+ first_result_coords = results.first.coordinates
17
+ result(first_result_coords)
18
+ end
19
+
20
+ def result(coordinates)
21
+ {
22
+ lat: coordinates[0],
23
+ long: coordinates[1]
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ require 'forwardable'
2
+
3
+ module LondonBikeHireCli
4
+ class QueryResponse
5
+ extend Forwardable
6
+
7
+ def_delegators :results, :[], :each, :map, :first, :size
8
+
9
+ def initialize(last_update: Time.now, results: [])
10
+ @last_update = last_update
11
+ @results = results
12
+ end
13
+
14
+ def time_of_feed
15
+ last_update.strftime('%Y-%m-%d %H:%M:%S')
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :results, :last_update
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ require 'kdtree'
2
+
3
+ module LondonBikeHireCli
4
+ class SpatialSearch
5
+ def initialize(datasource)
6
+ @stations = Kdtree.new(datasource)
7
+ end
8
+
9
+ # Public: Return the IDs of the nearest n stations
10
+ # point - a 2d point; { lat: x.x, long: x.x }
11
+ # limit - The maximum number of results
12
+ def nearest(point, limit = DEFAULT_SEARCH_LIMIT)
13
+ stations.nearestk(point[:lat], point[:long], limit)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :stations
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ require 'ostruct'
2
+
3
+ module LondonBikeHireCli
4
+ class Station < OpenStruct
5
+ def map_link
6
+ "https://www.google.co.uk/maps/preview/@#{lat},#{long},17z"
7
+ end
8
+
9
+ def position
10
+ {
11
+ lat: lat,
12
+ long: long
13
+ }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ module LondonBikeHireCli
2
+ class StationAdapter
3
+ def initialize(stations)
4
+ @stations = stations
5
+ end
6
+
7
+ # Public: Produce array of station triples
8
+ # E.g. [51.50810309, -0.12602103, 2]
9
+ # As used by kdtree
10
+ #
11
+ def to_triples
12
+ stations.map { |station| [station.lat, station.long, station.id] }
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :stations
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ module LondonBikeHireCli
2
+ class StationRepository
3
+ class StationNotFound < StandardError; end
4
+
5
+ def initialize(datasource)
6
+ @datasource = datasource
7
+ end
8
+
9
+ def all
10
+ return_query_obj(stations.values)
11
+ end
12
+
13
+ def find_by_id(*station_ids)
14
+ if station_ids.size == 1
15
+ matched_stations = stations[station_ids.first]
16
+ else
17
+ matched_stations = stations.values_at(*station_ids)
18
+ end
19
+
20
+ fail StationNotFound unless matched_stations
21
+
22
+ return_query_obj(matched_stations)
23
+ end
24
+
25
+ def find_by_name(station_name)
26
+ search_term = normalize_search_term(station_name)
27
+ results = stations.select { |_id, station| station.name.downcase.include?(search_term) }
28
+ return_query_obj(results.values)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :datasource, :last_update
34
+
35
+ def normalize_search_term(input)
36
+ input.downcase
37
+ end
38
+
39
+ def return_query_obj(results)
40
+ QueryResponse.new(last_update: last_update, results: Array(results))
41
+ end
42
+
43
+ def stations
44
+ @stations ||= begin
45
+ results = datasource.fetch
46
+ @last_update = results.delete(:last_update)
47
+ results
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,3 @@
1
+ module LondonBikeHireCli
2
+ VERSION = '1.3.0'
3
+ end
@@ -0,0 +1,37 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'london_bike_hire_cli/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'london-bike-hire-cli'
8
+ spec.version = LondonBikeHireCli::VERSION
9
+ spec.authors = ['Rob Murray']
10
+ spec.email = ['robmurray17@gmail.com']
11
+ spec.summary = %q(A command line interface to London's Bike Hire API.)
12
+ spec.description = %q(Find information about London's Bike Hire stations from command line interface.)
13
+ spec.homepage = 'https://github.com/rob-murray/london-bike-hire-cli'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.6'
22
+ spec.add_development_dependency 'rake'
23
+ spec.add_development_dependency 'pry'
24
+ spec.add_development_dependency 'pry-byebug'
25
+ spec.add_development_dependency 'rspec', '~> 3.0'
26
+ spec.add_development_dependency 'guard', '~> 2.6'
27
+ spec.add_development_dependency 'guard-rspec', '~> 4.3'
28
+ spec.add_development_dependency 'vcr', '~> 2.9'
29
+ spec.add_development_dependency 'webmock'
30
+ spec.add_development_dependency 'coveralls', '~> 0.7'
31
+ spec.add_development_dependency 'codeclimate-test-reporter'
32
+
33
+ spec.add_dependency 'commander', '~> 4.2'
34
+ spec.add_dependency 'nokogiri', '~> 1.6'
35
+ spec.add_dependency 'kdtree', '~> 0.3'
36
+ spec.add_dependency 'geocoder', '~> 1.2'
37
+ end
@@ -0,0 +1,186 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe LondonBikeHireCli::Controller do
4
+ let(:repository) { double }
5
+ let(:renderer) { double.as_null_object }
6
+ subject { described_class.new(repository: repository, renderer: renderer) }
7
+
8
+ describe '#all' do
9
+ it 'should request stations from repository' do
10
+ expect(repository).to receive(:all)
11
+
12
+ subject.all
13
+ end
14
+ end
15
+
16
+ describe '#find_by_id' do
17
+ it 'should request station from repository' do
18
+ expect(repository).to receive(:find_by_id).with(1)
19
+
20
+ subject.find_by_id(1)
21
+ end
22
+
23
+ context 'given a string argument' do
24
+ it 'should request station from repository' do
25
+ expect(repository).to receive(:find_by_id).with(1)
26
+
27
+ subject.find_by_id('1')
28
+ end
29
+ end
30
+
31
+ context 'given repository raising not found error' do
32
+ it 'does not raise_error' do
33
+ allow(repository).to receive(:find_by_id).and_raise(LondonBikeHireCli::StationRepository::StationNotFound)
34
+
35
+ expect do
36
+ subject.find_by_id(1)
37
+ end.not_to raise_error
38
+ end
39
+ end
40
+ end
41
+
42
+ describe '#where' do
43
+ context 'given a request with name parameter' do
44
+ it 'should request stations from repository' do
45
+ expect(repository).to receive(:find_by_name).with('kings')
46
+
47
+ subject.where(params: { name: 'kings' })
48
+ end
49
+
50
+ context 'given repository raising not found error' do
51
+ it 'does not raise_error' do
52
+ allow(repository).to receive(:find_by_name).and_raise(LondonBikeHireCli::StationRepository::StationNotFound)
53
+
54
+ expect do
55
+ subject.where(params: { name: 'kings' })
56
+ end.not_to raise_error
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ describe '#nearest' do
63
+ let(:stations) { TestDatasource.new.fetch.values }
64
+ let(:spatial_search) { double.as_null_object }
65
+ let(:geocoder) { double.as_null_object }
66
+ let(:geocoded_point) { { lat: 51.5309584, long: -0.1215387 } }
67
+
68
+ context 'given two nearest params' do
69
+ let(:params) do
70
+ { search_term: 'foo', id: 99 }
71
+ end
72
+
73
+ it 'does not process request' do
74
+ expect(repository).not_to receive(:all)
75
+
76
+ subject.nearest(params: params)
77
+ end
78
+ end
79
+
80
+ context 'given a request with search_term parameter' do
81
+ let(:search_term) { 'N19AE' }
82
+ before do
83
+ allow(repository).to receive(:all).and_return(stations)
84
+ allow(repository).to receive(:find_by_id).and_return(double.as_null_object)
85
+ allow(geocoder).to receive(:geocode).and_return(geocoded_point)
86
+ allow(spatial_search).to receive(:nearest).and_return([1])
87
+
88
+ subject.geocoder = geocoder
89
+ subject.spatial_service = spatial_search
90
+ end
91
+
92
+ it 'geocodes search_term via service' do
93
+ expect(geocoder).to receive(:geocode).with(search_term).and_return(geocoded_point)
94
+
95
+ subject.nearest(params: { search_term: search_term })
96
+ end
97
+
98
+ it 'requests all stations from repository' do
99
+ expect(repository).to receive(:all).and_return(stations)
100
+
101
+ subject.nearest(params: { search_term: search_term })
102
+ end
103
+
104
+ it 'passes all stations to spatial search' do
105
+ subject.spatial_service = nil
106
+ expect(LondonBikeHireCli::SpatialSearch).to receive(:new).and_return(spatial_search)
107
+
108
+ subject.nearest(params: { search_term: search_term })
109
+ end
110
+
111
+ it 'requests nearest ids from geocoded point' do
112
+ expect(spatial_search).to receive(:nearest).and_return([1])
113
+
114
+ subject.nearest(params: { search_term: search_term })
115
+ end
116
+
117
+ it 'retreives stations matched by search' do
118
+ expect(repository).to receive(:find_by_id).with(1)
119
+
120
+ subject.nearest(params: { search_term: search_term })
121
+ end
122
+
123
+ context 'with invalid search' do
124
+ let(:search_term) { '!' }
125
+ before do
126
+ allow(geocoder).to receive(:geocode).and_return(nil)
127
+ end
128
+
129
+ it 'does not request stations from repository' do
130
+ expect(repository).not_to receive(:all)
131
+ end
132
+ end
133
+ end
134
+
135
+ context 'given a request with id parameter' do
136
+ let(:search_id) { 1 }
137
+ before do
138
+ allow(repository).to receive(:all).and_return(stations)
139
+ allow(repository).to receive(:find_by_id).and_return(double.as_null_object)
140
+ allow(spatial_search).to receive(:nearest).and_return([1, 2])
141
+
142
+ subject.geocoder = geocoder
143
+ subject.spatial_service = spatial_search
144
+ end
145
+
146
+ it 'requests station from repository' do
147
+ expect(repository).to receive(:find_by_id).with(search_id)
148
+
149
+ subject.nearest(params: { id: search_id })
150
+ end
151
+
152
+ it 'requests all stations from repository' do
153
+ expect(repository).to receive(:all).and_return(stations)
154
+
155
+ subject.nearest(params: { id: search_id })
156
+ end
157
+
158
+ it 'passes all stations to spatial search' do
159
+ subject.spatial_service = nil
160
+ expect(LondonBikeHireCli::SpatialSearch).to receive(:new).and_return(spatial_search)
161
+
162
+ subject.nearest(params: { id: search_id })
163
+ end
164
+
165
+ it 'requests nearest ids from geocoded point' do
166
+ expect(spatial_search).to receive(:nearest).and_return([1])
167
+
168
+ subject.nearest(params: { id: search_id })
169
+ end
170
+
171
+ it 'retreives stations matched by search' do
172
+ expect(repository).to receive(:find_by_id).with(1)
173
+
174
+ subject.nearest(params: { id: search_id })
175
+ end
176
+
177
+ context 'with invalid search' do
178
+ let(:search_id) { 999 }
179
+
180
+ it 'does not request stations from repository' do
181
+ expect(repository).not_to receive(:all)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end