├── .gitignore ├── .luacheckrc ├── CHANGELOG.md ├── rockspec ├── multipart-post-1.0-1.rockspec ├── multipart-post-1.1-1.rockspec ├── multipart-post-1.2-1.rockspec ├── multipart-post-1.3-1.rockspec ├── multipart-post-1.4-1.rockspec └── multipart-post-scm-1.rockspec ├── .github └── workflows │ └── ci.yml ├── LICENSE.txt ├── multipart-post.test.lua ├── README.md └── multipart-post.lua /.gitignore: -------------------------------------------------------------------------------- 1 | localua.sh 2 | .lua/ 3 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | stds.mycompat = {globals={'unpack'}} 2 | std = "lua54+mycompat" 3 | exclude_files = { ".lua/*" } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # multipart-post CHANGELOG 2 | 3 | ## v1.0 4 | 5 | - First release. 6 | 7 | ## v1.1 8 | 9 | - `encode` [autogenerates the boundary by default](https://github.com/catwell/lua-multipart-post/pull/1) 10 | 11 | ## v1.2 12 | 13 | - Support non-ASCII file names 14 | - [Use ltn12 chunked streaming to allow for sending large files](https://github.com/catwell/lua-multipart-post/pull/5) 15 | 16 | ## v1.3 17 | 18 | - [Fix Lua 5.1 / LuaJIT support](https://github.com/catwell/lua-multipart-post/pull/6) 19 | 20 | ## 1.4 21 | 22 | - [Boundary no longer enclosed in quotes in Content-Type header](https://github.com/catwell/lua-multipart-post/pull/7). This avoids bugs in some Web servers. 23 | -------------------------------------------------------------------------------- /rockspec/multipart-post-1.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "multipart-post" 2 | version = "1.0-1" 3 | 4 | source = { 5 | url = "git://github.com/catwell/lua-multipart-post.git", 6 | branch = "v1.0", 7 | } 8 | 9 | description = { 10 | summary = "HTTP Multipart Post helper that does just that", 11 | detailed = [[ 12 | HTTP Multipart Post helper that does just that. 13 | ]], 14 | homepage = "https://github.com/catwell/lua-multipart-post", 15 | license = "MIT/X11", 16 | } 17 | 18 | dependencies = { 19 | "lua >= 5.1", 20 | "luasocket", 21 | } 22 | 23 | build = { 24 | type = "none", 25 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 26 | copy_directories = {}, 27 | } 28 | -------------------------------------------------------------------------------- /rockspec/multipart-post-1.1-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "multipart-post" 2 | version = "1.1-1" 3 | 4 | source = { 5 | url = "git://github.com/catwell/lua-multipart-post.git", 6 | branch = "v1.1", 7 | } 8 | 9 | description = { 10 | summary = "HTTP Multipart Post helper that does just that", 11 | detailed = [[ 12 | HTTP Multipart Post helper that does just that. 13 | ]], 14 | homepage = "https://github.com/catwell/lua-multipart-post", 15 | license = "MIT/X11", 16 | } 17 | 18 | dependencies = { 19 | "lua >= 5.1", 20 | "luasocket", 21 | } 22 | 23 | build = { 24 | type = "none", 25 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 26 | copy_directories = {}, 27 | } 28 | -------------------------------------------------------------------------------- /rockspec/multipart-post-1.2-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "multipart-post" 2 | version = "1.2-1" 3 | 4 | source = { 5 | url = "git://github.com/catwell/lua-multipart-post.git", 6 | branch = "v1.2", 7 | } 8 | 9 | description = { 10 | summary = "HTTP Multipart Post helper that does just that", 11 | detailed = [[ 12 | HTTP Multipart Post helper that does just that. 13 | ]], 14 | homepage = "https://github.com/catwell/lua-multipart-post", 15 | license = "MIT/X11", 16 | } 17 | 18 | dependencies = { 19 | "lua >= 5.1", 20 | "luasocket", 21 | } 22 | 23 | build = { 24 | type = "none", 25 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 26 | copy_directories = {}, 27 | } 28 | -------------------------------------------------------------------------------- /rockspec/multipart-post-1.3-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "multipart-post" 2 | version = "1.3-1" 3 | 4 | source = { 5 | url = "git://github.com/catwell/lua-multipart-post.git", 6 | branch = "v1.3", 7 | } 8 | 9 | description = { 10 | summary = "HTTP Multipart Post helper that does just that", 11 | detailed = [[ 12 | HTTP Multipart Post helper that does just that. 13 | ]], 14 | homepage = "https://github.com/catwell/lua-multipart-post", 15 | license = "MIT/X11", 16 | } 17 | 18 | dependencies = { 19 | "lua >= 5.1", 20 | "luasocket", 21 | } 22 | 23 | build = { 24 | type = "none", 25 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 26 | copy_directories = {}, 27 | } 28 | -------------------------------------------------------------------------------- /rockspec/multipart-post-1.4-1.rockspec: -------------------------------------------------------------------------------- 1 | rockspec_format = "3.0" 2 | 3 | package = "multipart-post" 4 | version = "1.4-1" 5 | 6 | source = { 7 | url = "git://github.com/catwell/lua-multipart-post.git", 8 | branch = "v1.4", 9 | } 10 | 11 | description = { 12 | summary = "HTTP Multipart Post helper that does just that", 13 | detailed = [[ 14 | HTTP Multipart Post helper that does just that. 15 | ]], 16 | homepage = "https://github.com/catwell/lua-multipart-post", 17 | license = "MIT/X11", 18 | } 19 | 20 | dependencies = { 21 | "lua >= 5.1", 22 | "luasocket", 23 | } 24 | 25 | build = { 26 | type = "none", 27 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 28 | copy_directories = {}, 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | 5 | luacheck: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: checkout 9 | uses: actions/checkout@v5 10 | 11 | - name: luacheck 12 | uses: lunarmodules/luacheck@v1 13 | 14 | tests: 15 | strategy: 16 | matrix: 17 | lua-version: ["5.1.5", "5.2.4", "5.3.6", "5.4.8"] 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v5 22 | 23 | - name: install readline 24 | run: sudo apt-get install -y libreadline-dev 25 | 26 | - name: localua 27 | run: | 28 | curl https://loadk.com/localua.sh -O 29 | chmod +x localua.sh 30 | ./localua.sh .lua "${{ matrix.lua-version }}" 31 | 32 | - name: run tests 33 | run: | 34 | ./.lua/bin/luarocks test 35 | -------------------------------------------------------------------------------- /rockspec/multipart-post-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | rockspec_format = "3.0" 2 | 3 | package = "multipart-post" 4 | version = "scm-1" 5 | 6 | source = { 7 | url = "git://github.com/catwell/lua-multipart-post.git", 8 | } 9 | 10 | description = { 11 | summary = "HTTP Multipart Post helper that does just that", 12 | detailed = [[ 13 | HTTP Multipart Post helper that does just that. 14 | ]], 15 | homepage = "https://github.com/catwell/lua-multipart-post", 16 | license = "MIT/X11", 17 | } 18 | 19 | dependencies = { 20 | "lua >= 5.1", 21 | "luasocket", 22 | } 23 | 24 | build = { 25 | type = "none", 26 | install = { lua = { ["multipart-post"] = "multipart-post.lua" } }, 27 | copy_directories = {}, 28 | } 29 | 30 | test_dependencies = { 31 | "cwtest", 32 | "lunajson", 33 | } 34 | 35 | test = { 36 | type = "command", 37 | script = "multipart-post.test.lua", 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012-2013 by Moodstocks SAS 2 | Copyright (C) since 2014 by Pierre Chapuis 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /multipart-post.test.lua: -------------------------------------------------------------------------------- 1 | local cwtest = require "cwtest" 2 | local ltn12 = require "ltn12" 3 | local H = (require "socket.http").request 4 | local mp = (require "multipart-post").gen_request 5 | local enc = (require "multipart-post").encode 6 | 7 | local J 8 | do -- Find a JSON parser 9 | local ok, json = pcall(require, "cjson") 10 | if not ok then ok, json = pcall(require, "lunajson") end 11 | J = json.decode 12 | assert(ok and J, "no JSON parser found :(") 13 | end 14 | 15 | local file = io.open("testfile.tmp", "w+") 16 | file:write("file data") 17 | file:flush() 18 | file:seek("set", 0) 19 | local fsz = file:seek("end") 20 | file:seek("set", 0) 21 | 22 | local T = cwtest.new() 23 | 24 | T:start("gen_request"); do 25 | local r = {} 26 | local rq = mp{ 27 | myfile = {name = "myfilename", data = "some data"}, 28 | diskfile = {name = "diskfilename", data = file, len = fsz}, 29 | ltn12file = { 30 | name = "ltn12filename", 31 | data = ltn12.source.string("ltn12 data"), 32 | len = string.len("ltn12 data") 33 | }, 34 | foo = "bar", 35 | } 36 | rq.headers.Authorization = "Bearer SomeToken42" 37 | 38 | rq.url = "http://httpbin.org/post" 39 | rq.sink = ltn12.sink.table(r) 40 | local _, c = H(rq) 41 | 42 | T:eq(c, 200) 43 | r = J(table.concat(r)) 44 | 45 | T:eq(r.files, { 46 | myfile="some data", 47 | diskfile="file data", 48 | ltn12file="ltn12 data" 49 | }) 50 | T:eq(r.form, {foo = "bar"}) 51 | T:eq(r.headers.Authorization, "Bearer SomeToken42") 52 | end; T:done() 53 | 54 | T:start("encode"); do 55 | local body, boundary = enc{foo="bar"} 56 | local r = {} 57 | 58 | local _, c = H{ 59 | url = "http://httpbin.org/post", 60 | source = ltn12.source.string(body), 61 | method = "POST", 62 | sink = ltn12.sink.table(r), 63 | headers = { 64 | ["content-length"] = string.len(body), 65 | ["content-type"] = string.format( 66 | "multipart/form-data; boundary=\"%s\"", boundary 67 | ), 68 | }, 69 | } 70 | T:eq(c, 200) 71 | r = J(table.concat(r)) 72 | T:eq(r.form, {foo = "bar"}) 73 | end; T:done() 74 | 75 | os.remove("testfile.tmp") 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multipart-post 2 | 3 | ![CI Status](https://github.com/catwell/lua-multipart-post/actions/workflows/ci.yml/badge.svg?branch=master) 4 | 5 | ## Presentation 6 | 7 | HTTP Multipart Post helper that does just that. 8 | 9 | ## Dependencies 10 | 11 | The module itself only depends on luasocket (for ltn12). 12 | 13 | Tests require [cwtest](https://github.com/catwell/cwtest), a JSON parser 14 | and the availability of [httpbin.org](http://httpbin.org). 15 | 16 | ## Usage 17 | 18 | ```lua 19 | local mp = require "multipart-post" 20 | local http = require "socket.http" 21 | 22 | local rq = mp.gen_request({myfile = {name = "myfilename", data = "some data"}}) 23 | rq.url = "http://httpbin.org/post" 24 | local b, c, h = http.request(rq) 25 | ``` 26 | 27 | See [LuaSocket](http://w3.impa.br/~diego/software/luasocket/http.html)'s 28 | `http.request` (generic interface) for more information. 29 | 30 | If you only need to get the multipart/form-data body use `encode`: 31 | 32 | ```lua 33 | local body, boundary = mp.encode({foo = "bar"}) 34 | -- use `boundary` to build the Content-Type header 35 | ``` 36 | 37 | ## Advanced Usage 38 | 39 | Example using ltn12 streaming via file handles 40 | 41 | ```lua 42 | local file = io.open("myfilename", "r") 43 | local file_length = file:seek("end") 44 | file:seek("set", 0) 45 | 46 | local rq = mp.gen_request({ 47 | myfile = { 48 | name = "myfilename", 49 | data = file, 50 | len = file_length, 51 | } 52 | }) 53 | ``` 54 | 55 | Example using ltn12 source streaming 56 | 57 | ```lua 58 | local ltn12 = require "socket.ltn12" 59 | 60 | local rq = mp.gen_request({ 61 | myfile = { 62 | name = "myfilename", 63 | data = ltn12.source.string("some data"), 64 | len = string.len("some data"), 65 | } 66 | }) 67 | rq.url = "http://httpbin.org/post" 68 | local b, c, h = http.request(rq) 69 | ``` 70 | 71 | Example setting extra headers (here Authorization): 72 | 73 | ```lua 74 | local rq = mp.gen_request({myfile = {name = "myfilename", data = "some data"}}) 75 | rq.url = "http://httpbin.org/post" 76 | rq.headers.Authorization = "Bearer SomeToken42" 77 | local b, c, h = http.request(rq) 78 | ``` 79 | 80 | ## Bugs 81 | 82 | Non-ASCII part names are not supported. 83 | According to [RFC 2388](http://tools.ietf.org/html/rfc2388): 84 | 85 | > Note that MIME headers are generally required to consist only of 7- 86 | > bit data in the US-ASCII character set. Hence field names should be 87 | > encoded according to the method in 88 | > [RFC 2047](http://tools.ietf.org/html/rfc2047) if they contain 89 | > characters outside of that set. 90 | 91 | Note that non-ASCII file names are supported since version 1.2. 92 | 93 | ## Contributors 94 | 95 | - Pierre Chapuis ([@catwell](https://github.com/catwell)) 96 | - Cédric Deltheil ([@deltheil](https://github.com/deltheil)) 97 | - TJ Miller ([@teejaded](https://github.com/teejaded)) 98 | - Rami Sabbagh ([@RamiLego4Game](https://github.com/RamiLego4Game)) 99 | - [@Gowa2017](https://github.com/Gowa2017) 100 | 101 | ## Copyright 102 | 103 | - Copyright (c) 2012 - 2013 Moodstocks SAS 104 | - Copyright (c) since 2014 Pierre Chapuis 105 | -------------------------------------------------------------------------------- /multipart-post.lua: -------------------------------------------------------------------------------- 1 | local ltn12 = require "ltn12" 2 | local url = require "socket.url" 3 | local unpack = table.unpack or unpack 4 | 5 | local _M = {} 6 | 7 | _M.CHARSET = "UTF-8" 8 | _M.LANGUAGE = "" 9 | 10 | local function fmt(p, ...) 11 | if select('#', ...) == 0 then 12 | return p 13 | end 14 | return string.format(p, ...) 15 | end 16 | 17 | local function tprintf(t, p, ...) 18 | t[#t+1] = fmt(p, ...) 19 | end 20 | 21 | local function section_header(r, k, extra) 22 | tprintf(r, "content-disposition: form-data; name=\"%s\"", k) 23 | if extra.filename then 24 | tprintf(r, "; filename=\"%s\"", extra.filename) 25 | tprintf( 26 | r, "; filename*=%s'%s'%s", 27 | _M.CHARSET, _M.LANGUAGE, url.escape(extra.filename) 28 | ) 29 | end 30 | if extra.content_type then 31 | tprintf(r, "\r\ncontent-type: %s", extra.content_type) 32 | end 33 | if extra.content_transfer_encoding then 34 | tprintf( 35 | r, "\r\ncontent-transfer-encoding: %s", 36 | extra.content_transfer_encoding 37 | ) 38 | end 39 | tprintf(r, "\r\n\r\n") 40 | end 41 | 42 | local function gen_boundary() 43 | local t = {"BOUNDARY-"} 44 | for i=2,17 do t[i] = string.char(math.random(65, 90)) end 45 | t[18] = "-BOUNDARY" 46 | return table.concat(t) 47 | end 48 | 49 | local function encode_header_to_table(r, k, v, boundary) 50 | local _t = type(v) 51 | 52 | tprintf(r, "--%s\r\n", boundary) 53 | if _t == "string" then 54 | section_header(r, k, {}) 55 | elseif _t == "table" then 56 | assert(v.data, "invalid input") 57 | local extra = { 58 | filename = v.filename or v.name, 59 | content_type = v.content_type or v.mimetype 60 | or "application/octet-stream", 61 | content_transfer_encoding = v.content_transfer_encoding 62 | or "binary", 63 | } 64 | section_header(r, k, extra) 65 | else 66 | error(string.format("unexpected type %s", _t)) 67 | end 68 | end 69 | 70 | local function encode_header_as_source(k, v, boundary, ctx) 71 | local r = {} 72 | encode_header_to_table(r, k, v, boundary, ctx) 73 | local s = table.concat(r) 74 | if ctx then 75 | ctx.headers_length = ctx.headers_length + #s 76 | end 77 | return ltn12.source.string(s) 78 | end 79 | 80 | local function data_len(d) 81 | local _t = type(d) 82 | 83 | if _t == "string" then 84 | return string.len(d) 85 | elseif _t == "table" then 86 | if type(d.data) == "string" then 87 | return string.len(d.data) 88 | end 89 | if d.len then return d.len end 90 | error("must provide data length for non-string datatypes") 91 | end 92 | end 93 | 94 | local function content_length(t, boundary, ctx) 95 | local r = ctx and ctx.headers_length or 0 96 | for k, v in pairs(t) do 97 | if not ctx then 98 | local tmp = {} 99 | encode_header_to_table(tmp, k, v, boundary) 100 | r = r + #table.concat(tmp) 101 | end 102 | r = r + data_len(v) + 2; -- `\r\n` 103 | end 104 | return r + #boundary + 6; -- `--BOUNDARY--\r\n` 105 | end 106 | 107 | local function get_data_src(v) 108 | local _t = type(v) 109 | if v.source then 110 | return v.source 111 | elseif _t == "string" then 112 | return ltn12.source.string(v) 113 | elseif _t == "table" then 114 | _t = type(v.data) 115 | if _t == "string" then 116 | return ltn12.source.string(v.data) 117 | elseif _t == "table" then 118 | return ltn12.source.table(v.data) 119 | elseif _t == "userdata" then 120 | return ltn12.source.file(v.data) 121 | elseif _t == "function" then 122 | return v.data 123 | end 124 | end 125 | error("invalid input") 126 | end 127 | 128 | local function set_ltn12_blksz(sz) 129 | assert(type(sz) == "number", "set_ltn12_blksz expects a number") 130 | ltn12.BLOCKSIZE = sz 131 | end 132 | _M.set_ltn12_blksz = set_ltn12_blksz 133 | 134 | local function source(t, boundary, ctx) 135 | local sources, n = {}, 1 136 | for k, v in pairs(t) do 137 | sources[n] = encode_header_as_source(k, v, boundary, ctx) 138 | sources[n+1] = get_data_src(v) 139 | sources[n+2] = ltn12.source.string("\r\n") 140 | n = n + 3 141 | end 142 | sources[n] = ltn12.source.string(string.format("--%s--\r\n", boundary)) 143 | return ltn12.source.cat(unpack(sources)) 144 | end 145 | _M.source = source 146 | 147 | function _M.gen_request(t) 148 | local boundary = gen_boundary() 149 | -- This is an optimization to avoid re-encoding headers twice. 150 | -- The length of the headers is stored when computing the source, 151 | -- and re-used when computing the content length. 152 | local ctx = {headers_length = 0} 153 | return { 154 | method = "POST", 155 | source = source(t, boundary, ctx), 156 | headers = { 157 | ["content-length"] = content_length(t, boundary, ctx), 158 | ["content-type"] = fmt( 159 | "multipart/form-data; boundary=%s", boundary 160 | ), 161 | }, 162 | } 163 | end 164 | 165 | function _M.encode(t, boundary) 166 | boundary = boundary or gen_boundary() 167 | local r = {} 168 | assert(ltn12.pump.all( 169 | (source(t, boundary)), 170 | (ltn12.sink.table(r)) 171 | )) 172 | return table.concat(r), boundary 173 | end 174 | 175 | return _M 176 | --------------------------------------------------------------------------------