multipart_ex

MultipartEx

Hex.pmHexDocsLicense

Client-agnostic multipart/form-data construction for Elixir.

multipart_ex builds multipart payloads from plain forms or manual %Multipart{} and %Multipart.Part{} structs, keeps file inputs explicit, and emits either iodata or streams depending on the parts involved.

Installation

Add multipart_ex to your dependencies:

def deps do
  [
    {:multipart_ex, "~> 0.1.0"}
  ]
end

The optional mime dependency improves Content-Type inference for file parts.

Quick Start

form = %{
  "name" => "Ada",
  "avatar" => {:path, "/tmp/avatar.png"},
  "meta" => %{"tags" => ["engineer", "tester"]}
}

multipart = Multipart.from_form(form)
headers = Multipart.Encoder.headers(multipart)
{content_type, body} = Multipart.Encoder.encode(multipart)

If every part is in memory, body is iodata. If any part is streamed, body is a stream.

Core Features

File Inputs

Use one of the explicit file shapes:

%{avatar: {:path, "/tmp/avatar.png"}}
%{avatar: {:content, File.read!("/tmp/avatar.png"), "avatar.png"}}
%{avatar: {"avatar.png", "raw-bytes"}}
%{avatar: {"avatar.png", "raw-bytes", "image/png"}}
%{avatar: {"avatar.png", "raw-bytes", "image/png", [{"x-upload-id", "123"}]}}

Serialization

Multipart.Form.serialize/2 supports three strategies:

Multipart.Form.serialize(%{user: %{name: "Sam"}}, strategy: :bracket)
# => [{"user[name]", "Sam"}]

Multipart.Form.serialize(%{user: %{name: "Sam"}}, strategy: :dot)
# => [{"user.name", "Sam"}]

Multipart.Form.serialize(%{name: "Sam"}, strategy: :flat)
# => [{"name", "Sam"}]

List handling for dot strategy:

Multipart.Form.serialize(%{roles: ["admin", "staff"]}, strategy: :dot)
# => [{"roles", "admin"}, {"roles", "staff"}]

Multipart.Form.serialize(%{roles: ["admin", "staff"]},
  strategy: :dot,
  list_format: :index
)
# => [{"roles[0]", "admin"}, {"roles[1]", "staff"}]

Nil handling:

Multipart.Form.serialize(%{name: nil, age: "10"}, nil: :skip)
Multipart.Form.serialize(%{name: nil, age: "10"}, nil: :empty)

Ordered pair lists are preserved exactly:

Multipart.Form.serialize([{"beta", "2"}, {"alpha", "1"}, {"beta", "3"}])
# => [{"beta", "2"}, {"alpha", "1"}, {"beta", "3"}]

Struct values are treated as leaves. This keeps scalar structs like Date intact and lets %Multipart.Part{} values pass through to to_parts/2.

Manual Parts And Multiparts

You can embed parts directly in a form:

form = %{
  metadata: "visible",
  attachment: Multipart.Part.file("attachment", {"report.txt", "hello"})
}

multipart = Multipart.from_form(form)

Or assemble the multipart payload yourself:

parts = [
  Multipart.Part.form("name", "Ada"),
  Multipart.Part.file("avatar", {:path, "/tmp/avatar.png"})
]

multipart = %Multipart{parts: parts}
{content_type, body} = Multipart.Encoder.encode(multipart)

If a manual %Multipart{} omits the boundary, the library derives a stable, validated boundary from the struct so headers and body stay consistent.

Content Length

Multipart.Encoder.content_length/1 returns {:ok, length} when every part size is known, otherwise :unknown.

case Multipart.Encoder.content_length(multipart) do
  {:ok, length} -> [{"content-length", Integer.to_string(length)}]
  :unknown -> []
end

Multipart.Encoder.headers/1 includes content-length automatically when it can be computed.

Adapters

Finch

{headers, {:stream, body}} = Multipart.Adapter.Finch.build(form)
Finch.build(:post, "https://example.com/upload", headers, body)

Req

opts = Multipart.Adapter.Req.request_opts(form)
Req.post!("https://example.com/upload", opts)

Or attach via a step:

step = Multipart.Adapter.Req.step(form)
request = step.(%{headers: []})

Safety Notes

Documentation

Generate local docs with:

mix docs

HexDocs navigation includes the README, focused guides under guides/, grouped API modules, and the changelog.