rubybikes 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +33 -0
  4. data/LICENSE +662 -0
  5. data/NOTICE +10 -0
  6. data/PROVIDERS.md +123 -0
  7. data/README.md +108 -0
  8. data/RELEASING.md +44 -0
  9. data/data/ATTRIBUTION.txt +290 -0
  10. data/data/geojson/ch_cantons.json +53 -0
  11. data/data/geojson/japan_prefectures.json +49 -0
  12. data/data/geojson/penang.json +63 -0
  13. data/data/graphql/charichari.graphql +12 -0
  14. data/data/graphql/ganxeta.graphql +22 -0
  15. data/data/graphql/otp.graphql +68 -0
  16. data/data/providers.json +1 -0
  17. data/data/pybikes/aksu.json +49 -0
  18. data/data/pybikes/ambici.json +166 -0
  19. data/data/pybikes/baksi.json +41 -0
  20. data/data/pybikes/batumvelo.json +19 -0
  21. data/data/pybikes/bcycle.json +326 -0
  22. data/data/pybikes/beryl.json +195 -0
  23. data/data/pybikes/bicicard.json +205 -0
  24. data/data/pybikes/bicicas.json +20 -0
  25. data/data/pybikes/bicielx.json +19 -0
  26. data/data/pybikes/biciferrolterra.json +21 -0
  27. data/data/pybikes/bicimad.json +16 -0
  28. data/data/pybikes/bicincitta.json +1551 -0
  29. data/data/pybikes/bicing.json +37 -0
  30. data/data/pybikes/bikeshare_ie.json +46 -0
  31. data/data/pybikes/bikeu.json +17 -0
  32. data/data/pybikes/bixi.json +21 -0
  33. data/data/pybikes/bizkaibizi.json +105 -0
  34. data/data/pybikes/careem.json +40 -0
  35. data/data/pybikes/charichari.json +106 -0
  36. data/data/pybikes/chartered_bike.json +86 -0
  37. data/data/pybikes/clujbike.json +17 -0
  38. data/data/pybikes/cyclocity.json +211 -0
  39. data/data/pybikes/cyclopolis.json +78 -0
  40. data/data/pybikes/cykleo.json +39 -0
  41. data/data/pybikes/decobike.json +18 -0
  42. data/data/pybikes/derp.json +114 -0
  43. data/data/pybikes/deutschebahn.json +97 -0
  44. data/data/pybikes/digitransit.json +123 -0
  45. data/data/pybikes/docomo.json +215 -0
  46. data/data/pybikes/easybike.json +160 -0
  47. data/data/pybikes/ecovelo.json +307 -0
  48. data/data/pybikes/emovity.json +17 -0
  49. data/data/pybikes/encicla.json +17 -0
  50. data/data/pybikes/fifteen.json +50 -0
  51. data/data/pybikes/fsm.json +16 -0
  52. data/data/pybikes/ganxeta.json +20 -0
  53. data/data/pybikes/gbfs.json +1578 -0
  54. data/data/pybikes/gira.json +20 -0
  55. data/data/pybikes/gyorbike.json +19 -0
  56. data/data/pybikes/hellocycling.json +248 -0
  57. data/data/pybikes/joco.json +22 -0
  58. data/data/pybikes/keolis.json +22 -0
  59. data/data/pybikes/kolumbus.json +18 -0
  60. data/data/pybikes/lastenvelo.json +30 -0
  61. data/data/pybikes/linkbike.json +17 -0
  62. data/data/pybikes/meiob.json +18 -0
  63. data/data/pybikes/mobhis.json +43 -0
  64. data/data/pybikes/movus.json +72 -0
  65. data/data/pybikes/nextbike.json +2443 -0
  66. data/data/pybikes/nextbike_gbfs.json +5 -0
  67. data/data/pybikes/nubija.json +18 -0
  68. data/data/pybikes/open_source_bike_share.json +28 -0
  69. data/data/pybikes/open_source_bike_share_v2.json +17 -0
  70. data/data/pybikes/openmove.json +20 -0
  71. data/data/pybikes/ov_fiets.json +20 -0
  72. data/data/pybikes/pedalada.json +66 -0
  73. data/data/pybikes/publibike.json +127 -0
  74. data/data/pybikes/qrobici.json +20 -0
  75. data/data/pybikes/roovee.json +245 -0
  76. data/data/pybikes/samba.json +52 -0
  77. data/data/pybikes/sejong.json +19 -0
  78. data/data/pybikes/seoulbike.json +18 -0
  79. data/data/pybikes/sharedmobility.json +63 -0
  80. data/data/pybikes/slovnaft_bajk.json +21 -0
  81. data/data/pybikes/smartbike.json +28 -0
  82. data/data/pybikes/smoove.json +26 -0
  83. data/data/pybikes/soltrafego.json +39 -0
  84. data/data/pybikes/tashu.json +20 -0
  85. data/data/pybikes/urbansharing.json +22 -0
  86. data/data/pybikes/velib.json +23 -0
  87. data/data/pybikes/velobike_kz.json +17 -0
  88. data/data/pybikes/velok.json +16 -0
  89. data/data/pybikes/veloway.json +20 -0
  90. data/data/pybikes/weelo.json +45 -0
  91. data/data/pybikes/wegoshare.json +141 -0
  92. data/data/pybikes/xian.json +19 -0
  93. data/data/pybikes/yelovelo.json +22 -0
  94. data/data/pybikes/youbike.json +173 -0
  95. data/data/upstream.json +7 -0
  96. data/lib/rubybikes/engine.rb +3 -0
  97. data/lib/rubybikes/native/authenticated.rb +95 -0
  98. data/lib/rubybikes/native/engine.rb +103 -0
  99. data/lib/rubybikes/native/gbfs.rb +169 -0
  100. data/lib/rubybikes/native/geometry.rb +53 -0
  101. data/lib/rubybikes/native/graphql.rb +135 -0
  102. data/lib/rubybikes/native/html_clients.rb +313 -0
  103. data/lib/rubybikes/native/json_providers.rb +174 -0
  104. data/lib/rubybikes/native/more_json.rb +126 -0
  105. data/lib/rubybikes/native/nextbike.rb +50 -0
  106. data/lib/rubybikes/native/provider.rb +84 -0
  107. data/lib/rubybikes/native/remaining_clients.rb +102 -0
  108. data/lib/rubybikes/native/response_cache.rb +78 -0
  109. data/lib/rubybikes/native/transport.rb +163 -0
  110. data/lib/rubybikes/native/xml_details.rb +159 -0
  111. data/lib/rubybikes/native.rb +30 -0
  112. data/lib/rubybikes/version.rb +5 -0
  113. data/lib/rubybikes.rb +201 -0
  114. data/sig/external.rbs +30 -0
  115. data/sig/manifest.yaml +8 -0
  116. data/sig/native.rbs +230 -0
  117. data/sig/rubybikes.rbs +143 -0
  118. metadata +230 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Native ports of PyBikes bixi.py, decobike.py, xian.py, bicincitta.py,
