Class: Nfe::Resources::AbstractResource

Inherits:
Object
  • Object
show all
Defined in:
lib/nfe/resources/abstract_resource.rb,
sig/nfe/resources/abstract_resource.rbs

Overview

Base class for every SDK resource. Constructed with the owning Client, it provides the HTTP verb helpers (+get+/+post+/+put+/ delete) routed through the client's transport for the resource's declared api_family, plus hydration and async-response helpers shared by every resource.

Subclasses declare their api_family (and optionally override api_version, which defaults to "v1"; the addresses resource returns "" because its host already embeds /v2). Business methods are added by the resource changes (add-invoice-resources, add-entity-resources, ...).

All HTTP helpers accept an optional request_options: (Nfe::RequestOptions) that is threaded through to Client#request for per-call multi-tenant overrides.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client) ⇒ AbstractResource

Returns a new instance of AbstractResource.

Parameters:



23
24
25
# File 'lib/nfe/resources/abstract_resource.rb', line 23

def initialize(client)
  @client = client
end

Instance Attribute Details

#clientNfe::Client (readonly)

Returns the owning client.

Returns:



30
31
32
# File 'lib/nfe/resources/abstract_resource.rb', line 30

def client
  @client
end

Instance Method Details

#api_familySymbol

The product family this resource belongs to (e.g. :main, :cte). Subclasses MUST override.

Returns:

  • (Symbol)

Raises:

  • (NotImplementedError)


36
37
38
# File 'lib/nfe/resources/abstract_resource.rb', line 36

def api_family
  raise NotImplementedError, "#{self.class} must declare an api_family"
end

#api_versionString

The path version segment prefixed to every request path. Defaults to "v1"; a resource whose host already embeds the version (addresses) overrides this to "".

Returns:

  • (String)


45
46
47
# File 'lib/nfe/resources/abstract_resource.rb', line 45

def api_version
  "v1"
end

#build_list_page(payload) ⇒ Nfe::ListPage

Detect the pagination shape of payload and build the matching ListPage. Page-style takes precedence when paging fields exist.

Parameters:

  • payload (Object)

Returns:



156
157
158
159
160
161
162
163
164
# File 'lib/nfe/resources/abstract_resource.rb', line 156

def build_list_page(payload)
  page_index = dig_key(payload, "pageIndex", :page_index)
  page_count = dig_key(payload, "pageCount", :page_count)
  total = payload["totalResults"] || dig_key(payload, "total", :total)

  return page_list_page(page_index, page_count, total) if page_index || page_count

  cursor_list_page(payload, total)
end

#build_multipart_body(parts, boundary) ⇒ String

Assemble the raw multipart/form-data body for parts under boundary.

Parameters:

  • parts (Hash[untyped, untyped])
  • boundary (String)

Returns:

  • (String)


238
239
240
241
242
243
244
245
246
# File 'lib/nfe/resources/abstract_resource.rb', line 238

def build_multipart_body(parts, boundary)
  buffer = String.new(encoding: Encoding::ASCII_8BIT)
  parts.each do |name, value|
    buffer << "--#{boundary}\r\n".b
    buffer << multipart_part(name.to_s, value)
  end
  buffer << "--#{boundary}--\r\n".b
  buffer
end

#cursor_list_page(payload, total) ⇒ Nfe::ListPage

Parameters:

  • payload (Object)
  • total (Object)

Returns:



175
176
177
178
179
180
181
# File 'lib/nfe/resources/abstract_resource.rb', line 175

def cursor_list_page(payload, total)
  starting_after = dig_key(payload, "startingAfter", :starting_after)
  ending_before = dig_key(payload, "endingBefore", :ending_before)
  return Nfe::ListPage.new(total: total) unless starting_after || ending_before

  Nfe::ListPage.new(starting_after: starting_after, ending_before: ending_before, total: total)
end

#delete(path, query: {}, request_options: nil, headers: {}) ⇒ Nfe::Http::Response

Issue a DELETE request for this resource's family.

Parameters:

  • path (String)
  • query: (Hash[untyped, untyped]) (defaults to: {})
  • request_options: (Nfe::RequestOptions, nil) (defaults to: nil)
  • headers: (Hash[untyped, untyped]) (defaults to: {})

Returns:



73
74
75
76
77
# File 'lib/nfe/resources/abstract_resource.rb', line 73

def delete(path, query: {}, request_options: nil, headers: {})
  client.request(:delete, family: api_family, path: full_path(path),
                          query: query, headers: headers,
                          request_options: request_options)
end

#dig_key(payload, camel_key, snake_key) ⇒ Object

Reads a value under either the camelCase (String) or snake_case (Symbol) key.

Parameters:

  • payload (Object)
  • camel_key (String)
  • snake_key (Symbol)

Returns:

  • (Object)


167
168
169
# File 'lib/nfe/resources/abstract_resource.rb', line 167

def dig_key(payload, camel_key, snake_key)
  payload[camel_key] || payload[snake_key]
end

#download(path, query: {}, request_options: nil, headers: {}) ⇒ String

