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.
- checksums.yaml +7 -0
- data/.git-hooks/pre_push/steep.rb +18 -0
- data/.git-hooks/pre_push/yard_doc.rb +18 -0
- data/.gitattributes +1 -0
- data/.overcommit.yml +69 -0
- data/.rspec +3 -0
- data/.yardopts +11 -0
- data/CHANGELOG.md +77 -0
- data/CLAUDE.md +118 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +105 -0
- data/data/schemas/schema_cte.json +2793 -0
- data/data/schemas/schema_cte_os.json +1335 -0
- data/data/schemas/schema_cte_os_transporte_rodoviario.json +109 -0
- data/data/schemas/schema_cte_transporte_aereo.json +115 -0
- data/data/schemas/schema_cte_transporte_aquaviario.json +174 -0
- data/data/schemas/schema_cte_transporte_dutoviario.json +65 -0
- data/data/schemas/schema_cte_transporte_ferroviario.json +144 -0
- data/data/schemas/schema_cte_transporte_multimodal.json +45 -0
- data/data/schemas/schema_cte_transporte_rodoviario.json +78 -0
- data/data/schemas/schema_dce.json +549 -0
- data/data/schemas/schema_mdfe.json +1102 -0
- data/data/schemas/schema_mdfe_transporte_aereo.json +44 -0
- data/data/schemas/schema_mdfe_transporte_aquaviario.json +209 -0
- data/data/schemas/schema_mdfe_transporte_ferroviario.json +99 -0
- data/data/schemas/schema_mdfe_transporte_rodoviario.json +628 -0
- data/data/schemas/schema_nfcom.json +1859 -0
- data/data/schemas/schema_nfe.json +4750 -0
- data/data/schemas/schema_nfe_forma_pagamento.json +97 -0
- data/data/schemas/schema_nfe_item.json +2574 -0
- data/data/schemas/schema_nfgas.json +2316 -0
- data/data/schemas/schema_nfse_nacional.json +1847 -0
- data/data/schemas/schema_nfse_recebida.json +548 -0
- data/lib/focus_nfe/client.rb +162 -0
- data/lib/focus_nfe/configuration.rb +104 -0
- data/lib/focus_nfe/errors.rb +123 -0
- data/lib/focus_nfe/esquemas/campo.rb +171 -0
- data/lib/focus_nfe/esquemas/catalogo.rb +34 -0
- data/lib/focus_nfe/esquemas/decimal.rb +66 -0
- data/lib/focus_nfe/esquemas/esquema.rb +72 -0
- data/lib/focus_nfe/esquemas/validador.rb +87 -0
- data/lib/focus_nfe/http/adapter.rb +25 -0
- data/lib/focus_nfe/http/adapters/net_http.rb +99 -0
- data/lib/focus_nfe/http/authentication.rb +23 -0
- data/lib/focus_nfe/http/connection.rb +118 -0
- data/lib/focus_nfe/http/logging.rb +100 -0
- data/lib/focus_nfe/http/response.rb +75 -0
- data/lib/focus_nfe/modelos/documento.rb +113 -0
- data/lib/focus_nfe/modelos/inutilizacao.rb +75 -0
- data/lib/focus_nfe/modelos/pagina.rb +54 -0
- data/lib/focus_nfe/recursos/backups.rb +17 -0
- data/lib/focus_nfe/recursos/base.rb +91 -0
- data/lib/focus_nfe/recursos/ceps.rb +16 -0
- data/lib/focus_nfe/recursos/cfops.rb +16 -0
- data/lib/focus_nfe/recursos/cnaes.rb +16 -0
- data/lib/focus_nfe/recursos/cnpjs.rb +13 -0
- data/lib/focus_nfe/recursos/concerns/baixavel.rb +41 -0
- data/lib/focus_nfe/recursos/concerns/baixavel_eventos.rb +34 -0
- data/lib/focus_nfe/recursos/concerns/cancelavel.rb +25 -0
- data/lib/focus_nfe/recursos/concerns/conciliavel.rb +66 -0
- data/lib/focus_nfe/recursos/concerns/consultavel.rb +26 -0
- data/lib/focus_nfe/recursos/concerns/corrigivel.rb +45 -0
- data/lib/focus_nfe/recursos/concerns/corrigivel_cte.rb +60 -0
- data/lib/focus_nfe/recursos/concerns/emitivel.rb +51 -0
- data/lib/focus_nfe/recursos/concerns/enviavel.rb +40 -0
- data/lib/focus_nfe/recursos/concerns/eventavel.rb +46 -0
- data/lib/focus_nfe/recursos/concerns/inutilizavel.rb +96 -0
- data/lib/focus_nfe/recursos/concerns/listavel.rb +22 -0
- data/lib/focus_nfe/recursos/concerns/localizavel.rb +22 -0
- data/lib/focus_nfe/recursos/concerns/notificavel.rb +23 -0
- data/lib/focus_nfe/recursos/concerns/removivel.rb +20 -0
- data/lib/focus_nfe/recursos/concerns/visualizavel.rb +28 -0
- data/lib/focus_nfe/recursos/cte.rb +35 -0
- data/lib/focus_nfe/recursos/cte_os.rb +29 -0
- data/lib/focus_nfe/recursos/ctes_recebidas.rb +38 -0
- data/lib/focus_nfe/recursos/dce.rb +16 -0
- data/lib/focus_nfe/recursos/emails_bloqueados.rb +31 -0
- data/lib/focus_nfe/recursos/empresas.rb +35 -0
- data/lib/focus_nfe/recursos/mdfe.rb +78 -0
- data/lib/focus_nfe/recursos/municipios.rb +60 -0
- data/lib/focus_nfe/recursos/ncms.rb +16 -0
- data/lib/focus_nfe/recursos/nfce.rb +19 -0
- data/lib/focus_nfe/recursos/nfcom.rb +16 -0
- data/lib/focus_nfe/recursos/nfe.rb +106 -0
- data/lib/focus_nfe/recursos/nfes_recebidas.rb +56 -0
- data/lib/focus_nfe/recursos/nfgas.rb +16 -0
- data/lib/focus_nfe/recursos/nfse.rb +18 -0
- data/lib/focus_nfe/recursos/nfse_nacional.rb +16 -0
- data/lib/focus_nfe/recursos/nfses_nacionais_recebidas.rb +21 -0
- data/lib/focus_nfe/recursos/webhooks.rb +22 -0
- data/lib/focus_nfe/version.rb +6 -0
- data/lib/focus_nfe/webhook.rb +68 -0
- data/lib/focus_nfe.rb +124 -0
- data/sig/focus_nfe/client.rbs +38 -0
- data/sig/focus_nfe/configuration.rbs +29 -0
- data/sig/focus_nfe/errors.rbs +59 -0
- data/sig/focus_nfe/esquemas/campo.rbs +47 -0
- data/sig/focus_nfe/esquemas/catalogo.rbs +8 -0
- data/sig/focus_nfe/esquemas/decimal.rbs +25 -0
- data/sig/focus_nfe/esquemas/esquema.rbs +30 -0
- data/sig/focus_nfe/esquemas/validador.rbs +20 -0
- data/sig/focus_nfe/http/adapter.rbs +7 -0
- data/sig/focus_nfe/http/adapters/net_http.rbs +25 -0
- data/sig/focus_nfe/http/authentication.rbs +9 -0
- data/sig/focus_nfe/http/connection.rbs +32 -0
- data/sig/focus_nfe/http/logging.rbs +30 -0
- data/sig/focus_nfe/http/response.rbs +28 -0
- data/sig/focus_nfe/modelos/documento.rbs +34 -0
- data/sig/focus_nfe/modelos/inutilizacao.rbs +24 -0
- data/sig/focus_nfe/modelos/pagina.rbs +21 -0
- data/sig/focus_nfe/recursos/backups.rbs +7 -0
- data/sig/focus_nfe/recursos/base.rbs +25 -0
- data/sig/focus_nfe/recursos/ceps.rbs +10 -0
- data/sig/focus_nfe/recursos/cfops.rbs +10 -0
- data/sig/focus_nfe/recursos/cnaes.rbs +10 -0
- data/sig/focus_nfe/recursos/cnpjs.rbs +7 -0
- data/sig/focus_nfe/recursos/concerns/baixavel.rbs +12 -0
- data/sig/focus_nfe/recursos/concerns/baixavel_eventos.rbs +14 -0
- data/sig/focus_nfe/recursos/concerns/cancelavel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/conciliavel.rbs +15 -0
- data/sig/focus_nfe/recursos/concerns/consultavel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/corrigivel.rbs +15 -0
- data/sig/focus_nfe/recursos/concerns/corrigivel_cte.rbs +17 -0
- data/sig/focus_nfe/recursos/concerns/emitivel.rbs +14 -0
- data/sig/focus_nfe/recursos/concerns/enviavel.rbs +15 -0
- data/sig/focus_nfe/recursos/concerns/eventavel.rbs +12 -0
- data/sig/focus_nfe/recursos/concerns/inutilizavel.rbs +20 -0
- data/sig/focus_nfe/recursos/concerns/listavel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/localizavel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/notificavel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/removivel.rbs +9 -0
- data/sig/focus_nfe/recursos/concerns/visualizavel.rbs +9 -0
- data/sig/focus_nfe/recursos/cte.rbs +17 -0
- data/sig/focus_nfe/recursos/cte_os.rbs +17 -0
- data/sig/focus_nfe/recursos/ctes_recebidas.rbs +14 -0
- data/sig/focus_nfe/recursos/dce.rbs +10 -0
- data/sig/focus_nfe/recursos/emails_bloqueados.rbs +12 -0
- data/sig/focus_nfe/recursos/empresas.rbs +12 -0
- data/sig/focus_nfe/recursos/mdfe.rbs +21 -0
- data/sig/focus_nfe/recursos/municipios.rbs +19 -0
- data/sig/focus_nfe/recursos/ncms.rbs +10 -0
- data/sig/focus_nfe/recursos/nfce.rbs +12 -0
- data/sig/focus_nfe/recursos/nfcom.rbs +10 -0
- data/sig/focus_nfe/recursos/nfe.rbs +23 -0
- data/sig/focus_nfe/recursos/nfes_recebidas.rbs +16 -0
- data/sig/focus_nfe/recursos/nfgas.rbs +10 -0
- data/sig/focus_nfe/recursos/nfse.rbs +11 -0
- data/sig/focus_nfe/recursos/nfse_nacional.rbs +10 -0
- data/sig/focus_nfe/recursos/nfses_nacionais_recebidas.rbs +11 -0
- data/sig/focus_nfe/recursos/webhooks.rbs +11 -0
- data/sig/focus_nfe/webhook.rbs +11 -0
- data/sig/focus_nfe.rbs +10 -0
- data/spec/focus_nfe/client_spec.rb +208 -0
- data/spec/focus_nfe/configuration_spec.rb +121 -0
- data/spec/focus_nfe/errors_mapping_spec.rb +68 -0
- data/spec/focus_nfe/errors_spec.rb +107 -0
- data/spec/focus_nfe/esquemas/campo_spec.rb +291 -0
- data/spec/focus_nfe/esquemas/decimal_spec.rb +54 -0
- data/spec/focus_nfe/esquemas/esquema_spec.rb +73 -0
- data/spec/focus_nfe/esquemas/validador_spec.rb +167 -0
- data/spec/focus_nfe/esquemas_spec.rb +42 -0
- data/spec/focus_nfe/http/adapter_spec.rb +8 -0
- data/spec/focus_nfe/http/adapters/net_http_spec.rb +181 -0
- data/spec/focus_nfe/http/authentication_spec.rb +24 -0
- data/spec/focus_nfe/http/connection_spec.rb +255 -0
- data/spec/focus_nfe/http/logging_spec.rb +83 -0
- data/spec/focus_nfe/http/response_spec.rb +161 -0
- data/spec/focus_nfe/modelos/documento_spec.rb +150 -0
- data/spec/focus_nfe/modelos/inutilizacao_spec.rb +91 -0
- data/spec/focus_nfe/modelos/pagina_spec.rb +77 -0
- data/spec/focus_nfe/recursos/backups_spec.rb +29 -0
- data/spec/focus_nfe/recursos/base_spec.rb +56 -0
- data/spec/focus_nfe/recursos/ceps_spec.rb +16 -0
- data/spec/focus_nfe/recursos/cfops_spec.rb +16 -0
- data/spec/focus_nfe/recursos/cnaes_spec.rb +20 -0
- data/spec/focus_nfe/recursos/cnpjs_spec.rb +11 -0
- data/spec/focus_nfe/recursos/concerns/emitivel_validacao_spec.rb +158 -0
- data/spec/focus_nfe/recursos/cte_os_spec.rb +9 -0
- data/spec/focus_nfe/recursos/cte_spec.rb +9 -0
- data/spec/focus_nfe/recursos/ctes_recebidas_spec.rb +56 -0
- data/spec/focus_nfe/recursos/dce_spec.rb +8 -0
- data/spec/focus_nfe/recursos/emails_bloqueados_spec.rb +29 -0
- data/spec/focus_nfe/recursos/empresas_spec.rb +45 -0
- data/spec/focus_nfe/recursos/mdfe_spec.rb +100 -0
- data/spec/focus_nfe/recursos/municipios_spec.rb +58 -0
- data/spec/focus_nfe/recursos/ncms_spec.rb +16 -0
- data/spec/focus_nfe/recursos/nfce_spec.rb +10 -0
- data/spec/focus_nfe/recursos/nfcom_spec.rb +8 -0
- data/spec/focus_nfe/recursos/nfe_spec.rb +262 -0
- data/spec/focus_nfe/recursos/nfes_recebidas_spec.rb +87 -0
- data/spec/focus_nfe/recursos/nfgas_spec.rb +8 -0
- data/spec/focus_nfe/recursos/nfse_nacional_spec.rb +8 -0
- data/spec/focus_nfe/recursos/nfse_spec.rb +9 -0
- data/spec/focus_nfe/recursos/nfses_nacionais_recebidas_spec.rb +17 -0
- data/spec/focus_nfe/recursos/webhooks_spec.rb +22 -0
- data/spec/focus_nfe/webhook_spec.rb +66 -0
- data/spec/focus_nfe_global_configuration_spec.rb +70 -0
- data/spec/focus_nfe_require_spec.rb +87 -0
- data/spec/focus_nfe_spec.rb +11 -0
- data/spec/spec_helper.rb +58 -0
- data/spec/support/shared_examples/recurso_fiscal.rb +445 -0
- data/spec/support/shared_examples/recurso_leitura.rb +217 -0
- data/tools/pull_fields.rb +62 -0
- 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
|