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.
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ class Client
6
+ attr_reader :configuration, :connection, :cost_manager
7
+
8
+ def initialize(username: nil, password: nil, environment: nil, **options)
9
+ @configuration = CUZK::SOAP.configuration.dup
10
+ @configuration.username = username if username
11
+ @configuration.password = password if password
12
+ @configuration.environment = environment if environment
13
+
14
+ options.each do |key, value|
15
+ @configuration.public_send(:"#{key}=", value) if @configuration.respond_to?(:"#{key}=")
16
+ end
17
+
18
+ @configuration.validate!
19
+ @connection = Connection.new(@configuration)
20
+ @cost_manager = CostManager.new(@configuration)
21
+ @services = {}
22
+ end
23
+
24
+ # --- Service accessors ---
25
+
26
+ def search_service
27
+ @services[:search] ||= Services::SearchService.new(@connection)
28
+ end
29
+
30
+ def report_service
31
+ @services[:report] ||= Services::ReportService.new(@connection)
32
+ end
33
+
34
+ def account_service
35
+ @services[:account] ||= Services::AccountService.new(@connection)
36
+ end
37
+
38
+ def informace_service
39
+ @services[:informace] ||= Services::InformaceService.new(@connection)
40
+ end
41
+
42
+ def ciselnik_service
43
+ @services[:ciselnik] ||= Services::CiselnikService.new(@connection)
44
+ end
45
+
46
+ # --- Account management ---
47
+
48
+ def account_status
49
+ account_service.stav_ws
50
+ end
51
+
52
+ def change_password(old_password:, new_password:)
53
+ account_service.zmen_heslo(stare_heslo: old_password, nove_heslo: new_password)
54
+ end
55
+
56
+ def cost_estimate(operations)
57
+ @cost_manager.estimate_batch_cost(operations)
58
+ end
59
+
60
+ # --- Search operations ---
61
+
62
+ def search_parcel(cadastral_unit_code: nil, parcel_number: nil, parcel_id: nil, **)
63
+ search_service.najdi_parcelu(
64
+ katastr_uzemi_kod: cadastral_unit_code,
65
+ kmenove_cislo: parcel_number,
66
+ parcela_id: parcel_id,
67
+ **
68
+ )
69
+ end
70
+
71
+ def search_building(building_id: nil, district_part_code: nil, building_type_code: nil,
72
+ building_number: nil, **)
73
+ search_service.najdi_stavbu(
74
+ stavba_id: building_id,
75
+ cast_obce_kod: district_part_code,
76
+ typ_stavby_kod: building_type_code,
77
+ cislo_domovni: building_number,
78
+ **
79
+ )
80
+ end
81
+
82
+ def search_unit(cadastral_unit_code: nil, unit_number: nil, unit_id: nil, **)
83
+ search_service.najdi_jednotku(
84
+ katastr_uzemi_kod: cadastral_unit_code,
85
+ cislo_jednotky: unit_number,
86
+ jednotka_id: unit_id,
87
+ **
88
+ )
89
+ end
90
+
91
+ def search_subject(**params)
92
+ search_service.najdi_os(**params)
93
+ end
94
+
95
+ # --- Report generation (async) ---
96
+
97
+ def generate_lv(lv_id:, format: 'pdf', **options)
98
+ with_cost_control(:lv_extract_pdf) do
99
+ report_service.generuj_lv(lv_id: lv_id, format: format, **options)
100
+ end
101
+ end
102
+
103
+ def generate_parcel_info(format: 'pdf', **options)
104
+ with_cost_control(:property_info) do
105
+ report_service.generuj_info_o_parcelach(format: format, **options)
106
+ end
107
+ end
108
+
109
+ def generate_building_info(format: 'pdf', **options)
110
+ with_cost_control(:building_info) do
111
+ report_service.generuj_info_o_stavbach(format: format, **options)
112
+ end
113
+ end
114
+
115
+ def generate_unit_info(jednotka_id:, format: 'pdf', **options)
116
+ with_cost_control(:unit_info) do
117
+ report_service.generuj_info_o_jednotkach(jednotka_id: jednotka_id, format: format, **options)
118
+ end
119
+ end
120
+
121
+ # --- Report management ---
122
+
123
+ def list_reports(id_sestavy: nil)
124
+ report_service.seznam_sestav(id_sestavy: id_sestavy)
125
+ end
126
+
127
+ def download_report(id_sestavy:)
128
+ report_service.vrat_sestavu(id_sestavy: id_sestavy)
129
+ end
130
+
131
+ def delete_report(id_sestavy:)
132
+ report_service.smaz_sestavu(id_sestavy: id_sestavy)
133
+ end
134
+
135
+ # --- Codelist operations ---
136
+
137
+ def list_cadastral_units
138
+ ciselnik_service.seznam_ku
139
+ end
140
+
141
+ # --- Batch processing ---
142
+
143
+ def batch_generate(operations, &progress_block)
144
+ processor = BatchProcessor.new(self, operations)
145
+ processor.on_progress(&progress_block) if progress_block
146
+ processor.start
147
+ end
148
+
149
+ # --- Monitoring ---
150
+
151
+ def service_status
152
+ {
153
+ environment: @configuration.environment,
154
+ wsdp_version: @configuration.wsdp_version,
155
+ connected: connection_alive?,
156
+ cost_manager: {
157
+ daily_spent: @cost_manager.daily_spent,
158
+ monthly_spent: @cost_manager.monthly_spent,
159
+ daily_remaining: @cost_manager.daily_budget_remaining,
160
+ monthly_remaining: @cost_manager.monthly_budget_remaining,
161
+ budget_alert: @cost_manager.budget_alert?
162
+ }
163
+ }
164
+ end
165
+
166
+ def wsdp_version_info
167
+ {
168
+ version: @configuration.wsdp_version,
169
+ available_services: @connection.wsdl_manager.available_services,
170
+ wsdl_urls: @connection.wsdl_manager.all_wsdl_urls,
171
+ soap_endpoints: @connection.wsdl_manager.all_soap_endpoints
172
+ }
173
+ end
174
+
175
+ private
176
+
177
+ def with_cost_control(operation, count: 1)
178
+ estimated = @cost_manager.estimate_cost(operation, count: count)
179
+
180
+ @cost_manager.check_budget!(estimated) if @configuration.auto_pause_on_budget_exceeded
181
+
182
+ result = yield
183
+
184
+ @cost_manager.record_transaction(operation, estimated)
185
+ result
186
+ end
187
+
188
+ def connection_alive?
189
+ account_service.stav_ws
190
+ true
191
+ rescue Error
192
+ false
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ class Configuration
6
+ # WSDP account credentials
7
+ attr_accessor :username, :password
8
+
9
+ # Environment: :production or :testing
10
+ attr_accessor :environment
11
+
12
+ # WSDP version: :v31 or :v29
13
+ attr_accessor :wsdp_version
14
+
15
+ # Cost management (CZK)
16
+ attr_accessor :daily_budget_limit, :monthly_budget_limit,
17
+ :cost_alert_threshold, :auto_pause_on_budget_exceeded
18
+
19
+ # SOAP client settings (WSDP uses SOAP 1.1 only)
20
+ attr_accessor :open_timeout, :read_timeout,
21
+ :max_retries, :retry_delay,
22
+ :ssl_verify_mode
23
+
24
+ # WSDP features
25
+ attr_accessor :validate_wsdl_on_startup, :cache_wsdl, :wsdl_cache_ttl
26
+
27
+ # Enterprise features
28
+ attr_accessor :audit_logging, :audit_log_path,
29
+ :performance_monitoring, :compliance_mode, :gdpr_anonymization
30
+
31
+ # Caching
32
+ attr_accessor :cache_store, :cache_ttl_extracts, :cache_ttl_searches,
33
+ :cache_ttl_account_status
34
+
35
+ # Logging
36
+ attr_accessor :logger, :log_level
37
+
38
+ # Rate limiting
39
+ attr_accessor :requests_per_minute, :concurrent_requests
40
+
41
+ # Circuit breaker
42
+ attr_accessor :circuit_breaker_threshold, :circuit_breaker_timeout
43
+
44
+ # Enabled services
45
+ attr_accessor :enabled_services
46
+
47
+ def initialize
48
+ @environment = :production
49
+ @wsdp_version = :v31
50
+
51
+ # Cost management defaults
52
+ @daily_budget_limit = nil
53
+ @monthly_budget_limit = nil
54
+ @cost_alert_threshold = 0.8
55
+ @auto_pause_on_budget_exceeded = false
56
+
57
+ # SOAP defaults (WSDP uses SOAP 1.1 exclusively)
58
+ @open_timeout = 30
59
+ @read_timeout = 120
60
+ @max_retries = 3
61
+ @retry_delay = 2
62
+ @ssl_verify_mode = :peer
63
+
64
+ # WSDP feature defaults
65
+ @validate_wsdl_on_startup = false
66
+ @cache_wsdl = true
67
+ @wsdl_cache_ttl = 86_400 # 24 hours in seconds
68
+
69
+ # Enterprise defaults
70
+ @audit_logging = false
71
+ @performance_monitoring = false
72
+ @compliance_mode = :standard
73
+ @gdpr_anonymization = true
74
+
75
+ # Caching defaults (seconds)
76
+ @cache_ttl_extracts = 1800 # 30 minutes
77
+ @cache_ttl_searches = 900 # 15 minutes
78
+ @cache_ttl_account_status = 300 # 5 minutes
79
+
80
+ # Logging defaults
81
+ @log_level = :info
82
+
83
+ # Rate limiting defaults
84
+ @requests_per_minute = 120
85
+ @concurrent_requests = 10
86
+
87
+ # Circuit breaker defaults
88
+ @circuit_breaker_threshold = 10
89
+ @circuit_breaker_timeout = 60
90
+
91
+ # All services enabled by default
92
+ @enabled_services = %i[vyhledat sestavy informace ciselnik ucet]
93
+ end
94
+
95
+ def production?
96
+ environment == :production
97
+ end
98
+
99
+ def testing?
100
+ environment == :testing
101
+ end
102
+
103
+ def validate!
104
+ raise ConfigurationError, 'username is required' if username.nil? || username.empty?
105
+ raise ConfigurationError, 'password is required' if password.nil? || password.empty?
106
+
107
+ unless %i[production testing].include?(environment)
108
+ raise ConfigurationError, 'environment must be :production or :testing'
109
+ end
110
+
111
+ raise ConfigurationError, 'wsdp_version must be :v31 or :v29' unless %i[v31 v29].include?(wsdp_version)
112
+
113
+ true
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'savon'
4
+ require 'retriable'
5
+
6
+ module CUZK
7
+ module SOAP
8
+ class Connection
9
+ attr_reader :wsdl_manager, :credentials
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ @wsdl_manager = WSDLManager.new(
14
+ version: configuration.wsdp_version,
15
+ environment: configuration.environment
16
+ )
17
+ @credentials = Authentication::WSDPCredentials.new(
18
+ username: configuration.username,
19
+ password: configuration.password,
20
+ environment: configuration.environment
21
+ )
22
+ @clients = {}
23
+ @circuit_breaker_failures = 0
24
+ @circuit_breaker_opened_at = nil
25
+ end
26
+
27
+ def call(service, operation, message = {})
28
+ check_circuit_breaker!
29
+
30
+ with_retry do
31
+ client = client_for(service)
32
+ response = client.call(operation, message: message)
33
+ reset_circuit_breaker
34
+ response
35
+ end
36
+ rescue Savon::SOAPFault => e
37
+ handle_soap_fault(e)
38
+ rescue Savon::HTTPError => e
39
+ record_failure
40
+ raise ServiceUnavailableError.new(
41
+ "HTTP error: #{e.message}",
42
+ fault_code: e.http.code.to_s
43
+ )
44
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
45
+ record_failure
46
+ raise NetworkTimeoutError, "Request timed out: #{e.message}"
47
+ end
48
+
49
+ def client_for(service)
50
+ @clients[service] ||= build_savon_client(service)
51
+ end
52
+
53
+ def available_operations(service)
54
+ client_for(service).operations
55
+ rescue StandardError => e
56
+ raise WSDPError, "Failed to retrieve operations for #{service}: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def build_savon_client(service)
62
+ wsdl_url = @wsdl_manager.wsdl_url(service)
63
+ # ČÚZK WSDLs use separate namespaces for service definitions and types.
64
+ # Savon defaults to the WSDL targetNamespace (services:*) but request
65
+ # elements must use the types namespace (types:*).
66
+ types_ns = @wsdl_manager.types_namespace(service)
67
+
68
+ Savon.client(
69
+ wsdl: wsdl_url,
70
+ namespace: types_ns,
71
+ wsse_auth: @credentials.savon_wsse_auth,
72
+ soap_version: 1, # WSDP uses SOAP 1.1 exclusively
73
+ open_timeout: @configuration.open_timeout,
74
+ read_timeout: @configuration.read_timeout,
75
+ encoding: 'UTF-8',
76
+ log: !@configuration.logger.nil?,
77
+ logger: @configuration.logger || Logger.new(File::NULL),
78
+ log_level: @configuration.log_level,
79
+ pretty_print_xml: true,
80
+ ssl_verify_mode: @configuration.ssl_verify_mode,
81
+ convert_request_keys_to: :none
82
+ )
83
+ end
84
+
85
+ def with_retry(&)
86
+ Retriable.retriable(
87
+ tries: @configuration.max_retries + 1,
88
+ base_interval: @configuration.retry_delay,
89
+ multiplier: 2.0,
90
+ on: [NetworkTimeoutError, ServiceUnavailableError],
91
+ &
92
+ )
93
+ end
94
+
95
+ def handle_soap_fault(error)
96
+ fault = error.to_hash[:fault] || {}
97
+ fault_code = fault[:faultcode].to_s
98
+ fault_string = fault[:faultstring].to_s
99
+
100
+ exception_class = case fault_code
101
+ when /InsufficientFunds/i then InsufficientFundsError
102
+ when /AccountSuspended/i then AccountSuspendedError
103
+ when /Authentication/i then AuthenticationError
104
+ when /InvalidRequest/i then InvalidRequestError
105
+ else WSDPError
106
+ end
107
+
108
+ record_failure
109
+ raise exception_class.new(fault_string, fault_code: fault_code, fault_string: fault_string)
110
+ end
111
+
112
+ def check_circuit_breaker!
113
+ return unless @circuit_breaker_opened_at
114
+
115
+ elapsed = Time.now - @circuit_breaker_opened_at
116
+ if elapsed >= @configuration.circuit_breaker_timeout
117
+ @circuit_breaker_opened_at = nil
118
+ @circuit_breaker_failures = 0
119
+ else
120
+ raise CircuitBreakerOpenError,
121
+ "Circuit breaker is open. Retry after #{(@configuration.circuit_breaker_timeout - elapsed).ceil}s"
122
+ end
123
+ end
124
+
125
+ def record_failure
126
+ @circuit_breaker_failures += 1
127
+ return unless @circuit_breaker_failures >= @configuration.circuit_breaker_threshold
128
+
129
+ @circuit_breaker_opened_at = Time.now
130
+ end
131
+
132
+ def reset_circuit_breaker
133
+ @circuit_breaker_failures = 0
134
+ @circuit_breaker_opened_at = nil
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CUZK
4
+ module SOAP
5
+ class CostManager
6
+ # Approximate WSDP pricing in CZK (actual prices per ČÚZK sazebník)
7
+ WSDP_PRICING = {
8
+ lv_extract_pdf: 50.0,
9
+ lv_extract_xml: 50.0,
10
+ property_info: 30.0,
11
+ building_info: 30.0,
12
+ unit_info: 30.0,
13
+ ownership_overview: 100.0,
14
+ ownership_with_properties: 100.0,
15
+ price_data: 40.0,
16
+ account_status: 0.0,
17
+ document_order: 0.0
18
+ }.freeze
19
+
20
+ attr_reader :daily_spent, :monthly_spent, :transaction_log
21
+
22
+ def initialize(configuration)
23
+ @configuration = configuration
24
+ @daily_spent = 0.0
25
+ @monthly_spent = 0.0
26
+ @transaction_log = []
27
+ @daily_reset_at = today_start
28
+ @monthly_reset_at = month_start
29
+ end
30
+
31
+ def estimate_cost(operation, count: 1)
32
+ unit_cost = WSDP_PRICING.fetch(operation, 0.0)
33
+ unit_cost * count
34
+ end
35
+
36
+ def estimate_batch_cost(operations)
37
+ operations.sum { |op, count| estimate_cost(op, count: count) }
38
+ end
39
+
40
+ def check_budget!(estimated_cost)
41
+ reset_counters_if_needed
42
+
43
+ if @configuration.daily_budget_limit && (daily_spent + estimated_cost) > @configuration.daily_budget_limit
44
+ raise CostLimitExceededError,
45
+ "Daily budget limit would be exceeded: #{daily_spent + estimated_cost} CZK " \
46
+ "(limit: #{@configuration.daily_budget_limit} CZK)"
47
+ end
48
+
49
+ if @configuration.monthly_budget_limit && (monthly_spent + estimated_cost) > @configuration.monthly_budget_limit
50
+ raise CostLimitExceededError,
51
+ "Monthly budget limit would be exceeded: #{monthly_spent + estimated_cost} CZK " \
52
+ "(limit: #{@configuration.monthly_budget_limit} CZK)"
53
+ end
54
+
55
+ true
56
+ end
57
+
58
+ def record_transaction(operation, cost, metadata = {})
59
+ reset_counters_if_needed
60
+
61
+ @daily_spent += cost
62
+ @monthly_spent += cost
63
+ @transaction_log << {
64
+ operation: operation,
65
+ cost: cost,
66
+ timestamp: Time.now.utc,
67
+ metadata: metadata
68
+ }
69
+ end
70
+
71
+ def budget_alert?
72
+ return false unless @configuration.cost_alert_threshold
73
+
74
+ threshold = @configuration.cost_alert_threshold
75
+
76
+ if @configuration.daily_budget_limit && (daily_spent >= (@configuration.daily_budget_limit * threshold))
77
+ return true
78
+ end
79
+
80
+ if @configuration.monthly_budget_limit && (monthly_spent >= (@configuration.monthly_budget_limit * threshold))
81
+ return true
82
+ end
83
+
84
+ false
85
+ end
86
+
87
+ def daily_budget_remaining
88
+ return Float::INFINITY unless @configuration.daily_budget_limit
89
+
90
+ [@configuration.daily_budget_limit - daily_spent, 0.0].max
91
+ end
92
+
93
+ def monthly_budget_remaining
94
+ return Float::INFINITY unless @configuration.monthly_budget_limit
95
+
96
+ [@configuration.monthly_budget_limit - monthly_spent, 0.0].max
97
+ end
98
+
99
+ def reset_daily!
100
+ @daily_spent = 0.0
101
+ @daily_reset_at = today_start
102
+ end
103
+
104
+ def reset_monthly!
105
+ @monthly_spent = 0.0
106
+ @monthly_reset_at = month_start
107
+ end
108
+
109
+ private
110
+
111
+ def reset_counters_if_needed
112
+ now = Time.now.utc
113
+
114
+ if now >= @daily_reset_at + 86_400
115
+ @daily_spent = 0.0
116
+ @daily_reset_at = today_start
117
+ end
118
+
119
+ return unless now.month != @monthly_reset_at.month || now.year != @monthly_reset_at.year
120
+
121
+ @monthly_spent = 0.0
122
+ @monthly_reset_at = month_start
123
+ end
124
+
125
+ def today_start
126
+ now = Time.now.utc
127
+ Time.utc(now.year, now.month, now.day)
128
+ end
129
+
130
+ def month_start
131
+ now = Time.now.utc
132
+ Time.utc(now.year, now.month, 1)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'fileutils'
5
+
6
+ module CUZK
7
+ module SOAP
8
+ class DocumentManager
9
+ attr_reader :storage_path
10
+
11
+ def initialize(storage_path: nil)
12
+ @storage_path = storage_path
13
+ @documents = {}
14
+ end
15
+
16
+ def store(document_id, content, format:, metadata: {})
17
+ entry = {
18
+ content: content,
19
+ format: format,
20
+ stored_at: Time.now.utc,
21
+ size: content.bytesize,
22
+ metadata: metadata
23
+ }
24
+
25
+ @documents[document_id] = entry
26
+
27
+ write_to_disk(document_id, content, format) if @storage_path
28
+
29
+ entry
30
+ end
31
+
32
+ def retrieve(document_id)
33
+ entry = @documents[document_id]
34
+ return entry if entry
35
+
36
+ return unless @storage_path
37
+
38
+ read_from_disk(document_id)
39
+ end
40
+
41
+ def delete(document_id)
42
+ @documents.delete(document_id)
43
+
44
+ return unless @storage_path
45
+
46
+ delete_from_disk(document_id)
47
+ end
48
+
49
+ def list(filter: nil)
50
+ docs = @documents.map do |id, entry|
51
+ {
52
+ document_id: id,
53
+ format: entry[:format],
54
+ stored_at: entry[:stored_at],
55
+ size: entry[:size],
56
+ metadata: entry[:metadata]
57
+ }
58
+ end
59
+
60
+ if filter
61
+ docs.select { |d| d[:format].to_s == filter.to_s }
62
+ else
63
+ docs
64
+ end
65
+ end
66
+
67
+ def save_to_file(document_id, file_path)
68
+ entry = retrieve(document_id)
69
+ raise DocumentError, "Document not found: #{document_id}" unless entry
70
+
71
+ File.binwrite(file_path, entry[:content])
72
+ file_path
73
+ end
74
+
75
+ private
76
+
77
+ def write_to_disk(document_id, content, format)
78
+ FileUtils.mkdir_p(@storage_path)
79
+ file_path = File.join(@storage_path, "#{sanitize_id(document_id)}.#{format}")
80
+ File.binwrite(file_path, content)
81
+ end
82
+
83
+ def read_from_disk(document_id)
84
+ Dir.glob(File.join(@storage_path, "#{sanitize_id(document_id)}.*")).first&.then do |path|
85
+ {
86
+ content: File.binread(path),
87
+ format: File.extname(path).delete('.'),
88
+ stored_at: File.mtime(path),
89
+ size: File.size(path),
90
+ metadata: {}
91
+ }
92
+ end
93
+ end
94
+
95
+ def delete_from_disk(document_id)
96
+ Dir.glob(File.join(@storage_path, "#{sanitize_id(document_id)}.*")).each do |path|
97
+ File.delete(path)
98
+ end
99
+ end
100
+
101
+ def sanitize_id(document_id)
102
+ document_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
103
+ end
104
+ end
105
+ end
106
+ end