underpass 0.9.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51f76547b39510c69d96638663caf0078df8c28d410b3ca2941f775da5b16297
4
- data.tar.gz: c929b78a88ef2e7fd52f4e329729128e37d283a33781c832df71afdec7c84d6b
3
+ metadata.gz: 7e5920d16962c26dd7711747653a0ce891250e870d2a675f51402354306a3e93
4
+ data.tar.gz: 1770e8e1e320916d9a53673df975ec1a7293d0d1064bdb0d68c2ab50cd168aeb
5
5
  SHA512:
6
- metadata.gz: 9404c1959b9696c10fbf58fa633a7dc23e4bd173a0fe6f59735833e5b180f297a0285852fac32942dcbefac00b771e8240a4ac4f381922d2d86d8aa405756e50
7
- data.tar.gz: c9687c1a6aaf9d99a06a44d017cb422831713027cfdc2b239b569fb160864e2ca3a01fe7ead7357d86d151a76af062d071f2ff636b261ffafca886bdf9b3110e
6
+ metadata.gz: f67e73666224ec23cf123b2803438a4b893359039da09fd9209506dea49b2b8685b04a8287955bc2c811224d786a9ca17be7ebcd22fc64242000147d10fe0ae8
7
+ data.tar.gz: dcbf032eae5a06497068f36861073dfa846d889c4bbab7681db81127a878824a70edb43dffb022bcae188afb8a40b2bea7deae6d3be9fc4cd429246fdf2e8fc4
data/README.md CHANGED
@@ -34,25 +34,26 @@ WKT
34
34
  bbox = RGeo::Geographic.spherical_factory.parse_wkt(wkt)
35
35
 
36
36
  # Query using raw Overpass QL
37
- query = 'way["heritage:operator"="lmi"]["ref:ro:lmi"="MM-II-m-B-04508"];'
37
+ query = 'way["heritage:operator"="lmi"]["heritage"="2"];'
38
38
  features = Underpass::QL::Query.perform(bbox, query)
39
39
 
40
40
  # Each result is a Feature with geometry and OSM tags
41
41
  features.each do |f|
42
42
  puts f.geometry.as_text # => "POLYGON ((...)"
43
- puts f.properties[:name] # => "Some Heritage Building"
44
- puts f.id # => 123456
43
+ puts f.properties[:name] # => "Biserica Romano-Catolică"
44
+ puts f.id # => 186213580
45
45
  puts f.type # => "way"
46
46
  end
47
47
  ```
48
48
 
49
- See [more usage examples](usage-examples.md).
49
+ See [more usage examples](docs/usage-examples.md).
50
50
 
51
- For comprehensive examples with real data covering all return types and functionality, see the [usage-examples.md](usage-examples.md) file which includes examples for:
51
+ For comprehensive examples with real data covering all return types and functionality, see the [usage-examples.md](docs/usage-examples.md) file which includes examples for:
52
52
  - Node queries (Point geometries) - restaurants, cafes, etc.
53
53
  - Way queries (LineString/Polygon geometries) - roads, buildings, parks
54
54
  - Relation queries (MultiPolygon/MultiLineString geometries) - lakes, bus routes
55
55
  - Area queries using `perform_in_area`
56
+ - Raw queries using `perform_raw` with inline bounding boxes
56
57
  - Around queries for proximity search
57
58
  - Builder DSL for constructing queries
58
59
  - Post-query filtering
@@ -90,9 +91,9 @@ query = Underpass::QL::Builder.new
90
91
 
91
92
  # Multiple tag filters
92
93
  query = Underpass::QL::Builder.new
93
- .way('heritage:operator': 'lmi', 'ref:ro:lmi': 'MM-II-m-B-04508')
94
+ .way('heritage:operator': 'lmi', heritage: '2')
94
95
  .to_ql
95
- # => 'way["heritage:operator"="lmi"]["ref:ro:lmi"="MM-II-m-B-04508"];'
96
+ # => 'way["heritage:operator"="lmi"]["heritage"="2"];'
96
97
 
97
98
  # nwr (node/way/relation) shorthand
98
99
  query = Underpass::QL::Builder.new
@@ -105,6 +106,27 @@ builder = Underpass::QL::Builder.new.way(building: 'yes')
105
106
  features = Underpass::QL::Query.perform(bbox, builder)
106
107
  ```
