focus_nfe 1.0.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.
Files changed (206) hide show
  1. checksums.yaml +7 -0
  2. data/.git-hooks/pre_push/steep.rb +18 -0
  3. data/.git-hooks/pre_push/yard_doc.rb +18 -0
  4. data/.gitattributes +1 -0
  5. data/.overcommit.yml +69 -0
  6. data/.rspec +3 -0
  7. data/.yardopts +11 -0
  8. data/CHANGELOG.md +77 -0
  9. data/CLAUDE.md +118 -0
  10. data/CODE_OF_CONDUCT.md +10 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +348 -0
  13. data/Rakefile +105 -0
  14. data/data/schemas/schema_cte.json +2793 -0
  15. data/data/schemas/schema_cte_os.json +1335 -0
  16. data/data/schemas/schema_cte_os_transporte_rodoviario.json +109 -0
  17. data/data/schemas/schema_cte_transporte_aereo.json +115 -0
  18. data/data/schemas/schema_cte_transporte_aquaviario.json +174 -0
  19. data/data/schemas/schema_cte_transporte_dutoviario.json +65 -0
  20. data/data/schemas/schema_cte_transporte_ferroviario.json +144 -0
  21. data/data/schemas/schema_cte_transporte_multimodal.json +45 -0
  22. data/data/schemas/schema_cte_transporte_rodoviario.json +78 -0
  23. data/data/schemas/schema_dce.json +549 -0
  24. data/data/schemas/schema_mdfe.json +1102 -0
  25. data/data/schemas/schema_mdfe_transporte_aereo.json +44 -0
  26. data/data/schemas/schema_mdfe_transporte_aquaviario.json +209 -0
  27. data/data/schemas/schema_mdfe_transporte_ferroviario.json +99 -0
  28. data/data/schemas/schema_mdfe_transporte_rodoviario.json +628 -0
  29. data/data/schemas/schema_nfcom.json +1859 -0
  30. data/data/schemas/schema_nfe.json +4750 -0
  31. data/data/schemas/schema_nfe_forma_pagamento.json +97 -0
  32. data/data/schemas/schema_nfe_item.json +2574 -0
  33. data/data/schemas/schema_nfgas.json +2316 -0
  34. data/data/schemas/schema_nfse_nacional.json +1847 -0
  35. data/data/schemas/schema_nfse_recebida.json +548 -0
  36. data/lib/focus_nfe/client.rb +162 -0
  37. data/lib/focus_nfe/configuration.rb +104 -0
  38. data/lib/focus_nfe/errors.rb +123 -0
  39. data/lib/focus_nfe/esquemas/campo.rb +171 -0
  40. data/lib/focus_nfe/esquemas/catalogo.rb +34 -0
  41. data/lib/focus_nfe/esquemas/decimal.rb +66 -0
  42. data/lib/focus_nfe/esquemas/esquema.rb +72 -0
  43. data/lib/focus_nfe/esquemas/validador.rb +87 -0
  44. data/lib/focus_nfe/http/adapter.rb +25 -0
  45. data/lib/focus_nfe/http/adapters/net_http.rb +99 -0
  46. data/lib/focus_nfe/http/authentication.rb +23 -0
  47. data/lib/focus_nfe/http/connection.rb +118 -0
  48. data/lib/focus_nfe/http/logging.rb +100 -0
  49. data/lib/focus_nfe/http/response.rb +75 -0
  50. data/lib/focus_nfe/modelos/documento.rb +113 -0
  51. data/lib/focus_nfe/modelos/inutilizacao.rb +75 -0
  52. data/lib/focus_nfe/modelos/pagina.rb +54 -0
  53. data/lib/focus_nfe/recursos/backups.rb +17 -0
  54. data/lib/focus_nfe/recursos/base.rb +91 -0
  55. data/lib/focus_nfe/recursos/ceps.rb +16 -0
  56. data/lib/focus_nfe/recursos/cfops.rb +16 -0
  57. data/lib/focus_nfe/recursos/cnaes.rb +16 -0
  58. data/lib/focus_nfe/recursos/cnpjs.rb +13 -0
  59. data/lib/focus_nfe/recursos/concerns/baixavel.rb +41 -0
  60. data/lib/focus_nfe/recursos/concerns/baixavel_eventos.rb +34 -0
  61. data/lib/focus_nfe/recursos/concerns/cancelavel.rb +25 -0
  62. data/lib/focus_nfe/recursos/concerns/conciliavel.rb +66 -0
  63. data/lib/focus_nfe/recursos/concerns/consultavel.rb +26 -0
  64. data/lib/focus_nfe/recursos/concerns/corrigivel.rb +45 -0
  65. data/lib/focus_nfe/recursos/concerns/corrigivel_cte.rb +60 -0
  66. data/lib/focus_nfe/recursos/concerns/emitivel.rb +51 -0
  67. data/lib/focus_nfe/recursos/concerns/enviavel.rb +40 -0
  68. data/lib/focus_nfe/recursos/concerns/eventavel.rb +46 -0
  69. data/lib/focus_nfe/recursos/concerns/inutilizavel.rb +96 -0
  70. data/lib/focus_nfe/recursos/concerns/listavel.rb +22 -0
  71. data/lib/focus_nfe/recursos/concerns/localizavel.rb +22 -0
  72. data/lib/focus_nfe/recursos/concerns/notificavel.rb +23 -0
  73. data/lib/focus_nfe/recursos/concerns/removivel.rb +20 -0
  74. data/lib/focus_nfe/recursos/concerns/visualizavel.rb +28 -0
  75. data/lib/focus_nfe/recursos/cte.rb +35 -0
  76. data/lib/focus_nfe/recursos/cte_os.rb +29 -0
  77. data/lib/focus_nfe/recursos/ctes_recebidas.rb +38 -0
  78. data/lib/focus_nfe/recursos/dce.rb +16 -0
  79. data/lib/focus_nfe/recursos/emails_bloqueados.rb +31 -0
  80. data/lib/focus_nfe/recursos/empresas.rb +35 -0
  81. data/lib/focus_nfe/recursos/mdfe.rb +78 -0
  82. data/lib/focus_nfe/recursos/municipios.rb +60 -0
  83. data/lib/focus_nfe/recursos/ncms.rb +16 -0
  84. data/lib/focus_nfe/recursos/nfce.rb +19 -0
  85. data/lib/focus_nfe/recursos/nfcom.rb +16 -0
  86. data/lib/focus_nfe/recursos/nfe.rb +106 -0
  87. data/lib/focus_nfe/recursos/nfes_recebidas.rb +56 -0
  88. data/lib/focus_nfe/recursos/nfgas.rb +16 -0
  89. data/lib/focus_nfe/recursos/nfse.rb +18 -0
  90. data/lib/focus_nfe/recursos/nfse_nacional.rb +16 -0
  91. data/lib/focus_nfe/recursos/nfses_nacionais_recebidas.rb +21 -0
  92. data/lib/focus_nfe/recursos/webhooks.rb +22 -0
  93. data/lib/focus_nfe/version.rb +6 -0
  94. data/lib/focus_nfe/webhook.rb +68 -0
  95. data/lib/focus_nfe.rb +124 -0
  96. data/sig/focus_nfe/client.rbs +38 -0
  97. data/sig/focus_nfe/configuration.rbs +29 -0
  98. data/sig/focus_nfe/errors.rbs +59 -0
  99. data/sig/focus_nfe/esquemas/campo.rbs +47 -0
  100. data/sig/focus_nfe/esquemas/catalogo.rbs +8 -0
  101. data/sig/focus_nfe/esquemas/decimal.rbs +25 -0
  102. data/sig/focus_nfe/esquemas/esquema.rbs +30 -0
  103. data/sig/focus_nfe/esquemas/validador.rbs +20 -0
  104. data/sig/focus_nfe/http/adapter.rbs +7 -0
  105. data/sig/focus_nfe/http/adapters/net_http.rbs +25 -0
  106. data/sig/focus_nfe/http/authentication.rbs +9 -0
  107. data/sig/focus_nfe/http/connection.rbs +32 -0
  108. data/sig/focus_nfe/http/logging.rbs +30 -0
  109. data/sig/focus_nfe/http/response.rbs +28 -0
  110. data/sig/focus_nfe/modelos/documento.rbs +34 -0
  111. data/sig/focus_nfe/modelos/inutilizacao.rbs +24 -0
  112. data/sig/focus_nfe/modelos/pagina.rbs +21 -0
  113. data/sig/focus_nfe/recursos/backups.rbs +7 -0
  114. data/sig/focus_nfe/recursos/base.rbs +25 -0
  115. data/sig/focus_nfe/recursos/ceps.rbs +10 -0
  116. data/sig/focus_nfe/recursos/cfops.rbs +10 -0
  117. data/sig/focus_nfe/recursos/cnaes.rbs +10 -0
  118. data/sig/focus_nfe/recursos/cnpjs.rbs +7 -0
  119. data/sig/focus_nfe/recursos/concerns/baixavel.rbs +12 -0
  120. data/sig/focus_nfe/recursos/concerns/baixavel_eventos.rbs +14 -0
  121. data/sig/focus_nfe/recursos/concerns/cancelavel.rbs +9 -0
  122. data/sig/focus_nfe/recursos/concerns/conciliavel.rbs +15 -0
  123. data/sig/focus_nfe/recursos/concerns/consultavel.rbs +9 -0
  124. data/sig/focus_nfe/recursos/concerns/corrigivel.rbs +15 -0
  125. data/sig/focus_nfe/recursos/concerns/corrigivel_cte.rbs +17 -0
  126. data/sig/focus_nfe/recursos/concerns/emitivel.rbs +14 -0
  127. data/sig/focus_nfe/recursos/concerns/enviavel.rbs +15 -0
  128. data/sig/focus_nfe/recursos/concerns/eventavel.rbs +12 -0
  129. data/sig/focus_nfe/recursos/concerns/inutilizavel.rbs +20 -0
  130. data/sig/focus_nfe/recursos/concerns/listavel.rbs +9 -0
  131. data/sig/focus_nfe/recursos/concerns/localizavel.rbs +9 -0
  132. data/sig/focus_nfe/recursos/concerns/notificavel.rbs +9 -0
  133. data/sig/focus_nfe/recursos/concerns/removivel.rbs +9 -0
  134. data/sig/focus_nfe/recursos/concerns/visualizavel.rbs +9 -0
  135. data/sig/focus_nfe/recursos/cte.rbs +17 -0
  136. data/sig/focus_nfe/recursos/cte_os.rbs +17 -0
  137. data/sig/focus_nfe/recursos/ctes_recebidas.rbs +14 -0
  138. data/sig/focus_nfe/recursos/dce.rbs +10 -0
  139. data/sig/focus_nfe/recursos/emails_bloqueados.rbs +12 -0
  140. data/sig/focus_nfe/recursos/empresas.rbs +12 -0
  141. data/sig/focus_nfe/recursos/mdfe.rbs +21 -0
  142. data/sig/focus_nfe/recursos/municipios.rbs +19 -0
  143. data/sig/focus_nfe/recursos/ncms.rbs +10 -0
  144. data/sig/focus_nfe/recursos/nfce.rbs +12 -0
  145. data/sig/focus_nfe/recursos/nfcom.rbs +10 -0
  146. data/sig/focus_nfe/recursos/nfe.rbs +23 -0
  147. data/sig/focus_nfe/recursos/nfes_recebidas.rbs +16 -0
  148. data/sig/focus_nfe/recursos/nfgas.rbs +10 -0
  149. data/sig/focus_nfe/recursos/nfse.rbs +11 -0
  150. data/sig/focus_nfe/recursos/nfse_nacional.rbs +10 -0
  151. data/sig/focus_nfe/recursos/nfses_nacionais_recebidas.rbs +11 -0
  152. data/sig/focus_nfe/recursos/webhooks.rbs +11 -0
  153. data/sig/focus_nfe/webhook.rbs +11 -0
  154. data/sig/focus_nfe.rbs +10 -0
  155. data/spec/focus_nfe/client_spec.rb +208 -0
  156. data/spec/focus_nfe/configuration_spec.rb +121 -0
  157. data/spec/focus_nfe/errors_mapping_spec.rb +68 -0
  158. data/spec/focus_nfe/errors_spec.rb +107 -0
  159. data/spec/focus_nfe/esquemas/campo_spec.rb +291 -0
  160. data/spec/focus_nfe/esquemas/decimal_spec.rb +54 -0
  161. data/spec/focus_nfe/esquemas/esquema_spec.rb +73 -0
  162. data/spec/focus_nfe/esquemas/validador_spec.rb +167 -0
  163. data/spec/focus_nfe/esquemas_spec.rb +42 -0
  164. data/spec/focus_nfe/http/adapter_spec.rb +8 -0
  165. data/spec/focus_nfe/http/adapters/net_http_spec.rb +181 -0
  166. data/spec/focus_nfe/http/authentication_spec.rb +24 -0
  167. data/spec/focus_nfe/http/connection_spec.rb +255 -0
  168. data/spec/focus_nfe/http/logging_spec.rb +83 -0
  169. data/spec/focus_nfe/http/response_spec.rb +161 -0
  170. data/spec/focus_nfe/modelos/documento_spec.rb +150 -0
  171. data/spec/focus_nfe/modelos/inutilizacao_spec.rb +91 -0
  172. data/spec/focus_nfe/modelos/pagina_spec.rb +77 -0
  173. data/spec/focus_nfe/recursos/backups_spec.rb +29 -0
  174. data/spec/focus_nfe/recursos/base_spec.rb +56 -0
  175. data/spec/focus_nfe/recursos/ceps_spec.rb +16 -0
  176. data/spec/focus_nfe/recursos/cfops_spec.rb +16 -0
  177. data/spec/focus_nfe/recursos/cnaes_spec.rb +20 -0
  178. data/spec/focus_nfe/recursos/cnpjs_spec.rb +11 -0
  179. data/spec/focus_nfe/recursos/concerns/emitivel_validacao_spec.rb +158 -0
  180. data/spec/focus_nfe/recursos/cte_os_spec.rb +9 -0
  181. data/spec/focus_nfe/recursos/cte_spec.rb +9 -0
  182. data/spec/focus_nfe/recursos/ctes_recebidas_spec.rb +56 -0
  183. data/spec/focus_nfe/recursos/dce_spec.rb +8 -0
  184. data/spec/focus_nfe/recursos/emails_bloqueados_spec.rb +29 -0
  185. data/spec/focus_nfe/recursos/empresas_spec.rb +45 -0
  186. data/spec/focus_nfe/recursos/mdfe_spec.rb +100 -0
  187. data/spec/focus_nfe/recursos/municipios_spec.rb +58 -0
  188. data/spec/focus_nfe/recursos/ncms_spec.rb +16 -0
  189. data/spec/focus_nfe/recursos/nfce_spec.rb +10 -0
  190. data/spec/focus_nfe/recursos/nfcom_spec.rb +8 -0
  191. data/spec/focus_nfe/recursos/nfe_spec.rb +262 -0
  192. data/spec/focus_nfe/recursos/nfes_recebidas_spec.rb +87 -0
  193. data/spec/focus_nfe/recursos/nfgas_spec.rb +8 -0
  194. data/spec/focus_nfe/recursos/nfse_nacional_spec.rb +8 -0
  195. data/spec/focus_nfe/recursos/nfse_spec.rb +9 -0
  196. data/spec/focus_nfe/recursos/nfses_nacionais_recebidas_spec.rb +17 -0
  197. data/spec/focus_nfe/recursos/webhooks_spec.rb +22 -0
  198. data/spec/focus_nfe/webhook_spec.rb +66 -0
  199. data/spec/focus_nfe_global_configuration_spec.rb +70 -0
  200. data/spec/focus_nfe_require_spec.rb +87 -0
  201. data/spec/focus_nfe_spec.rb +11 -0
  202. data/spec/spec_helper.rb +58 -0
  203. data/spec/support/shared_examples/recurso_fiscal.rb +445 -0
  204. data/spec/support/shared_examples/recurso_leitura.rb +217 -0
  205. data/tools/pull_fields.rb +62 -0
  206. metadata +420 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FocusNfe
