cuzk-soap 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 854f7991c5000a7bf1e10e0c83e117cd945be2962da139dad760fe9f408dd612
4
+ data.tar.gz: d71c566236109548a142e35e456a5bd7a4443ceed4f3b1e1b8f09e81bd756124
5
+ SHA512:
6
+ metadata.gz: d473c1d48115563b9f53917c52976878f2581dfe591b7d3f9b304bde01bf25cf65d2d04f2740143fd84aecfb95d04cf9736bbb3ab278b09d233f77c709d5f143
7
+ data.tar.gz: f4e484e612eb98dcb7d4b5e37027ff01c06e21da225ad96066e3cb59113a1ec658ea17edb2a2367b47c063750389b15b1313c6214775e562e56bacde35f1768f
data/README.md ADDED
@@ -0,0 +1,311 @@
1
+ # CUZK::SOAP
2
+
3
+ Ruby client for the ČÚZK WSDP SOAP API (Czech Cadastral Registry / Český úřad zeměměřický a katastrální).
4
+
5
+ Wraps the [WSDP (Webové služby dálkového přístupu)](https://www.cuzk.gov.cz/Katastr-nemovitosti/Poskytovani-udaju-z-KN/Dalkovypristup/Webove-sluzby-dalkoveho-pristupu.aspx) v3.1 and v2.9 services with a Ruby-friendly interface, including cost tracking, batch processing, and circuit breaker resilience.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'cuzk-soap'
13
+ ```
14
+
15
+ Then run `bundle install`. Requires Ruby >= 3.2.0.
16
+
17
+ ## Configuration
18
+
19
+ ### Global configuration
20
+
21
+ ```ruby
22
+ CUZK::SOAP.configure do |c|
23
+ c.username = 'YOUR_WSDP_USERNAME'
24
+ c.password = 'YOUR_WSDP_PASSWORD'
25
+ c.environment = :production # :production or :testing
26
+ c.wsdp_version = :v31 # :v31 (default) or :v29
27
+
28
+ # Optional: cost management (CZK)
29
+ c.daily_budget_limit = 1000.0
30
+ c.monthly_budget_limit = 15_000.0
31
+ c.auto_pause_on_budget_exceeded = true
32
+
33
+ # Optional: timeouts
34
+ c.open_timeout = 30
35
+ c.read_timeout = 120
36
+
37
+ # Optional: SSL verification (set :none for trial environment)
38
+ c.ssl_verify_mode = :peer
39
+ end
40
+ ```
41
+
42
+ ### Per-client configuration
43
+
44
+ You can also pass credentials and options directly when creating a client. These override the global configuration:
45
+
46
+ ```ruby
47
+ client = CUZK::SOAP::Client.new(
48
+ username: 'YOUR_WSDP_USERNAME',
49
+ password: 'YOUR_WSDP_PASSWORD',
50
+ environment: :testing,
51
+ daily_budget_limit: 500.0
52
+ )
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Search operations (`vyhledat` service)
58
+
59
+ Search for parcels, buildings, units, and subjects in the cadastral registry.
60
+
61
+ ```ruby
62
+ client = CUZK::SOAP::Client.new(
63
+ username: 'WSTEST', password: 'WSHESLO', environment: :testing
64
+ )
65
+
66
+ # Search parcel by cadastral unit code + parcel number
67
+ result = client.search_parcel(
68
+ cadastral_unit_code: '691232',
69
+ parcel_number: '68'
70
+ )
71
+
72
+ # Search parcel by internal ID
73
+ result = client.search_parcel(parcel_id: '12345')
74
+
75
+ # Search building by composite key
76
+ result = client.search_building(
77
+ district_part_code: '691232',
78
+ building_type_code: '1',
79
+ building_number: '50'
80
+ )
81
+
82
+ # Search building by internal ID
83
+ result = client.search_building(building_id: '999')
84
+
85
+ # Search unit
86
+ result = client.search_unit(
87
+ cadastral_unit_code: '691232',
88
+ unit_number: '10'
89
+ )
90
+
91
+ # Search subject (natural person)
92
+ result = client.search_subject(jmeno: 'Jan', prijmeni: 'Novak')
93
+
94
+ # Search subject (legal entity by IČO)
95
+ result = client.search_subject(ico: '12345678')
96
+ ```
97
+
98
+ ### Report generation (`sestavy` service)
99
+
100
+ Report generation is **asynchronous**. You request generation, poll for completion, then download.
101
+
102
+ ```ruby
103
+ # 1. Request LV (list vlastnictví) generation
104
+ result = client.generate_lv(lv_id: '1234', format: 'pdf')
105
+
106
+ # 2. Poll for status
107
+ reports = client.list_reports
108
+
109
+ # 3. Download when ready
110
+ report = client.download_report(id_sestavy: '42')
111
+
112
+ # 4. Clean up
113
+ client.delete_report(id_sestavy: '42')
114
+ ```
115
+
116
+ Other report types:
117
+
118
+ ```ruby
119
+ # Parcel info report
120
+ client.generate_parcel_info(
121
+ katastr_uzemi_kod: '691232',
122
+ kmenove_cislo: '68',
123
+ format: 'pdf'
124
+ )
125
+
126
+ # Building info report
127
+ client.generate_building_info(
128
+ stavba_id: '999',
129
+ format: 'pdf'
130
+ )
131
+
132
+ # Unit info report
133
+ client.generate_unit_info(jednotka_id: '555', format: 'pdf')
134
+
135
+ # Ownership overview
136
+ client.report_service.generuj_prehled_vlastnictvi(
137
+ opravneny_subjekt_id: '999',
138
+ format: 'pdf'
139
+ )
140
+
141
+ # Price data by property
142
+ client.report_service.generuj_cenove_udaje_dle_nemovitosti(
143
+ nemovitost_id: '123',
144
+ format: 'pdf'
145
+ )
146
+ ```
147
+
148
+ Supported formats: `pdf`, `xml`, `html`, `zip`.
149
+
150
+ ### Codelist operations (`ciselnik` service)
151
+
152
+ ```ruby
153
+ # List all cadastral units
154
+ units = client.list_cadastral_units
155
+ ```
156
+
157
+ ### Subject info (`informace` service)
158
+
159
+ ```ruby
160
+ # Read subject details by ID
161
+ subject = client.informace_service.cti_os(opravneny_subjekt_id: '999')
162
+ ```
163
+
164
+ ### Account management (`ucet` service)
165
+
166
+ ```ruby
167
+ # Check account status / connectivity
168
+ status = client.account_status
169
+
170
+ # Change password
171
+ client.change_password(
172
+ old_password: 'OLD_PASSWORD',
173
+ new_password: 'NEW_PASSWORD'
174
+ )
175
+ ```
176
+
177
+ ### Batch processing
178
+
179
+ Execute multiple operations in sequence with progress tracking:
180
+
181
+ ```ruby
182
+ operations = [
183
+ { method: :search_parcel, params: { cadastral_unit_code: '691232', parcel_number: '68' } },
184
+ { method: :search_parcel, params: { cadastral_unit_code: '691232', parcel_number: '69' } },
185
+ { method: :generate_lv, params: { lv_id: '1234' } }
186
+ ]
187
+
188
+ processor = client.batch_generate(operations) do |current, total, job_id|
189
+ puts "#{job_id}: #{current}/#{total}"
190
+ end
191
+
192
+ puts processor.summary
193
+ # => { job_id: "batch_...", status: :completed, total_operations: 3, successful: 3, failed: 0, progress: 100.0 }
194
+ ```
195
+
196
+ ### Cost management
197
+
198
+ The built-in cost manager tracks spending against ČÚZK fee schedules:
199
+
200
+ ```ruby
201
+ # Get cost estimate before running operations
202
+ estimate = client.cost_estimate(lv_extract_pdf: 5, property_info: 10)
203
+ # => 550.0 (CZK)
204
+
205
+ # Check remaining budget
206
+ status = client.service_status
207
+ status[:cost_manager][:daily_remaining] # => 450.0
208
+ status[:cost_manager][:monthly_remaining] # => 14450.0
209
+ status[:cost_manager][:budget_alert] # => false
210
+ ```
211
+
212
+ When `auto_pause_on_budget_exceeded` is enabled, paid operations raise `CUZK::SOAP::CostLimitExceededError` if the estimated cost would exceed the daily or monthly budget.
213
+
214
+ ### Direct service access
215
+
216
+ For operations not exposed through the `Client` convenience methods, access services directly. All Czech method names are available alongside English aliases:
217
+
218
+ ```ruby
219
+ # Czech names
220
+ client.search_service.najdi_parcelu(katastr_uzemi_kod: '691232', kmenove_cislo: '68')
221
+ client.search_service.najdi_stavbu(stavba_id: '999')
222
+ client.search_service.najdi_os(ico: '12345678')
223
+ client.search_service.najdi_rizeni(...)
224
+ client.report_service.generuj_lv(lv_id: '1234')
225
+
226
+ # English aliases
227
+ client.search_service.search_parcel(...)
228
+ client.search_service.search_building(...)
229
+ client.search_service.search_subject(...)
230
+ client.search_service.search_proceedings(...)
231
+ client.report_service.generate_lv(...)
232
+ ```
233
+
234
+ ### Monitoring
235
+
236
+ ```ruby
237
+ # Full service status with cost tracking
238
+ client.service_status
239
+ # => { environment: :testing, wsdp_version: :v31, connected: true, cost_manager: { ... } }
240
+
241
+ # WSDP version info with available services and endpoints
242
+ client.wsdp_version_info
243
+ # => { version: :v31, available_services: [:vyhledat, :sestavy, ...], wsdl_urls: { ... }, soap_endpoints: { ... } }
244
+ ```
245
+
246
+ ## WSDP Services
247
+
248
+ | Service | Description | Client accessor |
249
+ |-------------|------------------------------------|-------------------------|
250
+ | `vyhledat` | Search parcels, buildings, units, subjects | `client.search_service` |
251
+ | `sestavy` | Generate & manage reports (async) | `client.report_service` |
252
+ | `informace` | Read subject detail data | `client.informace_service` |
253
+ | `ciselnik` | Codelists (cadastral units, etc.) | `client.ciselnik_service` |
254
+ | `ucet` | Account status & password change | `client.account_service` |
255
+
256
+ ## Error handling
257
+
258
+ ```ruby
259
+ begin
260
+ client.search_parcel(cadastral_unit_code: '691232', parcel_number: '68')
261
+ rescue CUZK::SOAP::AuthenticationError => e
262
+ # Invalid credentials or expired session
263
+ rescue CUZK::SOAP::NetworkTimeoutError => e
264
+ # Connection timeout
265
+ rescue CUZK::SOAP::CostLimitExceededError => e
266
+ # Budget limit would be exceeded
267
+ rescue CUZK::SOAP::WSDPError => e
268
+ # WSDP service fault
269
+ e.fault_code # SOAP fault code
270
+ e.fault_string # SOAP fault description
271
+ rescue CUZK::SOAP::Error => e
272
+ # Base error class for all gem errors
273
+ end
274
+ ```
275
+
276
+ ## Trial environment
277
+
278
+ ČÚZK provides a trial environment for development and testing with pre-loaded test data (Prachatice district):
279
+
280
+ | Account | Username | Password |
281
+ |---------------|------------|------------|
282
+ | Standard | `WSTEST` | `WSHESLO` |
283
+ | Free | `WSTESTB` | `WSHESLOB` |
284
+ | Verification | `WSTESTO` | `WSHESLOO` |
285
+ | CzechPoint | `WSTESTC` | `WSHESLOC` |
286
+
287
+ ```ruby
288
+ client = CUZK::SOAP::Client.new(
289
+ username: 'WSTEST',
290
+ password: 'WSHESLO',
291
+ environment: :testing,
292
+ ssl_verify_mode: :none # trial environment may have CRL issues
293
+ )
294
+ ```
295
+
296
+ ## Development
297
+
298
+ ```bash
299
+ bin/setup # Install dependencies
300
+ bundle exec rspec spec/cuzk # Run unit tests (178 examples)
301
+ bundle exec rspec spec/integration # Run integration tests against VCR cassettes
302
+ bundle exec rspec spec/ # Run all tests (194 examples)
303
+ bundle exec rubocop # Run linter
304
+
305
+ # Re-record VCR cassettes against the live trial environment
306
+ VCR_RECORD=new_episodes bundle exec rspec spec/integration/
307
+ ```
308
+
309
+ ## License
310
+
311
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ module Authentication
6
+ class TestCredentials < WSDPCredentials
7
+ TEST_USERNAME = 'WSTEST'
8
+ TEST_PASSWORD = 'WSHESLO'
9
+
10
+ # Additional trial accounts
11
+ ACCOUNTS = {
12
+ standard: { username: 'WSTEST', password: 'WSHESLO' },
13
+ free: { username: 'WSTESTB', password: 'WSHESLOB' },
14
+ verification: { username: 'WSTESTO', password: 'WSHESLOO' },
15
+ czechpoint: { username: 'WSTESTC', password: 'WSHESLOC' }
16
+ }.freeze
17
+
18
+ def initialize(account: :standard)
19
+ creds = ACCOUNTS.fetch(account) do
20
+ raise ArgumentError, "Unknown test account: #{account}. Available: #{ACCOUNTS.keys.join(', ')}"
21
+ end
22
+
23
+ super(
24
+ username: creds[:username],
25
+ password: creds[:password],
26
+ environment: :testing
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'base64'
6
+ require 'time'
7
+
8
+ module CUZK
9
+ module SOAP
10
+ module Authentication
11
+ class WSDPCredentials
12
+ WSSE_NAMESPACE = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'
13
+ WSU_NAMESPACE = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'
14
+ PASSWORD_TEXT_TYPE =
15
+ 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText'
16
+
17
+ attr_reader :username, :environment
18
+
19
+ def initialize(username:, password:, environment: :production)
20
+ @username = username
21
+ @password = password
22
+ @environment = environment
23
+ end
24
+
25
+ # Build the WS-Security header hash for Savon's :soap_header option.
26
+ # WSDP requires PasswordText over HTTPS (not digest).
27
+ def wsse_security_header
28
+ {
29
+ 'wsse:Security' => {
30
+ '@xmlns:wsse' => WSSE_NAMESPACE,
31
+ '@xmlns:wsu' => WSU_NAMESPACE,
32
+ '@SOAP-ENV:mustUnderstand' => '1',
33
+ 'wsse:UsernameToken' => {
34
+ 'wsse:Username' => @username,
35
+ 'wsse:Password' => {
36
+ '@Type' => PASSWORD_TEXT_TYPE,
37
+ :content! => @password
38
+ }
39
+ }
40
+ }
41
+ }
42
+ end
43
+
44
+ # Savon wsse_auth array: [username, password] for PasswordText
45
+ # Savon uses plaintext by default, :digest flag enables digest mode.
46
+ def savon_wsse_auth
47
+ [@username, @password]
48
+ end
49
+
50
+ def validate_credentials
51
+ !@username.nil? && !@username.empty? &&
52
+ !@password.nil? && !@password.empty?
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ class BatchProcessor
6
+ attr_reader :job_id, :status, :operations, :results, :errors
7
+
8
+ def initialize(client, operations)
9
+ @client = client
10
+ @operations = operations
11
+ @job_id = generate_job_id
12
+ @status = :pending
13
+ @results = []
14
+ @errors = []
15
+ @progress_callbacks = []
16
+ @completion_callbacks = []
17
+ @error_callbacks = []
18
+ end
19
+
20
+ def start
21
+ @status = :running
22
+ total = @operations.size
23
+
24
+ @operations.each_with_index do |operation, index|
25
+ break if @status == :cancelled
26
+
27
+ execute_operation(operation, index)
28
+ notify_progress(index + 1, total)
29
+ end
30
+
31
+ @status = @errors.empty? ? :completed : :completed_with_errors
32
+ notify_completion
33
+ self
34
+ rescue StandardError => e
35
+ @status = :failed
36
+ raise BatchProcessingError, "Batch job #{@job_id} failed: #{e.message}"
37
+ end
38
+
39
+ def cancel
40
+ @status = :cancelled
41
+ end
42
+
43
+ def progress_percentage
44
+ return 0 if @operations.empty?
45
+
46
+ completed = @results.size + @errors.size
47
+ (completed.to_f / @operations.size * 100).round(1)
48
+ end
49
+
50
+ def successful_count
51
+ @results.size
52
+ end
53
+
54
+ def failed_count
55
+ @errors.size
56
+ end
57
+
58
+ def on_progress(&callback)
59
+ @progress_callbacks << callback
60
+ end
61
+
62
+ def on_completion(&callback)
63
+ @completion_callbacks << callback
64
+ end
65
+
66
+ def on_error(&callback)
67
+ @error_callbacks << callback
68
+ end
69
+
70
+ def summary
71
+ {
72
+ job_id: @job_id,
73
+ status: @status,
74
+ total_operations: @operations.size,
75
+ successful: successful_count,
76
+ failed: failed_count,
77
+ progress: progress_percentage
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def execute_operation(operation, index)
84
+ method_name = operation[:method]
85
+ params = operation[:params] || {}
86
+
87
+ result = @client.public_send(method_name, **params)
88
+ @results << { index: index, operation: operation, result: result }
89
+ rescue Error => e
90
+ error_entry = { index: index, operation: operation, error: e }
91
+ @errors << error_entry
92
+ notify_error(error_entry)
93
+ end
94
+
95
+ def notify_progress(current, total)
96
+ @progress_callbacks.each { |cb| cb.call(current, total, @job_id) }
97
+ end
98
+
99
+ def notify_completion
100
+ @completion_callbacks.each { |cb| cb.call(summary) }
101
+ end
102
+
103
+ def notify_error(error_entry)
104
+ @error_callbacks.each { |cb| cb.call(error_entry) }
105
+ end
106
+
107
+ def generate_job_id
108
+ "batch_#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_#{SecureRandom.hex(4)}"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ class ChangeTracker
6
+ attr_reader :tracked_properties, :last_check, :changes_detected
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ @tracked_properties = {}
11
+ @last_check = nil
12
+ @changes_detected = []
13
+ @alert_callbacks = []
14
+ end
15
+
16
+ def add_property(property_id, property_type:, monitoring_level: :standard, **metadata)
17
+ @tracked_properties[property_id] = {
18
+ property_type: property_type,
19
+ monitoring_level: monitoring_level,
20
+ added_at: Time.now.utc,
21
+ last_snapshot: nil,
22
+ metadata: metadata
23
+ }
24
+ end
25
+
26
+ def remove_property(property_id)
27
+ @tracked_properties.delete(property_id)
28
+ end
29
+
30
+ def check_for_changes
31
+ changes = []
32
+
33
+ @tracked_properties.each do |property_id, tracking_info|
34
+ current_data = fetch_current_data(property_id, tracking_info)
35
+ previous_data = tracking_info[:last_snapshot]
36
+
37
+ if previous_data && current_data != previous_data
38
+ change = {
39
+ property_id: property_id,
40
+ property_type: tracking_info[:property_type],
41
+ detected_at: Time.now.utc,
42
+ previous_data: previous_data,
43
+ current_data: current_data
44
+ }
45
+ changes << change
46
+ end
47
+
48
+ tracking_info[:last_snapshot] = current_data
49
+ end
50
+
51
+ @last_check = Time.now.utc
52
+ @changes_detected.concat(changes)
53
+ trigger_alerts(changes) unless changes.empty?
54
+ changes
55
+ end
56
+
57
+ def changes_since(date)
58
+ @changes_detected.select { |c| c[:detected_at] >= date }
59
+ end
60
+
61
+ def change_history(property_id)
62
+ @changes_detected.select { |c| c[:property_id] == property_id }
63
+ end
64
+
65
+ def on_change(&callback)
66
+ @alert_callbacks << callback
67
+ end
68
+
69
+ def summary
70
+ {
71
+ tracked_count: @tracked_properties.size,
72
+ total_changes: @changes_detected.size,
73
+ last_check: @last_check
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def fetch_current_data(property_id, tracking_info)
80
+ case tracking_info[:property_type]
81
+ when :parcel
82
+ parts = property_id.split('/')
83
+ @client.report_service.informace_o_parcele(
84
+ katastr_uzemi_kod: parts[0],
85
+ kmenove_cislo: parts[1],
86
+ format: 'xml'
87
+ )
88
+ when :building
89
+ parts = property_id.split('/')
90
+ @client.report_service.informace_o_budove(
91
+ katastr_uzemi_kod: parts[0],
92
+ cislo_budovy: parts[1],
93
+ format: 'xml'
94
+ )
95
+ when :unit
96
+ @client.report_service.informace_o_jednotce(
97
+ jednotka_id: property_id,
98
+ format: 'xml'
99
+ )
100
+ else
101
+ raise ArgumentError, "Unknown property type: #{tracking_info[:property_type]}"
102
+ end
103
+ end
104
+
105
+ def trigger_alerts(changes)
106
+ @alert_callbacks.each { |cb| cb.call(changes) }
107
+ end
108
+ end
109
+ end
110
+ end