107
108
 
109
+ ### Bounding Box Queries with Builder DSL
110
+
111
+ The Builder DSL is designed to work with `Query.perform`. Define a bounding box
112
+ as an RGeo geometry, build your query with the DSL, and pass both to `perform`:
113
+
114
+ ```ruby
115
+ # Define a bounding box
116
+ wkt = 'POLYGON ((26.08 44.42, 26.12 44.42, 26.12 44.45, 26.08 44.45, 26.08 44.42))'
117
+ bbox = RGeo::Geographic.spherical_factory.parse_wkt(wkt)
118
+
119
+ # Build the query
120
+ builder = Underpass::QL::Builder.new.node(amenity: 'cafe')
121
+
122
+ # Execute — the bounding box constrains results spatially
123
+ cafes = Underpass::QL::Query.perform(bbox, builder)
124
+ ```
125
+
126
+ Note: The Builder DSL generates the Overpass QL query body (e.g. `node["amenity"="cafe"];`),
127
+ while the bounding box is applied as a separate spatial constraint by `Query.perform`.
128
+ You can inspect the generated query with `builder.to_ql`.
129
+
108
130
  ## Proximity Queries (Around)
109
131
 
110
132
  Find elements within a radius (in meters) of a point:
@@ -158,6 +180,21 @@ builder = Underpass::QL::Builder.new.node(amenity: 'restaurant')
158
180
  features = Underpass::QL::Query.perform_in_area('Romania', builder)
159
181
  ```
160
182
 
183
+ ## Raw Queries (Inline Bounding Box)
184
+
185
+ When your query already contains its own bounding box (or other spatial filters)
186
+ inline, use `perform_raw` to skip the global bounding box:
187
+
188
+ ```ruby
189
+ query = 'node["name"="Peak"]["natural"="peak"](47.0,25.0,47.1,25.1);'
190
+ features = Underpass::QL::Query.perform_raw(query)
191
+ ```
192
+
193
+ The query body is wrapped in the standard Overpass request template (output format,
194
+ timeout, recurse) but no global bounding box is added. This is useful when you
195
+ construct queries with element-level bounding boxes or other spatial constraints
196
+ that don't fit the `perform` / `perform_in_area` patterns.
197
+
161
198
  ## Relation Support
162
199
 
163
200
  ### Multipolygon Relations
@@ -334,6 +371,16 @@ Underpass.configure do |c|
334
371
  end
335
372
  ```
336
373
 
374
+ ### Custom Max Retries
375
+
376
+ Change the maximum number of retry attempts for rate limiting and timeout errors (default: 3):
377
+
378
+ ```ruby
379
+ Underpass.configure do |c|
380
+ c.max_retries = 5
381
+ end
382
+ ```
383
+
337
384
  ### Reset Configuration
338
385
 
339
386
  ```ruby
@@ -344,24 +391,46 @@ Underpass.reset_configuration!
344
391
 
345
392
  The client automatically retries on transient errors with exponential backoff:
346
393
 
347
- - **HTTP 429** (rate limited) -- retries up to 3 times, then raises `Underpass::RateLimitError`
348
- - **HTTP 504** (gateway timeout) -- retries up to 3 times, then raises `Underpass::TimeoutError`
394
+ - **HTTP 429** (rate limited) -- retries up to `max_retries` times (default: 3), then raises `Underpass::RateLimitError`
395
+ - **HTTP 504** (gateway timeout) -- retries up to `max_retries` times (default: 3), then raises `Underpass::TimeoutError`
349
396
  - **Other errors** -- raises `Underpass::ApiError` immediately
350
397
 
351
398
  All errors inherit from `Underpass::Error`, which inherits from `StandardError`.
352
399
 
400
+ ### Structured Error Data
401
+
402
+ Errors include structured data parsed from Overpass API responses:
403
+
353
404
  ```ruby
