isds 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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ module LargeMessages
5
+ class Uploader
6
+ DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB
7
+
8
+ attr_reader :connection, :chunk_size
9
+
10
+ def initialize(connection:, chunk_size: DEFAULT_CHUNK_SIZE)
11
+ @connection = connection
12
+ @chunk_size = chunk_size
13
+ end
14
+
15
+ def upload(file_path, message_envelope, &progress_callback) # rubocop:disable Metrics/AbcSize
16
+ validate_file!(file_path)
17
+
18
+ file_size = File.size(file_path)
19
+ chunks = (file_size.to_f / chunk_size).ceil
20
+ uploaded_bytes = 0
21
+
22
+ File.open(file_path, 'rb') do |file|
23
+ chunks.times do |i|
24
+ chunk_data = file.read(chunk_size)
25
+ upload_chunk(message_envelope, chunk_data, i + 1, chunks)
26
+
27
+ uploaded_bytes += chunk_data.bytesize
28
+ progress_callback&.call(uploaded_bytes, file_size, i + 1, chunks)
29
+ end
30
+ end
31
+
32
+ finalize_upload(message_envelope)
33
+ end
34
+
35
+ private
36
+
37
+ def validate_file!(file_path)
38
+ raise AttachmentError, "File not found: #{file_path}" unless File.exist?(file_path)
39
+
40
+ file_size = File.size(file_path)
41
+ return unless file_size > Attachment::LARGE_MAX_FILE_SIZE
42
+
43
+ raise FileSizeExceededError, 'File exceeds maximum size of 100MB for large messages'
44
+ end
45
+
46
+ def upload_chunk(_envelope, chunk_data, sequence, total)
47
+ encoded = Base64.strict_encode64(chunk_data)
48
+
49
+ connection.call(:large_messages, :upload_chunk, {
50
+ 'isds:dmChunk' => {
51
+ 'isds:dmEncodedContent' => encoded,
52
+ 'isds:dmChunkSeq' => sequence,
53
+ 'isds:dmChunksTotal' => total
54
+ }
55
+ })
56
+ end
57
+
58
+ def finalize_upload(envelope)
59
+ connection.call(:large_messages, :finalize_upload, {
60
+ 'isds:dmEnvelope' => envelope
61
+ })
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Message # rubocop:disable Metrics/ClassLength
5
+ attr_reader :id, :databox_id, :subject, :sender, :recipient,
6
+ :status, :delivery_time, :acceptance_time,
7
+ :attachments, :annotation, :raw_data
8
+
9
+ ENVELOPE_OPTION_MAPPINGS = {
10
+ sender_org_unit: 'isds:dmSenderOrgUnit',
11
+ sender_org_unit_num: 'isds:dmSenderOrgUnitNum',
12
+ recipient_org_unit: 'isds:dmRecipientOrgUnit',
13
+ recipient_org_unit_num: 'isds:dmRecipientOrgUnitNum',
14
+ to_hands: 'isds:dmToHands',
15
+ personal_delivery: 'isds:dmPersonalDelivery',
16
+ allow_subst_delivery: 'isds:dmAllowSubstDelivery',
17
+ legal_title_law: 'isds:dmLegalTitleLaw',
18
+ legal_title_year: 'isds:dmLegalTitleYear',
19
+ legal_title_sect: 'isds:dmLegalTitleSect',
20
+ legal_title_par: 'isds:dmLegalTitlePar',
21
+ legal_title_point: 'isds:dmLegalTitlePoint',
22
+ type: 'isds:dmType'
23
+ }.freeze
24
+ private_constant :ENVELOPE_OPTION_MAPPINGS
25
+
26
+ def initialize(connection: nil, **attributes)
27
+ @connection = connection
28
+ @id = attributes[:id]
29
+ @databox_id = attributes[:databox_id]
30
+ @subject = attributes[:subject]
31
+ @sender = attributes[:sender]
32
+ @recipient = attributes[:recipient]
33
+ @status = attributes[:status]
34
+ @delivery_time = attributes[:delivery_time]
35
+ @acceptance_time = attributes[:acceptance_time]
36
+ @attachments = attributes[:attachments] || []
37
+ @annotation = attributes[:annotation]
38
+ @raw_data = attributes[:raw_data]
39
+ end
40
+
41
+ def create(to_databox_id, subject:, attachments: [], **options)
42
+ envelope = build_envelope(to_databox_id, subject, options)
43
+ files = build_attachments(attachments)
44
+
45
+ response = @connection.call(:operations, :create_message, {
46
+ 'isds:dmEnvelope' => envelope,
47
+ 'isds:dmFiles' => files
48
+ })
49
+
50
+ extract_message_id(response)
51
+ end
52
+
53
+ def delivered?
54
+ [4, 5].include?(status)
55
+ end
56
+
57
+ def read?
58
+ status == 6
59
+ end
60
+
61
+ def large_message?
62
+ attachments.any? { |a| a.respond_to?(:size) && a.size > 10 * 1024 * 1024 }
63
+ end
64
+
65
+ def download_attachment(attachment_id, path = nil)
66
+ response = @connection.call(:info, :message_download, { 'isds:dmID' => id })
67
+ extract_attachment(response, attachment_id, path)
68
+ end
69
+
70
+ def download_all_attachments(directory)
71
+ response = @connection.call(:info, :message_download, { 'isds:dmID' => id })
72
+ extract_all_attachments(response, directory)
73
+ end
74
+
75
+ def signed_content
76
+ response = @connection.call(:info, :signed_message_download, { 'isds:dmID' => id })
77
+ response.dig(:signed_message_download_response, :dm_signature)
78
+ end
79
+
80
+ class << self
81
+ def find(message_id, connection:)
82
+ response = connection.call(:info, :message_download, { 'isds:dmID' => message_id })
83
+ from_response(response, connection: connection)
84
+ end
85
+
86
+ def received(connection:, limit: 50, unread_only: true, **filters)
87
+ params = build_list_params(limit: limit, **filters)
88
+ params['isds:dmStatusFilter'] = unread_only ? '4' : '-1'
89
+
90
+ response = connection.call(:info, :get_list_of_received_messages, params)
91
+ parse_message_list(response, connection: connection)
92
+ end
93
+
94
+ def sent(connection:, limit: 50, **filters)
95
+ params = build_list_params(limit: limit, **filters)
96
+
97
+ response = connection.call(:info, :get_list_of_sent_messages, params)
98
+ parse_message_list(response, connection: connection)
99
+ end
100
+
101
+ def mark_as_read(message_id, connection:)
102
+ connection.call(:info, :mark_message_as_downloaded, { 'isds:dmID' => message_id })
103
+ end
104
+
105
+ private
106
+
107
+ def build_list_params(limit:, **filters)
108
+ params = { 'isds:dmLimit' => limit }
109
+ params['isds:dmFromTime'] = format_time_filter(filters[:from_time]) if filters[:from_time]
110
+ params['isds:dmToTime'] = format_time_filter(filters[:to_time]) if filters[:to_time]
111
+ params['isds:dmOffset'] = filters[:offset] if filters[:offset]
112
+ params
113
+ end
114
+
115
+ def format_time_filter(value)
116
+ value.is_a?(Time) ? value.iso8601 : value
117
+ end
118
+
119
+ def parse_message_list(response, connection:)
120
+ records_key = response.keys.first
121
+ records = response.dig(records_key, :dm_records, :dm_record)
122
+ return [] if records.nil?
123
+
124
+ records = [records] unless records.is_a?(Array)
125
+ records.map { |record| from_record(record, connection: connection) }
126
+ end
127
+
128
+ def from_response(response, connection:)
129
+ data = response.dig(:message_download_response, :dm_return_data_message)
130
+ return nil unless data
131
+
132
+ build_from_envelope(data[:dm_dm] || {}, data, connection)
133
+ end
134
+
135
+ def from_record(record, connection:)
136
+ build_from_envelope(record, record, connection)
137
+ end
138
+
139
+ def build_from_envelope(envelope, raw, connection)
140
+ new(
141
+ connection: connection,
142
+ id: envelope[:dm_id],
143
+ subject: envelope[:dm_annotation],
144
+ sender: envelope[:dm_sender],
145
+ recipient: envelope[:dm_recipient],
146
+ status: envelope[:dm_message_status]&.to_i,
147
+ delivery_time: envelope[:dm_delivery_time],
148
+ acceptance_time: envelope[:dm_acceptance_time],
149
+ raw_data: raw
150
+ )
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def build_envelope(to_databox_id, subject, options)
157
+ envelope = {
158
+ 'isds:dbIDRecipient' => to_databox_id,
159
+ 'isds:dmAnnotation' => subject
160
+ }
161
+
162
+ ENVELOPE_OPTION_MAPPINGS.each do |option_key, xml_key|
163
+ envelope[xml_key] = options[option_key] if options[option_key]
164
+ end
165
+
166
+ envelope
167
+ end
168
+
169
+ def build_attachments(attachments)
170
+ return {} if attachments.empty?
171
+
172
+ { 'isds:dmFile' => attachments.map { |a| Attachment.build_soap_element(a) } }
173
+ end
174
+
175
+ def extract_message_id(response)
176
+ response.dig(:create_message_response, :dm_id) ||
177
+ response.dig(:create_message_response, :message_id)
178
+ end
179
+
180
+ def extract_attachment(response, attachment_id, path)
181
+ files = extract_files(response)
182
+ file = files.find { |f| f[:dm_file_meta_type] == attachment_id || f[:dm_file_descr] == attachment_id }
183
+ return nil unless file
184
+
185
+ content = Base64.decode64(file[:dm_encoded_content])
186
+ path ? File.binwrite(path, content) && path : content
187
+ end
188
+
189
+ def extract_all_attachments(response, directory)
190
+ FileUtils.mkdir_p(directory)
191
+
192
+ extract_files(response).filter_map do |file|
193
+ next unless file[:dm_encoded_content]
194
+
195
+ content = Base64.decode64(file[:dm_encoded_content])
196
+ filename = file[:dm_file_descr] || "attachment_#{file[:dm_file_meta_type]}"
197
+ path = File.join(directory, filename)
198
+ File.binwrite(path, content)
199
+ path
200
+ end
201
+ end
202
+
203
+ def extract_files(response)
204
+ files = response.dig(:message_download_response, :dm_return_data_message, :dm_files, :dm_file)
205
+ files.is_a?(Array) ? files : [files]
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Search
5
+ DATABOX_TYPES = {
6
+ fo: 'FO',
7
+ pfo: 'PFO',
8
+ po: 'PO',
9
+ ovm: 'OVM'
10
+ }.freeze
11
+
12
+ def self.databoxes(query, connection:, **options)
13
+ criteria = build_search_criteria(query, **options)
14
+ response = connection.call(:search, :find_data_box, criteria)
15
+ parse_search_response(response)
16
+ end
17
+
18
+ def self.by_name(name, connection:, exact: false)
19
+ criteria = {
20
+ 'isds:dbOwnerInfo' => {
21
+ 'isds:firmName' => name
22
+ }
23
+ }
24
+
25
+ response = connection.call(:search, :find_data_box, criteria)
26
+ results = parse_search_response(response)
27
+
28
+ if exact
29
+ results.select { |db| db.name.casecmp?(name) }
30
+ else
31
+ results
32
+ end
33
+ end
34
+
35
+ def self.by_ico(ico, connection:)
36
+ Databox.find_by_ico(ico, connection: connection)
37
+ end
38
+
39
+ def self.by_address(connection:, **address_parts)
40
+ criteria = { 'isds:dbOwnerInfo' => {} }
41
+ criteria['isds:dbOwnerInfo']['isds:adCity'] = address_parts[:city] if address_parts[:city]
42
+ criteria['isds:dbOwnerInfo']['isds:adStreet'] = address_parts[:street] if address_parts[:street]
43
+ criteria['isds:dbOwnerInfo']['isds:adZipCode'] = address_parts[:zip_code] if address_parts[:zip_code]
44
+
45
+ response = connection.call(:search, :find_data_box, criteria)
46
+ parse_search_response(response)
47
+ end
48
+
49
+ def self.by_type(databox_type, connection:, query: nil)
50
+ type_value = DATABOX_TYPES[databox_type.to_sym] || databox_type.to_s
51
+ criteria = {
52
+ 'isds:dbOwnerInfo' => {
53
+ 'isds:dbType' => type_value
54
+ }
55
+ }
56
+ criteria['isds:dbOwnerInfo']['isds:firmName'] = query if query
57
+
58
+ response = connection.call(:search, :find_data_box, criteria)
59
+ parse_search_response(response)
60
+ end
61
+
62
+ def self.ovm_only(connection:, query: nil)
63
+ by_type(:ovm, connection: connection, query: query)
64
+ end
65
+
66
+ def self.paginated_search(query, connection:, page: 1, per_page: 100)
67
+ criteria = build_search_criteria(query)
68
+ criteria['isds:dbOffset'] = (page - 1) * per_page
69
+ criteria['isds:dbLimit'] = per_page
70
+
71
+ response = connection.call(:search, :find_data_box, criteria)
72
+ results = parse_search_response(response)
73
+
74
+ {
75
+ results: results,
76
+ page: page,
77
+ per_page: per_page,
78
+ total: results.length
79
+ }
80
+ end
81
+
82
+ def self.build_search_criteria(query, **options)
83
+ criteria = { 'isds:dbOwnerInfo' => {} }
84
+
85
+ case query
86
+ when Hash
87
+ query.each do |key, value|
88
+ criteria['isds:dbOwnerInfo']["isds:#{key}"] = value
89
+ end
90
+ when String
91
+ criteria['isds:dbOwnerInfo']['isds:firmName'] = query
92
+ end
93
+
94
+ criteria['isds:dbOwnerInfo']['isds:dbType'] = options[:type] if options[:type]
95
+ criteria
96
+ end
97
+ private_class_method :build_search_criteria
98
+
99
+ def self.parse_search_response(response)
100
+ key = response.keys.first
101
+ records = response.dig(key, :db_results, :db_owner_info)
102
+ return [] if records.nil?
103
+
104
+ records = [records] unless records.is_a?(Array)
105
+ records.map do |record|
106
+ Databox.send(:from_record, record)
107
+ end
108
+ end
109
+ private_class_method :parse_search_response
110
+ end
111
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class Status
5
+ CODES = {
6
+ 1 => :submitted,
7
+ 2 => :stamped,
8
+ 3 => :infected,
9
+ 4 => :delivered,
10
+ 5 => :delivered_by_fiction,
11
+ 6 => :read,
12
+ 7 => :undeliverable,
13
+ 8 => :removed,
14
+ 9 => :in_vault,
15
+ 10 => :in_vault_delivered
16
+ }.freeze
17
+
18
+ LABELS = {
19
+ submitted: 'Podána',
20
+ stamped: 'Opatřena časovým razítkem',
21
+ infected: 'Infikována virem',
22
+ delivered: 'Dodána',
23
+ delivered_by_fiction: 'Doručena fikcí',
24
+ read: 'Přečtena',
25
+ undeliverable: 'Nedoručitelná',
26
+ removed: 'Odstraněna',
27
+ in_vault: 'V datovém trezoru',
28
+ in_vault_delivered: 'V datovém trezoru - dodána'
29
+ }.freeze
30
+
31
+ attr_reader :code
32
+
33
+ def initialize(code)
34
+ @code = code.to_i
35
+ end
36
+
37
+ def name
38
+ CODES[code]
39
+ end
40
+
41
+ def label
42
+ LABELS[name]
43
+ end
44
+
45
+ def delivered?
46
+ %i[delivered delivered_by_fiction read in_vault_delivered].include?(name)
47
+ end
48
+
49
+ def read?
50
+ name == :read
51
+ end
52
+
53
+ def problematic?
54
+ %i[infected undeliverable removed].include?(name)
55
+ end
56
+
57
+ def final?
58
+ %i[read undeliverable removed in_vault in_vault_delivered].include?(name)
59
+ end
60
+
61
+ def to_s
62
+ "#{code} - #{label || 'Unknown'}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ module Timestamp
5
+ class Verifier
6
+ attr_reader :connection
7
+
8
+ def initialize(connection:)
9
+ @connection = connection
10
+ end
11
+
12
+ def verify(message_id)
13
+ response = connection.call(:info, :get_message_state_changes, {
14
+ 'isds:dmID' => message_id
15
+ })
16
+
17
+ key = response.keys.first
18
+ events = response.dig(key, :dm_events, :dm_event)
19
+ return [] if events.nil?
20
+
21
+ events = [events] unless events.is_a?(Array)
22
+ events.map { |event| parse_event(event) }
23
+ end
24
+
25
+ private
26
+
27
+ def parse_event(event)
28
+ {
29
+ time: event[:dm_event_time],
30
+ type: event[:dm_event_type],
31
+ description: event[:dm_event_descr]
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
data/lib/isds/types.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ module Types
5
+ include Dry.Types()
6
+
7
+ DataboxType = Types::Coercible::String.enum('FO', 'PFO', 'PFO_ADVOK', 'PFO_DANPOR', 'PFO_INSSPR',
8
+ 'PFO_AUDITOR', 'PO', 'PO_ZAK', 'PO_REQ', 'OVM', 'OVM_NOTAR',
9
+ 'OVM_EXEKUT', 'OVM_REQ')
10
+
11
+ MessageStatus = Types::Coercible::Integer.enum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
12
+ # 1 = submitted, 2 = stamped, 3 = infected, 4 = delivered,
13
+ # 5 = delivered by fiction, 6 = read, 7 = undeliverable,
14
+ # 8 = removed, 9 = in data vault, 10 = in data vault delivered
15
+
16
+ Environment = Types::Coercible::Symbol.enum(:test, :production)
17
+ AuthMethod = Types::Coercible::Symbol.enum(:basic, :certificate, :access_interface)
18
+ end
19
+ end
data/lib/isds/user.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ class User
5
+ attr_reader :id, :first_name, :last_name, :type, :privileges, :raw_data
6
+
7
+ def initialize(**attributes)
8
+ @id = attributes[:id]
9
+ @first_name = attributes[:first_name]
10
+ @last_name = attributes[:last_name]
11
+ @type = attributes[:type]
12
+ @privileges = attributes[:privileges] || {}
13
+ @raw_data = attributes[:raw_data]
14
+ end
15
+
16
+ def full_name
17
+ [first_name, last_name].compact.join(' ')
18
+ end
19
+
20
+ def self.list(connection:)
21
+ response = connection.call(:access, :get_data_box_users, {})
22
+ parse_users(response)
23
+ end
24
+
25
+ def self.add(connection:, databox_id:, **user_info)
26
+ Databox.validate_databox_id!(databox_id)
27
+
28
+ connection.call(:access, :add_data_box_user, {
29
+ 'isds:dbID' => databox_id,
30
+ 'isds:dbUserInfo' => build_user_info(user_info)
31
+ })
32
+ end
33
+
34
+ def self.remove(connection:, user_id:)
35
+ connection.call(:access, :delete_data_box_user, {
36
+ 'isds:dbUserID' => user_id
37
+ })
38
+ end
39
+
40
+ def self.build_user_info(info)
41
+ user = {}
42
+ user['isds:pnFirstName'] = info[:first_name] if info[:first_name]
43
+ user['isds:pnLastName'] = info[:last_name] if info[:last_name]
44
+ user['isds:userPrivils'] = info[:privileges] if info[:privileges]
45
+ user
46
+ end
47
+ private_class_method :build_user_info
48
+
49
+ def self.parse_users(response)
50
+ key = response.keys.first
51
+ records = response.dig(key, :db_users, :db_user_info)
52
+ return [] if records.nil?
53
+
54
+ records = [records] unless records.is_a?(Array)
55
+ records.map { |record| from_record(record) }
56
+ end
57
+ private_class_method :parse_users
58
+
59
+ def self.from_record(record)
60
+ new(
61
+ id: record[:user_id],
62
+ first_name: record[:pn_first_name],
63
+ last_name: record[:pn_last_name],
64
+ type: record[:user_type],
65
+ privileges: record[:user_privils],
66
+ raw_data: record
67
+ )
68
+ end
69
+ private_class_method :from_record
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ISDS
4
+ VERSION = '0.1.0'
5
+ end
data/lib/isds.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect('isds' => 'ISDS')
7
+ loader.setup
8
+
9
+ require 'savon'
10
+ require 'nokogiri'
11
+ require 'faraday'
12
+ require 'faraday/retry'
13
+ require 'dry-configurable'
14
+ require 'dry-validation'
15
+ require 'dry-struct'
16
+ require 'dry-types'
17
+
18
+ module ISDS
19
+ ISDS_NAMESPACE = 'http://isds.czechpoint.cz/v20'
20
+
21
+ ENDPOINTS = {
22
+ production: {
23
+ base_url: 'https://ws1.mojedatovaschranka.cz/',
24
+ ws_url: 'https://ws1.mojedatovaschranka.cz/soap',
25
+ ws2_url: 'https://ws1.mojedatovaschranka.cz/vdz_ws/'
26
+ },
27
+ test: {
28
+ base_url: 'https://www.czebox.cz/',
29
+ ws_url: 'https://www.czebox.cz/soap',
30
+ ws2_url: 'https://www.czebox.cz/vdz_ws/',
31
+ wsdl_base: 'https://www.czebox.cz/static/wsdl/v20/'
32
+ }
33
+ }.freeze
34
+
35
+ SERVICE_ENDPOINT_MAP = {
36
+ operations: :ws_url,
37
+ info: :ws_url,
38
+ search: :ws_url,
39
+ access: :ws_url,
40
+ supplementary: :ws_url,
41
+ large_messages: :ws2_url
42
+ }.freeze
43
+
44
+ class << self
45
+ def configure
46
+ yield(configuration)
47
+ end
48
+
49
+ def configuration
50
+ @configuration ||= Configuration.new
51
+ end
52
+
53
+ def reset_configuration!
54
+ @configuration = Configuration.new
55
+ end
56
+ end
57
+ end