defmodule HTTP.FormData do
@moduledoc """
HTTP form data and multipart/form-data encoding for file uploads.
This module provides a convenient API for building form submissions with support
for both URL-encoded forms and multipart file uploads. It automatically chooses
the appropriate encoding based on the presence of file fields.
## Encoding Selection
- **URL-encoded** (`application/x-www-form-urlencoded`): Used when form contains only text fields
- **Multipart** (`multipart/form-data`): Used when form contains file fields
## Features
- **Streaming file uploads**: Efficiently upload large files using `File.Stream`
- **Automatic encoding**: Selects appropriate encoding based on content
- **Boundary generation**: Automatically generates unique multipart boundaries
- **Mixed content**: Support for both text fields and files in the same form
## Basic Usage
# Simple form with text fields
form = HTTP.FormData.new()
|> HTTP.FormData.append_field("username", "john_doe")
|> HTTP.FormData.append_field("email", "john@example.com")
HTTP.fetch("https://api.example.com/signup", method: "POST", body: form)
## File Upload
# Single file upload
file_stream = File.stream!("document.pdf")
form = HTTP.FormData.new()
|> HTTP.FormData.append_field("title", "My Document")
|> HTTP.FormData.append_file("document", "document.pdf", file_stream, "application/pdf")
HTTP.fetch("https://api.example.com/upload", method: "POST", body: form)
## Multiple Files
# Upload multiple files
form = HTTP.FormData.new()
|> HTTP.FormData.append_field("description", "Photos from vacation")
|> HTTP.FormData.append_file("photo1", "beach.jpg", File.stream!("beach.jpg"), "image/jpeg")
|> HTTP.FormData.append_file("photo2", "sunset.jpg", File.stream!("sunset.jpg"), "image/jpeg")
HTTP.fetch("https://api.example.com/gallery", method: "POST", body: form)
## Content Types
When uploading files, you can specify the MIME type. If not provided, it defaults
to `"application/octet-stream"`:
# With explicit content type
form |> HTTP.FormData.append_file("image", "photo.jpg", stream, "image/jpeg")
# With default content type
form |> HTTP.FormData.append_file("data", "data.bin", stream)
"""
defstruct parts: [],
boundary: nil
@type form_part ::
{:field, String.t(), String.t()}
| {:file, String.t(), String.t(), String.t(), File.Stream.t()}
| {:file, String.t(), String.t(), String.t(), String.t()}
@type t :: %__MODULE__{
parts: [form_part()],
boundary: String.t() | nil
}
@doc """
Creates a new empty FormData struct.
## Examples
iex> HTTP.FormData.new()
%HTTP.FormData{parts: [], boundary: nil}
"""
@spec new() :: t()
def new, do: %__MODULE__{parts: [], boundary: nil}
@doc """
Adds a form field.
## Examples
iex> HTTP.FormData.new() |> HTTP.FormData.append_field("name", "value")
%HTTP.FormData{parts: [{:field, "name", "value"}], boundary: nil}
"""
@spec append_field(t(), String.t(), String.t()) :: t()
def append_field(%__MODULE__{parts: parts} = form, name, value) do
%{form | parts: parts ++ [{:field, name, value}]}
end
@doc """
Adds a file field for upload with streaming support.
## Examples
iex> file_stream = File.stream!("test.txt")
iex> HTTP.FormData.new() |> HTTP.FormData.append_file("upload", "test.txt", file_stream)
%HTTP.FormData{parts: [{:file, "upload", "test.txt", "text/plain", %File.Stream{}}], boundary: nil}
"""
@spec append_file(t(), String.t(), String.t(), File.Stream.t() | String.t(), String.t()) :: t()
def append_file(
%__MODULE__{parts: parts} = form,
name,
filename,
content,
content_type \\ "application/octet-stream"
) do
%{form | parts: parts ++ [{:file, name, filename, content_type, content}]}
end
@doc """
Generates a random boundary for multipart/form-data.
"""
@spec generate_boundary() :: String.t()
def generate_boundary do
"--boundary-#{System.unique_integer([:positive])}"
end
@doc """
Converts FormData to HTTP body content with appropriate encoding.
Returns {:url_encoded, body} for regular forms or {:multipart, body, boundary} for multipart.
The multipart body is returned as iodata for memory efficiency with large file uploads.
"""
@spec to_body(t()) :: {:url_encoded, String.t()} | {:multipart, iodata(), String.t()}
def to_body(%__MODULE__{parts: parts} = form) do
has_file? =
Enum.any?(parts, fn
{:file, _, _, _, %File.Stream{}} -> true
{:file, _, _, _, _} -> true
_ -> false
end)
if has_file? do
encode_multipart(form)
else
encode_url_encoded(form)
end
end
@doc """
Gets the appropriate Content-Type header for the form data.
"""
@spec get_content_type(t()) :: String.t()
def get_content_type(%__MODULE__{parts: parts}) do
has_file? =
Enum.any?(parts, fn
{:file, _, _, _, %File.Stream{}} -> true
{:file, _, _, _, _} -> true
_ -> false
end)
if has_file? do
boundary = generate_boundary()
"multipart/form-data; boundary=#{boundary}"
else
"application/x-www-form-urlencoded"
end
end
defp encode_url_encoded(%__MODULE__{parts: parts}) do
encoded =
parts
|> Enum.filter(fn
{:field, _, _} -> true
_ -> false
end)
|> Enum.map_join("&", fn {:field, name, value} ->
URI.encode_www_form(name) <> "=" <> URI.encode_www_form(value)
end)
{:url_encoded, encoded}
end
defp encode_multipart(%__MODULE__{parts: parts} = form) do
boundary = form.boundary || generate_boundary()
body_parts =
parts
|> Enum.flat_map(fn
{:field, name, value} ->
[encode_multipart_field(boundary, name, value), "\r\n"]
{:file, name, filename, content_type, %File.Stream{} = stream} ->
[encode_multipart_file_stream(boundary, name, filename, content_type, stream), "\r\n"]
{:file, name, filename, content_type, content} ->
[encode_multipart_file_content(boundary, name, filename, content_type, content), "\r\n"]
end)
# Build as iodata list for memory efficiency
body = [body_parts, "--", boundary, "--\r\n"]
{:multipart, body, boundary}
end
defp encode_multipart_field(boundary, name, value) do
[
"--#{boundary}\r\n",
"Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n",
value
]
end
defp encode_multipart_file_content(boundary, name, filename, content_type, content) do
[
"--#{boundary}\r\n",
"Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n",
"Content-Type: #{content_type}\r\n\r\n",
content
]
end
defp encode_multipart_file_stream(
boundary,
name,
filename,
content_type,
%File.Stream{} = stream
) do
# Reopen default line-mode File.Streams in byte chunks so arbitrary binary
# uploads are not interpreted as lines.
content_chunks =
stream
|> binary_file_stream()
|> Enum.to_list()
encode_multipart_file_content(boundary, name, filename, content_type, content_chunks)
end
defp binary_file_stream(%File.Stream{line_or_bytes: line_or_bytes} = stream)
when is_integer(line_or_bytes) do
stream
end
defp binary_file_stream(%File.Stream{} = stream) do
File.stream!(stream.path, 2048, stream.modes)
end
end