354
405
  begin
355
406
  features = Underpass::QL::Query.perform(bbox, query)
407
+ rescue Underpass::TimeoutError => e
408
+ e.code # => "timeout"
409
+ e.error_message # => "Query timed out in \"query\" at line 3 after 25 seconds."
410
+ e.details # => { line: 3, timeout_seconds: 25 }
411
+ e.http_status # => 504
412
+
413
+ # Convert to hash or JSON for logging/APIs
414
+ e.to_h # => { code: "timeout", message: "...", details: {...} }
415
+ e.to_json # => JSON string
356
416
  rescue Underpass::RateLimitError
357
417
  puts "Rate limited by the Overpass API, try again later"
358
- rescue Underpass::TimeoutError
359
- puts "Query timed out, try a smaller bounding box"
360
418
  rescue Underpass::ApiError => e
361
- puts "API error: #{e.message}"
419
+ puts "API error (#{e.code}): #{e.error_message}"
362
420
  end
363
421
  ```
364
422
 
423
+ ### Error Codes
424
+
425
+ | Code | Description | HTTP Status |
426
+ |------|-------------|-------------|
427
+ | `timeout` | Query exceeded time limit | 504 |
428
+ | `memory` | Query exceeded memory limit | 504 |
429
+ | `rate_limit` | Too many requests | 429 |
430
+ | `syntax` | Query syntax error | 400 |
431
+ | `runtime` | Other runtime errors | varies |
432
+ | `unknown` | Unparseable error | varies |
433
+
365
434
  ## Response Caching
366
435
 
367
436
  Enable in-memory caching to avoid redundant API calls during development:
@@ -409,7 +478,7 @@ Have a look at the [issue tracker](https://github.com/haiafara/underpass/issues)
409
478
 
410
479
  ## Comprehensive Examples
411
480
 
412
- For detailed, working examples with real data that cover all return types and functionality of the library, see the [usage-examples.md](usage-examples.md) file. These examples demonstrate:
481
+ For detailed, working examples with real data that cover all return types and functionality of the library, see the [usage-examples.md](docs/usage-examples.md) file. These examples demonstrate:
413
482
 
414
483
  - **Node queries** (Point geometries) - restaurants, cafes, bus stops
415
484
  - **Way queries** (LineString geometries) - primary roads, highways
@@ -10,20 +10,18 @@ module Underpass
10
10
  # Handles caching of responses and automatic retries with exponential
11
11
  # backoff for rate limiting (429) and timeout (504) responses.
12
12
  class Client
13
- # @return [Integer] default maximum number of retries
14
- MAX_RETRIES = 3
15
-
16
13
  # Performs the API request with automatic retries for rate limiting and timeouts.
17
14
  #
18
15
  # Results are cached when a {Cache} instance is configured via {Underpass.cache}.
19
16
  #
20
17
  # @param request [QL::Request] the prepared Overpass query request
21
- # @param max_retries [Integer] maximum number of retry attempts
18
+ # @param max_retries [Integer] maximum number of retry attempts (defaults to configured value)
22
19
  # @return [Net::HTTPResponse] the API response
23
20
  # @raise [RateLimitError] when rate limited after exhausting retries
24
21
  # @raise [TimeoutError] when the API times out after exhausting retries
25
22
  # @raise [ApiError] when the API returns an unexpected error
26
- def self.perform(request, max_retries: MAX_RETRIES)
23
+ def self.perform(request, max_retries: nil)
24
+ max_retries = Underpass.configuration.max_retries if max_retries.nil?
27
25
  cache_key = Digest::SHA256.hexdigest(request.to_query)
28
26
  cached = Underpass.cache&.fetch(cache_key)
29
27
  return cached if cached
@@ -54,14 +52,28 @@ module Underpass
54
52
  private_class_method :post_request
55
53
 
56
54
  def self.handle_error(response, retries, max_retries)
57
- error_class = { 429 => RateLimitError, 504 => TimeoutError }[response.code.to_i]
58
- raise ApiError, "Overpass API returned #{response.code}: #{response.body}" unless error_class
55
+ status_code = response.code.to_i
56
+ error_class = { 429 => RateLimitError, 504 => TimeoutError }[status_code] || ApiError
57
+
58
+ raise build_error(error_class, response.body, status_code) if error_class == ApiError
59
59
 
60
60
  retries += 1
61
- raise error_class, "#{error_class} after #{max_retries} retries" if retries > max_retries
61
+ raise build_error(error_class, response.body, status_code) if retries > max_retries
62
62
 
63
63
  retries
64
64
  end
65
65
  private_class_method :handle_error
66
+
67
+ def self.build_error(error_class, body, status_code)
68
+ parsed = ErrorParser.parse(body, status_code)
69
+ error_class.new(
70
+ parsed[:message],
71
+ code: parsed[:code],
72
+ error_message: parsed[:message],
73
+ details: parsed[:details],
74
+ http_status: status_code
75
+ )
76
+ end
77
+ private_class_method :build_error
66
78
  end
67
79
  end
@@ -7,6 +7,7 @@ module Underpass
7
7
  # Underpass.configure do |config|
8
8
  # config.api_endpoint = 'https://overpass.kumi.systems/api/interpreter'
9
9
  # config.timeout = 30
10
+ # config.max_retries = 5
10
11
  # end
11
12
  class Configuration
12
13
  # @return [String] the Overpass API endpoint URL
@@ -15,9 +16,13 @@ module Underpass
15
16
  # @return [Integer] the query timeout in seconds
16
17
  attr_accessor :timeout
17
18
 
19
+ # @return [Integer] maximum number of retry attempts for rate limiting and timeouts
20
+ attr_accessor :max_retries
21
+
18
22
  def initialize
19
23
  @api_endpoint = 'https://overpass-api.de/api/interpreter'
20
24
  @timeout = 25
25
+ @max_retries = 3
21
26
  end
22
27
  end
23
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
@@ -1,8 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Underpass
4
6
  # Base error class for all Underpass errors.
5
- class Error < StandardError; end
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
6
67
 
7
68
  # Raised when the Overpass API returns a 429 rate limit response.
8
69
  class RateLimitError < Error; end
@@ -13,6 +13,10 @@ module Underpass
13
13
  #
14
14
  # @example Query with a named area
15
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)
16
20
  class Query
17
21
  # Queries the Overpass API within a bounding box.
18
22
  #
@@ -43,6 +47,21 @@ module Underpass
43
47
  execute(request, query_string)
44
48
  end
45
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
+
46
65
  def self.resolve_query(query)
47
66
  query.respond_to?(:to_ql) ? query.to_ql : query
48
67
  end
@@ -13,7 +13,7 @@ module Underpass
13
13
  module VERSION
14
14
  MAJOR = 0
15
15
  MINOR = 9
16
- PATCH = 0
16
+ PATCH = 1
17
17
 
18
18
  STRING = [MAJOR, MINOR, PATCH].join('.')
19
19
  end
data/lib/underpass.rb CHANGED
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'underpass/cache'
7
7
  require 'underpass/configuration'
8
8
  require 'underpass/errors'
9
+ require 'underpass/error_parser'
9
10
  require 'underpass/client'
10
11
  require 'underpass/feature'
11
12
  require 'underpass/filter'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: underpass
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janos Rusiczki
@@ -50,6 +50,7 @@ files:
50
50
  - lib/underpass/cache.rb
51
51
  - lib/underpass/client.rb
52
52
  - lib/underpass/configuration.rb
53
+ - lib/underpass/error_parser.rb
53
54
  - lib/underpass/errors.rb
54
55
  - lib/underpass/feature.rb
55
56
  - lib/underpass/filter.rb