6
+ # Camada opcional de esquemas: modela os campos de emissão raspados de
7
+ # +campos.focusnfe.com.br+ (empacotados em +data/schemas/*.json+) como dado,
8
+ # habilitando a validação client-side opt-in via +emitir(..., validar: true)+.
9
+ module Esquemas
10
+ # Erro client-side de validação de schema, levantado antes do envio quando o
11
+ # payload não atende ao {Esquema}. Distinto de {Errors::ValidationError} (HTTP
12
+ # 422), pois não envolve resposta da API.
13
+ class ErroDeValidacao < Error
14
+ # @return [Array<String>] mensagens dos campos inválidos/ausentes
15
+ attr_reader :erros
16
+
17
+ # @param erros [Array<String>] mensagens de validação acumuladas
18
+ def initialize(erros)
19
+ @erros = erros
20
+ super("validação client-side falhou: #{erros.join("; ")}")
21
+ end
22
+ end
23
+
24
+ # Esquema de emissão de um documento fiscal: a coleção de {Campo}s esperados
25
+ # pela API para aquele tipo de documento.
26
+ class Esquema
27
+ # @return [String] diretório dos schemas empacotados (+data/schemas/+)
28
+ DIRETORIO = File.expand_path("../../../data/schemas", __dir__.to_s)
29
+
30
+ # @return [Regexp] nomes de schema aceitos (alfanumérico, hífen, underscore)
31
+ NOME_VALIDO = /\A[\w-]+\z/
32
+
33
+ class << self
34
+ # Carrega o schema empacotado de um documento, memoizando por nome. Nomes
35
+ # fora do padrão alfanumérico são rejeitados (devolve +nil+) antes de tocar
36
+ # o filesystem, evitando travessia de caminho via +nome+ e não poluindo o
37
+ # cache com entradas arbitrárias.
38
+ #
39
+ # @param nome [String] nome do documento (ex.: +"nfe"+), igual ao +caminho_base+
40
+ # @return [Esquema, nil] o esquema, ou +nil+ se o nome for inválido ou não houver arquivo
41
+ def carregar(nome)
42
+ return cache[nome] if cache.key?(nome)
43
+ return nil unless nome.to_s.match?(NOME_VALIDO)
44
+
45
+ caminho = File.join(DIRETORIO, "schema_#{nome}.json")
46
+ cache[nome] = File.exist?(caminho) ? new(JSON.parse(File.read(caminho))) : nil
47
+ end
48
+
49
+ private
50
+
51
+ def cache
52
+ @cache ||= {}
53
+ end
54
+ end
55
+
56
+ # @return [Array<Campo>] campos definidos pelo esquema
57
+ attr_reader :campos
58
+
59
+ # @param definicoes [Array<Hash>] entradas de campo já parseadas do JSON
60
+ def initialize(definicoes)
61
+ @campos = definicoes.map { |definicao| Campo.new(definicao) }
62
+ end
63
+
64
+ # Descrição serializável do esquema: um Hash por campo, na ordem do schema.
65
+ #
66
+ # @return [Array<Hash>] descrição estruturada de cada {Campo}
67
+ def descrever
68
+ campos.map(&:to_h)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FocusNfe
4
+ module Esquemas
5
+ # Validação client-side opt-in de um payload de emissão contra um {Esquema}:
6
+ # confere presença dos campos obrigatórios e tipo/tamanho dos campos escalares.
7
+ # Recorre em coleções: para cada item do Array valida seus subcampos contra o
8
+ # sub-esquema da coleção, em profundidade arbitrária, prefixando os erros com o
9
+ # caminho — a posição do item é base 1 (ex.: +itens[1].descricao+,
10
+ # +itens[1].adicoes[3].numero+). A restrição de cada campo (tipo/tamanho,
11
+ # decimal, data e enum) é delegada a {Campo#validar_valor}.
12
+ #
13
+ # Além do esquema de topo, aceita sub-esquemas +aninhados+ — indexados pela
14
+ # chave do payload que os contém (ex.: +modal_rodoviario+) — para validar
15
+ # objetos aninhados cujo esquema depende de dados de runtime. Os erros do
16
+ # objeto aninhado vêm prefixados pela chave (ex.: +modal_rodoviario.rntrc+).
17
+ class Validador
18
+ # @param esquema [Esquema] esquema dos campos de topo do documento
19
+ # @param aninhados [Hash{String => Esquema}] sub-esquemas por chave aninhada do payload
20
+ def initialize(esquema, aninhados: {})
21
+ @esquema = esquema
22
+ @aninhados = aninhados
23
+ end
24
+
25
+ # @param dados [Hash] payload de emissão (chaves String ou Symbol)
26
+ # @return [Array<String>] mensagens dos campos inválidos/ausentes (vazio se válido)
27
+ def validar(dados)
28
+ normalizados = normalizar(dados)
29
+
30
+ validar_campos(@esquema, normalizados) + validar_aninhados(normalizados)
31
+ end
32
+
33
+ # @param dados [Hash] payload de emissão
34
+ # @return [void]
35
+ # @raise [ErroDeValidacao] se houver qualquer campo inválido/ausente
36
+ def validar!(dados)
37
+ erros = validar(dados)
38
+ raise ErroDeValidacao, erros unless erros.empty?
39
+ end
40
+
41
+ private
42
+
43
+ def validar_campos(esquema, dados)
44
+ esquema.campos.flat_map { |campo| erros_para(campo, dados) }
45
+ end
46
+
47
+ def validar_aninhados(dados)
48
+ @aninhados.flat_map do |chave, esquema|
49
+ objeto = dados[chave]
50
+ next ["#{chave}: campo obrigatório ausente"] if objeto.nil?
51
+ next ["#{chave}: deve ser um objeto"] unless objeto.is_a?(Hash)
52
+
53
+ validar_campos(esquema, normalizar(objeto)).map { |erro| "#{chave}.#{erro}" }
54
+ end
55
+ end
56
+
57
+ def erros_para(campo, dados)
58
+ ausente = !dados.key?(campo.nome) || dados[campo.nome].nil?
59
+
60
+ return ["#{campo.nome}: campo obrigatório ausente"] if campo.obrigatorio? && ausente
61
+ return [] if ausente
62
+ return validar_colecao(campo, dados[campo.nome]) if campo.colecao?
63
+
64
+ erro = campo.validar_valor(dados[campo.nome])
65
+ erro ? [erro] : []
66
+ end
67
+
68
+ def validar_colecao(campo, valor)
69
+ return ["#{campo.nome}: deve ser uma coleção"] unless valor.is_a?(Array)
70
+
71
+ sub = campo.esquema_colecao
72
+ return [] unless sub
73
+
74
+ valor.each_with_index.flat_map do |item, indice|
75
+ prefixo = "#{campo.nome}[#{indice + 1}]"
76
+ next ["#{prefixo}: deve ser um objeto"] unless item.is_a?(Hash)
77
+
78
+ validar_campos(sub, normalizar(item)).map { |erro| "#{prefixo}.#{erro}" }
79
+ end
80
+ end
81
+
82
+ def normalizar(dados)
83
+ dados.transform_keys(&:to_s)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FocusNfe
4
+ # Camada de transporte HTTP: conexão, autenticação, resposta e os adaptadores
5
+ # plugáveis que executam as requisições.
6
+ module HTTP
7
+ # Interface de um cliente HTTP plugável. A {Connection} despacha cada
8
+ # requisição já montada (URL absoluta, cabeçalhos e corpo serializado) para um
9
+ # adaptador, que executa o transporte e devolve uma {Response}.
10
+ #
11
+ # Implementações concretas sobrescrevem +#call+. Timeouts não trafegam por
12
+ # +#call+ — são injetados no construtor do adaptador concreto.
13
+ class Adapter
14
+ # @param method [Symbol] verbo HTTP (:get, :post, :put, :delete)
15
+ # @param url [String] URL absoluta da requisição
16
+ # @param headers [Hash{String=>String}] cabeçalhos da requisição
17
+ # @param body [String, nil] corpo já serializado, ou nil
18
+ # @return [FocusNfe::HTTP::Response]
19
+ # @raise [NotImplementedError] sempre, na interface abstrata
20
+ def call(method, url, headers: {}, body: nil)
21
+ raise NotImplementedError, "#{self.class} deve implementar #call"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module FocusNfe
7
+ module HTTP
8
+ # Implementações concretas de {Adapter}.
9
+ module Adapters
10
+ # Adaptador HTTP padrão, implementado sobre a stdlib (+Net::HTTP+), sem
11
+ # dependências externas. Aplica os timeouts recebidos no construtor, usa TLS
12
+ # para URLs +https+, segue redirecionamentos +302+ de download apenas para
13
+ # destinos +https+ (sem reenviar +Authorization+) e relança falhas de
14
+ # transporte como {Errors::ConnectionError}.
15
+ class NetHttp < Adapter
16
+ # @return [Integer] número máximo de redirecionamentos 302 seguidos
17
+ MAX_REDIRECTS = 5
18
+
19
+ # @return [Hash{Symbol=>Class}] verbo => classe de requisição Net::HTTP
20
+ VERBS = {
21
+ get: Net::HTTP::Get,
22
+ post: Net::HTTP::Post,
23
+ put: Net::HTTP::Put,
24
+ delete: Net::HTTP::Delete
25
+ }.freeze
26
+
27
+ # @param timeout [Integer] timeout de leitura, em segundos
28
+ # @param open_timeout [Integer] timeout de conexão, em segundos
29
+ def initialize(timeout: nil, open_timeout: nil)
30
+ super()
31
+ @timeout = timeout
32
+ @open_timeout = open_timeout
33
+ end
34
+
35
+ # @param method [Symbol] verbo HTTP (:get, :post, :put, :delete)
36
+ # @param url [String] URL absoluta da requisição
37
+ # @param headers [Hash{String=>String}] cabeçalhos da requisição
38
+ # @param body [String, nil] corpo já serializado, ou nil
39
+ # @return [FocusNfe::HTTP::Response]
40
+ # @raise [FocusNfe::Errors::ConnectionError] falha de transporte ou excesso de redirecionamentos
41
+ def call(method, url, headers: {}, body: nil)
42
+ dispatch(method, URI(url), headers, body, MAX_REDIRECTS)
43
+ end
44
+
45
+ private
46
+
47
+ def dispatch(method, uri, headers, body, redirects_left)
48
+ raw = transport(method, uri, headers, body)
49
+ location = raw["location"]
50
+
51
+ if raw.code.to_i == 302 && location
52
+ raise Errors::ConnectionError, "excedido o limite de redirecionamentos" if redirects_left.zero?
53
+
54
+ target = URI.join(uri.to_s, location)
55
+ raise Errors::ConnectionError, "redirecionamento não-https recusado" unless target.scheme == "https"
56
+
57
+ return dispatch(:get, target, without_authorization(headers), nil, redirects_left - 1)
58
+ end
59
+
60
+ build_response(raw)
61
+ end
62
+
63
+ def transport(method, uri, headers, body)
64
+ request = build_request(method, uri, headers, body)
65
+ http_for(uri).request(request)
66
+ rescue Timeout::Error, IOError, SystemCallError => e
67
+ raise Errors::ConnectionError, "falha de transporte: #{e.message}"
68
+ end
69
+
70
+ def build_request(method, uri, headers, body)
71
+ klass = VERBS.fetch(method) { raise ArgumentError, "verbo HTTP não suportado: #{method.inspect}" }
72
+ request = klass.new(uri)
73
+ headers.each { |name, value| request[name] = value }
74
+ request.body = body unless body.nil?
75
+ request
76
+ end
77
+
78
+ def http_for(uri)
79
+ Net::HTTP.new(uri.host.to_s, uri.port).tap do |http|
80
+ http.use_ssl = uri.scheme == "https"
81
+ read_timeout = @timeout
82
+ open_timeout = @open_timeout
83
+ http.read_timeout = read_timeout if read_timeout
84
+ http.open_timeout = open_timeout if open_timeout
85
+ end
86
+ end
87
+
88
+ def build_response(raw)
89
+ headers = raw.each_header.to_h { |name, value| [name, value] }
90
+ Response.new(status: raw.code.to_i, headers: headers, body: raw.body)
91
+ end
92
+
93
+ def without_authorization(headers)
94
+ headers.reject { |name, _| name.to_s.casecmp?("authorization") }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FocusNfe
4
+ module HTTP
5
+ # Calcula o cabeçalho HTTP Basic exigido pela API: o token entra como
6
+ # usuário e a senha fica vazia ("token:"). O Base64 vem de Array#pack("m0")
7
+ # (core do Ruby, sem `require`), equivalente exato a
8
+ # Base64.strict_encode64 — evitando a lib `base64`, que deixou de ser
9
+ # default gem no Ruby 4.x, e mantendo zero dependências de runtime.
10
+ module Authentication
11
+ # @return [String] nome do cabeçalho HTTP de autenticação
12
+ NAME = "Authorization"
13
+
14
+ module_function
15
+
16
+ # @param token [String] token de acesso, usado como usuário do Basic Auth
17
+ # @return [Hash{String=>String}] { "Authorization" => "Basic <base64('token:')>" }
18
+ def header(token)
19
+ { NAME => "Basic #{["#{token}:"].pack("m0")}" }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module FocusNfe
7
+ module HTTP
8
+ # Ponto único de transporte: monta a URL com o prefixo +/v2+, injeta os
9
+ # cabeçalhos padrão (JSON, User-Agent, Basic Auth) mais os extras da
10
+ # {Configuration}, serializa o corpo Hash para JSON e despacha ao adaptador.
11
+ # Devolve a {Response} em 2xx e levanta a exceção tipada em não-2xx — assim
12
+ # cada recurso futuro vira uma camada fina sobre esta classe.
13
+ class Connection
14
+ # @return [String] prefixo de versão da API, inserido em toda URL
15
+ PREFIX = "v2"
16
+
17
+ # @return [Hash{String=>String}] cabeçalhos enviados em toda requisição
18
+ DEFAULT_HEADERS = {
19
+ "Content-Type" => "application/json",
20
+ "Accept" => "application/json",
21
+ "User-Agent" => "focus_nfe/#{FocusNfe::VERSION}"
22
+ }.freeze
23
+
24
+ # @param configuration [FocusNfe::Configuration] configuração já validada
25
+ # @param token [String] token que autentica esta conexão (empresa ou conta)
26
+ def initialize(configuration, token:)
27
+ @configuration = configuration
28
+ @token = token
29
+ end
30
+
31
+ # @param path [String] caminho do recurso, sem o prefixo /v2 (ex.: "nfe")
32
+ # @param params [Hash] pares convertidos em query string
33
+ # @param body [Hash, String, nil] Hash é serializado para JSON; nil não envia corpo
34
+ # @param headers [Hash] cabeçalhos extras desta chamada
35
+ # @return [FocusNfe::HTTP::Response] em respostas 2xx
36
+ # @raise [FocusNfe::Errors::HttpError] a exceção tipada correspondente em não-2xx
37
+ def get(path, params: {}, body: nil, headers: {})
38
+ execute(:get, path, params: params, body: body, headers: headers)
39
+ end
40
+
41
+ # (see #get)
42
+ def post(path, params: {}, body: nil, headers: {})
43
+ execute(:post, path, params: params, body: body, headers: headers)
44
+ end
45
+
46
+ # (see #get)
47
+ def put(path, params: {}, body: nil, headers: {})
48
+ execute(:put, path, params: params, body: body, headers: headers)
49
+ end
50
+
51
+ # (see #get)
52
+ def delete(path, params: {}, body: nil, headers: {})
53
+ execute(:delete, path, params: params, body: body, headers: headers)
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :configuration, :token
59
+
60
+ def execute(verb, path, params:, body:, headers:)
61
+ url = build_url(path, params)
62
+ request_headers = build_headers(headers)
63
+ logging.request(verb, url, request_headers)
64
+
65
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ response = call_adapter(verb, url, request_headers, body, started)
67
+ logging.response(verb, url, response.status, elapsed_since(started), response.raw_body)
68
+
69
+ return response if response.success?
70
+
71
+ raise Errors.from_response(response)
72
+ end
73
+
74
+ def call_adapter(verb, url, request_headers, body, started)
75
+ adapter.call(verb, url, headers: request_headers, body: serialize(body))
76
+ rescue Errors::ConnectionError => e
77
+ logging.failure(verb, url, e, elapsed_since(started))
78
+ raise
79
+ end
80
+
81
+ def elapsed_since(started)
82
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
83
+ end
84
+
85
+ def build_url(path, params)
86
+ url = "#{configuration.base_url}/#{PREFIX}/#{path.to_s.delete_prefix("/")}"
87
+ return url if params.nil? || params.empty?
88
+
89
+ "#{url}?#{URI.encode_www_form(params)}"
90
+ end
91
+
92
+ def build_headers(call_headers)
93
+ DEFAULT_HEADERS
94
+ .merge(configuration.headers)
95
+ .merge(call_headers)
96
+ .merge(Authentication.header(token))
97
+ end
98
+
99
+ def serialize(body)
100
+ return if body.nil?
101
+
102
+ body.is_a?(String) ? body : JSON.generate(body)
103
+ end
104
+
105
+ def logging
106
+ @logging ||= Logging.new(configuration.logger)
107
+ end
108
+
109
+ def adapter
110
+ @adapter ||= configuration.http_adapter ||
111
+ Adapters::NetHttp.new(
112
+ timeout: configuration.timeout,
113
+ open_timeout: configuration.open_timeout
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FocusNfe
4
+ module HTTP
5
+ # Registra cada requisição/resposta no logger configurado, redigindo dados
6
+ # sensíveis. Concentra o nil-guard, a redação de cabeçalhos e a formatação,
7
+ # mantendo a {Connection} enxuta.
8
+ #
9
+ # == Contrato do logger plugável
10
+ #
11
+ # O logger pode ser qualquer objeto compatível com o +Logger+ da biblioteca
12
+ # padrão: responde a +debug+, +info+, +warn+ e +error+, cada um aceitando uma
13
+ # mensagem *ou um bloco*. A gem sempre usa a forma com bloco
14
+ # (+logger.info { ... }+), de modo que a mensagem só é construída quando o
15
+ # nível está habilitado. +Logger.new($stdout)+, +Rails.logger+ e
16
+ # +ActiveSupport::Logger+ conformam. Quando o logger é +nil+ (padrão da
17
+ # {Configuration}), o logging fica inteiramente desligado, sem custo.
18
+ #
19
+ # == Dados sensíveis
20
+ #
21
+ # Cabeçalhos em {SENSITIVE_HEADERS} (notadamente +Authorization+) nunca são
22
+ # registrados — seus valores viram {REDACTED}. O corpo da *requisição* (que
23
+ # carrega o payload fiscal com CPF/CNPJ e valores) nunca é logado; o corpo da
24
+ # *resposta* só aparece em erros (status >= 400), truncado a {BODY_MAX}.
25
+ class Logging
26
+ # @return [String] substituto registrado no lugar de valores sensíveis
27
+ REDACTED = "[FILTERED]"
28
+
29
+ # @return [Array<String>] nomes de cabeçalho redigidos (comparados em minúsculas)
30
+ SENSITIVE_HEADERS = %w[authorization].freeze
31
+
32
+ # @return [Integer] tamanho máximo do corpo de erro registrado
33
+ BODY_MAX = 2_000
34
+
35
+ # @param logger [_Logger, nil] logger plugável ou +nil+ para desligar
36
+ def initialize(logger)
37
+ @logger = logger
38
+ end
39
+
40
+ # @param verb [Symbol] verbo HTTP da requisição
41
+ # @param url [String] URL absoluta da requisição
42
+ # @param headers [Hash{String=>String}] cabeçalhos enviados (redigidos antes de logar)
43
+ # @return [void]
44
+ def request(verb, url, headers)
45
+ logger = @logger
46
+ return unless logger
47
+
48
+ logger.debug { "[focus_nfe] → #{verb.to_s.upcase} #{url} headers=#{redact(headers)}" }
49
+ end
50
+
51
+ # @param verb [Symbol] verbo HTTP da requisição
52
+ # @param url [String] URL absoluta da requisição
53
+ # @param status [Integer] código de status HTTP recebido
54
+ # @param elapsed [Float] tempo decorrido, em segundos
55
+ # @param body [String, nil] corpo cru da resposta, logado apenas em erros (status >= 400)
56
+ # @return [void]
57
+ def response(verb, url, status, elapsed, body)
58
+ logger = @logger
59
+ return unless logger
60
+
61
+ linha = "[focus_nfe] ← #{status} #{verb.to_s.upcase} #{url} (#{ms(elapsed)}ms)"
62
+
63
+ if status >= 400
64
+ logger.warn { "#{linha} body=#{truncate(body)}" }
65
+ else
66
+ logger.info { linha }
67
+ end
68
+ end
69
+
70
+ # @param verb [Symbol] verbo HTTP da requisição
71
+ # @param url [String] URL absoluta da requisição
72
+ # @param error [Exception] falha de transporte ocorrida
73
+ # @param elapsed [Float] tempo decorrido até a falha, em segundos
74
+ # @return [void]
75
+ def failure(verb, url, error, elapsed)
76
+ logger = @logger
77
+ return unless logger
78
+
79
+ logger.error { "[focus_nfe] ✕ #{verb.to_s.upcase} #{url} #{error.message} (#{ms(elapsed)}ms)" }
80
+ end
81
+
82
+ private
83
+
84
+ def redact(headers)
85
+ headers.each_with_object({}) do |(name, value), memo|
86
+ memo[name] = SENSITIVE_HEADERS.include?(name.to_s.downcase) ? REDACTED : value
87
+ end
88
+ end
89
+
90
+ def truncate(body)
91
+ text = body.to_s
92
+ text.length > BODY_MAX ? "#{text[0, BODY_MAX]}…" : text
93
+ end
94
+
95
+ def ms(elapsed)
96
+ (elapsed * 1000).round
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FocusNfe
6
+ module HTTP
7
+ # Wrapper imutável de uma resposta HTTP da API Focus NFe.
8
+ #
9
+ # Expõe +status+, +headers+ (leitura case-insensitive), +body+ (JSON
10
+ # parseado quando o +Content-Type+ indica JSON, senão a string crua) e
11
+ # +raw_body+ (sempre a string original). O parsing é eager no construtor —
12
+ # a instância é congelada, então não há memoização preguiçosa.
13
+ class Response
14
+ # Conjunto de cabeçalhos com acesso insensível a maiúsculas/minúsculas.
15
+ class Headers
16
+ # @param source [Hash] cabeçalhos recebidos, em qualquer caixa
17
+ def initialize(source)
18
+ @data = source.each_with_object({}) do |(key, value), memo|
19
+ memo[key.to_s.downcase] = value
20
+ end
21
+ @data.freeze
22
+ freeze
23
+ end
24
+
25
+ # @param key [String] nome do cabeçalho, em qualquer caixa
26
+ # @return [String, nil] valor do cabeçalho ou +nil+ se ausente
27
+ def [](key)
28
+ @data[key.to_s.downcase]
29
+ end
30
+
31
+ # @return [Hash{String => String}] cópia dos cabeçalhos normalizados
32
+ def to_h
33
+ @data.dup
34
+ end
35
+ end
36
+
37
+ # @return [Regexp] reconhece +Content-Type+ JSON (incluindo sufixos +.../...+json+)
38
+ JSON_TYPE = %r{\bapplication/(?:[\w.+-]+\+)?json\b}i
39
+
40
+ attr_reader :status, :headers, :body, :raw_body
41
+
42
+ # @param status [Integer] código de status HTTP
43
+ # @param headers [Hash] cabeçalhos da resposta
44
+ # @param body [String, nil] corpo cru recebido
45
+ def initialize(status:, headers: {}, body: nil)
46
+ @status = status
47
+ @headers = Headers.new(headers)
48
+ @raw_body = body
49
+ @body = parse_body
50
+ freeze
51
+ end
52
+
53
+ # @return [Boolean] +true+ quando o status está na faixa 2xx
54
+ def success?
55
+ status.between?(200, 299)
56
+ end
57
+
58
+ private
59
+
60
+ def parse_body
61
+ return raw_body unless json?
62
+ return nil if raw_body.nil? || raw_body.strip.empty?
63
+
64
+ JSON.parse(raw_body)
65
+ rescue JSON::ParserError
66
+ raw_body
67
+ end
68
+
69
+ def json?
70
+ type = headers["Content-Type"]
71
+ !type.nil? && type.match?(JSON_TYPE)
72
+ end
73
+ end
74
+ end
75
+ end