Packages
multipart_ex
0.1.0
Client-agnostic multipart/form-data builder with explicit file handling and streaming encoding. Safe, predictable file inputs and configurable serialization strategies for complex forms. Adapters for common HTTP clients provided without coupling the core to any specific client.
Current section
Files
Jump to
Current section
Files
multipart_ex
README.md
README.md
<p align="center">
<img src="assets/multipart_ex.svg" alt="multipart_ex" width="400" />
</p>
# MultipartEx
<p align="center">
<a href="https://hex.pm/packages/multipart_ex"><img alt="Hex.pm" src="https://img.shields.io/hexpm/v/multipart_ex.svg"></a>
<a href="https://hexdocs.pm/multipart_ex"><img alt="HexDocs" src="https://img.shields.io/badge/docs-hexdocs-blue.svg"></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg"></a>
</p>
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:
```elixir
def deps do
[
{:multipart_ex, "~> 0.1.0"}
]
end
```
The optional `mime` dependency improves `Content-Type` inference for file parts.
## Quick Start
```elixir
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:
```elixir
%{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:
```elixir
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:
```elixir
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:
```elixir
Multipart.Form.serialize(%{name: nil, age: "10"}, nil: :skip)
Multipart.Form.serialize(%{name: nil, age: "10"}, nil: :empty)
```
Ordered pair lists are preserved exactly:
```elixir
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:
```elixir
form = %{
metadata: "visible",
attachment: Multipart.Part.file("attachment", {"report.txt", "hello"})
}
multipart = Multipart.from_form(form)
```
Or assemble the multipart payload yourself:
```elixir
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`.
```elixir
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
```elixir
{headers, {:stream, body}} = Multipart.Adapter.Finch.build(form)
Finch.build(:post, "https://example.com/upload", headers, body)
```
### Req
```elixir
opts = Multipart.Adapter.Req.request_opts(form)
Req.post!("https://example.com/upload", opts)
```
Or attach via a step:
```elixir
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:
```bash
mix docs
```
HexDocs navigation includes the README, focused guides under `guides/`, grouped
API modules, and the changelog.