multipart_form_data.pony

use "format"
use "random"
use "time"

class ref MultipartFormData
  """
  Build a `multipart/form-data` body for file uploads and mixed form
  submissions (RFC 7578).

  Use `field()` for plain text values and `file()` for file attachments.
  After adding all parts, pass the builder to `multipart_body()` on the
  request builder, which sets both the body and `Content-Type` header.

  For simple key-value form data without files, use `FormEncoder` with
  `form_body()` instead — it produces the more compact
  `application/x-www-form-urlencoded` format.

  ```pony
  let form = MultipartFormData
    .> field("username", "alice")
    .> file("avatar", "photo.jpg", "image/jpeg", image_data)
  let req = Request.post("/upload")
    .multipart_body(form)
    .build()
  ```
  """
  let _boundary: String
  embed _parts: Array[_MultipartPart]

  new ref create() =>
    """
    Create a new builder with a randomly generated boundary string.
    """
    _parts = Array[_MultipartPart]
    (let secs, let nanos) = Time.now()
    let rand = Rand(secs.u64(), nanos.u64())
    let a = rand.next()
    let b = rand.next()
    _boundary = "----pony" +
      Format.int[U64](a, FormatHexSmallBare where width = 16, fill = '0') +
      Format.int[U64](b, FormatHexSmallBare where width = 16, fill = '0')

  fun ref field(name: String, value: String): MultipartFormData ref =>
    """
    Add a text field to the form.

    `"` and `\` in the name are automatically backslash-escaped in the
    serialized `Content-Disposition` quoted-string.
    """
    _parts.push(_MultipartField(name, value))
    this

  fun ref file(
    name: String,
    filename: String,
    file_content_type: String,
    data: Array[U8] val)
    : MultipartFormData ref
  =>
    """
    Add a file attachment to the form.

    `"` and `\` in the name and filename are automatically backslash-escaped
    in the serialized `Content-Disposition` quoted-string.
    """
    _parts.push(_MultipartFile(name, filename, file_content_type, data))
    this

  fun content_type(): String =>
    """
    Return the `Content-Type` header value including the boundary parameter.

    Pass this to the request's `Content-Type` header so the server knows how
    to parse the body. The `multipart_body()` method on the request builder
    does this automatically.
    """
    "multipart/form-data; boundary=" + _boundary

  fun body(): Array[U8] val =>
    """
    Serialize all parts into the `multipart/form-data` wire format.

    Each part is delimited by the boundary string. Field parts include a
    `Content-Disposition` header; file parts additionally include a `filename`
    parameter and a `Content-Type` header. The body ends with a closing
    boundary.

    Field names and filenames are backslash-escaped (`"` becomes `\"`, `\`
    becomes `\\`) within `Content-Disposition` quoted-strings.
    """
    let buf = recover iso Array[U8] end
    for part in _parts.values() do
      buf.append("--")
      buf.append(_boundary)
      buf.append("\r\n")
      match \exhaustive\ part
      | let f: _MultipartField val =>
        buf.append("Content-Disposition: form-data; name=\"")
        buf.append(_escape_quoted(f.name))
        buf.append("\"\r\n\r\n")
        buf.append(f.value)
      | let f: _MultipartFile val =>
        buf.append("Content-Disposition: form-data; name=\"")
        buf.append(_escape_quoted(f.name))
        buf.append("\"; filename=\"")
        buf.append(_escape_quoted(f.filename))
        buf.append("\"\r\nContent-Type: ")
        buf.append(f.content_type)
        buf.append("\r\n\r\n")
        buf.append(f.data)
      end
      buf.append("\r\n")
    end
    buf.append("--")
    buf.append(_boundary)
    buf.append("--\r\n")
    consume buf

  fun _escape_quoted(input: String): String iso^ =>
    let buf = recover iso String(input.size()) end
    for byte in input.values() do
      if (byte == '"') or (byte == '\\') then
        buf.push('\\')
      end
      buf.push(byte)
    end
    consume buf