module HTTP::FormData # Builds a multipart/form-data message. # # ### Example # # ``` # require "http" # # io = IO::Memory.new # builder = HTTP::FormData::Builder.new(io, "aA47") # builder.field("name", "joe") # file = IO::Memory.new("file contents") # builder.file("upload", file, HTTP::FormData::FileMetadata.new(filename: "test.txt")) # builder.finish # io.to_s # => "--aA47\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\njoe\r\n--aA47\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"test.txt\"\r\n\r\nfile contents\r\n--aA47--" # ``` class Builder private enum State START FIELD FINISHED end # Creates a new `FormData::Builder` which writes to *io*, using the # multipart boundary *boundary*. def initialize(@io : IO, @boundary = MIME::Multipart.generate_boundary) @state = State::START end getter boundary # Returns a content type header with correct boundary parameter. # # ``` # builder = HTTP::FormData::Builder.new(io, "a4VF") # builder.content_type # => "multipart/form-data; boundary=\"a4VF\"" # ``` def content_type : String String.build do |str| str << "multipart/form-data; boundary=\"" HTTP.quote_string(@boundary, str) str << '"' end end # Adds a form part with the given *name* and *value*. *Headers* can # optionally be provided for the form part. def field(name : String, value : _, headers : HTTP::Headers = HTTP::Headers.new) : Nil file(name, IO::Memory.new(value.to_s), headers: headers) end # Adds a form part called *name*, with data from *io* as the value. # *Metadata* can be provided to add extra metadata about the file to the # Content-Disposition header for the form part. Other headers can be added # using *headers*. def file(name : String, io : IO, metadata : FileMetadata = FileMetadata.new, headers : HTTP::Headers = HTTP::Headers.new) : Nil fail "Cannot add form part: already finished" if @state.finished? headers["Content-Disposition"] = generate_content_disposition(name, metadata) # We don't add a crlf before the first boundary if this is the first body part. @io << "\r\n" unless @state.start? @io << "--" << @boundary headers.each do |name, values| values.each do |value| @io << "\r\n" << name << ": " << value end end @io << "\r\n\r\n" IO.copy(io, @io) @state = State::FIELD end # Finalizes the multipart message, this method must be called before the # generated multipart message written to the IO is considered valid. def finish : Nil case @state in .start? fail "Cannot finish form-data: no body parts" in .finished? fail "Cannot finish form-data: already finished" in .field? @io << "\r\n--" << @boundary << "--" @state = State::FINISHED end end private def generate_content_disposition(name, metadata) String.build do |io| io << "form-data; name=\"" HTTP.quote_string(name, io) io << '"' if filename = metadata.filename io << "; filename=\"" HTTP.quote_string(filename, io) io << '"' end if creation_time = metadata.creation_time io << %(; creation-date=") creation_time.to_s(io, "%a, %d %b %Y %H:%M:%S %z") io << '"' end if modification_time = metadata.modification_time io << %(; modification-date=") modification_time.to_s(io, "%a, %d %b %Y %H:%M:%S %z") io << '"' end if read_time = metadata.read_time io << %(; read-date=") read_time.to_s(io, "%a, %d %b %Y %H:%M:%S %z") io << '"' end if size = metadata.size io << %(; size=) << size end end end private def fail(msg) raise FormData::Error.new(msg) end end end