enel-ruby-sdk 0.1.2

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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ require_relative '../endpoint'
6
+ require_relative '../errors'
7
+ require_relative '../record'
8
+ require_relative '../validators'
9
+
10
+ module EnelX
11
+ module Endpoints
12
+ # History queries (PDF 5.6 to 5.10). Fields use the names of the PDF; the API has no
13
+ # webhooks, so this is the only way to learn what happened to a sale.
14
+ class History
15
+ # forms: name => [required fields, optional fields]
16
+ Query = Struct.new(:service, :path, :forms, :record, keyword_init: true)
17
+
18
+ BY_CUSTOMER = %w[empresa numeroCliente empresaParc].freeze
19
+ BY_DATE = %w[empresa dataOcorrencia empresaParc].freeze
20
+
21
+ QUERIES = {
22
+ subscription_history: Query.new(
23
+ service: :subscription_history, path: '/services/bdp/v1/subscription-history',
24
+ forms: { customer: [BY_CUSTOMER + %w[codProduto], []],
25
+ date: [%w[empresa empresaParc dataOcorrencia], %w[operacao]] },
26
+ record: Subscription
27
+ ),
28
+ request_history: Query.new(
29
+ service: :request_history, path: '/services/bdp/v1/request-history',
30
+ forms: { customer: [BY_CUSTOMER, %w[codProduto]],
31
+ date: [BY_DATE, %w[codProduto]] },
32
+ record: RequestHistoryItem
33
+ ),
34
+ invoice_history: Query.new(
35
+ service: :invoice_history, path: '/services/bdp/v1/invoice-history',
36
+ forms: { customer: [BY_CUSTOMER, %w[codProduto]],
37
+ date: [BY_DATE, %w[codProduto]] },
38
+ record: InvoiceHistoryItem
39
+ ),
40
+ collections: Query.new(
41
+ service: :collections, path: '/services/bdp/v1/invoice',
42
+ forms: { date: [BY_DATE, %w[codProduto]] }, record: Collection
43
+ ),
44
+ movements: Query.new(
45
+ service: :movements, path: '/services/bdp/v1/movement',
46
+ forms: { customer: [BY_CUSTOMER + %w[codProduto], []] }, record: Movement
47
+ ),
48
+ }.freeze
49
+
50
+ OPERATIONS = %w[I C].freeze
51
+
52
+ def initialize(client, name)
53
+ @client = client
54
+ @query = QUERIES.fetch(name)
55
+ end
56
+
57
+ # Returns an Array of records. Needs `empresaParc` or a configured `partner_code`.
58
+ # [ASSUMPTION p.26-33] the PDF fixes no ClientSystem/ServerSystem/Functionality for
59
+ # the bdp queries, only "Read" for GET (p.11): Read/Read, the configured client
60
+ # system and XCustomerB2CBR, as in the writes.
61
+ def call(fields, context: nil)
62
+ params = build_params(normalize(fields))
63
+ endpoint = Endpoint.new(
64
+ service: @query.service, verb: :get, path: @query.path, operation: 'Read',
65
+ functionality: 'Read', server_system: 'XCustomerB2CBR'
66
+ )
67
+ items(@client.request(endpoint, payload: params, context:).body)
68
+ end
69
+
70
+ private
71
+
72
+ def normalize(fields)
73
+ unless fields.respond_to?(:to_h)
74
+ Validators.invalid('fields', 'must be a Hash with the PDF field names')
75
+ end
76
+ fields = fields.to_h.transform_keys(&:to_s)
77
+ partner = @client.configuration.partner_code
78
+ fields['empresaParc'] ||= partner.to_s unless partner.nil?
79
+ fields
80
+ end
81
+
82
+ def build_params(fields)
83
+ required, optional = @query.forms.fetch(form_for(fields))
84
+ unknown = fields.keys - required - optional
85
+ unless unknown.empty?
86
+ Validators.invalid(unknown.first,
87
+ 'not a field of this query')
88
+ end
89
+
90
+ (required + optional).each_with_object({}) do |name, params|
91
+ next if optional.include?(name) && !fields.key?(name)
92
+
93
+ params[name] = value_for(name, fields[name])
94
+ end
95
+ end
96
+
97
+ def form_for(fields)
98
+ forms = @query.forms
99
+ return forms.keys.first if forms.one?
100
+
101
+ fields.key?('numeroCliente') ? :customer : :date
102
+ end
103
+
104
+ def value_for(name, value)
105
+ case name
106
+ when 'empresa' then Validators.company(name, value)
107
+ when 'numeroCliente' then Validators.text(name, value, max: 80)
108
+ when 'dataOcorrencia' then us_date(name, value)
109
+ when 'operacao' then Validators.one_of(name, value, OPERATIONS)
110
+ else Validators.text(name, value, max: 255)
111
+ end
112
+ end
113
+
114
+ # The API wants MM/DD/YYYY. Accepts Date, Time or a YYYY-MM-DD string.
115
+ def us_date(name, value)
116
+ date = if value.respond_to?(:to_date)
117
+ value.to_date
118
+ else
119
+ Date.strptime(value.to_s,
120
+ '%Y-%m-%d')
121
+ end
122
+ date.strftime('%m/%d/%Y')
123
+ rescue Date::Error, NoMethodError
124
+ Validators.invalid(name, 'Date, Time or YYYY-MM-DD', value)
125
+ end
126
+
127
+ def items(body)
128
+ list = body.is_a?(Array) ? body : body.values.find { |value| value.is_a?(Array) }
129
+ unless list&.all?(Hash)
130
+ raise Error.new('unexpected history response', kind: :retryable)
131
+ end
132
+
133
+ list.map { |item| @query.record.from_hash(item) }
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'order_helpers'
4
+
5
+ module EnelX
6
+ module Endpoints
7
+ # Alteração de Valores (PDF 5.4).
8
+ class OrderChange
9
+ include OrderHelpers
10
+
11
+ ENDPOINT = Endpoint.new(
12
+ service: :order_change, path: '/services/v1/third-party-billing/order/change',
13
+ **WRITE
14
+ )
15
+ ALLOWED = %w[
16
+ Nome Sobrenome Endereco EnderecoNumero Bairro Cidade CEP empresa numeroCliente
17
+ numParcelas valorParcela codProduto codOcorr dataOcorrencia codCanalVenda
18
+ numeroClienteEmpPar
19
+ ].freeze
20
+ # Table 7.1: 61 is "Alteração de débito de produto".
21
+ DEFAULT_OCCURRENCE = Reference::PartnerCode::CHANGE_CODE
22
+
23
+ def initialize(client)
24
+ @client = client
25
+ end
26
+
27
+ def call(fields, context: nil)
28
+ fields = stringify(fields, ALLOWED)
29
+ send_order(ENDPOINT, 'OrderChange', build(fields), context)
30
+ end
31
+
32
+ private
33
+
34
+ def build(fields)
35
+ person(fields).merge(
36
+ customer(fields), charge(fields), product(fields), occurrence(fields),
37
+ occurrence_date(fields), address(fields), partner_optionals(fields)
38
+ )
39
+ end
40
+
41
+ def charge(fields)
42
+ {
43
+ 'numParcelas' => Validators.installments('numParcelas', fields['numParcelas']),
44
+ 'valorParcela' => Validators.amount('valorParcela', fields['valorParcela']),
45
+ }
46
+ end
47
+
48
+ # [ASSUMPTION p.22] the 5.4.4 example sends codOcorr "32"; table 7.1 defines 61 for
49
+ # changes, so 61 is the default and any other code is left to the caller.
50
+ def occurrence(fields)
51
+ value = fields.fetch('codOcorr', DEFAULT_OCCURRENCE)
52
+ { 'codOcorr' => Validators.text('codOcorr', value, max: 255) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'order_helpers'
4
+
5
+ module EnelX
6
+ module Endpoints
7
+ # Cancelamento (PDF 5.5). Every required field must carry a value, otherwise the API
8
+ # processes nothing and returns nothing.
9
+ class OrderDisconnect
10
+ include OrderHelpers
11
+
12
+ ENDPOINT = Endpoint.new(
13
+ service: :order_disconnect,
14
+ path: '/services/v1/third-party-billing/order/disconnect', **WRITE
15
+ )
16
+ ALLOWED = %w[
17
+ Nome Sobrenome empresa numeroCliente valorParcela codProduto codOcorr
18
+ dataOcorrencia codCanalVenda Endereco EnderecoNumero Bairro Cidade CEP
19
+ numeroClienteEmpPar
20
+ ].freeze
21
+
22
+ def initialize(client)
23
+ @client = client
24
+ end
25
+
26
+ def call(fields, context: nil)
27
+ fields = stringify(fields, ALLOWED)
28
+ send_order(ENDPOINT, 'OrderDisconnect', build(fields), context)
29
+ end
30
+
31
+ private
32
+
33
+ # [ASSUMPTION p.25] the 5.5.4 example sends numParcelas but 5.5.2 does not list it:
34
+ # the SDK does not send it. The same example uses codOcorr "20", which is an
35
+ # automatic reason of 7.2; table 7.1 only allows 22 to 32 from the partner.
36
+ def build(fields)
37
+ person(fields).merge(
38
+ customer(fields), 'valorParcela' => amount(fields), **product(fields),
39
+ 'codOcorr' => reason(fields), **occurrence_date(fields),
40
+ **address(fields), **partner_optionals(fields)
41
+ )
42
+ end
43
+
44
+ def amount(fields)
45
+ Validators.amount('valorParcela', fields['valorParcela'], allow_zero: true)
46
+ end
47
+
48
+ def reason(fields)
49
+ codes = Reference::PartnerCode.cancellation_reasons.map(&:code)
50
+ Validators.one_of('codOcorr', fields['codOcorr'], codes)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../endpoint'
4
+ require_relative '../reference/partner_code'
5
+ require_relative '../validators'
6
+
7
+ module EnelX
8
+ module Endpoints
9
+ # Field groups shared by sale, change and disconnect (PDF 5.3.2, 5.4.2, 5.5.2).
10
+ # Callers pass fields named as in the PDF; string or symbol keys both work.
11
+ module OrderHelpers
12
+ WRITE = { verb: :post, operation: 'Create', functionality: 'Create',
13
+ server_system: 'XCustomerB2CBR' }.freeze
14
+
15
+ private
16
+
17
+ def stringify(fields, allowed)
18
+ unless fields.respond_to?(:to_h)
19
+ Validators.invalid('fields', 'must be a Hash with the PDF field names')
20
+ end
21
+ fields = fields.to_h.transform_keys(&:to_s)
22
+ unknown = fields.keys - allowed
23
+ unless unknown.empty?
24
+ Validators.invalid(unknown.first,
25
+ 'not a field of this operation')
26
+ end
27
+ fields
28
+ end
29
+
30
+ def person(fields)
31
+ {
32
+ 'Nome' => Validators.text('Nome', fields['Nome'], max: 40),
33
+ 'Sobrenome' => Validators.text('Sobrenome', fields['Sobrenome'], max: 40),
34
+ }
35
+ end
36
+
37
+ def address(fields)
38
+ {
39
+ 'Endereco' => Validators.text('Endereco', fields['Endereco'], max: 255),
40
+ 'EnderecoNumero' =>
41
+ Validators.text('EnderecoNumero', fields['EnderecoNumero'], max: 100),
42
+ # [ASSUMPTION p.24] 5.5.2 lists Bairro as String(255); 40 applies everywhere
43
+ # because a longer value makes the sale return nothing (p.15).
44
+ 'Bairro' => Validators.text('Bairro', fields['Bairro'], max: 40),
45
+ 'Cidade' => Validators.text('Cidade', fields['Cidade'], max: 255),
46
+ 'CEP' => Validators.cep('CEP', fields['CEP']),
47
+ }
48
+ end
49
+
50
+ def customer(fields)
51
+ {
52
+ 'empresa' => Validators.company('empresa', fields['empresa']),
53
+ 'numeroCliente' =>
54
+ Validators.text('numeroCliente', fields['numeroCliente'], max: 80),
55
+ }
56
+ end
57
+
58
+ def product(fields)
59
+ { 'codProduto' => Validators.text('codProduto', fields['codProduto'], max: 255) }
60
+ end
61
+
62
+ def occurrence_date(fields)
63
+ { 'dataOcorrencia' => Validators.date('dataOcorrencia',
64
+ fields['dataOcorrencia']) }
65
+ end
66
+
67
+ def partner_optionals(fields)
68
+ %w[codCanalVenda numeroClienteEmpPar].each_with_object({}) do |name, hash|
69
+ next unless fields.key?(name)
70
+
71
+ hash[name] = Validators.text(name, fields[name], max: 255, required: false)
72
+ end
73
+ end
74
+
75
+ def send_order(endpoint, root, body, context)
76
+ @client.request(endpoint, payload: { root => body }, context:)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ require_relative '../attachment'
6
+ require_relative 'order_helpers'
7
+
8
+ module EnelX
9
+ module Endpoints
10
+ # Solicitação de Venda (PDF 5.3), residential and business customers.
11
+ class OrderSale
12
+ include OrderHelpers
13
+
14
+ ENDPOINT = Endpoint.new(
15
+ service: :order_sale, path: '/services/v2/third-party-billing/order/sale',
16
+ **WRITE
17
+ )
18
+ ALLOWED = %w[
19
+ Nome Sobrenome dtNascimento Telefone Email Endereco EnderecoNumero Bairro Cidade
20
+ CEP empresa numeroCliente CPF numParcelas valorParcela codProduto dataOcorrencia
21
+ codCanalVenda numeroClienteEmpPar anexos CNPJ NomeEmpresa
22
+ ].freeze
23
+
24
+ def initialize(client)
25
+ @client = client
26
+ end
27
+
28
+ # Success only means "received": the sale is confirmed by occurrences 996 and 998
29
+ # (Consulta de Ocorrências). Cidade must be the exact IBGE name; the SDK cannot
30
+ # check that.
31
+ def call(fields, context: nil)
32
+ body = build(stringify(fields, ALLOWED))
33
+ warn_past_date(body['dataOcorrencia'])
34
+ send_order(ENDPOINT, 'OrderSale', body, context)
35
+ end
36
+
37
+ private
38
+
39
+ def build(fields)
40
+ business(fields).merge(person(fields), contact(fields), address(fields),
41
+ customer(fields), charge(fields), product(fields),
42
+ occurrence_date(fields), partner_optionals(fields),
43
+ 'anexos' => attachments(fields['anexos']))
44
+ end
45
+
46
+ def business(fields)
47
+ return {} unless fields.key?('CNPJ')
48
+
49
+ {
50
+ 'CNPJ' => Validators.digits('CNPJ', fields['CNPJ'], exactly: 14),
51
+ 'NomeEmpresa' => Validators.text('NomeEmpresa', fields['NomeEmpresa'], max: 40),
52
+ }
53
+ end
54
+
55
+ def contact(fields)
56
+ contact = {
57
+ 'Telefone' => Validators.phone('Telefone', fields['Telefone']),
58
+ 'Email' => Validators.email('Email', fields['Email']),
59
+ 'CPF' => Validators.digits('CPF', fields['CPF']),
60
+ }
61
+ return contact unless present?(fields['dtNascimento'])
62
+
63
+ contact.merge('dtNascimento' => Validators.date('dtNascimento',
64
+ fields['dtNascimento']))
65
+ end
66
+
67
+ def charge(fields)
68
+ {
69
+ 'numParcelas' => Validators.installments('numParcelas', fields['numParcelas']),
70
+ 'valorParcela' => Validators.amount('valorParcela', fields['valorParcela']),
71
+ }
72
+ end
73
+
74
+ def attachments(list)
75
+ items = Array(list).map { |item| Attachment.coerce(item) }
76
+ if items.empty?
77
+ Validators.invalid('anexos',
78
+ 'at least one attachment is required')
79
+ end
80
+ unless items.any?(&:adhesion?)
81
+ Validators.invalid('anexos', 'one attachment must be of type Termo de Adesão')
82
+ end
83
+ items.map(&:to_h)
84
+ end
85
+
86
+ def present?(value)
87
+ !value.nil? && !value.to_s.strip.empty?
88
+ end
89
+
90
+ def warn_past_date(date)
91
+ return unless Date.strptime(date, '%Y-%m-%d') < Date.today
92
+
93
+ @client.configuration.logger.warn(
94
+ "EnelX order_sale: dataOcorrencia #{date} is in the past; billing periods " \
95
+ 'before the start date may not be invoiced (PDF 5.3.2)',
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module EnelX
6
+ # PDF 7.3 as data. Specific data errors come first because many of them also start
7
+ # with "ERROR." or "INTERNAL ERROR", which are retryable only when generic.
8
+ module ErrorCatalog
9
+ Entry = Struct.new(:pattern, :kind, :reason, :error_class)
10
+
11
+ def self.data(pattern, reason = nil, error_class = Error)
12
+ Entry.new(pattern, :data, reason, error_class)
13
+ end
14
+
15
+ def self.retryable(pattern)
16
+ Entry.new(pattern, :retryable, nil, Error)
17
+ end
18
+
19
+ def self.prefixes(lead, *tails)
20
+ /\A#{Regexp.escape(lead)}(?:#{tails.map { |tail| Regexp.escape(tail) }.join('|')})/
21
+ end
22
+
23
+ ENTRIES = [
24
+ data(/\AERROR[.:] Account not found/, :account_not_found, UcNotFound),
25
+ data(/\AERROR: POS not found/, :pos_not_found, UcNotFound),
26
+ # [ASSUMPTION p.37] the PDF does not say Contract/Asset not found mean a missing UC,
27
+ # so they are not UcNotFound.
28
+ data(/\AERROR\. Contract not found/, :contract_not_found),
29
+ data(/\AERROR\. Asset not found/, :asset_not_found),
30
+ data(/\AINTERNAL ERROR\. This opportunity already exists/, :opportunity_exists),
31
+ data(/\AINTERNAL ERROR: An Account with the provided document already exists/,
32
+ :document_in_use),
33
+ data(/\AINTERNAL ERROR: An Account with the provided email already exists/,
34
+ :email_in_use),
35
+ data(/\AINTERNAL ERROR: An Account with the provided email and document/,
36
+ :email_and_document_in_use),
37
+ data(/\AError on CartAPI with transactionID/),
38
+ data(prefixes('ERROR. ', 'Address ', 'Address.PointOfService.', 'BillingProfile.',
39
+ 'CatalogItem', 'Contact', 'Format of Identity', 'Identity',
40
+ 'Missing mandatory field', 'PointOfService', 'The Identity',
41
+ 'There is not a valid contact')),
42
+ data(prefixes('ERROR: ', 'Address section', 'Market Account validation',
43
+ 'Missing identityNumber', 'You must fill both')),
44
+ data(prefixes('INTERNAL ERROR: ', 'Address with id', 'In Address',
45
+ 'In BillingProfile',
46
+ 'In Contact', 'In Identity', 'In Update Billing', 'Missing Address',
47
+ 'No Billing Profile')),
48
+ # [ASSUMPTION p.22, p.37] only a bare "Bad Request" is the generic retryable error;
49
+ # "Bad Request. <detail>" (example 2 of 5.4.4) names a data problem.
50
+ retryable(/\ABad Request\.?\z/),
51
+ retryable(/\AERROR\./),
52
+ retryable(/\AGateway Timeout/),
53
+ retryable(/\AINTERNAL ERROR/),
54
+ retryable(/\AResponse code 500 mapped as failure/),
55
+ retryable(/\AServer Error/),
56
+ ].freeze
57
+
58
+ # Unknown messages are :data, so the consumer never loops on a data error.
59
+ UNKNOWN = Entry.new(nil, :data, nil, Error)
60
+
61
+ module_function
62
+
63
+ def classify(message)
64
+ text = message.to_s.gsub(/\s+/, ' ').strip
65
+ ENTRIES.find { |entry| entry.pattern.match?(text) } || UNKNOWN
66
+ end
67
+
68
+ def build_error(message, code: nil, transaction_id: nil)
69
+ entry = classify(message)
70
+ entry.error_class.new(
71
+ message, kind: entry.kind, reason: entry.reason, code:,
72
+ transaction_id:
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnelX
4
+ # kind: :retryable (resend as is), :data (fix the input) or :configuration.
5
+ class Error < StandardError
6
+ attr_reader :kind, :code, :transaction_id, :reason
7
+
8
+ def initialize(message = nil, kind: :data, code: nil, transaction_id: nil,
9
+ reason: nil)
10
+ super(message)
11
+ @kind = kind
12
+ @code = code
13
+ @transaction_id = transaction_id
14
+ @reason = reason
15
+ end
16
+ end
17
+
18
+ # Bad credentials, host or persistent 401: the consumer must fix its setup.
19
+ class ConfigurationError < Error
20
+ def initialize(message = nil, **)
21
+ super(message, **, kind: :configuration)
22
+ end
23
+ end
24
+
25
+ # Raised before any network call. value_preview is already filtered of personal data.
26
+ class ValidationError < Error
27
+ attr_reader :field, :limit, :value_preview
28
+
29
+ def initialize(message, field:, limit: nil, value_preview: nil)
30
+ super(message, kind: :data)
31
+ @field = field
32
+ @limit = limit
33
+ @value_preview = value_preview
34
+ end
35
+ end
36
+
37
+ # The distributor does not know the consumption unit (PDF 7.3: Account/POS not found).
38
+ class UcNotFound < Error; end
39
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnelX
4
+ # Removes secrets, attachments and personal data before anything leaves the SDK.
5
+ module Filter
6
+ FILTERED = '[FILTERED]'
7
+
8
+ HIDDEN_KEYS = %w[
9
+ authorization clientsecret accesstoken conteudobase64 nome sobrenome bairro cidade
10
+ cep telefone email dtnascimento nomeempresa commoditycustomername
11
+ contractbillingaddress addressdata
12
+ ].freeze
13
+ HIDDEN_PREFIXES = %w[endereco].freeze
14
+ MASKED_KEYS = %w[cpf cnpj identitynumber commodityidentitynumber].freeze
15
+
16
+ module_function
17
+
18
+ def apply(value, key = nil)
19
+ case value
20
+ when Hash then value.to_h { |name, item| [name, apply(item, name)] }
21
+ when Array then value.map { |item| apply(item, key) }
22
+ else scalar(value, normalize(key))
23
+ end
24
+ end
25
+
26
+ def scalar(value, key)
27
+ return value if value.nil? || key.nil?
28
+ return FILTERED if hidden?(key)
29
+
30
+ MASKED_KEYS.include?(key) ? mask(value) : value
31
+ end
32
+
33
+ def hidden?(key)
34
+ HIDDEN_KEYS.include?(key) || HIDDEN_PREFIXES.any? do |prefix|
35
+ key.start_with?(prefix)
36
+ end
37
+ end
38
+
39
+ # Keeps only the last two digits (PDF documents are CPF or CNPJ).
40
+ def mask(value)
41
+ digits = value.to_s.gsub(/\D/, '')
42
+ case digits.length
43
+ when 11 then "***.***.***-#{digits[-2..]}"
44
+ when 14 then "**.***.***/****-#{digits[-2..]}"
45
+ else FILTERED
46
+ end
47
+ end
48
+
49
+ def normalize(key)
50
+ key.to_s.downcase.gsub(/[^a-z0-9]/, '') unless key.nil?
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module EnelX
6
+ # Per-call headers from PDF 5.1. Authorization is added by the client.
7
+ module Headers
8
+ module_function
9
+
10
+ def build(operation:, functionality:, client_system:, server_system:)
11
+ {
12
+ 'Content-Type' => 'application/json; charset=UTF-8',
13
+ 'cache-control' => 'no-cache',
14
+ 'TransactionID' => SecureRandom.uuid,
15
+ 'DateTimeMessage' => timestamp(Time.now),
16
+ 'Operation' => operation,
17
+ 'Functionality' => functionality,
18
+ 'ClientSystem' => client_system,
19
+ 'ServerSystem' => server_system,
20
+ }
21
+ end
22
+
23
+ # YYYYMMDDHH24MISSmil: 17 characters, milliseconds last.
24
+ def timestamp(time)
25
+ time.strftime('%Y%m%d%H%M%S%L')
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ require_relative 'errors'
9
+
10
+ module EnelX
11
+ # Thin net/http wrapper: network failures become retryable EnelX::Error.
12
+ class Http
13
+ Response = Struct.new(:status, :body)
14
+
15
+ NETWORK_ERRORS = [
16
+ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
17
+ Net::OpenTimeout, Net::ReadTimeout
18
+ ].freeze
19
+
20
+ def initialize(open_timeout:, read_timeout:)
21
+ @open_timeout = open_timeout
22
+ @read_timeout = read_timeout
23
+ end
24
+
25
+ def request(method, url, headers: {}, body: nil)
26
+ uri = URI(url)
27
+ request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
28
+ headers.each { |name, value| request[name] = value }
29
+ request.body = body if body
30
+
31
+ response = connection(uri).request(request)
32
+ Response.new(response.code.to_i, response.body.to_s)
33
+ rescue *NETWORK_ERRORS => e
34
+ raise Error.new("network failure: #{e.class}", kind: :retryable)
35
+ end
36
+
37
+ private
38
+
39
+ def connection(uri)
40
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
41
+ http.use_ssl = uri.scheme == 'https'
42
+ http.open_timeout = @open_timeout
43
+ http.read_timeout = @read_timeout
44
+ end
45
+ end
46
+ end
47
+ end