docs/guides/file_uploads.md

# Multipart File Uploads

Tinkex can now build multipart/form-data requests without any external helpers. Pass a `:files` option to Tinkex.API.post/3 and the SDK will:

- Normalize file inputs (binaries, iodata, `File.Stream`, or file paths) and read paths into memory
- Flatten the request body into form fields using bracket notation (e.g., `%{foo: %{bar: 1}}` → `"foo[bar]" => "1"`)
- Generate multipart boundaries and set `Content-Type` automatically (respects a caller-provided `boundary` if present)
- Preserve tuple metadata `(filename, content, content_type, headers)` per Python SDK parity

> Requires only `TINKER_API_KEY` and works against any Tinker endpoint that expects multipart bodies.

## Supported file inputs

You can supply files as a map or list (mirrors Python’s `files` API):

- Raw binaries or iodata: `%{"upload" => "hello"}`, `%{"upload" => <<1, 2, 3>>}`
- File paths: `%{"upload" => "/tmp/file.txt"}` (Tinkex reads the file and uses the basename as filename)
- Tuples with metadata:
  - `{filename, content}`
  - `{filename, content, content_type}`
  - `{filename, content, content_type, headers}` (headers can be a map or list of `{key, value}`)

## Quickstart

```elixir
{:ok, _} = Application.ensure_all_started(:tinkex)

config = Tinkex.Config.new(
  api_key: System.fetch_env!("TINKER_API_KEY"),
  base_url: System.get_env("TINKER_BASE_URL", "https://tinker.thinkingmachines.dev/services/tinker-prod")
)

files = %{
  "file" =>
    System.get_env("TINKER_UPLOAD_FILE") ||
      "examples/uploads/sample_upload.bin"
}

body = %{note: "multipart demo"}

upload_path = "/"

case Tinkex.API.post(upload_path, body, config: config, files: files) do
  {:ok, response} -> IO.inspect(response, label: "upload response")
  {:error, error} -> IO.puts("Upload failed: #{inspect(error)}")
end
```

If you need to preview what will be sent, you can use the lower-level helpers directly:

```elixir
{:ok, normalized} = Tinkex.Files.Transform.transform_files(files)
form_fields = Tinkex.Multipart.FormSerializer.serialize_form_fields(body)
{:ok, _body, content_type} = Tinkex.Multipart.Encoder.encode_multipart(form_fields, normalized)
IO.puts("Multipart Content-Type: #{content_type}")
```

## Header behavior

- If `:files` are provided (or you set `content-type: multipart/form-data`), Tinkex switches to multipart encoding and replaces the Content-Type header with one that includes the boundary (unless you already set a boundary).
- If no files are provided, JSON encoding is preserved exactly as before.

## Troubleshooting

- 415/422 responses usually mean the endpoint rejected the payload; check that the path expects multipart and that filenames/content-types match what the API requires.
- Paths are read eagerly into memory to match the Python SDK; for large payloads consider chunked uploads via a dedicated endpoint when available.