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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +416 -0
- data/app/controllers/overture_maps/application_controller.rb +28 -0
- data/app/controllers/overture_maps/attribution_controller.rb +15 -0
- data/app/controllers/overture_maps/features_controller.rb +125 -0
- data/app/controllers/overture_maps/search_controller.rb +31 -0
- data/app/controllers/overture_maps/tiles_controller.rb +85 -0
- data/config/routes.rb +13 -0
- data/exe/overture-maps-mcp +6 -0
- data/lib/generators/overture_maps/address_generator.rb +19 -0
- data/lib/generators/overture_maps/base_features_generator.rb +19 -0
- data/lib/generators/overture_maps/base_generator.rb +22 -0
- data/lib/generators/overture_maps/building_generator.rb +19 -0
- data/lib/generators/overture_maps/division_generator.rb +19 -0
- data/lib/generators/overture_maps/install_generator.rb +42 -0
- data/lib/generators/overture_maps/place_generator.rb +19 -0
- data/lib/generators/overture_maps/transportation_generator.rb +20 -0
- data/lib/generators/templates/address_migration.rb.tt +25 -0
- data/lib/generators/templates/address_model.rb.tt +4 -0
- data/lib/generators/templates/base_feature_model.rb.tt +4 -0
- data/lib/generators/templates/base_features_migration.rb.tt +27 -0
- data/lib/generators/templates/building_migration.rb.tt +24 -0
- data/lib/generators/templates/building_model.rb.tt +4 -0
- data/lib/generators/templates/category_migration.rb.tt +15 -0
- data/lib/generators/templates/category_model.rb.tt +4 -0
- data/lib/generators/templates/connector_model.rb.tt +4 -0
- data/lib/generators/templates/division_migration.rb.tt +31 -0
- data/lib/generators/templates/division_model.rb.tt +4 -0
- data/lib/generators/templates/imported_areas_migration.rb.tt +20 -0
- data/lib/generators/templates/place_migration.rb.tt +30 -0
- data/lib/generators/templates/place_model.rb.tt +4 -0
- data/lib/generators/templates/postgis_migration.rb.tt +5 -0
- data/lib/generators/templates/segment_model.rb.tt +4 -0
- data/lib/generators/templates/transportation_migration.rb.tt +38 -0
- data/lib/overture_maps/attribution.rb +66 -0
- data/lib/overture_maps/bounding_box.rb +80 -0
- data/lib/overture_maps/changelog.rb +70 -0
- data/lib/overture_maps/configuration.rb +43 -0
- data/lib/overture_maps/database.rb +74 -0
- data/lib/overture_maps/division_search.rb +44 -0
- data/lib/overture_maps/engine.rb +32 -0
- data/lib/overture_maps/geometry_parser.rb +41 -0
- data/lib/overture_maps/gers.rb +30 -0
- data/lib/overture_maps/import/downloader.rb +248 -0
- data/lib/overture_maps/import/location_based_runner.rb +176 -0
- data/lib/overture_maps/import/parquet_reader.rb +32 -0
- data/lib/overture_maps/import/record_mapper.rb +182 -0
- data/lib/overture_maps/import/runner.rb +125 -0
- data/lib/overture_maps/import.rb +27 -0
- data/lib/overture_maps/mcp_server.rb +222 -0
- data/lib/overture_maps/models/address.rb +32 -0
- data/lib/overture_maps/models/base.rb +39 -0
- data/lib/overture_maps/models/base_feature.rb +31 -0
- data/lib/overture_maps/models/building.rb +35 -0
- data/lib/overture_maps/models/category.rb +32 -0
- data/lib/overture_maps/models/connector.rb +11 -0
- data/lib/overture_maps/models/division.rb +43 -0
- data/lib/overture_maps/models/imported_area.rb +42 -0
- data/lib/overture_maps/models/place.rb +43 -0
- data/lib/overture_maps/models/segment.rb +22 -0
- data/lib/overture_maps/models.rb +25 -0
- data/lib/overture_maps/query.rb +168 -0
- data/lib/overture_maps/query_engine.rb +268 -0
- data/lib/overture_maps/releases.rb +74 -0
- data/lib/overture_maps/storage.rb +121 -0
- data/lib/overture_maps/syncer.rb +99 -0
- data/lib/overture_maps/util.rb +30 -0
- data/lib/overture_maps/version.rb +3 -0
- data/lib/overture_maps.rb +42 -0
- data/lib/tasks/overture_maps.rake +554 -0
- 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
|