overture_maps 0.1.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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +416 -0
  5. data/app/controllers/overture_maps/application_controller.rb +28 -0
  6. data/app/controllers/overture_maps/attribution_controller.rb +15 -0
  7. data/app/controllers/overture_maps/features_controller.rb +125 -0
  8. data/app/controllers/overture_maps/search_controller.rb +31 -0
  9. data/app/controllers/overture_maps/tiles_controller.rb +85 -0
  10. data/config/routes.rb +13 -0
  11. data/exe/overture-maps-mcp +6 -0
  12. data/lib/generators/overture_maps/address_generator.rb +19 -0
  13. data/lib/generators/overture_maps/base_features_generator.rb +19 -0
  14. data/lib/generators/overture_maps/base_generator.rb +22 -0
  15. data/lib/generators/overture_maps/building_generator.rb +19 -0
  16. data/lib/generators/overture_maps/division_generator.rb +19 -0
  17. data/lib/generators/overture_maps/install_generator.rb +42 -0
  18. data/lib/generators/overture_maps/place_generator.rb +19 -0
  19. data/lib/generators/overture_maps/transportation_generator.rb +20 -0
  20. data/lib/generators/templates/address_migration.rb.tt +25 -0
  21. data/lib/generators/templates/address_model.rb.tt +4 -0
  22. data/lib/generators/templates/base_feature_model.rb.tt +4 -0
  23. data/lib/generators/templates/base_features_migration.rb.tt +27 -0
  24. data/lib/generators/templates/building_migration.rb.tt +24 -0
  25. data/lib/generators/templates/building_model.rb.tt +4 -0
  26. data/lib/generators/templates/category_migration.rb.tt +15 -0
  27. data/lib/generators/templates/category_model.rb.tt +4 -0
  28. data/lib/generators/templates/connector_model.rb.tt +4 -0
  29. data/lib/generators/templates/division_migration.rb.tt +31 -0
  30. data/lib/generators/templates/division_model.rb.tt +4 -0
  31. data/lib/generators/templates/imported_areas_migration.rb.tt +20 -0
  32. data/lib/generators/templates/place_migration.rb.tt +30 -0
  33. data/lib/generators/templates/place_model.rb.tt +4 -0
  34. data/lib/generators/templates/postgis_migration.rb.tt +5 -0
  35. data/lib/generators/templates/segment_model.rb.tt +4 -0
  36. data/lib/generators/templates/transportation_migration.rb.tt +38 -0
  37. data/lib/overture_maps/attribution.rb +66 -0
  38. data/lib/overture_maps/bounding_box.rb +80 -0
  39. data/lib/overture_maps/changelog.rb +70 -0
  40. data/lib/overture_maps/configuration.rb +43 -0
  41. data/lib/overture_maps/database.rb +74 -0
  42. data/lib/overture_maps/division_search.rb +44 -0
  43. data/lib/overture_maps/engine.rb +32 -0
  44. data/lib/overture_maps/geometry_parser.rb +41 -0
  45. data/lib/overture_maps/gers.rb +30 -0
  46. data/lib/overture_maps/import/downloader.rb +248 -0
  47. data/lib/overture_maps/import/location_based_runner.rb +176 -0
  48. data/lib/overture_maps/import/parquet_reader.rb +32 -0
  49. data/lib/overture_maps/import/record_mapper.rb +182 -0
  50. data/lib/overture_maps/import/runner.rb +125 -0
  51. data/lib/overture_maps/import.rb +27 -0
  52. data/lib/overture_maps/mcp_server.rb +222 -0
  53. data/lib/overture_maps/models/address.rb +32 -0
  54. data/lib/overture_maps/models/base.rb +39 -0
  55. data/lib/overture_maps/models/base_feature.rb +31 -0
  56. data/lib/overture_maps/models/building.rb +35 -0
  57. data/lib/overture_maps/models/category.rb +32 -0
  58. data/lib/overture_maps/models/connector.rb +11 -0
  59. data/lib/overture_maps/models/division.rb +43 -0
  60. data/lib/overture_maps/models/imported_area.rb +42 -0
  61. data/lib/overture_maps/models/place.rb +43 -0
  62. data/lib/overture_maps/models/segment.rb +22 -0
  63. data/lib/overture_maps/models.rb +25 -0
  64. data/lib/overture_maps/query.rb +168 -0
  65. data/lib/overture_maps/query_engine.rb +268 -0
  66. data/lib/overture_maps/releases.rb +74 -0
  67. data/lib/overture_maps/storage.rb +121 -0
  68. data/lib/overture_maps/syncer.rb +99 -0
  69. data/lib/overture_maps/util.rb +30 -0
  70. data/lib/overture_maps/version.rb +3 -0
  71. data/lib/overture_maps.rb +42 -0
  72. data/lib/tasks/overture_maps.rake +554 -0
  73. metadata +301 -0
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module OvertureMaps
8
+ # Ad-hoc queries against Overture GeoParquet — no PostGIS import needed.
9
+ #
10
+ # OvertureMaps.query(theme: "places", location: "Seattle").limit(10).each { |r| ... }
11
+ # OvertureMaps.query(theme: "buildings", bbox: [47.5, -122.4, 47.7, -122.2]).count
12
+ # OvertureMaps.query(theme: "places", bbox: "47.5,-122.4,47.7,-122.2").export("places.geojson")
13
+ #
14
+ # Unlimited queries spool through the same cache files the import pipeline
15
+ # uses (so a query warms the cache for a later import and vice versa);
16
+ # limited queries use a throwaway temp file. Records are hashes with the
17
+ # geometry parsed to an RGeo feature.
18
+ class Query
19
+ include Enumerable
20
+
21
+ EXPORT_EXTENSIONS = {
22
+ ".parquet" => "parquet",
23
+ ".geojson" => "geojson",
24
+ ".geojsonseq" => "geojsonseq",
25
+ ".gpkg" => "gpkg"
26
+ }.freeze
27
+
28
+ attr_reader :theme, :type, :release
29
+
30
+ def initialize(theme:, type: nil, bbox: nil, location: nil, release: nil, limit: nil)
31
+ @theme = theme
32
+ @type = type || infer_type(theme)
33
+ @release = Releases.validate!(release || Releases.current)
34
+ @location = location
35
+ @bbox = coerce_bbox(bbox) if bbox
36
+ raise ArgumentError, "provide bbox: or location:" unless @bbox || @location
37
+
38
+ @limit = limit && Integer(limit)
39
+ end
40
+
41
+ def limit(count)
42
+ self.class.new(theme: theme, type: type, bbox: bbox, location: @location,
43
+ release: release, limit: count)
44
+ end
45
+
46
+ # The resolved bounding box (resolves a location name on first use).
47
+ def bbox
48
+ @bbox ||= resolve_location
49
+ end
50
+
51
+ # Fast remote count via row-group pushdown — nothing is downloaded.
52
+ def count
53
+ sql, params = Import::Downloader.bbox_query(
54
+ theme: theme, type: type, release: release, bbox: bbox, limit: @limit
55
+ )
56
+ rows = QueryEngine.instance.query("SELECT count(*) AS n FROM (#{sql})", params)
57
+ Integer(rows.first["n"])
58
+ end
59
+
60
+ def each(&block)
61
+ return enum_for(:each) unless block
62
+
63
+ with_extract do |path|
64
+ reader = Import::ParquetReader.new(theme: theme)
65
+ reader.each_record(source: path) do |record|
66
+ record["geometry"] = GeometryParser.parse(record["geometry"])
67
+ block.call(record)
68
+ end
69
+ end
70
+ self
71
+ end
72
+
73
+ def each_batch(size: 1000)
74
+ return enum_for(:each_batch, size: size) unless block_given?
75
+
76
+ batch = []
77
+ each do |record|
78
+ batch << record
79
+ if batch.length >= size
80
+ yield batch
81
+ batch = []
82
+ end
83
+ end
84
+ yield batch if batch.any?
85
+ self
86
+ end
87
+
88
+ # Writes the query result straight to a file; format inferred from the
89
+ # extension unless given explicitly.
90
+ def export(path, format: nil)
91
+ format ||= EXPORT_EXTENSIONS[File.extname(path).downcase] or
92
+ raise ArgumentError, "cannot infer format from #{path}; pass format:"
93
+
94
+ downloader.extract_bbox(bbox, format: format, output_path: path, limit: @limit) or
95
+ raise Error, "query returned no data"
96
+ end
97
+
98
+ # A GeoJSON FeatureCollection hash. Materializes everything — use
99
+ # #export for large results.
100
+ def to_geojson
101
+ features = map do |record|
102
+ geometry = record["geometry"]
103
+ {
104
+ type: "Feature",
105
+ geometry: geometry && RGeo::GeoJSON.encode(geometry),
106
+ properties: record.except("geometry", "bbox")
107
+ }
108
+ end
109
+ { type: "FeatureCollection", features: features }
110
+ end
111
+
112
+ private
113
+
114
+ def infer_type(theme)
115
+ types = Import::Downloader.types_for_theme(theme)
116
+ raise ArgumentError, "unknown theme: #{theme}" if types.empty?
117
+ return types.first if types.length == 1
118
+
119
+ raise ArgumentError, "theme #{theme} has multiple types (#{types.join(", ")}); pass type:"
120
+ end
121
+
122
+ def coerce_bbox(value)
123
+ case value
124
+ when BoundingBox then value
125
+ when Array
126
+ raise ArgumentError, "bbox array must be [lat1, lng1, lat2, lng2]" unless value.length == 4
127
+
128
+ BoundingBox.new(lat1: value[0], lng1: value[1], lat2: value[2], lng2: value[3])
129
+ when String
130
+ BoundingBox.parse(value) or raise ArgumentError, "unparseable bbox: #{value.inspect}"
131
+ else
132
+ raise ArgumentError, "bbox must be a BoundingBox, array, or string"
133
+ end
134
+ end
135
+
136
+ def resolve_location
137
+ results = DivisionSearch.search(query: @location, release: release)
138
+ raise Error, "no divisions found matching #{@location.inspect}" if results.empty?
139
+
140
+ results.first[:bbox]
141
+ end
142
+
143
+ def downloader
144
+ Import::Downloader.new(theme: theme, type: type, release: release)
145
+ end
146
+
147
+ # Unlimited queries share the import pipeline's cache files; limited
148
+ # queries spool to a temp file that is removed afterwards.
149
+ def with_extract
150
+ if @limit
151
+ path = File.join(Dir.tmpdir, "overture_query_#{SecureRandom.hex(8)}.parquet")
152
+ begin
153
+ extracted = downloader.extract_bbox(bbox, output_path: path, limit: @limit)
154
+ yield extracted if extracted
155
+ ensure
156
+ FileUtils.rm_f(path)
157
+ end
158
+ else
159
+ path = downloader.cached_extract(bbox) || downloader.extract_bbox(bbox)
160
+ yield path if path
161
+ end
162
+ end
163
+ end
164
+
165
+ def self.query(**options)
166
+ Query.new(**options)
167
+ end
168
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module OvertureMaps
8
+ # Runs spatial SQL against Overture GeoParquet via DuckDB.
9
+ #
10
+ # Prefers the ruby-duckdb gem (in-process, bound parameters, no shell).
11
+ # Falls back to the DuckDB CLI: PATH lookup first, then a verified download.
12
+ # All user-supplied values go through bound parameters (native) or strict
13
+ # literal quoting (CLI) — never raw string interpolation.
14
+ class QueryEngine
15
+ DUCKDB_CLI_VERSION = "1.5.3"
16
+
17
+ class Error < OvertureMaps::Error; end
18
+
19
+ class << self
20
+ def instance
21
+ @instance ||= new
22
+ end
23
+
24
+ def reset!
25
+ @instance = nil
26
+ end
27
+ end
28
+
29
+ # Returns an array of row hashes. Intended for modest result sets
30
+ # (division searches, lookups) — bulk data must use #copy_to.
31
+ def query(sql, params = [])
32
+ if native?
33
+ native_query(sql, params)
34
+ else
35
+ cli_query(sql, params)
36
+ end
37
+ end
38
+
39
+ # Exports a query's results to a local file (parquet by default). This is
40
+ # the bulk-data path: DuckDB streams row groups from S3 and writes the
41
+ # file without materializing results in Ruby.
42
+ def copy_to(sql, params: [], output_path:, format: "parquet")
43
+ copy_sql = build_copy_sql(sql, output_path, format)
44
+
45
+ if native?
46
+ native_execute(copy_sql, params)
47
+ else
48
+ cli_execute(interpolate(copy_sql, params))
49
+ end
50
+ output_path
51
+ end
52
+
53
+ def native?
54
+ return @native unless @native.nil?
55
+
56
+ @native =
57
+ begin
58
+ require "duckdb"
59
+ true
60
+ rescue LoadError
61
+ false
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def build_copy_sql(sql, output_path, format)
68
+ path = quote_string(output_path.to_s)
69
+ case format.to_s.downcase
70
+ when "parquet"
71
+ "COPY (#{sql}) TO #{path} (FORMAT PARQUET)"
72
+ when "geojson"
73
+ "COPY (#{sql}) TO #{path} WITH (FORMAT GDAL, DRIVER 'GeoJSON')"
74
+ when "geojsonseq"
75
+ "COPY (#{sql}) TO #{path} WITH (FORMAT GDAL, DRIVER 'GeoJSONSeq')"
76
+ when "gpkg", "geopackage"
77
+ "COPY (#{sql}) TO #{path} WITH (FORMAT GDAL, DRIVER 'GPKG')"
78
+ else
79
+ raise ArgumentError, "unknown export format: #{format}"
80
+ end
81
+ end
82
+
83
+ def init_statements
84
+ region = OvertureMaps.configuration.s3_region
85
+ raise Error, "invalid s3_region: #{region.inspect}" unless region.match?(/\A[a-z0-9-]+\z/)
86
+
87
+ # Division areas and building footprints have large row groups; the
88
+ # httpfs default of 30s times out on slower links, so give bulk
89
+ # fetches at least two minutes and a few retries.
90
+ http_timeout_ms = [Integer(OvertureMaps.configuration.timeout) * 1000, 120_000].max
91
+
92
+ [
93
+ "INSTALL spatial",
94
+ "LOAD spatial",
95
+ "INSTALL httpfs",
96
+ "LOAD httpfs",
97
+ "SET s3_region='#{region}'",
98
+ "SET http_timeout=#{http_timeout_ms}",
99
+ "SET http_retries=5"
100
+ ]
101
+ end
102
+
103
+ # --- native (ruby-duckdb) backend ---
104
+
105
+ def native_connection
106
+ @native_db ||= ::DuckDB::Database.open
107
+ con = @native_db.connect
108
+ init_statements.each { |stmt| con.query(stmt) }
109
+ con
110
+ end
111
+
112
+ def native_query(sql, params)
113
+ con = native_connection
114
+ result = con.query(sql, *params)
115
+ columns = result.columns.map(&:name)
116
+ result.map { |row| columns.zip(row.map { |v| normalize_value(v) }).to_h }
117
+ ensure
118
+ con&.close
119
+ end
120
+
121
+ def native_execute(sql, params)
122
+ con = native_connection
123
+ con.query(sql, *params)
124
+ nil
125
+ ensure
126
+ con&.close
127
+ end
128
+
129
+ def normalize_value(value)
130
+ case value
131
+ when ::DuckDB::Interval then value.to_s
132
+ else value
133
+ end
134
+ rescue NameError
135
+ value
136
+ end
137
+
138
+ # --- CLI backend ---
139
+
140
+ def cli_query(sql, params)
141
+ output = run_cli(interpolate(sql, params), json: true)
142
+ output = output.strip
143
+ return [] if output.empty?
144
+
145
+ JSON.parse(output)
146
+ end
147
+
148
+ def cli_execute(sql)
149
+ run_cli(sql, json: false)
150
+ nil
151
+ end
152
+
153
+ def run_cli(sql, json:)
154
+ cli = self.class.cli_path
155
+ full_sql = (init_statements + [sql]).join(";\n") + ";"
156
+ argv = [cli]
157
+ argv << "-json" if json
158
+ argv += ["-batch", "-noheader"] unless json
159
+
160
+ stdout, stderr, status = Open3.capture3(*argv, stdin_data: full_sql)
161
+ unless status.success?
162
+ detail = stderr.strip.empty? ? stdout.strip : stderr.strip
163
+ raise Error, "DuckDB CLI failed (exit #{status.exitstatus}): #{detail}"
164
+ end
165
+
166
+ stdout
167
+ end
168
+
169
+ # Replaces `?` placeholders with safely quoted literals for the CLI
170
+ # backend. Question marks inside string literals are not supported in
171
+ # our SQL, which we control.
172
+ def interpolate(sql, params)
173
+ remaining = params.dup
174
+ result = sql.gsub("?") do
175
+ raise Error, "not enough bind params for SQL" if remaining.empty?
176
+
177
+ quote(remaining.shift)
178
+ end
179
+ raise Error, "too many bind params for SQL" unless remaining.empty?
180
+
181
+ result
182
+ end
183
+
184
+ def quote(value)
185
+ case value
186
+ when Integer, Float then value.to_s
187
+ when Numeric then Float(value).to_s
188
+ when nil then "NULL"
189
+ when true, false then value.to_s
190
+ else quote_string(value.to_s)
191
+ end
192
+ end
193
+
194
+ def quote_string(str)
195
+ "'#{str.gsub("'", "''")}'"
196
+ end
197
+
198
+ class << self
199
+ # Resolve the DuckDB CLI: configured path, PATH, cached download —
200
+ # downloading a pinned release if necessary.
201
+ def cli_path
202
+ configured = OvertureMaps.configuration.duckdb_cli_path
203
+ return configured if configured && File.executable?(configured)
204
+
205
+ found = find_in_path("duckdb")
206
+ return found if found
207
+
208
+ cached = cached_cli_path
209
+ return cached if File.executable?(cached)
210
+
211
+ download_cli(cached)
212
+ cached
213
+ end
214
+
215
+ def cli_available?
216
+ !!(OvertureMaps.configuration.duckdb_cli_path || find_in_path("duckdb") ||
217
+ File.executable?(cached_cli_path))
218
+ end
219
+
220
+ private
221
+
222
+ def find_in_path(name)
223
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
224
+ candidate = File.join(dir, name)
225
+ return candidate if File.file?(candidate) && File.executable?(candidate)
226
+ end
227
+ nil
228
+ end
229
+
230
+ def cached_cli_path
231
+ File.join(cache_root, "duckdb-#{DUCKDB_CLI_VERSION}", "duckdb")
232
+ end
233
+
234
+ def cache_root
235
+ base = ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache")
236
+ File.join(base, "overture_maps")
237
+ end
238
+
239
+ def cli_archive_name
240
+ case RUBY_PLATFORM
241
+ when /darwin/ then "duckdb_cli-osx-universal.zip"
242
+ when /aarch64-linux|arm64-linux/ then "duckdb_cli-linux-arm64.zip"
243
+ when /linux/ then "duckdb_cli-linux-amd64.zip"
244
+ else
245
+ raise Error, "no DuckDB CLI build for #{RUBY_PLATFORM}; install duckdb " \
246
+ "manually (https://duckdb.org) or add the duckdb gem"
247
+ end
248
+ end
249
+
250
+ def download_cli(target)
251
+ require "overture_maps/storage"
252
+
253
+ url = "https://github.com/duckdb/duckdb/releases/download/v#{DUCKDB_CLI_VERSION}/#{cli_archive_name}"
254
+ dir = File.dirname(target)
255
+ FileUtils.mkdir_p(dir)
256
+ zip_path = File.join(dir, "cli.zip")
257
+
258
+ OvertureMaps.configuration.logger&.info("Downloading DuckDB CLI #{DUCKDB_CLI_VERSION}...")
259
+ Storage.download_url(url, to: zip_path)
260
+
261
+ system("unzip", "-o", "-q", "-j", zip_path, "-d", dir, exception: true)
262
+ FileUtils.chmod("+x", target)
263
+ ensure
264
+ FileUtils.rm_f(zip_path) if zip_path
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OvertureMaps
6
+ # Discovers available Overture releases. Primary source is the official
7
+ # STAC catalog; falls back to listing the bucket's release/ prefixes.
8
+ # Results are memoized per process.
9
+ module Releases
10
+ STAC_CATALOG_URL = "https://stac.overturemaps.org/catalog.json"
11
+ RELEASE_FORMAT = /\A\d{4}-\d{2}-\d{2}(?:\.\d+)?\z/
12
+ RELEASE_PATTERN = /\d{4}-\d{2}-\d{2}(?:\.\d+)?/
13
+
14
+ class Error < OvertureMaps::Error; end
15
+
16
+ class << self
17
+ # All known releases, newest first.
18
+ def all
19
+ @all ||= (from_stac || from_bucket).sort.reverse.freeze
20
+ end
21
+
22
+ def latest
23
+ all.first or raise Error, "no Overture releases found"
24
+ end
25
+
26
+ # The release to use: explicit config wins, otherwise latest.
27
+ def current
28
+ configured = OvertureMaps.configuration.release
29
+ return validate!(configured) if configured
30
+
31
+ latest
32
+ end
33
+
34
+ def validate!(release)
35
+ unless release.to_s.match?(RELEASE_FORMAT)
36
+ raise Error, "invalid release #{release.inspect} (expected e.g. 2026-06-17.0)"
37
+ end
38
+
39
+ release.to_s
40
+ end
41
+
42
+ def reset!
43
+ @all = nil
44
+ end
45
+
46
+ private
47
+
48
+ def from_stac
49
+ require "overture_maps/storage"
50
+
51
+ body = Storage.get(STAC_CATALOG_URL)
52
+ catalog = JSON.parse(body)
53
+ releases = Array(catalog["links"])
54
+ .select { |link| link["rel"] == "child" }
55
+ .filter_map { |link| link["href"].to_s[RELEASE_PATTERN] }
56
+ .uniq
57
+ releases.empty? ? nil : releases
58
+ rescue StandardError => e
59
+ OvertureMaps.configuration.logger&.warn("STAC catalog unavailable (#{e.message}); listing bucket instead")
60
+ nil
61
+ end
62
+
63
+ def from_bucket
64
+ require "overture_maps/storage"
65
+
66
+ listing = Storage.list(prefix: "release/", delimiter: "/")
67
+ releases = listing[:prefixes].filter_map { |p| p[RELEASE_PATTERN] }.uniq
68
+ raise Error, "could not discover Overture releases from STAC or the bucket" if releases.empty?
69
+
70
+ releases
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "rexml/document"
6
+ require "fileutils"
7
+
8
+ module OvertureMaps
9
+ # Anonymous HTTP access to the public Overture bucket (or a configured
10
+ # mirror): object listing via the S3 REST API's XML responses and streaming
11
+ # downloads. No AWS SDK, no credentials, no global state.
12
+ module Storage
13
+ class Error < OvertureMaps::Error; end
14
+
15
+ MAX_REDIRECTS = 5
16
+
17
+ class << self
18
+ # Lists objects under a prefix. Returns { objects: [{key:, size:}],
19
+ # prefixes: [String] }, following continuation tokens.
20
+ def list(prefix:, delimiter: nil)
21
+ objects = []
22
+ prefixes = []
23
+ token = nil
24
+
25
+ loop do
26
+ params = { "list-type" => "2", "prefix" => prefix }
27
+ params["delimiter"] = delimiter if delimiter
28
+ params["continuation-token"] = token if token
29
+
30
+ doc = REXML::Document.new(get("#{base_url}/?#{URI.encode_www_form(params)}"))
31
+ root = doc.root
32
+ raise Error, "unexpected listing response" unless root&.name == "ListBucketResult"
33
+
34
+ root.each_element("Contents") do |el|
35
+ objects << {
36
+ key: el.elements["Key"]&.text,
37
+ size: el.elements["Size"]&.text.to_i
38
+ }
39
+ end
40
+ root.each_element("CommonPrefixes/Prefix") { |el| prefixes << el.text }
41
+
42
+ token = root.elements["NextContinuationToken"]&.text
43
+ break unless token
44
+ end
45
+
46
+ { objects: objects, prefixes: prefixes }
47
+ end
48
+
49
+ # Streams an object to a local file. Skips the download when the local
50
+ # file already has the expected size.
51
+ def download(key, to:, expected_size: nil)
52
+ if expected_size && File.exist?(to) && File.size(to) == expected_size
53
+ return :skipped
54
+ end
55
+
56
+ download_url("#{base_url}/#{escape_key(key)}", to: to)
57
+ :downloaded
58
+ end
59
+
60
+ # Downloads any URL to a file, following redirects, writing through a
61
+ # temp file so failures never leave a truncated file at the target path.
62
+ def download_url(url, to:)
63
+ FileUtils.mkdir_p(File.dirname(to))
64
+ tmp = "#{to}.part"
65
+
66
+ fetch_streaming(url) do |response|
67
+ File.open(tmp, "wb") do |file|
68
+ response.read_body { |chunk| file.write(chunk) }
69
+ end
70
+ end
71
+
72
+ FileUtils.mv(tmp, to)
73
+ to
74
+ ensure
75
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
76
+ end
77
+
78
+ # GET returning the body as a string, following redirects.
79
+ def get(url)
80
+ body = nil
81
+ fetch_streaming(url) { |response| body = response.body }
82
+ body
83
+ end
84
+
85
+ private
86
+
87
+ def base_url
88
+ OvertureMaps.configuration.s3_http_url.chomp("/")
89
+ end
90
+
91
+ def escape_key(key)
92
+ key.split("/").map { |part| URI.encode_uri_component(part) }.join("/")
93
+ end
94
+
95
+ def fetch_streaming(url, redirects_left = MAX_REDIRECTS, &block)
96
+ uri = URI(url)
97
+ timeout = OvertureMaps.configuration.timeout
98
+
99
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
100
+ open_timeout: timeout, read_timeout: timeout) do |http|
101
+ http.request(Net::HTTP::Get.new(uri)) do |response|
102
+ case response
103
+ when Net::HTTPRedirection
104
+ raise Error, "too many redirects for #{url}" if redirects_left.zero?
105
+
106
+ location = response["location"]
107
+ raise Error, "redirect without location from #{url}" unless location
108
+
109
+ response.read_body # drain before following
110
+ return fetch_streaming(URI.join(url, location).to_s, redirects_left - 1, &block)
111
+ when Net::HTTPSuccess
112
+ yield response
113
+ else
114
+ raise Error, "HTTP #{response.code} for #{url}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end