std.multipart
Multipart bodies (RFC 2046 §5.1), and forms sent as one, form-data
(RFC 7578): a body read into its parts (parse) and written of them
(render), each part its headers and its body. The boundary is a
Content-Type’s (boundaryOf); a part’s form name and file name are
its Content-Disposition’s (formName, fileName). Go’s
mime/multipart is the model: its NextRawPart, as a body is kept as
it is written, whatever its Content-Transfer-Encoding. Import it by
a name, import std.multipart as Multipart.
import bytes as Bytesimport std.multipart as Multipart
-- | >>> fields "multipart/form-data; boundary=X" (Bytes.fromString "--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--X--\r\n")-- | [MkPair "a" "1"]fields : String -> Bytes -> List (String, String)fields contentType body = case Multipart.boundaryOf contentType of Nothing -> Nil Just b -> case Multipart.parse body (boundary := b) of Left e -> Nil Right parts -> List.mapMaybe (\(p : Multipart.Part) -> maybe Nothing (\n -> Just (n, Bytes.asString p.body)) (Multipart.formName p)) partsPart
type Part = { headers : List (String, String), body : Bytes }A part: its headers, a name and a value each, in their order and as they are written (a header is looked for by its name, its case not minded), and its body.
ParseError
type ParseError = { offset : Int, message : String }Where a body is not a multipart one: its byte there, and what is wrong.
boundaryOf
boundaryOf : String -> Maybe StringThe boundary of a Content-Type of a multipart body (multipart/
and any subtype), as std.mime reads it, if it has one, not empty.
>>> boundaryOf "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"Just "----WebKitFormBoundary7MA4YWxkTrZu0gW">>> boundaryOf "Multipart/Mixed; boundary=\"simple boundary\""Just "simple boundary">>> List.map boundaryOf ["text/plain; boundary=x", "multipart/mixed", "multipart/mixed; boundary=\"\"", "multipart/mixed; boundary="][Nothing, Nothing, Nothing, Nothing]parse
parse : Bytes -> (boundary : String) -> Either ParseError (List Part)The parts of a multipart body of the boundary, as Go’s Reader
reads them: its preamble, before the first delimiter, and its
epilogue, after the close delimiter, not read; each part after a
delimiter line, -- and the boundary, its header lines up to an
empty one, and its body up to the line break before the next
delimiter. A delimiter line may have spaces and tabs before its
line break; the close delimiter, -- after it, ends at a line break
or the end. Lines end with a carriage return and a line feed, or a
line feed alone, as the first delimiter line ends (in the headers,
either); a header line beginning with a space or a tab goes on the
one before it, the two joined by a space.
>>> parse (Bytes.fromString "--AaB03x\r\ncontent-disposition: form-data; name=\"field1\"\r\n\r\nJoe Blow\r\n--AaB03x\r\ncontent-disposition: form-data; name=\"pics\"; filename=\"file1.txt\"\r\nContent-Type: text/plain\r\n\r\n ... contents of file1.txt ...\r\n--AaB03x--\r\n") (boundary := "AaB03x")Right [{ headers = [MkPair "content-disposition" "form-data; name=\"field1\""], body = b["Joe Blow"] }, { headers = [MkPair "content-disposition" "form-data; name=\"pics\"; filename=\"file1.txt\"", MkPair "Content-Type" "text/plain"], body = b[" ... contents of file1.txt ..."] }](RFC 2046’s example: a preamble, a part of no headers, a body ended by a line break, an epilogue)
>>> parse (Bytes.fromString "This is the preamble.\r\n--simple boundary\r\n\r\nThis is implicitly typed plain US-ASCII text.\r\n--simple boundary\r\nContent-type: text/plain; charset=us-ascii\r\n\r\nThis DOES end with a linebreak.\r\n\r\n--simple boundary--\r\n\r\nThis is the epilogue.\r\n") (boundary := "simple boundary")Right [{ headers = [], body = b["This is implicitly typed plain US-ASCII text."] }, { headers = [MkPair "Content-type" "text/plain; charset=us-ascii"], body = b["This DOES end with a linebreak.", 13, 10] }](line feeds alone; spaces after a delimiter; a header line folded; an empty body, and one where a delimiter line would follow at once; a boundary’s text in a body, not after a line break or before another byte; no parts)
>>> parse (Bytes.fromString "--b \t\nA: 1\n and 2\n\nx\n--b\nB:2\r\n\r\n\n--b--") (boundary := "b")Right [{ headers = [MkPair "A" "1 and 2"], body = b["x"] }, { headers = [MkPair "B" "2"], body = b[] }]>>> List.map (\(r : Either ParseError (List Part)) -> either (\_ -> Nil) (List.map (\(p : Part) -> Bytes.asString p.body)) r) [parse (Bytes.fromString "--b\r\n\r\n--b\r\n\r\n--b--") (boundary := "b"), parse (Bytes.fromString "--b\r\n\r\nx--b\r\n--bc\r\n--b-\r\n--b--") (boundary := "b"), parse (Bytes.fromString "preamble\r\n--b--\r\n") (boundary := "b")][["", ""], ["x--b\r\n--bc\r\n--b-"], []](where it is not one: no delimiter, no close delimiter, a header line not one, a delimiter line with more after it)
>>> parse (Bytes.fromString "no parts here\r\n") (boundary := "b")Left { offset = 15, message = "missing close delimiter" }>>> parse (Bytes.fromString "--b\r\n\r\nbody\r\n--b\r\n") (boundary := "b")Left { offset = 18, message = "missing close delimiter" }>>> parse (Bytes.fromString "--b\r\nA: 1\r\nno colon\r\n\r\n\r\n--b--") (boundary := "b")Left { offset = 11, message = "malformed MIME header line" }>>> List.map (\s -> parse (Bytes.fromString s) (boundary := "b")) ["--b\r\n A: 1\r\n\r\n\r\n--b--", "--b\r\nA B: 1\r\n\r\n\r\n--b--"][Left { offset = 5, message = "malformed MIME header initial line" }, Left { offset = 5, message = "malformed MIME header line" }]>>> List.map (\s -> parse (Bytes.fromString s) (boundary := "b")) ["--b\r\n\r\nx\r\n--b x\r\n", "--b\r\n\r\nx\r\n--b--x", "--b\n\nx\n--b\r\n\n--b--"][Left { offset = 14, message = "malformed delimiter line" }, Left { offset = 15, message = "malformed delimiter line" }, Left { offset = 10, message = "malformed delimiter line" }]render
render : List Part -> (boundary : String) -> BytesA multipart body of the parts and the boundary, as Go’s Writer
writes one: each part after a delimiter line, -- and the boundary,
its headers in their order, Name: value, an empty line and its
body, a line break after it; then the close delimiter, -- after
the boundary, and a line break — each line break a carriage return
and a line feed. A header’s value is written as it is: one with a
line break in it, or a body with a delimiter line, does not read back.
>>> Bytes.asString (render [{ headers = [("Content-Disposition", "form-data; name=\"a\"")], body = Bytes.fromString "1" }, { headers = Nil, body = Bytes.empty }] (boundary := "X"))"--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--X\r\n\r\n\r\n--X--\r\n">>> Bytes.asString (render Nil (boundary := "X"))"--X--\r\n"(what it writes, parse reads back)
>>> parse (render [{ headers = [("Content-Disposition", "form-data; name=\"f\"; filename=\"a.bin\""), ("Content-Type", "application/octet-stream")], body = Bytes.fromList [0, 13, 10, 45, 45, 255] }, { headers = [("X", "")], body = Bytes.fromString "\r\n" }] (boundary := "b0und")) (boundary := "b0und")Right [{ headers = [MkPair "Content-Disposition" "form-data; name=\"f\"; filename=\"a.bin\"", MkPair "Content-Type" "application/octet-stream"], body = b[0, 13, 10, "--", 255] }, { headers = [MkPair "X" ""], body = b[13, 10] }]formName
formName : Part -> Maybe StringThe name of a part of a form, its Content-Disposition’s name,
where it is form-data.
>>> formName { headers = [("content-disposition", "form-data; name=\"user\"")], body = Bytes.empty }Just "user">>> List.map (\h -> formName { headers = [h], body = Bytes.empty }) [("Content-Disposition", "attachment; name=x"), ("Content-Disposition", "form-data"), ("Content-Type", "form-data; name=x")][Nothing, Nothing, Nothing]fileName
fileName : Part -> Maybe StringThe file name of a part, its Content-Disposition’s filename, not
empty, and of it only what is after its last /, as RFC 7578 §4.2
asks and Go’s FileName takes it (by filepath.Base on a Unix: a
\ kept, a / at the end not minded).
>>> fileName { headers = [("Content-Disposition", "form-data; name=\"f\"; filename=\"file1.txt\"")], body = Bytes.empty }Just "file1.txt">>> List.map (\d -> fileName { headers = [("Content-Disposition", d)], body = Bytes.empty }) ["attachment; filename=\"../../etc/passwd\"", "form-data; filename=\"dir/\"", "form-data; filename*=utf-8''%E2%82%AC.txt", "form-data; filename=\"C:\\dev\\a.txt\"", "form-data; filename=\"\"", "form-data; name=f"][Just "passwd", Just "dir", Just "\u{20ac}.txt", Just "C:\\dev\\a.txt", Nothing, Nothing]