Module: Scanii::Multipart

Defined in:
lib/scanii/multipart.rb

Overview

Hand-rolled multipart/form-data encoder (RFC 7578).

Ruby’s stdlib Net::HTTP does not bundle a multipart encoder; this is the smallest viable implementation that covers the Scanii POST /files payload.

Class Method Summary collapse

Class Method Details

.guess_content_type(filename) ⇒ Object

Best-effort content-type lookup by filename extension. Falls back to application/octet-stream. The Scanii API does not require an accurate content-type on the multipart part – the server inspects the bytes – so a short table is sufficient.



59
60
61
62
# File 'lib/scanii/multipart.rb', line 59

def guess_content_type(filename)
  ext = File.extname(filename.to_s).delete_prefix(".").downcase
  MIME_TYPES.fetch(ext, "application/octet-stream")
end

.make_boundaryObject

Generate a unique multipart boundary.



13
14
15
# File 'lib/scanii/multipart.rb', line 13

def make_boundary
  "----scanii-ruby-boundary-#{SecureRandom.hex(16)}"
end

.make_content_type(boundary) ⇒ Object

Build the Content-Type header value for a request using boundary.



18
19
20
# File 'lib/scanii/multipart.rb', line 18

def make_content_type(boundary)
  "multipart/form-data; boundary=#{boundary}"
end

.stream_encode(fields, io, filename, content_type = nil, file_field: "file") ⇒ Array(ChainedIO, String, Integer)

Encode a multipart body as a streaming ChainedIO.

Builds the RFC 7578 prologue and epilogue as binary Strings, chains them around the caller’s IO, and returns the triple required for Net::HTTP body_stream= uploads. The caller’s IO is never read here – only when Net::HTTP reads from the returned ChainedIO.

Parameters:

  • fields (Hash{String=>String})

    text form fields (e.g. metadata=v, callback)

  • io (#read, #size)

    IO-like object (anything responding to read(n))

  • filename (String)

    filename for the file part

  • content_type (String, nil) (defaults to: nil)

    content-type of the file part; falls back to extension lookup

  • file_field (String) (defaults to: "file")

    name of the file form field; default “file”

Returns:

  • (Array(ChainedIO, String, Integer))
    body_stream, content_type_header, content_length


35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/scanii/multipart.rb', line 35

def stream_encode(fields, io, filename, content_type = nil, file_field: "file")
  boundary = make_boundary
  ct = content_type || guess_content_type(filename)

  prologue = String.new(encoding: Encoding::BINARY)
  fields.each do |name, value|
    write_text_part(prologue, boundary, name.to_s, value.to_s)
  end
  prologue << "--#{boundary}\r\n".b
  prologue << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"\r\n".b
  prologue << "Content-Type: #{ct}\r\n\r\n".b

  epilogue = "\r\n--#{boundary}--\r\n".b

  io_size = io_remaining_bytes(io)
  total_length = prologue.bytesize + io_size + epilogue.bytesize

  [ChainedIO.new(prologue, io, epilogue), make_content_type(boundary), total_length]
end