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 - **File stream inputs**: Accepts `File.Stream` values when building multipart bodies - **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. ## 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