require "./formdata/**" require "mime/multipart" # Contains utilities for parsing `multipart/form-data` messages, which are # commonly used for encoding HTML form data. # # NOTE: To use `FormData`, you must explicitly import it with `require "http"` # # ### Examples # # Commonly, you'll want to parse a from response from a HTTP request, and # process it. An example server which performs this task is shown below. # # ``` # require "http" # # server = HTTP::Server.new do |context| # name = nil # file = nil # HTTP::FormData.parse(context.request) do |part| # case part.name # when "name" # name = part.body.gets_to_end # when "file" # file = File.tempfile("upload") do |file| # IO.copy(part.body, file) # end # end # end # # unless name && file # context.response.respond_with_status(:bad_request) # next # end # # context.response << file.path # end # # server.bind_tcp 8085 # server.listen # ``` # # To test the server, use the curl command below. # # ```console # $ curl http://localhost:8085/ -F name=foo -F file=@/path/to/test.file # /tmp/upload.Yxn7cc # ``` # # Another common case is sending formdata to a server using HTTP::Client. Here # is an example showing how to upload a file to the server above in crystal. # # ``` # require "http" # # IO.pipe do |reader, writer| # channel = Channel(String).new(1) # # spawn do # HTTP::FormData.build(writer) do |formdata| # channel.send(formdata.content_type) # # formdata.field("name", "foo") # File.open("foo.png") do |file| # metadata = HTTP::FormData::FileMetadata.new(filename: "foo.png") # headers = HTTP::Headers{"Content-Type" => "image/png"} # formdata.file("file", file, metadata, headers) # end # end # # writer.close # end # # headers = HTTP::Headers{"Content-Type" => channel.receive} # response = HTTP::Client.post("http://localhost:8085/", body: reader, headers: headers) # # puts "Response code #{response.status_code}" # puts "File path: #{response.body}" # end # ``` module HTTP::FormData # Parses a multipart/form-data message, yielding a `FormData::Part`. # # ``` # require "http" # # form_data = "--aA40\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nfield data\r\n--aA40--" # HTTP::FormData.parse(IO::Memory.new(form_data), "aA40") do |part| # part.name # => "field1" # part.body.gets_to_end # => "field data" # end # ``` # # See: `FormData::Parser` def self.parse(io, boundary, &) parser = Parser.new(io, boundary) while parser.has_next? parser.next { |part| yield part } end end # Parses a multipart/form-data message, yielding a `FormData::Part`. # # ``` # require "http" # # headers = HTTP::Headers{"Content-Type" => "multipart/form-data; boundary=aA40"} # body = "--aA40\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nfield data\r\n--aA40--" # request = HTTP::Request.new("POST", "/", headers, body) # # HTTP::FormData.parse(request) do |part| # part.name # => "field1" # part.body.gets_to_end # => "field data" # end # ``` # # See: `FormData::Parser` def self.parse(request : HTTP::Request, &) body = request.body raise Error.new "Cannot extract form-data from HTTP request: body is empty" unless body boundary = request.headers["Content-Type"]?.try { |header| MIME::Multipart.parse_boundary(header) } raise Error.new "Cannot extract form-data from HTTP request: could not find boundary in Content-Type" unless boundary parse(body, boundary) { |part| yield part } end # Parses a `Content-Disposition` header string into a field name and # `FileMetadata`. Please note that the `Content-Disposition` header for # `multipart/form-data` is not compatible with the original definition in # [RFC 2183](https://tools.ietf.org/html/rfc2183), but are instead specified # in [RFC 2388](https://tools.ietf.org/html/rfc2388). def self.parse_content_disposition(content_disposition : String) : {String, FileMetadata} filename = nil creation_time = nil modification_time = nil read_time = nil size = nil name = nil parts = content_disposition.split(';') type = parts.shift? raise Error.new("Invalid Content-Disposition: not form-data") unless type == "form-data" parts.each do |part| key, _, value = part.partition('=') key = key.strip value = value.strip if value[0] == '"' value = HTTP.dequote_string(value[1...-1]) end case key when "filename" filename = value when "creation-date" creation_time = HTTP.parse_time value when "modification-date" modification_time = HTTP.parse_time value when "read-date" read_time = HTTP.parse_time value when "size" size = value.to_u64 when "name" name = value else # not a field we are interested in end end raise Error.new("Invalid Content-Disposition: no name field") unless name {name, FileMetadata.new(filename, creation_time, modification_time, read_time, size)} end # Builds a multipart/form-data message, yielding a `FormData::Builder` # object to the block which writes to *io* using *boundary*. # `Builder#finish` is called on the builder when the block returns. # # ``` # require "http" # # io = IO::Memory.new # HTTP::FormData.build(io, "boundary") do |builder| # builder.field("foo", "bar") # end # io.to_s # => "--boundary\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nbar\r\n--boundary--" # ``` # # See: `FormData::Builder` def self.build(io, boundary = MIME::Multipart.generate_boundary, &) builder = Builder.new(io, boundary) yield builder builder.finish end # Builds a multipart/form-data message, yielding a `FormData::Builder` # object to the block which writes to *response* using *boundary. # Content-Type is set on *response* and `Builder#finish` is called on the # builder when the block returns. # # ``` # require "http" # # io = IO::Memory.new # response = HTTP::Server::Response.new io # HTTP::FormData.build(response, "boundary") do |builder| # builder.field("foo", "bar") # end # response.close # # response.headers["Content-Type"] # => "multipart/form-data; boundary=\"boundary\"" # io.to_s # => "HTTP/1.1 200 OK\r\nContent-Type: multipart/form-data; boundary=\"boundary\"\r\nContent-Length: 75\r\n\r\n--boundary\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nbar\r\n--boundary--" # ``` # # See: `FormData::Builder` def self.build(response : HTTP::Server::Response, boundary = MIME::Multipart.generate_boundary, &) builder = Builder.new(response, boundary) yield builder builder.finish response.headers["Content-Type"] = builder.content_type end # Metadata which may be available for uploaded files. record FileMetadata, filename : String? = nil, creation_time : Time? = nil, modification_time : Time? = nil, read_time : Time? = nil, size : UInt64? = nil class Error < Exception end end