4
+ # pedalada.py and fsm.py. Copyright (C) eskerda, Martin Gonzalez Gomez,
5
+ # aronsky and contributors. Distributed under AGPL-3.0; see LICENSE and NOTICE.
6
+
7
+ class RubyBikes
8
+ module Native
9
+ class JsonProviders
10
+ DECOBIKE_HEADERS = {
11
+ 'Accept' => 'application/xml, text/xml, */*; q=0.01',
12
+ 'Accept-Language' => 'en-US,en;q=0.9',
13
+ 'User-Agent' => 'Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36',
14
+ 'X-Requested-With' => 'com.citibike.miami'
15
+ }.freeze
16
+ BICINCITTA_STATUSES = { 0 => 'online', 1 => 'maintenance', 2 => 'offline', 3 => 'under construction', 4 => 'planned' }.freeze
17
+ DETAIL_JSON_HEADERS = { 'Content-Type' => 'application/json; charset=utf-8' }.freeze
18
+ FSM_ENDPOINT = 'https://www.tel-o-fun.co.il/api/privatearea/v1'
19
+ FSM_HEADERS = { 'Accept-Language' => 'en' }.freeze
20
+ XIAN_SOAP = <<~XML
21
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:q0="http://ws.itcast.cn/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
22
+ <soapenv:Body><q0:findBikeSites></q0:findBikeSites></soapenv:Body>
23
+ </soapenv:Envelope>
24
+ XML
25
+
26
+ # Metadata generated by these clients belongs to their description too,
27
+ # before the first network update.
28
+ def initialize(spec, parameters, key: nil)
29
+ super
30
+ case spec.fetch('module')
31
+ when 'bicincitta'
32
+ endpoint = p('endpoint') || 'https://www.bicincitta.com/'
33
+ @meta['source'] = URI.join(endpoint, "frmLeStazioni.aspx?ID=#{p('city_ids').fetch(0)}").to_s
34
+ when 'pedalada'
35
+ @meta['company'] = Array(@parameters.fetch('meta')['company']) + Array(spec.fetch('meta')['company'])
36
+ end
37
+ end
38
+
39
+ def update_bixi
40
+ detail_xml(p('feed_url')).xpath('/stations/station').each do |node|
41
+ name = xml_text(node, 'name')
42
+ terminal = xml_text(node, 'terminalName')
43
+ extra = {
44
+ 'uid' => number(xml_text(node, 'id')),
45
+ 'name' => name,
46
+ 'terminalName' => terminal,
47
+ 'locked' => xml_boolean?(xml_text(node, 'locked')),
48
+ 'installed' => xml_boolean?(xml_text(node, 'installed')),
49
+ 'temporary' => xml_boolean?(xml_text(node, 'temporary')),
50
+ 'installDate' => xml_text(node, 'installDate'),
51
+ 'removalDate' => xml_text(node, 'removalDate')
52
+ }
53
+ updated = xml_text(node, 'latestUpdateTime')
54
+ extra['latestUpdateTime'] = updated if updated && updated != '0'
55
+ station("#{terminal} - #{name}", xml_text(node, 'lat'), xml_text(node, 'long'),
56
+ number(xml_text(node, 'nbBikes')), number(xml_text(node, 'nbEmptyDocks')), extra)
57
+ end
58
+ end
59
+
60
+ def update_decobike
61
+ detail_xml(p('feed_url'), headers: DECOBIKE_HEADERS).xpath('//location').each do |node|
62
+ uid = xml_text(node, 'Id')
63
+ address = xml_text(node, 'Address')
64
+ station("#{uid} - #{address}", xml_text(node, 'Latitude'), xml_text(node, 'Longitude'),
65
+ number(xml_text(node, 'Bikes')), number(xml_text(node, 'Dockings')),
66
+ { 'uid' => uid, 'address' => address })
67
+ end
68
+ end
69
+
70
+ def update_xian
71
+ document = detail_xml(p('endpoint'), method: 'POST', data: XIAN_SOAP,
72
+ headers: { 'Content-Type' => 'text/xml; charset=utf-8' })
73
+ payload = document.xpath('string(//ns1:out)', 'ns1' => 'http://inter.webservice.web.ptpportal.guloo.org').delete(' ')
74
+ JSON.parse(payload).each do |item|
75
+ slots = number(item.fetch('locknum'))
76
+ free = number(item.fetch('emptynum'))
77
+ station(item.fetch('sitename'), item.fetch('latitude'), item.fetch('longitude'), slots - free, free,
78
+ { 'uid' => item.fetch('siteid'), 'slots' => slots, 'address' => item.fetch('location') })
79
+ end
80
+ end
81
+
82
+ def update_bicincitta
83
+ endpoint = p('endpoint') || 'https://www.bicincitta.com/'
84
+ p('city_ids').each do |city|
85
+ payload = json(URI.join(endpoint, 'frmLeStazioniComune.aspx/RefreshStations').to_s,
86
+ method: 'POST', data: JSON.generate('IDComune' => city.to_s), headers: DETAIL_JSON_HEADERS)
87
+ payload.fetch('d').drop(1).each do |raw|
88
+ uid, lat, lon, name, number, status = raw.split('§')
89
+ extra = { 'uid' => uid, 'number' => number(number), 'status' => BICINCITTA_STATUSES.fetch(number(status)) }
90
+ detail = json(URI.join(endpoint, 'frmLeStazioni.aspx/RefreshPopup').to_s,
91
+ method: 'POST', data: JSON.generate('IDStazione' => uid.to_s), headers: DETAIL_JSON_HEADERS)
92
+ _, reviews, score, _, availability = detail.fetch('d').split('§').take(5)
93
+ extra['score'] = Float(score.tr(',', '.'))
94
+ extra['reviews'] = number(reviews)
95
+ station(name, bicincitta_coordinate(lat), bicincitta_coordinate(lon), availability.count('4'), availability.count('0'), extra)
96
+ end
97
+ end
98
+ end
99
+
100
+ def update_pedalada
101
+ endpoint = p('endpoint')
102
+ json("#{endpoint}/php/get-stations-details-map.php").each do |item|
103
+ uid = item.fetch('stationNumber')
104
+ slots = item.fetch('maximumNumberOfRides')
105
+ detail = json("#{endpoint}/php/get-station-details-and-rides.php", params: { 'n' => uid })
106
+ bikes = detail.fetch('rides').length
107
+ extra = {
108
+ 'uid' => uid, 'slots' => slots, 'online' => item.fetch('state') == 1,
109
+ 'photo' => "#{endpoint}/images/stations/station_#{uid}.jpg",
110
+ 'status' => pedalada_status(detail.fetch('details').fetch(0))
111
+ }
112
+ station(item.fetch('stationName'), item.fetch('latitude'), item.fetch('longitude'), bikes, slots - bikes, extra)
113
+ end
114
+ end
115
+
116
+ def update_fsm
117
+ json("#{FSM_ENDPOINT}/map", headers: FSM_HEADERS).fetch('stations').each do |item|
118
+ detail = json("#{FSM_ENDPOINT}/station/#{item.fetch('id')}", headers: FSM_HEADERS)
119
+ electric = item.fetch('omniBikes')
120
+ normal = item.fetch('novatechBikes')
121
+ electric_slots = detail.fetch('availableOmniPoles')
122
+ normal_slots = detail.fetch('availableNovatechPoles')
123
+ extra = {
124
+ 'uid' => item.fetch('id'), 'number' => item.fetch('stationNumber'),
125
+ 'shabbat' => item.fetch('isShabbatStation'), 'has_ebikes' => true,
126
+ 'ebikes' => electric, 'normal_bikes' => normal, 'normal_slots' => normal_slots,
127
+ 'e_slots' => electric_slots, 'address' => detail.fetch('address')
128
+ }
129
+ station(item.fetch('name'), item.fetch('location').fetch('lat'), item.fetch('location').fetch('lng'),
130
+ normal + electric, normal_slots + electric_slots, extra)
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def detail_xml(url, **options)
137
+ Nokogiri::XML(request(url, **options)) { |config| config.strict.nonet }
138
+ end
139
+
140
+ def xml_text(node, name)
141
+ node.at_xpath(name)&.text
142
+ end
143
+
144
+ def xml_boolean?(value)
145
+ %w[yes true t 1].include?(value.downcase)
146
+ end
147
+
148
+ def bicincitta_coordinate(value)
149
+ Float(value[/\d+\.\d{5,15}/] || value)
150
+ end
151
+
152
+ def pedalada_status(detail)
153
+ return 'maintenance' if truthy?(detail.fetch('stationInMaintenance'))
154
+
155
+ truthy?(detail.fetch('stationIsOpen')) ? 'open' : 'closed'
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/OneClassPerFile -- The loader establishes data paths before exposing the native Engine alias.
4
+
5
+ require_relative 'version'
6
+ class RubyBikes
7
+ module Native
8
+ DATA = File.expand_path('../../data', File.dirname(__FILE__))
9
+ end
10
+ end
11
+ require_relative 'native/geometry'
12
+ require_relative 'native/response_cache'
13
+ require_relative 'native/transport'
14
+ require_relative 'native/provider'
15
+ require_relative 'native/gbfs'
16
+ require_relative 'native/nextbike'
17
+ require_relative 'native/json_providers'
18
+ require_relative 'native/engine'
19
+ require_relative 'native/authenticated'
20
+ require_relative 'native/graphql'
21
+ require_relative 'native/more_json'
22
+ require_relative 'native/remaining_clients'
23
+ require_relative 'native/html_clients'
24
+ require_relative 'native/xml_details'
25
+ class RubyBikes
26
+ Engine = Native::Engine unless const_defined?(:Engine, false)
27
+ end
28
+ require_relative '../rubybikes' unless defined?(RubyBikes::System)
29
+
30
+ # rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyBikes
4
+ VERSION = '0.3.0'
5
+ end
data/lib/rubybikes.rb ADDED
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (C) Eduardo Mucelli Rezende Oliveira
4
+ # Distributed under the AGPL license, see LICENSE.
5
+
6
+ require 'json'
7
+ require 'time'
8
+
9
+ require_relative 'rubybikes/version'
10
+
11
+ class RubyBikes
12
+ class Error < StandardError
13
+ attr_reader :details
14
+
15
+ def initialize(message, details: {})
16
+ @details = details
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ class DependencyError < Error; end
22
+ class ProviderError < Error; end
23
+ class ProtocolError < Error; end
24
+ class TimeoutError < Error; end
25
+
26
+ class RateLimitedError < ProviderError
27
+ def retry_after = details.fetch('retry_after', 300).to_f
28
+ def host = details['host']
29
+ end
30
+
31
+ class NoSystemFoundError < Error; end
32
+ class NoGetCriteriaError < Error; end
33
+ class APIKeyNotAvailableError < Error; end
34
+
35
+ def initialize(timeout: 60, keys: {}, engine: nil)
36
+ @engine = engine || Engine.new(timeout: timeout)
37
+ @keys = keys.transform_keys(&:to_s)
38
+ end
39
+
40
+ def catalog
41
+ @catalog ||= @engine.request('catalog').fetch('networks')
42
+ end
43
+
44
+ def engine_info = @engine.request('version')
45
+ def providers = @providers ||= @engine.request('providers').fetch('providers')
46
+ def tags = catalog.map { |entry| entry.fetch('tag') }
47
+ def labels = catalog.map { |entry| entry.fetch('label') }.uniq.sort
48
+
49
+ # Original string-keyed hashes and modern keyword/string forms are accepted.
50
+ def get(options = {}, **keywords)
51
+ raise ArgumentError, 'options must be a tag or hash' unless options.is_a?(String) || options.is_a?(Hash)
52
+
53
+ options = options.is_a?(String) ? { 'tag' => options } : options.transform_keys(&:to_s)
54
+ options = options.merge(keywords.transform_keys(&:to_s))
55
+ if (tag = options['tag'])
56
+ entry = catalog.find { |row| row['tag'] == tag }
57
+ raise NoSystemFoundError, "Unknown system tag: #{tag}" unless entry
58
+
59
+ key = options['api_key'] || options['key'] || @keys[tag] || @keys[entry['module']] || @keys[entry['class']]
60
+ description = @engine.request('describe', tag: tag, key: key)
61
+ System.new(@engine, description, key)
62
+ elsif (label = options['label'])
63
+ entries = catalog.select do |row|
64
+ [row['label'], row['class'], row['module']].any? { |value| value.to_s.casecmp?(label.to_s) }
65
+ end
66
+ entries.map { |entry| get(options.merge('tag' => entry['tag'])) }
67
+ else
68
+ raise NoGetCriteriaError, "Supply a 'tag' or 'label'"
69
+ end
70
+ end
71
+
72
+ # Instantiate any upstream client, including clients without catalog entries.
73
+ # Parameters follow that client's PyBikes constructor (feed_url, domain, etc.).
74
+ def build(provider:, tag:, meta:, class_name: nil, api_key: nil, **parameters)
75
+ entries = providers.select { |row| row['class'].casecmp?(provider.to_s) } unless class_name
76
+ if !entries || entries.empty?
77
+ entries = providers.select do |row|
78
+ row['module'].casecmp?(provider.to_s) && (!class_name || row['class'].casecmp?(class_name.to_s))
79
+ end
80
+ end
81
+ raise NoSystemFoundError, "Unknown provider: #{provider}" if entries.empty?
82
+ raise ArgumentError, 'Select a class_name for this provider module' if entries.size > 1
83
+
84
+ entry = entries.first
85
+ key = api_key || @keys[tag] || @keys[entry['module']] || @keys[entry['class']]
86
+ definition = { 'module' => entry['module'], 'class' => entry['class'],
87
+ 'parameters' => parameters.merge(meta: meta) }
88
+ # Detach nested caller-owned values so updates and process restarts keep
89
+ # using the same definition that was originally described.
90
+ definition = JSON.parse(JSON.generate(definition))
91
+ description = @engine.request('describe', tag: tag, key: key, definition: definition)
92
+ System.new(@engine, description, key, definition: definition)
93
+ end
94
+
95
+ def close = @engine.close
96
+
97
+ def self.open(**options)
98
+ factory = new(**options)
99
+ return factory unless block_given?
100
+
101
+ begin
102
+ yield factory
103
+ ensure
104
+ factory.close
105
+ end
106
+ end
107
+
108
+ class Station
109
+ attr_reader :id, :name, :latitude, :longitude, :bikes, :free, :timestamp, :extra
110
+
111
+ def initialize(row)
112
+ @id, @name = row.fetch('id'), row.fetch('name')
113
+ @latitude, @longitude = row.fetch('latitude'), row.fetch('longitude')
114
+ @bikes, @free, @extra = row['bikes'], row['free'], row.fetch('extra', {})
115
+ @timestamp = row['timestamp'] && Time.iso8601(row['timestamp']).utc
116
+ end
117
+
118
+ def get_hash = id # rubocop:disable Naming/AccessorMethodName -- Retain the public compatibility method.
119
+
120
+ def to_h
121
+ { 'id' => id, 'name' => name, 'latitude' => latitude, 'longitude' => longitude,
122
+ 'bikes' => bikes, 'free' => free, 'timestamp' => timestamp&.iso8601(6), 'extra' => extra }
123
+ end
124
+ alias to_dict to_h
125
+ def to_json(state = nil) = to_h.to_json(state)
126
+
127
+ def to_geojson
128
+ { 'type' => 'Feature', 'geometry' => { 'type' => 'Point', 'coordinates' => [longitude, latitude] },
129
+ 'properties' => to_h.except('id', 'timestamp').merge('type' => 'station') }
130
+ end
131
+ end
132
+
133
+ class Vehicle
134
+ attr_reader :id, :latitude, :longitude, :kind, :timestamp, :extra
135
+
136
+ def initialize(row)
137
+ @id, @latitude, @longitude, @kind = row.values_at('id', 'latitude', 'longitude', 'kind')
138
+ @extra = row.fetch('extra', {})
139
+ @timestamp = row['timestamp'] && Time.iso8601(row['timestamp']).utc
140
+ end
141
+
142
+ def get_hash = id # rubocop:disable Naming/AccessorMethodName -- Retain the public compatibility method.
143
+
144
+ def to_h
145
+ { 'id' => id, 'latitude' => latitude, 'longitude' => longitude, 'kind' => kind,
146
+ 'timestamp' => timestamp&.iso8601(6), 'extra' => extra }
147
+ end
148
+ alias to_dict to_h
149
+ def to_json(state = nil) = to_h.to_json(state)
150
+
151
+ def to_geojson
152
+ { 'type' => 'Feature', 'geometry' => { 'type' => 'Point', 'coordinates' => [longitude, latitude] },
153
+ 'properties' => to_h.except('timestamp').merge('type' => 'vehicle') }
154
+ end
155
+ end
156
+
157
+ class System
158
+ attr_reader :tag, :meta, :stations, :vehicles, :authed, :sync, :unifeed, :provider, :client_class
159
+
160
+ def initialize(engine, description, key, definition: nil)
161
+ @engine, @key = engine, key
162
+ @definition = definition
163
+ @tag, @meta, @authed, @sync = description.values_at('tag', 'meta', 'authed', 'sync')
164
+ @provider, @client_class, @unifeed = description.values_at('module', 'class', 'unifeed')
165
+ @stations, @vehicles = [], []
166
+ end
167
+
168
+ def update(timeout: @engine.timeout, scraper: {})
169
+ arguments = { tag: tag, key: @key, scraper: scraper }
170
+ arguments[:definition] = @definition if @definition
171
+ row = @engine.request('update', timeout: timeout, **arguments)
172
+ raise ProtocolError, 'Provider returned a different system' unless row.fetch('tag') == tag
173
+
174
+ # Build the complete snapshot first, retaining the previous one on failure.
175
+ stations = row.fetch('stations').map { |station| Station.new(station) }
176
+ # @type var empty_vehicles: Array[RubyBikes::object]
177
+ empty_vehicles = []
178
+ vehicles = row.fetch('vehicles', empty_vehicles).map { |vehicle| Vehicle.new(vehicle) }
179
+ @meta, @stations, @vehicles = row.fetch('meta'), stations, vehicles
180
+ self
181
+ rescue KeyError, TypeError, ArgumentError
182
+ raise ProtocolError, 'Provider returned an invalid snapshot'
183
+ end
184
+
185
+ def to_h
186
+ { 'tag' => tag, 'meta' => meta, 'stations' => stations.map(&:to_h), 'vehicles' => vehicles.map(&:to_h) }
187
+ end
188
+ alias to_dict to_h
189
+ def to_json(state = nil) = to_h.to_json(state)
190
+
191
+ def to_geojson
192
+ { 'type' => 'FeatureCollection', 'features' => (stations + vehicles).map(&:to_geojson) }
193
+ end
194
+ end
195
+ end
196
+
197
+ # Preserve the exception names used by existing RubyBikes consumers.
198
+ NoSystemFoundError = RubyBikes::NoSystemFoundError unless defined?(NoSystemFoundError)
199
+ NoGetCriteriaError = RubyBikes::NoGetCriteriaError unless defined?(NoGetCriteriaError)
200
+ APIKeyNotAvailableError = RubyBikes::APIKeyNotAvailableError unless defined?(APIKeyNotAvailableError)
201
+ require_relative 'rubybikes/engine'
data/sig/external.rbs ADDED
@@ -0,0 +1,30 @@
1
+ # Parser and cookie libraries form dynamic external-data boundaries.
2
+ module Nokogiri
3
+ def self.XML: (String) ?{ (untyped) -> untyped } -> untyped
4
+ def self.HTML: (String) -> untyped
5
+ module HTML
6
+ def self.fragment: (String) -> untyped
7
+ end
8
+ end
9
+
10
+ module HTTP
11
+ class Cookie
12
+ def name: () -> String
13
+ def value: () -> String
14
+ end
15
+ class CookieJar
16
+ def initialize: () -> void
17
+ def cookies: (URI::Generic) -> Array[Cookie]
18
+ def parse: (String, URI::Generic) -> Array[Cookie]
19
+ end
20
+ end
21
+
22
+ # JSON generation accepts the optional JSON state passed by JSON.dump.
23
+ class Hash[unchecked out K, unchecked out V]
24
+ def to_json: (?untyped) -> String
25
+ end
26
+
27
+ # RBS 3.9 omits the mandatory block on this documented synchronization API.
28
+ class Thread
29
+ def self.handle_interrupt: [A] (untyped) { () -> A } -> A
30
+ end
data/sig/manifest.yaml ADDED
@@ -0,0 +1,8 @@
1
+ dependencies:
2
+ - name: json
3
+ - name: time
4
+ - name: uri
5
+ - name: net-http
6
+ - name: timeout
7
+ - name: csv
8
+ - name: strscan
data/sig/native.rbs ADDED
@@ -0,0 +1,230 @@
1
+ class RubyBikes
2
+ module Native
3
+ DATA: String
4
+ type bounds = Hash[String, untyped] | Array[untyped] | nil
5
+
6
+ module Geometry
7
+ def self?.contains?: (bounds, Float, Float) -> bool
8
+ def self?.polygon?: (untyped, Float, Float) -> bool
9
+ def self?.ring: (untyped, Float, Float) -> (:inside | :outside | :boundary)
10
+ end
11
+
12
+ class ResponseCache
13
+ @ttl: Integer | Float
14
+ @max_bytes: Integer
15
+ @max_entries: Integer
16
+ @entries: Hash[untyped, Entry]
17
+ @bytes: Integer
18
+ @mutex: Mutex
19
+ class Entry < Struct[untyped]
20
+ attr_accessor response: Transport::Response
21
+ attr_accessor stored_at: Float
22
+ attr_accessor bytes: Integer
23
+ def self.new: (response: Transport::Response, stored_at: Float, bytes: Integer) -> Entry
24
+ def initialize: (response: Transport::Response, stored_at: Float, bytes: Integer) -> void
25
+ end
26
+ def initialize: (?ttl: Integer | Float, ?max_bytes: Integer, ?max_entries: Integer) -> void
27
+ def get: (untyped, Float) -> Transport::Response?
28
+ def put: (untyped, Transport::Response, Float) -> void
29
+ private
30
+ def atomic: [A] () { () -> A } -> A
31
+ def expire: (Float) -> void
32
+ end
33
+
34
+ class Transport
35
+ @clock: ^() -> Float
36
+ @deadline: Float
37
+ @headers: Hash[String, String]
38
+ @proxy: URI::Generic?
39
+ @cooldowns: Hash[String, Float]
40
+ @cookies: untyped
41
+ @cache: ResponseCache
42
+ @last_response: Response
43
+ @deadline_active: bool
44
+ attr_reader headers: Hash[String, String]
45
+ attr_reader last_response: Response
46
+ class Response < Struct[untyped]
47
+ attr_accessor status: Integer
48
+ attr_accessor headers: Hash[String, String]
49
+ attr_accessor body: String
50
+ def self.new: (status: Integer, headers: Hash[String, String], body: String) -> Response
51
+ def initialize: (status: Integer, headers: Hash[String, String], body: String) -> void
52
+ end
53
+ def initialize: (?timeout: Numeric, ?headers: Hash[String, String], ?proxy: String?, ?cooldowns: Hash[String, Float], ?cache: ResponseCache?, ?clock: ^() -> Float) -> void
54
+ def remaining: () -> Float
55
+ def within_deadline: [A] () { () -> A } -> A
56
+ def request: (String, ?method: String, ?params: untyped, ?data: untyped, ?headers: Hash[String, String], ?raw: bool, ?skip_cache: bool, ?default_encoding: String) -> String
57
+ private
58
+ def perform_request: (String, method: String, params: untyped, data: untyped, headers: Hash[String, String], raw: bool, skip_cache: bool, default_encoding: String) -> String
59
+ def decode: (Response, bool, String) -> String
60
+ def transport_uri: (String) -> URI::HTTP
61
+ def fetch: (URI::HTTP, String, untyped, Hash[String, String], Integer) -> Response
62
+ end
63
+
64
+ class Provider
65
+ @spec: object
66
+ @parameters: object
67
+ @tag: String
68
+ @meta: object
69
+ @key: untyped
70
+ @transport: Transport
71
+ @stations: Array[station_row]
72
+ @vehicles: Array[vehicle_row]
73
+ attr_reader tag: String
74
+ attr_reader meta: object
75
+ attr_reader spec: object
76
+ attr_reader parameters: object
77
+ def initialize: (object, object, ?key: api_key) -> void
78
+ def snapshot: (Transport) -> object
79
+ def update: () -> void
80
+ def station: (String?, untyped, untyped, ?Integer?, ?Integer?, ?object) -> Array[station_row]?
81
+ def vehicle: (untyped, untyped, ?object, ?String) -> Array[vehicle_row]?
82
+ def request: (String, **untyped) -> String
83
+ def json: (String, **untyped) -> untyped
84
+ def xml: (String, **untyped) -> untyped
85
+ def html: (String, **untyped) -> untyped
86
+ def p: (String, ?untyped) -> untyped
87
+ def number: (untyped) -> Integer
88
+ def list: (untyped) -> Array[untyped]
89
+ def integer: (untyped) -> Integer?
90
+ def truthy?: (untyped) -> bool?
91
+ def iso_date: (untyped) -> String?
92
+ end
93
+
94
+ class Gbfs < Provider
95
+ @feed_url: String
96
+ def initialize: (object, object, ?key: api_key) -> void
97
+ def update: () -> void
98
+ def vehicle_kind: (object) -> [String?, String?]
99
+ private
100
+ def discover_feeds: (untyped) -> Hash[String, String]
101
+ def parse_gbfs_station: (object, object) -> void
102
+ end
103
+
104
+ class Nextbike < Provider
105
+ def update: () -> void
106
+ end
107
+
108
+ class JsonProviders < Provider
109
+ EMOVITY_USER_AGENT: String
110
+ SAMBA_USER_AGENT: String
111
+ DECOBIKE_HEADERS: Hash[String, String]
112
+ BICINCITTA_STATUSES: Hash[Integer, String]
113
+ DETAIL_JSON_HEADERS: Hash[String, String]
114
+ FSM_ENDPOINT: String
115
+ FSM_HEADERS: Hash[String, String]
116
+ XIAN_SOAP: String
117
+ def initialize: (object, object, ?key: api_key) -> void
118
+ def update: () -> void
119
+ def update_aksu: () -> void
120
+ def update_baksi: () -> void
121
+ def update_batumvelo: () -> void
122
+ def update_bicicard: () -> void
123
+ def update_bicicas: () -> void
124
+ def update_bicielx: () -> void
125
+ def update_biciferrolterra: () -> void
126
+ def update_bicimad: () -> void
127
+ def update_bicincitta: () -> void
128
+ def update_bicing: () -> void
129
+ def update_bikeshare_ie: () -> void
130
+ def update_bikeu: () -> void
131
+ def update_bixi: () -> void
132
+ def update_charichari: () -> void
133
+ def update_chartered_bike: () -> void
134
+ def update_clujbike: () -> void
135
+ def update_cyclopolis: () -> void
136
+ def update_cyclopolis_api: () -> void
137
+ def update_cykleo: () -> void
138
+ def update_decobike: () -> void
139
+ def update_digitransit: () -> void
140
+ def update_easybike: () -> void
141
+ def update_emovity: () -> void
142
+ def update_encicla: () -> void
143
+ def update_fifteen: () -> void
144
+ def update_fsm: () -> void
145
+ def update_ganxeta: () -> void
146
+ def update_gira: () -> void
147
+ def update_gyorbike: () -> void
148
+ def update_joco: () -> void
149
+ def update_keolis: () -> void
150
+ def update_kolumbus: () -> void
151
+ def update_lastenvelo: () -> void
152
+ def update_linkbike: () -> void
153
+ def update_meiob: () -> void
154
+ def update_mobhis: () -> void
155
+ def update_movus: () -> void
156
+ def update_nubija: () -> void
157
+ def update_open_source_bike_share: () -> void
158
+ def update_openmove: () -> void
159
+ def update_otp: () -> void
160
+ def update_ov_fiets: () -> void
161
+ def update_pedalada: () -> void
162
+ def update_publibike: () -> void
163
+ def update_qrobici: () -> void
164
+ def update_roovee: () -> void
165
+ def update_samba: () -> void
166
+ def update_sejong: () -> void
167
+ def update_seoulbike: () -> void
168
+ def update_slovnaft_bajk: () -> void
169
+ def update_smartbike: () -> void
170
+ def update_smoove: () -> void
171
+ def update_soltrafego: () -> void
172
+ def update_tashu: () -> void
173
+ def update_urbansharing: () -> void
174
+ def update_velobike_kz: () -> void
175
+ def update_velok: () -> void
176
+ def update_veloway: () -> void
177
+ def update_weelo: () -> void
178
+ def update_wegoshare: () -> void
179
+ def update_xian: () -> void
180
+ def update_yelovelo: () -> void
181
+ def update_youbike: () -> void
182
+ def lower_keys: (untyped) -> untyped
183
+ def graphql: (String, String, ?object, ?operation: String?) -> untyped
184
+ def otp_type: (object) -> [String?, String]
185
+ private
186
+ def html_scan: (String, Regexp) -> Array[untyped]
187
+ def html_match: (String, Regexp) -> MatchData
188
+ def html_capture: (String, Regexp) -> String
189
+ def html_text: (untyped, String) -> String
190
+ def gyorbike_count: (untyped, String) -> Integer
191
+ def velobike_number: (untyped) -> Integer
192
+ def detail_xml: (String, **untyped) -> untyped
193
+ def xml_text: (untyped, String) -> untyped
194
+ def xml_boolean?: (String) -> bool
195
+ def bicincitta_coordinate: (String) -> Float
196
+ def pedalada_status: (object) -> String
197
+ def cyclopolis_count: (String) -> Integer
198
+ end
199
+
200
+ class DataLiteral
201
+ @scanner: StringScanner
202
+ def self.parse: (String) -> json
203
+ def initialize: (String) -> void
204
+ def parse: () -> json
205
+ private
206
+ def whitespace: () -> Integer?
207
+ def value: (Integer) -> json
208
+ def sequence: (Integer, String) -> Array[json]
209
+ def string: () -> String
210
+ end
211
+
212
+ class Engine
213
+ MANIFEST: object
214
+ @timeout: Float
215
+ @providers: Array[object]
216
+ @definitions: Hash[String, object]
217
+ @mutex: Mutex
218
+ @cooldowns: Hash[String, Float]
219
+ @systems: Hash[String, Provider]
220
+ @response_cache: ResponseCache
221
+ attr_reader timeout: Float
222
+ def initialize: (?timeout: Numeric) -> void
223
+ def request: (String, ?timeout: Numeric, **untyped) -> untyped
224
+ def close: () -> void
225
+ private
226
+ def catalog_instances: (untyped) -> Array[untyped]
227
+ def find_spec: (object) -> object
228
+ end
229
+ end
230
+ end