Perform a GET and return the response body as binary-safe bytes (+ASCII-8BIT+), so binary documents (PDF/XML/ZIP) are not corrupted.

Parameters:

  • path (String)
  • query: (Hash[untyped, untyped]) (defaults to: {})
  • request_options: (Nfe::RequestOptions, nil) (defaults to: nil)
  • headers: (Hash[untyped, untyped]) (defaults to: {})

Returns:

  • (String)

    the body, encoded ASCII-8BIT.



106
107
108
109
110
# File 'lib/nfe/resources/abstract_resource.rb', line 106

def download(path, query: {}, request_options: nil, headers: {})
  response = get(path, query: query, request_options: request_options,
                       headers: headers)
  (response.body || "").dup.force_encoding(Encoding::ASCII_8BIT)
end

#extract_invoice_id(location) ⇒ String?

Extract the trailing id from a Location path, e.g. /v1/companies/x/serviceinvoices/abc-123 -> "abc-123".

Parameters:

  • location (String)

Returns:

  • (String, nil)


185
186
187
188
# File 'lib/nfe/resources/abstract_resource.rb', line 185

def extract_invoice_id(location)
  match = location.match(%r{/([a-z0-9-]+)\z}i)
  match ? match[1] : nil
end

#full_path(path) ⇒ String

Prefix path with /#{api_version} unless the version is empty (in which case path is returned unchanged, avoiding a doubled slash).

Parameters:

  • path (String)

Returns:

  • (String)


84
85
86
87
88
89
# File 'lib/nfe/resources/abstract_resource.rb', line 84

def full_path(path)
  version = api_version.to_s
  return path if version.empty?

  "/#{version}#{path}"
end

#get(path, query: {}, request_options: nil, headers: {}) ⇒ Nfe::Http::Response

Issue a GET request for this resource's family.

Parameters:

  • path (String)
  • query: (Hash[untyped, untyped]) (defaults to: {})
  • request_options: (Nfe::RequestOptions, nil) (defaults to: nil)
  • headers: (Hash[untyped, untyped]) (defaults to: {})

Returns:



50
51
52
53
54
# File 'lib/nfe/resources/abstract_resource.rb', line 50

def get(path, query: {}, request_options: nil, headers: {})
  client.request(:get, family: api_family, path: full_path(path),
                       query: query, headers: headers,
                       request_options: request_options)
end

#handle_async_response(response, issued_klass:) ⇒ Nfe::Pending, Nfe::Issued

Interpret an emission response. A 202 with a Location header yields an Pending (its invoice_id parsed from the final path segment); a 202 without Location is a protocol violation raising InvoiceProcessingError. A 201/200 hydrates issued_klass from the JSON body and yields an Issued.

Parameters:

  • response (Nfe::Http::Response)
  • issued_klass (Class)

    generated DTO for the materialized resource.

  • issued_klass: (Object)

Returns:



137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/nfe/resources/abstract_resource.rb', line 137

def handle_async_response(response, issued_klass:)
  if response.status == 202
    location = response.location
    if location.nil? || location.empty?
      raise Nfe::InvoiceProcessingError.new(
        "Resposta 202 sem cabeçalho Location: não é possível identificar a nota em processamento.",
        status_code: response.status, response_headers: response.headers
      )
    end
    Nfe::Pending.new(invoice_id: extract_invoice_id(location), location: location)
  else
    Nfe::Issued.new(resource: hydrate(issued_klass, parse_json(response.body)))
  end
end

#hydrate(klass, payload) ⇒ Object

Materialize a generated DTO by delegating to its from_api factory (camelCase -> snake_case, unknown-key drop, nested recursion). The factory itself is produced by add-openapi-pipeline; this is only the call site.

Parameters:

  • klass (Class)

    a generated DTO class responding to from_api.

  • payload (Hash, nil)

Returns:

  • (Object)


98
99
100
# File 'lib/nfe/resources/abstract_resource.rb', line 98

def hydrate(klass, payload)
  klass.from_api(payload)
end

#hydrate_list(klass, payload, wrapper_key:) ⇒ Nfe::ListResponse

Unwrap payload[wrapper_key], hydrate each item with klass, and build an ListResponse whose page reflects the pagination shape: page-style (+page_index+/+page_count+) or cursor-style (+starting_after+/+ending_before+).

Parameters:

  • klass (Class)

    generated DTO class for each item.

  • payload (Hash)

    the parsed response body.

  • wrapper_key (String, Symbol)

    key carrying the item array.

  • wrapper_key: (String, Symbol)

Returns:



121
122
123
124
125
126
# File 'lib/nfe/resources/abstract_resource.rb', line 121

def hydrate_list(klass, payload, wrapper_key:)
  payload ||= {} #: Hash[untyped, untyped]
  raw_items = payload[wrapper_key.to_s] || payload[wrapper_key.to_sym] || []
  items = raw_items.map { |item| hydrate(klass, item) }
  Nfe::ListResponse.new(data: items, page: build_list_page(payload))
end

#multipart_part(name, value) ⇒ String

