MultipartEx
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
- Explicit file handling. Bare strings are never treated as paths.
- Flexible serialization for nested maps and lists.
- Ordered pair lists stay ordered, including duplicate keys.
-
Manual
%Multipart{}and embedded%Multipart.Part{}values are supported. - Boundaries are validated and remain stable even for manually-built structs without a boundary.
- Part headers and disposition parameters reject control characters to prevent multipart header injection.
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 -> []
endMultipart.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
- Multipart boundaries are validated against a conservative RFC 2046-safe character set.
- Header names, header values, field names, and filenames reject control characters.
- Bare strings are treated as field values, never as filesystem paths.
Documentation
Generate local docs with:
mix docs
HexDocs navigation includes the README, focused guides under guides/, grouped
API modules, and the changelog.