cuzk-rest 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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ class Configuration
6
+ DEFAULTS = {
7
+ base_url: 'https://api-kn.cuzk.gov.cz',
8
+ api_version: 'v1',
9
+ timeout: 30,
10
+ open_timeout: 10,
11
+ retries: 3,
12
+ retry_interval: 0.5,
13
+ requests_per_minute: 60,
14
+ burst_limit: 10,
15
+ cache_ttl: 86_400, # 24 hours in seconds
16
+ enable_caching: false,
17
+ log_requests: false,
18
+ log_responses: false,
19
+ privacy_mode: :strict,
20
+ mask_personal_data: true
21
+ }.freeze
22
+
23
+ attr_accessor :api_key, :base_url, :api_version,
24
+ :timeout, :open_timeout,
25
+ :retries, :retry_interval,
26
+ :requests_per_minute, :burst_limit,
27
+ :cache_store, :cache_ttl, :enable_caching,
28
+ :logger, :log_requests, :log_responses,
29
+ :privacy_mode, :mask_personal_data,
30
+ :user_agent
31
+
32
+ def initialize
33
+ DEFAULTS.each { |key, value| public_send(:"#{key}=", value) }
34
+ @api_key = ENV.fetch('CUZK_REST_API_KEY', nil)
35
+ @user_agent = "cuzk-rest/#{CUZK::REST::VERSION} Ruby/#{RUBY_VERSION}"
36
+ end
37
+
38
+ def api_url
39
+ "#{base_url}/api/#{api_version}"
40
+ end
41
+
42
+ def validate!
43
+ raise ConfigurationError, 'api_key is required' if api_key.nil? || api_key.empty?
44
+ raise ConfigurationError, 'base_url is required' if base_url.nil? || base_url.empty?
45
+
46
+ true
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ class Connection
6
+ attr_reader :config, :last_response_metadata
7
+
8
+ def initialize(config = nil)
9
+ @config = config || CUZK::REST.configuration
10
+ @config.validate!
11
+ @rate_limiter = RateLimiter.new(
12
+ requests_per_minute: @config.requests_per_minute,
13
+ burst_limit: @config.burst_limit
14
+ )
15
+ @last_response_metadata = {}
16
+ end
17
+
18
+ def get(path, params = {})
19
+ request(:get, path, params)
20
+ end
21
+
22
+ def post(path, body = {})
23
+ request(:post, path, body)
24
+ end
25
+
26
+ private
27
+
28
+ def request(method, path, payload = {})
29
+ @rate_limiter.throttle!
30
+
31
+ response = connection.public_send(method, path) do |req|
32
+ case method
33
+ when :get
34
+ req.params.update(payload) unless payload.empty?
35
+ when :post
36
+ req.body = Oj.dump(payload, mode: :compat) unless payload.empty?
37
+ end
38
+ end
39
+
40
+ handle_response(response)
41
+ rescue Faraday::TimeoutError, ::Timeout::Error => e
42
+ raise TimeoutError.new("Request timed out: #{e.message}", original_error: e)
43
+ rescue Faraday::ConnectionFailed => e
44
+ if e.message.include?('execution expired') || e.message.include?('timed out') || e.cause.is_a?(::Timeout::Error)
45
+ raise TimeoutError.new("Request timed out: #{e.message}", original_error: e)
46
+ end
47
+
48
+ raise ConnectionError.new("Connection failed: #{e.message}", original_error: e)
49
+ rescue Faraday::RetriableResponse => e
50
+ handle_response(e.response)
51
+ rescue Faraday::Error => e
52
+ raise NetworkError.new("Network error: #{e.message}", original_error: e)
53
+ end
54
+
55
+ def connection
56
+ @connection ||= Faraday.new(url: config.api_url) do |f|
57
+ f.headers['Accept'] = 'application/json'
58
+ f.headers['Content-Type'] = 'application/json'
59
+ f.headers['User-Agent'] = config.user_agent
60
+ f.headers['Accept-Language'] = 'cs-CZ'
61
+ f.headers['ApiKey'] = config.api_key if config.api_key
62
+
63
+ if config.retries.positive?
64
+ f.request :retry, max: config.retries,
65
+ interval: config.retry_interval,
66
+ retry_statuses: [429, 500, 502, 503, 504],
67
+ methods: %i[get],
68
+ exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
69
+ end
70
+
71
+ f.options.timeout = config.timeout
72
+ f.options.open_timeout = config.open_timeout
73
+
74
+ f.response :logger, config.logger, bodies: config.log_responses if config.logger && config.log_requests
75
+
76
+ f.adapter Faraday.default_adapter
77
+ end
78
+ end
79
+
80
+ def handle_response(response)
81
+ body = response.respond_to?(:body) ? response.body : response[:body]
82
+ status = response.respond_to?(:status) ? response.status : response[:status]
83
+
84
+ case status
85
+ when 200..299
86
+ parse_body(body)
87
+ when 401
88
+ raise AuthenticationError.new('Authentication failed', status: status, response_body: body)
89
+ when 403
90
+ raise AuthorizationError.new('Access forbidden', status: status, response_body: body)
91
+ when 404
92
+ raise NotFoundError.new('Resource not found', status: status, response_body: body)
93
+ when 422
94
+ raise ValidationError.new('Validation failed', status: status, response_body: body)
95
+ when 429
96
+ raise RateLimitedError.new('Rate limit exceeded', status: status, response_body: body)
97
+ when 400..499
98
+ raise APIError.new("Client error (#{status})", status: status, response_body: body)
99
+ when 500..599
100
+ raise ServerError.new("Server error (#{status})", status: status, response_body: body)
101
+ else
102
+ raise InvalidResponseError, "Unexpected response status: #{status}"
103
+ end
104
+ end
105
+
106
+ def parse_body(body)
107
+ return nil if body.nil? || body.empty?
108
+
109
+ parsed = Oj.load(body, mode: :compat, symbol_keys: false)
110
+ unwrap_response(parsed)
111
+ rescue EncodingError, Oj::ParseError, Oj::Error => e
112
+ raise ParsingError.new("Failed to parse response: #{e.message}", original_error: e)
113
+ end
114
+
115
+ def unwrap_response(parsed)
116
+ if wrapped_response?(parsed)
117
+ @last_response_metadata = parsed.except('data')
118
+ parsed['data']
119
+ else
120
+ @last_response_metadata = {}
121
+ parsed
122
+ end
123
+ end
124
+
125
+ def wrapped_response?(parsed)
126
+ parsed.is_a?(Hash) && parsed.key?('data') &&
127
+ (parsed.key?('zpravy') || parsed.key?('provedenoVolani') || parsed.key?('aktualnostDatK'))
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ # Base error for all CUZK REST errors
6
+ class Error < StandardError
7
+ attr_reader :original_error
8
+
9
+ def initialize(message = nil, original_error: nil)
10
+ @original_error = original_error
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # Configuration errors
16
+ class ConfigurationError < Error; end
17
+
18
+ # API errors (HTTP response errors)
19
+ class APIError < Error
20
+ attr_reader :status, :response_body
21
+
22
+ def initialize(message = nil, status: nil, response_body: nil, **kwargs)
23
+ @status = status
24
+ @response_body = response_body
25
+ super(message, **kwargs)
26
+ end
27
+ end
28
+
29
+ class AuthenticationError < APIError; end
30
+ class AuthorizationError < APIError; end
31
+ class NotFoundError < APIError; end
32
+ class ValidationError < APIError; end
33
+ class RateLimitedError < APIError; end
34
+ class ServerError < APIError; end
35
+
36
+ # Network errors
37
+ class NetworkError < Error; end
38
+ class TimeoutError < NetworkError; end
39
+ class ConnectionError < NetworkError; end
40
+
41
+ # Data errors
42
+ class DataError < Error; end
43
+ class ParsingError < DataError; end
44
+ class InvalidResponseError < DataError; end
45
+
46
+ # Privacy & compliance errors
47
+ class PrivacyError < Error; end
48
+ class GDPRViolationError < PrivacyError; end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ class RateLimiter
6
+ def initialize(requests_per_minute: 60, burst_limit: 10)
7
+ @requests_per_minute = requests_per_minute
8
+ @burst_limit = burst_limit
9
+ @timestamps = []
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def throttle!
14
+ @mutex.synchronize do
15
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ cleanup_old_timestamps(now)
17
+
18
+ if @timestamps.size >= @requests_per_minute
19
+ oldest = @timestamps.first
20
+ wait_time = 60.0 - (now - oldest)
21
+ sleep(wait_time) if wait_time.positive?
22
+ cleanup_old_timestamps(Process.clock_gettime(Process::CLOCK_MONOTONIC))
23
+ end
24
+
25
+ @timestamps << Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ end
27
+ end
28
+
29
+ def requests_remaining
30
+ @mutex.synchronize do
31
+ cleanup_old_timestamps(Process.clock_gettime(Process::CLOCK_MONOTONIC))
32
+ @requests_per_minute - @timestamps.size
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def cleanup_old_timestamps(now)
39
+ @timestamps.reject! { |ts| now - ts > 60.0 }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ # Base class for all API resource objects.
6
+ # Provides common attribute handling and connection management.
7
+ class Resource
8
+ class << self
9
+ def connection
10
+ @connection || CUZK::REST::Client.default_connection
11
+ end
12
+
13
+ attr_writer :connection, :endpoint_path
14
+
15
+ def endpoint_path
16
+ @endpoint_path || raise(NotImplementedError, "#{name} must define endpoint_path")
17
+ end
18
+
19
+ private
20
+
21
+ def get(path, params = {})
22
+ connection.get("#{endpoint_path}#{path}", params)
23
+ end
24
+
25
+ def build(data)
26
+ new(data)
27
+ end
28
+
29
+ def build_collection(data, key: nil)
30
+ items = key ? data.fetch(key, []) : data
31
+ return [] unless items.is_a?(Array)
32
+
33
+ items.map { |item| build(item) }
34
+ end
35
+ end
36
+
37
+ attr_reader :attributes
38
+
39
+ def initialize(attributes = {})
40
+ @attributes = attributes.transform_keys(&:to_s)
41
+ end
42
+
43
+ def [](key)
44
+ @attributes[key.to_s]
45
+ end
46
+
47
+ def to_h
48
+ @attributes.dup
49
+ end
50
+
51
+ def inspect
52
+ "#<#{self.class.name} #{summary_attributes}>"
53
+ end
54
+
55
+ private
56
+
57
+ def summary_attributes
58
+ ''
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ module Resources
6
+ class AppService < Resource
7
+ self.endpoint_path = 'AplikacniSluzby'
8
+
9
+ def status
10
+ attributes['status']
11
+ end
12
+
13
+ def last_check
14
+ attributes['lastCheck']
15
+ end
16
+
17
+ def total_duration
18
+ attributes['totalDuration']
19
+ end
20
+
21
+ def entries
22
+ attributes['entries'] || []
23
+ end
24
+
25
+ class << self
26
+ def health
27
+ data = get('/Health')
28
+ build(data)
29
+ end
30
+
31
+ def account_status
32
+ data = get('/StavUctu')
33
+ build(data)
34
+ end
35
+
36
+ def data_currency
37
+ data = get('/AktualnostDat')
38
+ build(data)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def summary_attributes
45
+ "status=#{status}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ module Resources
6
+ class Building < Resource
7
+ self.endpoint_path = 'Stavby'
8
+
9
+ def id
10
+ attributes['id']
11
+ end
12
+
13
+ def descriptive_number
14
+ attributes['cisloPopisne']
15
+ end
16
+
17
+ def registration_number
18
+ attributes['cisloEvidencni']
19
+ end
20
+
21
+ def number
22
+ descriptive_number || registration_number
23
+ end
24
+
25
+ def number_type
26
+ if descriptive_number
27
+ :descriptive
28
+ elsif registration_number
29
+ :registration
30
+ end
31
+ end
32
+
33
+ def building_type
34
+ attributes['typStavby']
35
+ end
36
+
37
+ def building_type_code
38
+ attributes['kodTypuStavby']
39
+ end
40
+
41
+ def purpose
42
+ attributes['zpusobVyuziti']
43
+ end
44
+
45
+ def purpose_code
46
+ attributes['kodZpusobuVyuziti']
47
+ end
48
+
49
+ def cadastral_unit_code
50
+ attributes['kodKatastralnihoUzemi']
51
+ end
52
+
53
+ def cadastral_unit_name
54
+ attributes['nazevKatastralnihoUzemi']
55
+ end
56
+
57
+ def parcel_id
58
+ attributes['idParcely']
59
+ end
60
+
61
+ def lv_number
62
+ attributes['cisloLv']
63
+ end
64
+
65
+ def municipality
66
+ attributes['obec']
67
+ end
68
+
69
+ def municipality_part
70
+ attributes['castObce']
71
+ end
72
+
73
+ def street
74
+ attributes['ulice']
75
+ end
76
+
77
+ def house_number
78
+ attributes['cisloDomovni']
79
+ end
80
+
81
+ def postal_code
82
+ attributes['psc']
83
+ end
84
+
85
+ def full_address
86
+ parts = [street, house_number, municipality_part, municipality, postal_code].compact
87
+ parts.join(', ')
88
+ end
89
+
90
+ class << self
91
+ def find(id)
92
+ data = get("/#{id}")
93
+ build(data)
94
+ end
95
+
96
+ def search(params = {})
97
+ data = get('/Vyhledani', normalize_search_params(params))
98
+ build_collection(data, key: 'stavby')
99
+ end
100
+
101
+ def find_by_address_point(address_code)
102
+ data = get("/AdresniMisto/#{address_code}")
103
+ build(data)
104
+ end
105
+
106
+ private
107
+
108
+ def normalize_search_params(params)
109
+ mapping = {
110
+ municipality_part: 'KodCastiObce',
111
+ building_type: 'TypStavby',
112
+ house_number: 'CisloDomovni'
113
+ }
114
+
115
+ params.each_with_object({}) do |(key, value), result|
116
+ api_key = mapping[key] || key.to_s
117
+ result[api_key] = value
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def summary_attributes
125
+ "id=#{id} number=#{number} type=#{number_type} municipality=#{municipality}"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ module Resources
6
+ class BuildingRight < Resource
7
+ self.endpoint_path = 'PravaStavby'
8
+
9
+ def id
10
+ attributes['id']
11
+ end
12
+
13
+ def parcel_id
14
+ attributes['idParcely']
15
+ end
16
+
17
+ def building_id
18
+ attributes['idStavby']
19
+ end
20
+
21
+ def description
22
+ attributes['popis']
23
+ end
24
+
25
+ class << self
26
+ def find(id)
27
+ data = get("/#{id}")
28
+ build(data)
29
+ end
30
+
31
+ def find_by_parcel(parcel_id)
32
+ data = get("/Parcela/#{parcel_id}")
33
+ build_collection(data, key: 'pravaStavby')
34
+ end
35
+
36
+ def find_by_building(building_id)
37
+ data = get("/Stavba/#{building_id}")
38
+ build_collection(data, key: 'pravaStavby')
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def summary_attributes
45
+ "id=#{id} parcel=#{parcel_id} building=#{building_id}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module REST
5
+ module Resources
6
+ # Data model for ownership records. The ČÚZK REST API does not have a standalone
7
+ # ownership endpoint. Ownership data is embedded within other resources (parcels,
8
+ # buildings, units) via the LV (list vlastnictví) number.
9
+ class Ownership < Resource
10
+ def id
11
+ attributes['id']
12
+ end
13
+
14
+ def owner_name
15
+ if masked?
16
+ attributes['jmenoMaskovane'] || '[chráněno]'
17
+ else
18
+ attributes['jmeno']
19
+ end
20
+ end
21
+
22
+ def owner_type
23
+ attributes['typVlastnika']
24
+ end
25
+
26
+ def owner_type_code
27
+ attributes['kodTypuVlastnika']
28
+ end
29
+
30
+ def ownership_type
31
+ attributes['typVlastnictvi']
32
+ end
33
+
34
+ def ownership_type_code
35
+ attributes['kodTypuVlastnictvi']
36
+ end
37
+
38
+ def share_numerator
39
+ attributes['podilCitatel']
40
+ end
41
+
42
+ def share_denominator
43
+ attributes['podilJmenovatel']
44
+ end
45
+
46
+ def ownership_share
47
+ return nil unless share_numerator && share_denominator
48
+
49
+ "#{share_numerator}/#{share_denominator}"
50
+ end
51
+
52
+ def share_percentage
53
+ return nil unless share_numerator && share_denominator&.to_f&.positive?
54
+
55
+ (share_numerator.to_f / share_denominator * 100).round(2)
56
+ end
57
+
58
+ def property_id
59
+ attributes['idNemovitosti']
60
+ end
61
+
62
+ def property_type
63
+ attributes['typNemovitosti']
64
+ end
65
+
66
+ def lv_number
67
+ attributes['cisloLv']
68
+ end
69
+
70
+ def cadastral_unit_code
71
+ attributes['kodKatastralnihoUzemi']
72
+ end
73
+
74
+ def legal_basis
75
+ attributes['pravniDuvod']
76
+ end
77
+
78
+ def acquisition_date
79
+ attributes['datumNabiti']
80
+ end
81
+
82
+ def restrictions
83
+ attributes['omezeni'] || []
84
+ end
85
+
86
+ def encumbrances
87
+ attributes['vecnaBremena'] || []
88
+ end
89
+
90
+ private
91
+
92
+ def masked?
93
+ CUZK::REST.configuration.mask_personal_data
94
+ end
95
+
96
+ def summary_attributes
97
+ "id=#{id} owner=#{owner_name} share=#{ownership_share} lv=#{lv_number}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end