Encode a single multipart part (scalar field or file).

Parameters:

  • name (String)
  • value (Object)

Returns:

  • (String)


249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/nfe/resources/abstract_resource.rb', line 249

def multipart_part(name, value)
  part = String.new(encoding: Encoding::ASCII_8BIT)
  if value.is_a?(Hash)
    filename = value[:filename] || value["filename"] || "file"
    content_type = value[:content_type] || value["content_type"] || "application/octet-stream"
    content = value[:content] || value["content"] || ""
    part << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"\r\n).b
    part << "Content-Type: #{content_type}\r\n\r\n".b
    part << content.dup.force_encoding(Encoding::ASCII_8BIT)
  else
    part << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n).b
    part << value.to_s.dup.force_encoding(Encoding::ASCII_8BIT)
  end
  part << "\r\n".b
  part
end

#page_list_page(page_index, page_count, total) ⇒ Nfe::ListPage

Parameters:

  • page_index (Object)
  • page_count (Object)
  • total (Object)

Returns:



171
172
173
# File 'lib/nfe/resources/abstract_resource.rb', line 171

def page_list_page(page_index, page_count, total)
  Nfe::ListPage.new(page_index: page_index, page_count: page_count, total: total)
end

#parse_json(body) ⇒ Object

Parse a JSON body, tolerating nil/empty/malformed bodies.

Parameters:

  • body (String, nil)

Returns:

  • (Object)


191
192
193
194
195
196
197
# File 'lib/nfe/resources/abstract_resource.rb', line 191

def parse_json(body)
  return nil if body.nil? || body.empty?

  JSON.parse(body)
rescue JSON::ParserError
  nil
end

#post(path, body: nil, query: {}, request_options: nil, headers: {}, idempotency_key: nil) ⇒ Nfe::Http::Response

Issue a POST request for this resource's family.

Parameters:

  • path (String)
  • body: (Object) (defaults to: nil)
  • query: (Hash[untyped, untyped]) (defaults to: {})
  • request_options: (Nfe::RequestOptions, nil) (defaults to: nil)
  • headers: (Hash[untyped, untyped]) (defaults to: {})
  • idempotency_key: (String, nil) (defaults to: nil)

Returns:



57
58
59
60
61
62
63
# File 'lib/nfe/resources/abstract_resource.rb', line 57

def post(path, body: nil, query: {}, request_options: nil, headers: {},
         idempotency_key: nil)
  client.request(:post, family: api_family, path: full_path(path),
                        query: query, body: body, headers: headers,
                        idempotency_key: idempotency_key,
                        request_options: request_options)
end

#put(path, body: nil, query: {}, request_options: nil, headers: {}) ⇒ Nfe::Http::Response

Issue a PUT request for this resource's family.

Parameters:

  • path (String)
  • body: (Object) (defaults to: nil)
  • query: (Hash[untyped, untyped]) (defaults to: {})
  • request_options: (Nfe::RequestOptions, nil) (defaults to: nil)
  • headers: (Hash[untyped, untyped]) (defaults to: {})

Returns:



66
67
68
69
70
# File 'lib/nfe/resources/abstract_resource.rb', line 66

def put(path, body: nil, query: {}, request_options: nil, headers: {})
  client.request(:put, family: api_family, path: full_path(path),
                       query: query, body: body, headers: headers,
                       request_options: request_options)
end

#unwrap(payload, key) ⇒ Object

Unwrap an API envelope: return payload[key] when present (String or Symbol key), otherwise return payload unchanged. Tolerant of a missing envelope so callers can pass a wrapped or already-unwrapped Hash.

Parameters:

  • payload (Hash, nil)

    the parsed response body.

  • key (String, Symbol)

    the envelope key (e.g. "companies").

Returns:

  • (Object)

    the unwrapped value, or payload.



206
207
208
209
210
211
212
213
214
215
216
# File 'lib/nfe/resources/abstract_resource.rb', line 206

def unwrap(payload, key)
  return payload unless payload.is_a?(Hash)

  if payload.key?(key.to_s)
    payload[key.to_s]
  elsif payload.key?(key.to_sym)
    payload[key.to_sym]
  else
    payload
  end
end

#upload_multipart(path, parts) ⇒ Nfe::Http::Response

POST a multipart/form-data body built from parts using only Ruby stdlib, introducing no new runtime dependency. Each part is either a scalar field value (+String+) or a file part described by a Hash with :filename, :content, and optional :content_type.

The body is assembled as binary (+ASCII-8BIT+) so certificate bytes are never corrupted, and the Content-Type header carries the generated boundary. The multipart body is intentionally never logged.

Parameters:

  • path (String)

    request path (already family-relative).

  • parts (Hash{String,Symbol => String, Hash})

    form fields/files.

Returns:



230
231
232
233
234
235
# File 'lib/nfe/resources/abstract_resource.rb', line 230

def upload_multipart(path, parts)
  boundary = "----NfeBoundary#{parts.object_id}"
  body = build_multipart_body(parts, boundary)
  headers = { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
  post(path, body: body, headers: headers)
end