underpass 0.0.7 → 0.9.1
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 +5 -5
- data/README.md +497 -18
- data/lib/underpass/cache.rb +45 -0
- data/lib/underpass/client.rb +79 -0
- data/lib/underpass/configuration.rb +28 -0
- data/lib/underpass/error_parser.rb +140 -0
- data/lib/underpass/errors.rb +76 -0
- data/lib/underpass/feature.rb +34 -0
- data/lib/underpass/filter.rb +56 -0
- data/lib/underpass/geo_json.rb +23 -0
- data/lib/underpass/matcher.rb +105 -0
- data/lib/underpass/ql/bounding_box.rb +19 -6
- data/lib/underpass/ql/builder.rb +97 -0
- data/lib/underpass/ql/query.rb +72 -7
- data/lib/underpass/ql/query_analyzer.rb +51 -0
- data/lib/underpass/ql/request.rb +45 -12
- data/lib/underpass/ql/response.rb +51 -0
- data/lib/underpass/shape.rb +118 -0
- data/lib/underpass/version.rb +2 -2
- data/lib/underpass/way_chain.rb +71 -0
- data/lib/underpass.rb +66 -19
- metadata +26 -103
- data/lib/underpass/ql/parser.rb +0 -72
- data/lib/underpass/ql/ql.rb +0 -15
- data/lib/underpass/ql/shape.rb +0 -40
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
# Stores configuration options for the Underpass library.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# Underpass.configure do |config|
|
|
8
|
+
# config.api_endpoint = 'https://overpass.kumi.systems/api/interpreter'
|
|
9
|
+
# config.timeout = 30
|
|
10
|
+
# config.max_retries = 5
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# @return [String] the Overpass API endpoint URL
|
|
14
|
+
attr_accessor :api_endpoint
|
|
15
|
+
|
|
16
|
+
# @return [Integer] the query timeout in seconds
|
|
17
|
+
attr_accessor :timeout
|
|
18
|
+
|
|
19
|
+
# @return [Integer] maximum number of retry attempts for rate limiting and timeouts
|
|
20
|
+
attr_accessor :max_retries
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@api_endpoint = 'https://overpass-api.de/api/interpreter'
|
|
24
|
+
@timeout = 25
|
|
25
|
+
@max_retries = 3
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
# Parses HTML error responses from the Overpass API into structured data.
|
|
5
|
+
#
|
|
6
|
+
# The Overpass API returns HTML error pages when queries fail. This class
|
|
7
|
+
# extracts error information from those responses and returns structured
|
|
8
|
+
# hashes with code, message, and details.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# result = ErrorParser.parse("<html>...runtime error: Query timed out...</html>", 504)
|
|
12
|
+
# result[:code] # => "timeout"
|
|
13
|
+
# result[:message] # => "Query timed out in \"query\" at line 3 after 25 seconds."
|
|
14
|
+
# result[:details] # => { line: 3, timeout_seconds: 25 }
|
|
15
|
+
class ErrorParser
|
|
16
|
+
# Known error patterns from Overpass API responses
|
|
17
|
+
PATTERNS = {
|
|
18
|
+
timeout: /Query timed out.*?at line (\d+) after (\d+) seconds/i,
|
|
19
|
+
memory: /Query run out of memory.*?(\d+)\s*MB/i,
|
|
20
|
+
syntax: /parse error:?\s*(.+)/i,
|
|
21
|
+
runtime: /runtime error:?\s*(.+)/i
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Parses an error response body and returns structured error data.
|
|
25
|
+
#
|
|
26
|
+
# @param response_body [String] the raw response body (usually HTML)
|
|
27
|
+
# @param status_code [Integer] the HTTP status code
|
|
28
|
+
# @return [Hash] structured error data with :code, :message, and :details keys
|
|
29
|
+
def self.parse(response_body, status_code)
|
|
30
|
+
return rate_limit_result if status_code == 429
|
|
31
|
+
|
|
32
|
+
text = extract_error_text(response_body)
|
|
33
|
+
parse_error_text(text, status_code)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extracts error text from HTML or returns the body as-is.
|
|
37
|
+
#
|
|
38
|
+
# @param body [String] the response body
|
|
39
|
+
# @return [String] the extracted error text
|
|
40
|
+
def self.extract_error_text(body)
|
|
41
|
+
return '' if body.nil? || body.empty?
|
|
42
|
+
|
|
43
|
+
# Try to extract text from <strong> tags (common Overpass error format)
|
|
44
|
+
if (match = body.match(%r{<strong[^>]*>(.*?)</strong>}im))
|
|
45
|
+
return match[1].gsub(/<[^>]+>/, '').strip
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Try to extract from <p> tags
|
|
49
|
+
if (match = body.match(%r{<p[^>]*>(.*?)</p>}im))
|
|
50
|
+
return match[1].gsub(/<[^>]+>/, '').strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fall back to stripping all HTML tags
|
|
54
|
+
body.gsub(/<[^>]+>/, ' ').gsub(/\s+/, ' ').strip
|
|
55
|
+
end
|
|
56
|
+
private_class_method :extract_error_text
|
|
57
|
+
|
|
58
|
+
# Parses the error text against known patterns.
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] the extracted error text
|
|
61
|
+
# @param status_code [Integer] the HTTP status code
|
|
62
|
+
# @return [Hash] structured error data
|
|
63
|
+
def self.parse_error_text(text, status_code)
|
|
64
|
+
parse_timeout(text) ||
|
|
65
|
+
parse_memory(text) ||
|
|
66
|
+
parse_syntax(text) ||
|
|
67
|
+
parse_runtime(text) ||
|
|
68
|
+
unknown_result(text, status_code)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :parse_error_text
|
|
71
|
+
|
|
72
|
+
def self.parse_timeout(text)
|
|
73
|
+
return unless (match = text.match(PATTERNS[:timeout]))
|
|
74
|
+
|
|
75
|
+
{ code: 'timeout', message: text, details: { line: match[1].to_i, timeout_seconds: match[2].to_i } }
|
|
76
|
+
end
|
|
77
|
+
private_class_method :parse_timeout
|
|
78
|
+
|
|
79
|
+
def self.parse_memory(text)
|
|
80
|
+
return unless (match = text.match(PATTERNS[:memory]))
|
|
81
|
+
|
|
82
|
+
{ code: 'memory', message: text, details: { memory_mb: match[1].to_i } }
|
|
83
|
+
end
|
|
84
|
+
private_class_method :parse_memory
|
|
85
|
+
|
|
86
|
+
def self.parse_syntax(text)
|
|
87
|
+
return unless (match = text.match(PATTERNS[:syntax]))
|
|
88
|
+
|
|
89
|
+
{ code: 'syntax', message: match[1].strip, details: extract_syntax_details(match[1]) }
|
|
90
|
+
end
|
|
91
|
+
private_class_method :parse_syntax
|
|
92
|
+
|
|
93
|
+
def self.parse_runtime(text)
|
|
94
|
+
return unless (match = text.match(PATTERNS[:runtime]))
|
|
95
|
+
|
|
96
|
+
{ code: 'runtime', message: match[1].strip, details: {} }
|
|
97
|
+
end
|
|
98
|
+
private_class_method :parse_runtime
|
|
99
|
+
|
|
100
|
+
# Extracts line number from syntax error messages if present.
|
|
101
|
+
#
|
|
102
|
+
# @param message [String] the error message
|
|
103
|
+
# @return [Hash] details hash with line number if found
|
|
104
|
+
def self.extract_syntax_details(message)
|
|
105
|
+
if (match = message.match(/line\s+(\d+)/i))
|
|
106
|
+
{ line: match[1].to_i }
|
|
107
|
+
else
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_class_method :extract_syntax_details
|
|
112
|
+
|
|
113
|
+
# Returns a rate limit error result.
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash] structured error data for rate limiting
|
|
116
|
+
def self.rate_limit_result
|
|
117
|
+
{
|
|
118
|
+
code: 'rate_limit',
|
|
119
|
+
message: 'Rate limited by the Overpass API',
|
|
120
|
+
details: {}
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
private_class_method :rate_limit_result
|
|
124
|
+
|
|
125
|
+
# Returns an unknown error result.
|
|
126
|
+
#
|
|
127
|
+
# @param text [String] the error text
|
|
128
|
+
# @param status_code [Integer] the HTTP status code
|
|
129
|
+
# @return [Hash] structured error data for unknown errors
|
|
130
|
+
def self.unknown_result(text, status_code)
|
|
131
|
+
message = text.empty? ? "HTTP #{status_code} error" : text
|
|
132
|
+
{
|
|
133
|
+
code: 'unknown',
|
|
134
|
+
message: message,
|
|
135
|
+
details: {}
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
private_class_method :unknown_result
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Underpass
|
|
6
|
+
# Base error class for all Underpass errors.
|
|
7
|
+
#
|
|
8
|
+
# Provides structured error data parsed from Overpass API responses.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# begin
|
|
12
|
+
# features = Underpass::QL::Query.perform(bbox, query)
|
|
13
|
+
# rescue Underpass::Error => e
|
|
14
|
+
# e.code # => "timeout"
|
|
15
|
+
# e.error_message # => "Query timed out..."
|
|
16
|
+
# e.details # => { line: 3, timeout_seconds: 25 }
|
|
17
|
+
# e.http_status # => 504
|
|
18
|
+
# e.to_h # => { code: "timeout", message: "...", details: {...} }
|
|
19
|
+
# end
|
|
20
|
+
class Error < StandardError
|
|
21
|
+
# @return [String, nil] the error code (e.g., "timeout", "memory", "syntax")
|
|
22
|
+
attr_reader :code
|
|
23
|
+
|
|
24
|
+
# @return [String, nil] the human-readable error message
|
|
25
|
+
attr_reader :error_message
|
|
26
|
+
|
|
27
|
+
# @return [Hash] additional error details (varies by error type)
|
|
28
|
+
attr_reader :details
|
|
29
|
+
|
|
30
|
+
# @return [Integer, nil] the HTTP status code from the API response
|
|
31
|
+
attr_reader :http_status
|
|
32
|
+
|
|
33
|
+
# Creates a new error with optional structured data.
|
|
34
|
+
#
|
|
35
|
+
# @param message [String, nil] the error message (used by StandardError)
|
|
36
|
+
# @param code [String, nil] the error code
|
|
37
|
+
# @param error_message [String, nil] the detailed error message
|
|
38
|
+
# @param details [Hash] additional error details
|
|
39
|
+
# @param http_status [Integer, nil] the HTTP status code
|
|
40
|
+
def initialize(message = nil, code: nil, error_message: nil, details: {}, http_status: nil)
|
|
41
|
+
@code = code
|
|
42
|
+
@error_message = error_message || message
|
|
43
|
+
@details = details || {}
|
|
44
|
+
@http_status = http_status
|
|
45
|
+
super(@error_message || message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns a hash representation of the error.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash] the error as a hash with :code, :message, and :details keys
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
code: code,
|
|
54
|
+
message: error_message,
|
|
55
|
+
details: details
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a JSON representation of the error.
|
|
60
|
+
#
|
|
61
|
+
# @param args [Array] arguments passed to JSON.generate
|
|
62
|
+
# @return [String] the error as a JSON string
|
|
63
|
+
def to_json(*)
|
|
64
|
+
to_h.to_json(*)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Raised when the Overpass API returns a 429 rate limit response.
|
|
69
|
+
class RateLimitError < Error; end
|
|
70
|
+
|
|
71
|
+
# Raised when the Overpass API returns a 504 timeout response.
|
|
72
|
+
class TimeoutError < Error; end
|
|
73
|
+
|
|
74
|
+
# Raised when the Overpass API returns an unexpected error response.
|
|
75
|
+
class ApiError < Error; end
|
|
76
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
# Wraps an RGeo geometry with OSM metadata (tags, id, type).
|
|
5
|
+
#
|
|
6
|
+
# Returned by {QL::Query.perform} and {Matcher#matches} for each matched
|
|
7
|
+
# element in the Overpass API response.
|
|
8
|
+
class Feature
|
|
9
|
+
# @return [RGeo::Feature::Geometry] the RGeo geometry object
|
|
10
|
+
attr_reader :geometry
|
|
11
|
+
|
|
12
|
+
# @return [Hash{Symbol => String}] the OSM tags for this element
|
|
13
|
+
attr_reader :properties
|
|
14
|
+
|
|
15
|
+
# @return [Integer, nil] the OSM element ID
|
|
16
|
+
attr_reader :id
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] the OSM element type ("node", "way", or "relation")
|
|
19
|
+
attr_reader :type
|
|
20
|
+
|
|
21
|
+
# Creates a new Feature.
|
|
22
|
+
#
|
|
23
|
+
# @param geometry [RGeo::Feature::Geometry] the RGeo geometry
|
|
24
|
+
# @param properties [Hash] the OSM tags
|
|
25
|
+
# @param id [Integer, nil] the OSM element ID
|
|
26
|
+
# @param type [String, nil] the OSM element type
|
|
27
|
+
def initialize(geometry:, properties: {}, id: nil, type: nil)
|
|
28
|
+
@geometry = geometry
|
|
29
|
+
@properties = properties
|
|
30
|
+
@id = id
|
|
31
|
+
@type = type
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
# Post-query filtering of {Feature} objects by tag properties.
|
|
5
|
+
#
|
|
6
|
+
# @example Filter restaurants by cuisine
|
|
7
|
+
# filter = Underpass::Filter.new(features)
|
|
8
|
+
# italian = filter.where(cuisine: 'italian')
|
|
9
|
+
class Filter
|
|
10
|
+
# Creates a new filter for the given features.
|
|
11
|
+
#
|
|
12
|
+
# @param features [Array<Feature>] the features to filter
|
|
13
|
+
def initialize(features)
|
|
14
|
+
@features = features
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns features whose properties match all given conditions.
|
|
18
|
+
#
|
|
19
|
+
# Conditions can be exact values, regular expressions, or arrays of values.
|
|
20
|
+
#
|
|
21
|
+
# @param conditions [Hash{Symbol => String, Regexp, Array}] tag conditions to match
|
|
22
|
+
# @return [Array<Feature>] features matching all conditions
|
|
23
|
+
#
|
|
24
|
+
# @example Exact match
|
|
25
|
+
# filter.where(cuisine: 'italian')
|
|
26
|
+
# @example Regex match
|
|
27
|
+
# filter.where(name: /pizza/i)
|
|
28
|
+
# @example Array inclusion
|
|
29
|
+
# filter.where(cuisine: ['italian', 'mexican'])
|
|
30
|
+
def where(conditions = {})
|
|
31
|
+
@features.select do |feature|
|
|
32
|
+
conditions.all? { |key, value| match_condition?(feature.properties[key], value) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns features that do not match any of the given conditions.
|
|
37
|
+
#
|
|
38
|
+
# @param conditions [Hash{Symbol => String}] tag conditions to reject
|
|
39
|
+
# @return [Array<Feature>] features not matching any condition
|
|
40
|
+
def reject(conditions = {})
|
|
41
|
+
@features.reject do |feature|
|
|
42
|
+
conditions.any? { |key, value| feature.properties[key] == value.to_s }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def match_condition?(prop_value, condition)
|
|
49
|
+
case condition
|
|
50
|
+
when Regexp then prop_value&.match?(condition)
|
|
51
|
+
when Array then condition.include?(prop_value)
|
|
52
|
+
else prop_value == condition.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgeo/geo_json'
|
|
4
|
+
|
|
5
|
+
module Underpass
|
|
6
|
+
# Encodes {Feature} arrays as GeoJSON FeatureCollections.
|
|
7
|
+
#
|
|
8
|
+
# @example Export query results to GeoJSON
|
|
9
|
+
# features = Underpass::QL::Query.perform(bbox, query)
|
|
10
|
+
# geojson = Underpass::GeoJSON.encode(features)
|
|
11
|
+
module GeoJSON
|
|
12
|
+
# Encodes an array of features as a GeoJSON FeatureCollection hash.
|
|
13
|
+
#
|
|
14
|
+
# @param features [Array<Feature>] the features to encode
|
|
15
|
+
# @return [Hash] a GeoJSON FeatureCollection
|
|
16
|
+
def self.encode(features)
|
|
17
|
+
geo_features = features.map do |f|
|
|
18
|
+
RGeo::GeoJSON::Feature.new(f.geometry, f.id, f.properties)
|
|
19
|
+
end
|
|
20
|
+
RGeo::GeoJSON.encode(RGeo::GeoJSON::FeatureCollection.new(geo_features))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
# Extracts matching elements from an Overpass API response.
|
|
5
|
+
#
|
|
6
|
+
# A "match" is a response element that has a +tags+ key, indicating it is
|
|
7
|
+
# a tagged OSM element rather than a bare geometry node. Each match is
|
|
8
|
+
# converted into a {Feature} with the appropriate RGeo geometry.
|
|
9
|
+
class Matcher
|
|
10
|
+
# Creates a new matcher for the given response.
|
|
11
|
+
#
|
|
12
|
+
# @param response [QL::Response] a parsed API response
|
|
13
|
+
# @param requested_types [Array<String>, nil] element types to include
|
|
14
|
+
# (e.g. +["node", "way"]+). Defaults to all types when +nil+.
|
|
15
|
+
def initialize(response, requested_types = nil)
|
|
16
|
+
@nodes = response.nodes
|
|
17
|
+
@ways = response.ways
|
|
18
|
+
@relations = response.relations
|
|
19
|
+
@requested_types = requested_types || %w[node way relation]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns all matched features as an array.
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<Feature>] the matched features
|
|
25
|
+
def matches
|
|
26
|
+
@matches ||= lazy_matches.to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a lazy enumerator of matched features.
|
|
30
|
+
#
|
|
31
|
+
# @return [Enumerator::Lazy<Feature>] lazy enumerator of features
|
|
32
|
+
def lazy_matches
|
|
33
|
+
tagged_elements.lazy.flat_map { |element| features_for(element) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def tagged_elements
|
|
39
|
+
elements = []
|
|
40
|
+
elements.concat(@nodes.values.select { |n| n.key?(:tags) }) if @requested_types.include?('node')
|
|
41
|
+
elements.concat(@ways.values.select { |w| w.key?(:tags) }) if @requested_types.include?('way')
|
|
42
|
+
elements.concat(@relations.values.select { |r| r.key?(:tags) }) if @requested_types.include?('relation')
|
|
43
|
+
elements
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def features_for(element)
|
|
47
|
+
case element[:type]
|
|
48
|
+
when 'node'
|
|
49
|
+
[build_feature(Shape.point_from_node(element), element)]
|
|
50
|
+
when 'way'
|
|
51
|
+
[build_feature(way_geometry(element), element)]
|
|
52
|
+
when 'relation'
|
|
53
|
+
relation_features(element)
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def relation_features(relation)
|
|
60
|
+
geometry = relation_geometry(relation)
|
|
61
|
+
if geometry.is_a?(Array)
|
|
62
|
+
geometry.map { |g| build_feature(g, relation) }
|
|
63
|
+
else
|
|
64
|
+
[build_feature(geometry, relation)]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def relation_geometry(relation)
|
|
69
|
+
case relation[:tags][:type]
|
|
70
|
+
when 'multipolygon'
|
|
71
|
+
Shape.multipolygon_from_relation(relation, @ways, @nodes)
|
|
72
|
+
when 'route'
|
|
73
|
+
Shape.multi_line_string_from_relation(relation, @ways, @nodes)
|
|
74
|
+
else
|
|
75
|
+
expand_relation_members(relation)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def expand_relation_members(relation)
|
|
80
|
+
relation[:members].filter_map do |member|
|
|
81
|
+
case member[:type]
|
|
82
|
+
when 'node' then Shape.point_from_node(@nodes[member[:ref]])
|
|
83
|
+
when 'way' then way_geometry(@ways[member[:ref]])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def way_geometry(way)
|
|
89
|
+
if Shape.open_way?(way)
|
|
90
|
+
Shape.polygon_from_way(way, @nodes)
|
|
91
|
+
else
|
|
92
|
+
Shape.line_string_from_way(way, @nodes)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_feature(geometry, element)
|
|
97
|
+
Feature.new(
|
|
98
|
+
geometry: geometry,
|
|
99
|
+
properties: element[:tags] || {},
|
|
100
|
+
id: element[:id],
|
|
101
|
+
type: element[:type]
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -2,13 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Underpass
|
|
4
4
|
module QL
|
|
5
|
-
#
|
|
5
|
+
# Converts RGeo geometries and WKT strings into Overpass QL bounding box syntax.
|
|
6
6
|
class BoundingBox
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"bbox
|
|
7
|
+
class << self
|
|
8
|
+
# Returns the Overpass QL bounding box string from a WKT string.
|
|
9
|
+
#
|
|
10
|
+
# @param wkt [String] a Well Known Text geometry string
|
|
11
|
+
# @return [String] an Overpass QL bounding box (e.g. +"bbox:47.65,23.669,47.674,23.725"+)
|
|
12
|
+
def from_wkt(wkt)
|
|
13
|
+
geometry = RGeo::Geographic.spherical_factory.parse_wkt(wkt)
|
|
14
|
+
from_geometry(geometry)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the Overpass QL bounding box string from an RGeo geometry.
|
|
18
|
+
#
|
|
19
|
+
# @param geometry [RGeo::Feature::Geometry] an RGeo geometry
|
|
20
|
+
# @return [String] an Overpass QL bounding box (e.g. +"bbox:47.65,23.669,47.674,23.725"+)
|
|
21
|
+
def from_geometry(geometry)
|
|
22
|
+
r_bb = RGeo::Cartesian::BoundingBox.create_from_geometry(geometry)
|
|
23
|
+
"bbox:#{r_bb.min_y},#{r_bb.min_x},#{r_bb.max_y},#{r_bb.max_x}"
|
|
24
|
+
end
|
|
12
25
|
end
|
|
13
26
|
end
|
|
14
27
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Underpass
|
|
4
|
+
module QL
|
|
5
|
+
# DSL for building Overpass QL queries programmatically.
|
|
6
|
+
#
|
|
7
|
+
# @example Query for restaurants
|
|
8
|
+
# builder = Underpass::QL::Builder.new
|
|
9
|
+
# builder.node('amenity' => 'restaurant')
|
|
10
|
+
# Underpass::QL::Query.perform(bbox, builder)
|
|
11
|
+
#
|
|
12
|
+
# @example Proximity search with around
|
|
13
|
+
# builder = Underpass::QL::Builder.new
|
|
14
|
+
# builder.node('amenity' => 'cafe').around(500, 44.4268, 26.1025)
|
|
15
|
+
class Builder
|
|
16
|
+
# Creates a new empty builder.
|
|
17
|
+
def initialize
|
|
18
|
+
@statements = []
|
|
19
|
+
@around = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Adds a node query statement.
|
|
23
|
+
#
|
|
24
|
+
# @param tags [Hash{String => String}] tag filters
|
|
25
|
+
# @return [self] for method chaining
|
|
26
|
+
def node(tags = {})
|
|
27
|
+
@statements << build_statement('node', tags)
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Adds a way query statement.
|
|
32
|
+
#
|
|
33
|
+
# @param tags [Hash{String => String}] tag filters
|
|
34
|
+
# @return [self] for method chaining
|
|
35
|
+
def way(tags = {})
|
|
36
|
+
@statements << build_statement('way', tags)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Adds a relation query statement.
|
|
41
|
+
#
|
|
42
|
+
# @param tags [Hash{String => String}] tag filters
|
|
43
|
+
# @return [self] for method chaining
|
|
44
|
+
def relation(tags = {})
|
|
45
|
+
@statements << build_statement('relation', tags)
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Adds a node/way/relation (nwr) query statement.
|
|
50
|
+
#
|
|
51
|
+
# @param tags [Hash{String => String}] tag filters
|
|
52
|
+
# @return [self] for method chaining
|
|
53
|
+
def nwr(tags = {})
|
|
54
|
+
@statements << build_statement('nwr', tags)
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Sets a proximity filter for all statements.
|
|
59
|
+
#
|
|
60
|
+
# @param radius [Numeric] search radius in meters
|
|
61
|
+
# @param lat_or_point [Numeric, RGeo::Feature::Point] latitude or an RGeo point
|
|
62
|
+
# @param lon [Numeric, nil] longitude (required when +lat_or_point+ is numeric)
|
|
63
|
+
# @return [self] for method chaining
|
|
64
|
+
def around(radius, lat_or_point, lon = nil)
|
|
65
|
+
@around = if lat_or_point.respond_to?(:y)
|
|
66
|
+
{ radius: radius, lat: lat_or_point.y, lon: lat_or_point.x }
|
|
67
|
+
else
|
|
68
|
+
{ radius: radius, lat: lat_or_point, lon: lon }
|
|
69
|
+
end
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Converts the builder into an Overpass QL query string.
|
|
74
|
+
#
|
|
75
|
+
# @return [String] the Overpass QL query
|
|
76
|
+
def to_ql
|
|
77
|
+
if @around
|
|
78
|
+
@statements.map { |s| append_around(s) }.join("\n")
|
|
79
|
+
else
|
|
80
|
+
@statements.join("\n")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def build_statement(type, tags)
|
|
87
|
+
tag_filters = tags.map { |k, v| "[\"#{k}\"=\"#{v}\"]" }.join
|
|
88
|
+
"#{type}#{tag_filters};"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def append_around(statement)
|
|
92
|
+
around_filter = "(around:#{@around[:radius]},#{@around[:lat]},#{@around[:lon]})"
|
|
93
|
+
statement.sub(';', "#{around_filter};")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/underpass/ql/query.rb
CHANGED
|
@@ -1,17 +1,82 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Underpass
|
|
4
|
+
# Namespace for Overpass Query Language related classes.
|
|
4
5
|
module QL
|
|
5
|
-
#
|
|
6
|
+
# High-level entry point for querying the Overpass API.
|
|
7
|
+
#
|
|
8
|
+
# Glues together {Request}, {Client}, {Response}, {QueryAnalyzer}, and
|
|
9
|
+
# {Matcher} to provide a single-call interface.
|
|
10
|
+
#
|
|
11
|
+
# @example Query with a bounding box
|
|
12
|
+
# features = Underpass::QL::Query.perform(bbox, 'way["building"="yes"];')
|
|
13
|
+
#
|
|
14
|
+
# @example Query with a named area
|
|
15
|
+
# features = Underpass::QL::Query.perform_in_area('Romania', 'node["place"="city"];')
|
|
16
|
+
#
|
|
17
|
+
# @example Raw query with inline bounding box
|
|
18
|
+
# query = 'node["name"="Peak"]["natural"="peak"](47.0,25.0,47.1,25.1);'
|
|
19
|
+
# features = Underpass::QL::Query.perform_raw(query)
|
|
6
20
|
class Query
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
21
|
+
# Queries the Overpass API within a bounding box.
|
|
22
|
+
#
|
|
23
|
+
# @param bounding_box [RGeo::Feature::Geometry] an RGeo polygon defining the search area
|
|
24
|
+
# @param query [String, Builder] an Overpass QL query string or a {Builder} instance
|
|
25
|
+
# @return [Array<Feature>] the matched features
|
|
26
|
+
# @raise [RateLimitError] when rate limited after exhausting retries
|
|
27
|
+
# @raise [TimeoutError] when the API times out after exhausting retries
|
|
28
|
+
# @raise [ApiError] when the API returns an unexpected error
|
|
10
29
|
def self.perform(bounding_box, query)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Underpass::QL::
|
|
30
|
+
query_string = resolve_query(query)
|
|
31
|
+
op_bbox = Underpass::QL::BoundingBox.from_geometry(bounding_box)
|
|
32
|
+
request = Underpass::QL::Request.new(query_string, op_bbox)
|
|
33
|
+
execute(request, query_string)
|
|
14
34
|
end
|
|
35
|
+
|
|
36
|
+
# Queries the Overpass API within a named area (e.g. "Romania").
|
|
37
|
+
#
|
|
38
|
+
# @param area_name [String] an OSM area name
|
|
39
|
+
# @param query [String, Builder] an Overpass QL query string or a {Builder} instance
|
|
40
|
+
# @return [Array<Feature>] the matched features
|
|
41
|
+
# @raise [RateLimitError] when rate limited after exhausting retries
|
|
42
|
+
# @raise [TimeoutError] when the API times out after exhausting retries
|
|
43
|
+
# @raise [ApiError] when the API returns an unexpected error
|
|
44
|
+
def self.perform_in_area(area_name, query)
|
|
45
|
+
query_string = resolve_query(query)
|
|
46
|
+
request = Underpass::QL::Request.new(query_string, nil, area_name: area_name)
|
|
47
|
+
execute(request, query_string)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Executes a pre-built query body that includes its own inline bbox.
|
|
51
|
+
# Wraps it in the standard Request template for output format and timeout,
|
|
52
|
+
# without adding a global bounding box.
|
|
53
|
+
#
|
|
54
|
+
# @param query_body [String] Overpass QL body with inline bbox
|
|
55
|
+
# (e.g. +'node["name"="Peak"]["natural"="peak"](47.0,25.0,47.1,25.1);'+)
|
|
56
|
+
# @return [Array<Feature>] the matched features
|
|
57
|
+
# @raise [RateLimitError] when rate limited after exhausting retries
|
|
58
|
+
# @raise [TimeoutError] when the API times out after exhausting retries
|
|
59
|
+
# @raise [ApiError] when the API returns an unexpected error
|
|
60
|
+
def self.perform_raw(query_body)
|
|
61
|
+
request = Underpass::QL::Request.new(query_body, nil)
|
|
62
|
+
execute(request, query_body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.resolve_query(query)
|
|
66
|
+
query.respond_to?(:to_ql) ? query.to_ql : query
|
|
67
|
+
end
|
|
68
|
+
private_class_method :resolve_query
|
|
69
|
+
|
|
70
|
+
def self.execute(request, query_string)
|
|
71
|
+
api_response = Underpass::Client.perform(request)
|
|
72
|
+
response = Underpass::QL::Response.new(api_response)
|
|
73
|
+
query_analyzer = Underpass::QL::QueryAnalyzer.new(query_string)
|
|
74
|
+
requested_types = query_analyzer.requested_types
|
|
75
|
+
matcher = Underpass::Matcher.new(response, requested_types)
|
|
76
|
+
|
|
77
|
+
matcher.matches
|
|
78
|
+
end
|
|
79
|
+
private_class_method :execute
|
|
15
80
|
end
|
|
16
81
|
end
|
|
17
82
|
end
|