njtransit 1.0.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.
- checksums.yaml +7 -0
- data/.claude/commands/njtransit.md +196 -0
- data/.mcp.json.example +12 -0
- data/.mcp.json.sample +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +87 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +159 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/Rakefile +12 -0
- data/docs/plans/2025-01-24-njtransit-gem-design.md +112 -0
- data/docs/plans/2026-01-24-bus-api-design.md +119 -0
- data/docs/plans/2026-01-24-gtfs-implementation.md +2216 -0
- data/docs/plans/2026-01-24-gtfs-loader-design.md +351 -0
- data/docs/superpowers/plans/2026-03-26-dev-infra-and-agent.md +480 -0
- data/lefthook.yml +17 -0
- data/lib/njtransit/client.rb +291 -0
- data/lib/njtransit/configuration.rb +49 -0
- data/lib/njtransit/error.rb +50 -0
- data/lib/njtransit/gtfs/database.rb +145 -0
- data/lib/njtransit/gtfs/importer.rb +124 -0
- data/lib/njtransit/gtfs/models/route.rb +59 -0
- data/lib/njtransit/gtfs/models/stop.rb +63 -0
- data/lib/njtransit/gtfs/queries/routes_between.rb +62 -0
- data/lib/njtransit/gtfs/queries/schedule.rb +75 -0
- data/lib/njtransit/gtfs.rb +119 -0
- data/lib/njtransit/railtie.rb +9 -0
- data/lib/njtransit/resources/base.rb +35 -0
- data/lib/njtransit/resources/bus/enrichment.rb +105 -0
- data/lib/njtransit/resources/bus.rb +95 -0
- data/lib/njtransit/resources/bus_gtfs.rb +34 -0
- data/lib/njtransit/resources/rail.rb +47 -0
- data/lib/njtransit/resources/rail_gtfs.rb +27 -0
- data/lib/njtransit/tasks.rb +74 -0
- data/lib/njtransit/version.rb +5 -0
- data/lib/njtransit.rb +40 -0
- data/sig/njtransit.rbs +4 -0
- metadata +177 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/typhoeus"
|
|
5
|
+
require "faraday/multipart"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
require_relative "resources/base"
|
|
9
|
+
require_relative "resources/bus"
|
|
10
|
+
require_relative "resources/rail"
|
|
11
|
+
require_relative "resources/bus_gtfs"
|
|
12
|
+
require_relative "resources/rail_gtfs"
|
|
13
|
+
|
|
14
|
+
module NJTransit
|
|
15
|
+
class Client
|
|
16
|
+
DEFAULT_AUTH_PATH = "/api/BUSDV2/authenticateUser"
|
|
17
|
+
|
|
18
|
+
attr_reader :username, :password, :log_level, :base_url, :timeout, :auth_path
|
|
19
|
+
|
|
20
|
+
def initialize(username:, password:, log_level: "silent", base_url: Configuration::DEFAULT_BASE_URL,
|
|
21
|
+
timeout: Configuration::DEFAULT_TIMEOUT, auth_path: DEFAULT_AUTH_PATH)
|
|
22
|
+
@username = username
|
|
23
|
+
@password = password
|
|
24
|
+
@log_level = log_level
|
|
25
|
+
@base_url = base_url
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@auth_path = auth_path
|
|
28
|
+
@token = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bus
|
|
32
|
+
@bus ||= Resources::Bus.new(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rail
|
|
36
|
+
@rail ||= Resources::Rail.new(self)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def bus_gtfs
|
|
40
|
+
@bus_gtfs ||= Resources::BusGTFS.new(self)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bus_gtfs_g2
|
|
44
|
+
@bus_gtfs_g2 ||= Resources::BusGTFS.new(self, api_prefix: "/api/GTFSG2")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rail_gtfs
|
|
48
|
+
@rail_gtfs ||= Resources::RailGTFS.new(self)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def get(path, params = {})
|
|
52
|
+
request(:get, path, params)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def post(path, body = {})
|
|
56
|
+
request(:post, path, body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def post_form(path, params = {})
|
|
60
|
+
request_form(:post, path, params)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def put(path, body = {})
|
|
64
|
+
request(:put, path, body)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def patch(path, body = {})
|
|
68
|
+
request(:patch, path, body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete(path, params = {})
|
|
72
|
+
request(:delete, path, params)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post_form_raw(path, params = {})
|
|
76
|
+
request_form_raw(:post, path, params)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def authenticate!
|
|
80
|
+
cached = self.class.token_cache[base_url]
|
|
81
|
+
if cached
|
|
82
|
+
@token = cached
|
|
83
|
+
return @token
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
response = form_connection.post(auth_path) do |req|
|
|
87
|
+
req.body = { username: username, password: password }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
result = parse_body(response.body)
|
|
91
|
+
|
|
92
|
+
unless result.is_a?(Hash) && result["Authenticated"] == "True"
|
|
93
|
+
message = result.is_a?(Hash) && result["errorMessage"] ? result["errorMessage"] : "Authentication failed"
|
|
94
|
+
raise AuthenticationError, message
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@token = result["UserToken"]
|
|
98
|
+
self.class.token_cache[base_url] = @token
|
|
99
|
+
@token
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def token
|
|
103
|
+
authenticate! if @token.nil?
|
|
104
|
+
@token
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def clear_token!
|
|
108
|
+
@token = nil
|
|
109
|
+
self.class.token_cache.delete(base_url)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.token_cache
|
|
113
|
+
@token_cache ||= {}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.clear_token_cache!
|
|
117
|
+
@token_cache = {}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def request(method, path, params_or_body = {})
|
|
123
|
+
response = json_connection.public_send(method) do |req|
|
|
124
|
+
req.url(path)
|
|
125
|
+
if %i[get delete].include?(method)
|
|
126
|
+
req.params = params_or_body
|
|
127
|
+
else
|
|
128
|
+
req.body = JSON.generate(params_or_body)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
handle_response(response)
|
|
133
|
+
rescue Faraday::TimeoutError => e
|
|
134
|
+
raise TimeoutError, e.message
|
|
135
|
+
rescue Faraday::ConnectionFailed => e
|
|
136
|
+
raise ConnectionError, e.message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def request_form(method, path, params = {}, retry_auth: true)
|
|
140
|
+
response = form_connection.public_send(method) do |req|
|
|
141
|
+
req.url(path)
|
|
142
|
+
req.body = params
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
result = handle_response(response)
|
|
146
|
+
|
|
147
|
+
if token_expired?(result) && retry_auth
|
|
148
|
+
clear_token!
|
|
149
|
+
authenticate!
|
|
150
|
+
params[:token] = @token
|
|
151
|
+
return request_form(method, path, params, retry_auth: false)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
check_api_error!(result)
|
|
155
|
+
result
|
|
156
|
+
rescue Faraday::TimeoutError => e
|
|
157
|
+
raise TimeoutError, e.message
|
|
158
|
+
rescue Faraday::ConnectionFailed => e
|
|
159
|
+
raise ConnectionError, e.message
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def request_form_raw(method, path, params = {})
|
|
163
|
+
params[:token] = token
|
|
164
|
+
response = raw_connection.public_send(method) do |req|
|
|
165
|
+
req.url(path)
|
|
166
|
+
req.body = params
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise error_for_status(response.status).new(error_message(response), response: response) unless response.success?
|
|
170
|
+
|
|
171
|
+
response.body
|
|
172
|
+
rescue Faraday::TimeoutError => e
|
|
173
|
+
raise TimeoutError, e.message
|
|
174
|
+
rescue Faraday::ConnectionFailed => e
|
|
175
|
+
raise ConnectionError, e.message
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def token_expired?(result)
|
|
179
|
+
result.is_a?(Hash) && result["errorMessage"] == "Invalid token."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def check_api_error!(result)
|
|
183
|
+
return unless result.is_a?(Hash) && result["errorMessage"]
|
|
184
|
+
|
|
185
|
+
raise APIError, result["errorMessage"]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def json_connection
|
|
189
|
+
@json_connection ||= Faraday.new(url: base_url) do |f|
|
|
190
|
+
f.request :json
|
|
191
|
+
f.response :logger, logger, { headers: log_headers?, bodies: log_bodies? } if logging_enabled?
|
|
192
|
+
f.adapter :typhoeus
|
|
193
|
+
f.options.timeout = timeout
|
|
194
|
+
f.options.open_timeout = timeout
|
|
195
|
+
f.headers["Content-Type"] = "application/json"
|
|
196
|
+
f.headers["Accept"] = "application/json"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def form_connection
|
|
201
|
+
@form_connection ||= Faraday.new(url: base_url) do |f|
|
|
202
|
+
f.request :multipart
|
|
203
|
+
f.request :url_encoded
|
|
204
|
+
f.response :logger, logger, { headers: log_headers?, bodies: log_bodies? } if logging_enabled?
|
|
205
|
+
f.adapter :typhoeus
|
|
206
|
+
f.options.timeout = timeout
|
|
207
|
+
f.options.open_timeout = timeout
|
|
208
|
+
f.headers["Accept"] = "text/plain"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def raw_connection
|
|
213
|
+
@raw_connection ||= Faraday.new(url: base_url) do |f|
|
|
214
|
+
f.request :multipart
|
|
215
|
+
f.request :url_encoded
|
|
216
|
+
f.adapter :typhoeus
|
|
217
|
+
f.options.timeout = timeout
|
|
218
|
+
f.options.open_timeout = timeout
|
|
219
|
+
f.headers["Accept"] = "*/*"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def handle_response(response)
|
|
224
|
+
return parse_body(response.body) if response.success?
|
|
225
|
+
|
|
226
|
+
raise error_for_status(response.status).new(
|
|
227
|
+
error_message(response),
|
|
228
|
+
response: response
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def parse_body(body)
|
|
233
|
+
return nil if body.nil? || body.empty?
|
|
234
|
+
|
|
235
|
+
JSON.parse(body)
|
|
236
|
+
rescue JSON::ParserError
|
|
237
|
+
body
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def error_message(response)
|
|
241
|
+
parsed = parse_body(response.body)
|
|
242
|
+
if parsed.is_a?(Hash)
|
|
243
|
+
parsed["error"] || parsed["message"] || parsed["errorMessage"] || response.body
|
|
244
|
+
else
|
|
245
|
+
response.body
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def error_for_status(status)
|
|
250
|
+
case status
|
|
251
|
+
when 400 then BadRequestError
|
|
252
|
+
when 401 then AuthenticationError
|
|
253
|
+
when 403 then ForbiddenError
|
|
254
|
+
when 404 then NotFoundError
|
|
255
|
+
when 405 then MethodNotAllowedError
|
|
256
|
+
when 409 then ConflictError
|
|
257
|
+
when 410 then GoneError
|
|
258
|
+
when 422 then UnprocessableEntityError
|
|
259
|
+
when 429 then RateLimitError
|
|
260
|
+
when 500 then InternalServerError
|
|
261
|
+
when 502 then BadGatewayError
|
|
262
|
+
when 503 then ServiceUnavailableError
|
|
263
|
+
when 504 then GatewayTimeoutError
|
|
264
|
+
when 400..499 then ClientError
|
|
265
|
+
when 500..599 then ServerError
|
|
266
|
+
else Error
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def logging_enabled?
|
|
271
|
+
%w[info debug].include?(log_level)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def log_headers?
|
|
275
|
+
log_level == "debug"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def log_bodies?
|
|
279
|
+
log_level == "debug"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def logger
|
|
283
|
+
@logger ||= Logger.new($stdout).tap do |log|
|
|
284
|
+
log.level = log_level == "debug" ? Logger::DEBUG : Logger::INFO
|
|
285
|
+
log.formatter = proc do |severity, _datetime, _progname, msg|
|
|
286
|
+
"[NJTransit #{severity}] #{msg}\n"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NJTransit
|
|
4
|
+
class Configuration
|
|
5
|
+
VALID_LOG_LEVELS = %w[silent info debug].freeze
|
|
6
|
+
DEFAULT_BASE_URL = "https://pcsdata.njtransit.com"
|
|
7
|
+
DEFAULT_RAIL_BASE_URL = "https://raildata.njtransit.com"
|
|
8
|
+
DEFAULT_TIMEOUT = 30
|
|
9
|
+
|
|
10
|
+
attr_accessor :username, :password, :base_url, :timeout, :gtfs_database_path
|
|
11
|
+
attr_reader :log_level
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@username = ENV.fetch("NJTRANSIT_USERNAME", nil)
|
|
15
|
+
@password = ENV.fetch("NJTRANSIT_PASSWORD", nil)
|
|
16
|
+
@log_level = ENV.fetch("NJTRANSIT_LOG_LEVEL", "silent")
|
|
17
|
+
@base_url = ENV.fetch("NJTRANSIT_BASE_URL", DEFAULT_BASE_URL)
|
|
18
|
+
@timeout = ENV.fetch("NJTRANSIT_TIMEOUT", DEFAULT_TIMEOUT).to_i
|
|
19
|
+
@gtfs_database_path = ENV.fetch("NJTRANSIT_GTFS_DATABASE_PATH", nil) || default_gtfs_database_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_level=(level)
|
|
23
|
+
level = level.to_s.downcase
|
|
24
|
+
unless VALID_LOG_LEVELS.include?(level)
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"Invalid log level: #{level}. Valid levels: #{VALID_LOG_LEVELS.join(", ")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@log_level = level
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
{
|
|
34
|
+
username: username,
|
|
35
|
+
password: password,
|
|
36
|
+
log_level: log_level,
|
|
37
|
+
base_url: base_url,
|
|
38
|
+
timeout: timeout
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def default_gtfs_database_path
|
|
45
|
+
base = ENV["XDG_DATA_HOME"] || File.expand_path("~/.local/share")
|
|
46
|
+
File.join(base, "njtransit", "gtfs.sqlite3")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NJTransit
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :response
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, response: nil)
|
|
8
|
+
@response = response
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# API-level errors (returned in response body)
|
|
14
|
+
class APIError < Error; end
|
|
15
|
+
|
|
16
|
+
# Client errors (4xx)
|
|
17
|
+
class ClientError < Error; end
|
|
18
|
+
class BadRequestError < ClientError; end # 400
|
|
19
|
+
class AuthenticationError < ClientError; end # 401
|
|
20
|
+
class ForbiddenError < ClientError; end # 403
|
|
21
|
+
class NotFoundError < ClientError; end # 404
|
|
22
|
+
class MethodNotAllowedError < ClientError; end # 405
|
|
23
|
+
class ConflictError < ClientError; end # 409
|
|
24
|
+
class GoneError < ClientError; end # 410
|
|
25
|
+
class UnprocessableEntityError < ClientError; end # 422
|
|
26
|
+
class RateLimitError < ClientError; end # 429
|
|
27
|
+
|
|
28
|
+
# Server errors (5xx)
|
|
29
|
+
class ServerError < Error; end
|
|
30
|
+
class InternalServerError < ServerError; end # 500
|
|
31
|
+
class BadGatewayError < ServerError; end # 502
|
|
32
|
+
class ServiceUnavailableError < ServerError; end # 503
|
|
33
|
+
class GatewayTimeoutError < ServerError; end # 504
|
|
34
|
+
|
|
35
|
+
# Connection issues
|
|
36
|
+
class ConnectionError < Error; end
|
|
37
|
+
class TimeoutError < ConnectionError; end
|
|
38
|
+
|
|
39
|
+
# GTFS not imported
|
|
40
|
+
class GTFSNotImportedError < Error
|
|
41
|
+
def initialize(detected_path: nil)
|
|
42
|
+
message = "GTFS data not found. Run: rake njtransit:gtfs:import[/path/to/bus_data]"
|
|
43
|
+
if detected_path
|
|
44
|
+
message += "\n\nDetected GTFS files at: #{detected_path}"
|
|
45
|
+
message += "\nHint: rake njtransit:gtfs:import[#{detected_path}]"
|
|
46
|
+
end
|
|
47
|
+
super(message)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module NJTransit
|
|
7
|
+
module GTFS
|
|
8
|
+
# Database module for managing GTFS SQLite storage
|
|
9
|
+
module Database
|
|
10
|
+
class << self
|
|
11
|
+
def connection(path = nil)
|
|
12
|
+
@path = path if path
|
|
13
|
+
@connection ||= begin
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
15
|
+
Sequel.sqlite(@path)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def disconnect
|
|
20
|
+
@connection&.disconnect
|
|
21
|
+
@connection = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exists?(path)
|
|
25
|
+
return false unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
db = Sequel.sqlite(path)
|
|
28
|
+
db.table_exists?(:agencies) && db.table_exists?(:stops)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
ensure
|
|
32
|
+
db&.disconnect
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def setup_schema!
|
|
36
|
+
create_agencies_table
|
|
37
|
+
create_routes_table
|
|
38
|
+
create_stops_table
|
|
39
|
+
create_trips_table
|
|
40
|
+
create_stop_times_table
|
|
41
|
+
create_calendar_dates_table
|
|
42
|
+
create_shapes_table
|
|
43
|
+
create_import_metadata_table
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear!
|
|
47
|
+
db = connection
|
|
48
|
+
%i[agencies routes stops trips stop_times calendar_dates shapes import_metadata].each do |table|
|
|
49
|
+
db.drop_table?(table)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def create_agencies_table
|
|
56
|
+
connection.create_table?(:agencies) do
|
|
57
|
+
String :agency_id, primary_key: true
|
|
58
|
+
String :agency_name
|
|
59
|
+
String :agency_url
|
|
60
|
+
String :agency_timezone
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_routes_table
|
|
65
|
+
connection.create_table?(:routes) do
|
|
66
|
+
String :route_id, primary_key: true
|
|
67
|
+
String :agency_id
|
|
68
|
+
String :route_short_name
|
|
69
|
+
String :route_long_name
|
|
70
|
+
Integer :route_type
|
|
71
|
+
String :route_color
|
|
72
|
+
index :route_short_name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_stops_table
|
|
77
|
+
connection.create_table?(:stops) do
|
|
78
|
+
String :stop_id, primary_key: true
|
|
79
|
+
String :stop_code
|
|
80
|
+
String :stop_name
|
|
81
|
+
Float :stop_lat
|
|
82
|
+
Float :stop_lon
|
|
83
|
+
String :zone_id
|
|
84
|
+
index :stop_code
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def create_trips_table
|
|
89
|
+
connection.create_table?(:trips) do
|
|
90
|
+
String :trip_id, primary_key: true
|
|
91
|
+
String :route_id
|
|
92
|
+
String :service_id
|
|
93
|
+
String :trip_headsign
|
|
94
|
+
Integer :direction_id
|
|
95
|
+
String :shape_id
|
|
96
|
+
index :route_id
|
|
97
|
+
index :service_id
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create_stop_times_table
|
|
102
|
+
connection.create_table?(:stop_times) do
|
|
103
|
+
primary_key :id
|
|
104
|
+
String :trip_id
|
|
105
|
+
String :stop_id
|
|
106
|
+
String :arrival_time
|
|
107
|
+
String :departure_time
|
|
108
|
+
Integer :stop_sequence
|
|
109
|
+
index :trip_id
|
|
110
|
+
index :stop_id
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_calendar_dates_table
|
|
115
|
+
connection.create_table?(:calendar_dates) do
|
|
116
|
+
primary_key :id
|
|
117
|
+
String :service_id
|
|
118
|
+
String :date
|
|
119
|
+
Integer :exception_type
|
|
120
|
+
index %i[service_id date]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create_shapes_table
|
|
125
|
+
connection.create_table?(:shapes) do
|
|
126
|
+
primary_key :id
|
|
127
|
+
String :shape_id
|
|
128
|
+
Float :shape_pt_lat
|
|
129
|
+
Float :shape_pt_lon
|
|
130
|
+
Integer :shape_pt_sequence
|
|
131
|
+
index :shape_id
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def create_import_metadata_table
|
|
136
|
+
connection.create_table?(:import_metadata) do
|
|
137
|
+
primary_key :id
|
|
138
|
+
DateTime :imported_at
|
|
139
|
+
String :source_path
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module NJTransit
|
|
6
|
+
module GTFS
|
|
7
|
+
class Importer
|
|
8
|
+
REQUIRED_FILES = %w[agency.txt routes.txt stops.txt].freeze
|
|
9
|
+
OPTIONAL_FILES = %w[trips.txt stop_times.txt calendar_dates.txt shapes.txt].freeze
|
|
10
|
+
|
|
11
|
+
# Table configurations: [filename, table, batch_size, field_mapper]
|
|
12
|
+
TABLE_CONFIGS = {
|
|
13
|
+
agencies: ["agency.txt", 1000, ->(r) { agency_fields(r) }],
|
|
14
|
+
routes: ["routes.txt", 1000, ->(r) { route_fields(r) }],
|
|
15
|
+
stops: ["stops.txt", 1000, ->(r) { stop_fields(r) }],
|
|
16
|
+
trips: ["trips.txt", 1000, ->(r) { trip_fields(r) }],
|
|
17
|
+
stop_times: ["stop_times.txt", 10_000, ->(r) { stop_time_fields(r) }],
|
|
18
|
+
calendar_dates: ["calendar_dates.txt", 1000, ->(r) { calendar_date_fields(r) }],
|
|
19
|
+
shapes: ["shapes.txt", 50_000, ->(r) { shape_fields(r) }]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :source_path, :db_path
|
|
23
|
+
|
|
24
|
+
def initialize(source_path, db_path)
|
|
25
|
+
@source_path = source_path
|
|
26
|
+
@db_path = db_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def import(force: false)
|
|
30
|
+
validate_can_import!(force)
|
|
31
|
+
prepare_database(force)
|
|
32
|
+
import_all_tables
|
|
33
|
+
record_metadata
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def valid_gtfs_directory?
|
|
37
|
+
return false unless File.directory?(source_path)
|
|
38
|
+
|
|
39
|
+
REQUIRED_FILES.all? { |f| File.exist?(File.join(source_path, f)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def agency_fields(row)
|
|
44
|
+
{ agency_id: row["agency_id"], agency_name: row["agency_name"],
|
|
45
|
+
agency_url: row["agency_url"], agency_timezone: row["agency_timezone"] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def route_fields(row)
|
|
49
|
+
{ route_id: row["route_id"], agency_id: row["agency_id"],
|
|
50
|
+
route_short_name: row["route_short_name"], route_long_name: row["route_long_name"],
|
|
51
|
+
route_type: row["route_type"]&.to_i, route_color: row["route_color"] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stop_fields(row)
|
|
55
|
+
{ stop_id: row["stop_id"], stop_code: row["stop_code"], stop_name: row["stop_name"],
|
|
56
|
+
stop_lat: row["stop_lat"]&.to_f, stop_lon: row["stop_lon"]&.to_f, zone_id: row["zone_id"] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def trip_fields(row)
|
|
60
|
+
{ trip_id: row["trip_id"], route_id: row["route_id"], service_id: row["service_id"],
|
|
61
|
+
trip_headsign: row["trip_headsign"], direction_id: row["direction_id"]&.to_i, shape_id: row["shape_id"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop_time_fields(row)
|
|
65
|
+
{ trip_id: row["trip_id"], stop_id: row["stop_id"], arrival_time: row["arrival_time"],
|
|
66
|
+
departure_time: row["departure_time"], stop_sequence: row["stop_sequence"]&.to_i }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def calendar_date_fields(row)
|
|
70
|
+
{ service_id: row["service_id"], date: row["date"], exception_type: row["exception_type"]&.to_i }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shape_fields(row)
|
|
74
|
+
{ shape_id: row["shape_id"], shape_pt_lat: row["shape_pt_lat"]&.to_f,
|
|
75
|
+
shape_pt_lon: row["shape_pt_lon"]&.to_f, shape_pt_sequence: row["shape_pt_sequence"]&.to_i }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def validate_can_import!(force)
|
|
82
|
+
return unless Database.exists?(db_path) && !force
|
|
83
|
+
|
|
84
|
+
raise NJTransit::Error, "GTFS database already exists at #{db_path}. Use force: true to reimport."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prepare_database(force)
|
|
88
|
+
Database.disconnect
|
|
89
|
+
FileUtils.rm_f(db_path) if force
|
|
90
|
+
Database.connection(db_path)
|
|
91
|
+
Database.setup_schema!
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def import_all_tables
|
|
95
|
+
TABLE_CONFIGS.each do |table, (filename, batch_size, mapper)|
|
|
96
|
+
import_csv(filename, table, batch_size: batch_size, &mapper)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def import_csv(filename, table, batch_size: 1000)
|
|
101
|
+
path = File.join(source_path, filename)
|
|
102
|
+
return unless File.exist?(path)
|
|
103
|
+
|
|
104
|
+
batch = []
|
|
105
|
+
CSV.foreach(path, headers: true) do |row|
|
|
106
|
+
batch << yield(row)
|
|
107
|
+
flush_batch(table, batch) if batch.size >= batch_size
|
|
108
|
+
end
|
|
109
|
+
flush_batch(table, batch)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def flush_batch(table, batch)
|
|
113
|
+
return if batch.empty?
|
|
114
|
+
|
|
115
|
+
Database.connection[table].multi_insert(batch)
|
|
116
|
+
batch.clear
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def record_metadata
|
|
120
|
+
Database.connection[:import_metadata].insert(imported_at: Time.now, source_path: source_path)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|