├── .gitignore ├── README.markdown ├── dist.ini ├── lib └── resty │ └── post.lua └── t └── post.t /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *~ 4 | go 5 | t/servroot/ 6 | reindex 7 | *.t_ 8 | tags 9 | .hg 10 | .hgignore 11 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | lua-resty-post 2 | ============== 3 | 4 | Openresty utility for HTTP post 5 | 6 | Table of Contents 7 | ================= 8 | * [Status](#status) 9 | * [Description](#description) 10 | * [Installation](#installation) 11 | * [How to use](#how-to-use) 12 | * [Copyright and License](#copyright-and-license) 13 | * [See Also](#see-also) 14 | 15 | Status 16 | ====== 17 | 18 | This library beta tested and used in production. 19 | 20 | Description 21 | =========== 22 | 23 | This library processed HTTP using [lua-resty-upload](https://github.com/openresty/lua-resty-upload) which very fast and low memory used, it handles multiple type of HTTP POST and converted into lua table: 24 | * application/x-www-form-urlencoded 25 | * application/json 26 | * multipart/form-data 27 | * [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) 28 | * [File Upload](#file-upload) 29 | * [Array Input](#array-input) 30 | 31 | [Back to TOC](#table-of-contents) 32 | 33 | Installation 34 | ============ 35 | 36 | * Download or clone this repo 37 | * copy or link to openresty/lualib/resty/ or to any your lua_package_path 38 | 39 | [Back to TOC](#table-of-contents) 40 | 41 | How to use 42 | ========== 43 | 44 | ```lua 45 | local resty_post = require 'resty.post' 46 | local post = resty_post:new() 47 | local m = post:read() 48 | -- return table with all form value and file 49 | ``` 50 | 51 | [Back to TOC](#table-of-contents) 52 | 53 | File Upload 54 | =========== 55 | 56 | * Support multiple file upload 57 | * Files info are stored in files property using field name as key 58 | ```lua 59 | { 60 | files = { 61 | file1 = { -- input name 62 | name = "a.txt", 63 | type = "text/plain", 64 | size = 10240, 65 | tmp_name = 1454551131.5459 66 | }, 67 | file2 = { 68 | name = "b.png", 69 | type = "image/png", 70 | size = 20480, 71 | tmp_name = 1454553275.6401 72 | } 73 | } 74 | ``` 75 | 76 | * Define path for files upload or default to logs directory (follow ngx.config.prefix) 77 | * Default file will be saved to tmp name (require moving action to destination) 78 | ``` lua 79 | local resty_post = require "resty.post" 80 | local post = resty_post:new({ 81 | path = "/my/path", -- path upload file will be saved 82 | chunk_size = 10240, -- default 8192 83 | no_tmp = true, -- if set original name will uses or generate random name 84 | name = function(name, field) -- overide name with user defined function 85 | return name.."_"..field 86 | end 87 | }) 88 | post:read() 89 | ``` 90 | 91 | 92 | Array Input 93 | =========== 94 | 95 | Support multiple input of similar name 96 | -------------------------------------- 97 | It is useful for thing like HTML input checkboxes or select in multiple mode 98 | ```html 99 | 100 | 101 | 106 | ``` 107 | converted into 108 | ``` lua 109 | { 110 | check_multi = { 1, 2 }, 111 | select_multi = { 1, 2 } 112 | } 113 | ``` 114 | When checked one similar with [ngx.req.get_post_args](https://github.com/openresty/lua-nginx-module#ngxreqget_post_args) 115 | ``` lua 116 | { 117 | check_multi = 2, 118 | select_multi = 1 119 | } 120 | ``` 121 | 122 | 123 | Support array input with name 124 | -------------------------------------- 125 | This is like supporting input which mimic class and property, which can be uses to handle dynamic input 126 | support PHP style (dynamic language) and ASP.NET MVC binding style (static language which uses class) 127 | ```html 128 |
129 | 130 | 131 |
132 |
133 | 134 | 135 |
136 |
137 | 138 | 139 |
140 |
141 | 142 | 143 |
144 | ``` 145 | converted into 146 | ```lua 147 | { 148 | name = { 149 | "Bar", 150 | "Foo" 151 | }, 152 | user = { 153 | title = "Mr.", 154 | name = "Foo Bar" 155 | }, 156 | users = { 157 | { 158 | title = "Mr.", 159 | name = "John Do" 160 | }, 161 | { 162 | title = "Ms.", 163 | name = "Jane Do" 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | [Back to TOC](#table-of-contents) 170 | 171 | Copyright and License 172 | ===================== 173 | 174 | This module is licensed under the BSD license. 175 | 176 | Copyright (C) 2015, by Anton Heryanto Hasan. 177 | 178 | All rights reserved. 179 | 180 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 181 | 182 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 183 | 184 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 185 | 186 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 187 | 188 | [Back to TOC](#table-of-contents) 189 | 190 | See Also 191 | ======== 192 | * [lua-resty-stack](https://github.com/antonheryanto/lua-resty-stack) 193 | * [lua-resty-upload](https://github.com/openresty/lua-resty-upload) 194 | * [lua-nginx-module](https://github.com/openresty/lua-nginx-module) 195 | 196 | [Back to TOC](#table-of-contents) 197 | -------------------------------------------------------------------------------- /dist.ini: -------------------------------------------------------------------------------- 1 | name = lua-resty-post 2 | abstract = Openresty utility for parsing HTTP POST data 3 | author = Anton Heryanto 4 | is_original = yes 5 | license = 2bsd 6 | lib_dir = lib 7 | doc_dir = lib 8 | repo_link = https://github.com/antonheryanto/lua-resty-post 9 | main_module = lib/resty/post.lua 10 | -------------------------------------------------------------------------------- /lib/resty/post.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (C) Anton heryanto. 2 | 3 | local cjson = require "cjson" 4 | local upload = require "resty.upload" 5 | local table_new_ok, new_tab = pcall(require, "table.new") 6 | 7 | 8 | local open = io.open 9 | local sub = string.sub 10 | local find = string.find 11 | local byte = string.byte 12 | local lower = string.lower 13 | local type = type 14 | local tonumber = tonumber 15 | local setmetatable = setmetatable 16 | local random = math.random 17 | local re_find = ngx.re.find 18 | local read_body = ngx.req.read_body 19 | local get_post_args = ngx.req.get_post_args 20 | local var = ngx.var 21 | local log = ngx.log 22 | local WARN = ngx.WARN 23 | local prefix = ngx.config.prefix()..'logs/' 24 | local now = ngx.now 25 | 26 | 27 | if not table_new_ok then 28 | new_tab = function(narr, nrec) return {} end 29 | end 30 | 31 | 32 | local _M = new_tab(0, 3) 33 | local mt = { __index = _M } 34 | _M.VERSION = '0.2.3' 35 | 36 | 37 | local function tmp() 38 | return now() + random() 39 | end 40 | 41 | 42 | local function original(name) 43 | return name 44 | end 45 | 46 | 47 | function _M.new(self, opts) 48 | local ot = type(opts) 49 | opts = ot == 'string' and {path = opts} or ot == 'table' and opts or {} 50 | opts.path = type(opts.path) == 'string' and opts.path or prefix 51 | opts.chunk_size = tonumber(opts.chunk_size, 10) or 8192 52 | opts.name = type(opts.name) == 'function' and opts.name or opts.no_tmp 53 | and original or tmp 54 | return setmetatable(opts, mt) 55 | end 56 | 57 | 58 | local function decode_disposition(self, data) 59 | local needle = 'filename="' 60 | local needle_len = 10 -- #needle 61 | local name_pos = 18 -- #'form-data; name="' 62 | local last_quote_pos = #data - 1 63 | local filename_pos = find(data, needle) 64 | 65 | if not filename_pos then 66 | return sub(data,name_pos,last_quote_pos) 67 | end 68 | 69 | local field = sub(data,name_pos,filename_pos - 4) 70 | local name = sub(data,filename_pos + needle_len, last_quote_pos) 71 | if not name or name == '' then 72 | return 73 | end 74 | 75 | local fn = self.name 76 | local path = self.path 77 | local tmp_name = fn(name, field) 78 | local filename = path .. tmp_name 79 | local handler = open(filename, 'w+b') 80 | 81 | if not handler then 82 | log(WARN, 'failed to open file ', filename) 83 | end 84 | 85 | return field, name, handler, tmp_name 86 | end 87 | 88 | 89 | local function multipart(self) 90 | local chunk_size = self.chunk_size 91 | local form, e = upload:new(chunk_size) 92 | if not form then 93 | log(WARN, 'failed to new upload: ', e) 94 | return 95 | end 96 | 97 | local m = { files = {} } 98 | local files = {} 99 | local handler, key, value 100 | while true do 101 | local ctype, res, er = form:read() 102 | 103 | if not ctype then 104 | log(WARN, 'failed to read: ', er) 105 | return 106 | end 107 | 108 | if ctype == 'header' then 109 | local header, data = res[1], res[2] 110 | 111 | if lower(header or '') == 'content-disposition' then 112 | local tmp_name 113 | key, value, handler, tmp_name = decode_disposition(self, data) 114 | 115 | if handler then 116 | files[key] = { name = value, tmp_name = tmp_name } 117 | end 118 | end 119 | 120 | if handler and lower(header or '') == 'content-type' then 121 | files[key].type = data 122 | end 123 | end 124 | 125 | if ctype == 'body' then 126 | if handler then 127 | handler:write(res) 128 | elseif res ~= '' then 129 | value = value and value .. res or res 130 | end 131 | end 132 | 133 | if ctype == 'part_end' then 134 | if handler then 135 | files[key].size = handler:seek('end') 136 | handler:close() 137 | if m.files[key] then 138 | local nf = #m.files[key] 139 | if nf > 0 then 140 | m.files[key][nf + 1] = files[key] 141 | else 142 | m.files[key] = { m.files[key], files[key] } 143 | end 144 | else 145 | m.files[key] = files[key] 146 | end 147 | 148 | elseif key then 149 | -- handle array input, checkboxes 150 | -- handle one dimension array input 151 | -- name[0] 152 | -- user.name and user[name] 153 | -- user[0].name and user[0][name] 154 | -- TODO [0].name ? 155 | -- FIXME track mk 156 | local from, to = re_find(key, '(\\[\\w+\\])|(\\.)','jo') 157 | if from then 158 | -- check 46(.) 159 | local index = byte(key, from) == 46 and '' or 160 | sub(key, from + 1, to - 1) 161 | local name = sub(key, 0, from - 1) 162 | local field 163 | if #key == to then -- parse input[name] 164 | local ix = tonumber(index, 10) 165 | field = ix and ix + 1 or index 166 | index = '' 167 | else 168 | -- parse input[index].field or input[index][field] 169 | local ns = index == '' and 1 or 2 170 | local ne = #key 171 | if index ~= '' and byte(key, to + 1) ~= 46 then 172 | ne = ne - 1 173 | end 174 | field = sub(key, to + ns, ne) 175 | index = index == '' and index or (index + 1) 176 | end 177 | 178 | if type(m[name]) ~= 'table' then 179 | m[name] = {} 180 | end 181 | 182 | if index ~= '' and type(m[name][index]) ~= 'table' then 183 | m[name][index] = {} 184 | end 185 | 186 | if index ~= '' and m[name][index] then 187 | m[name][index][field] = value -- input[0].name 188 | else 189 | m[name][field] = value 190 | end 191 | 192 | elseif m[key] then 193 | local mk = m[key] 194 | if type(mk) == 'table' then 195 | m[key][#mk + 1] = value 196 | else 197 | m[key] = { mk, value } 198 | end 199 | else 200 | m[key] = value 201 | end 202 | key = nil 203 | value = nil 204 | end 205 | end 206 | 207 | if ctype == 'eof' then break end 208 | 209 | end 210 | return m 211 | end 212 | 213 | 214 | -- proses post based on content type 215 | function _M.read(self) 216 | local ctype = var.content_type 217 | 218 | if ctype and find(ctype, 'multipart') then 219 | return multipart(self) 220 | end 221 | 222 | read_body() 223 | 224 | if ctype and find(ctype, 'json') then 225 | local body = var.request_body 226 | return body and cjson.decode(body) or {} 227 | end 228 | 229 | return get_post_args() 230 | end 231 | 232 | 233 | return _M 234 | -------------------------------------------------------------------------------- /t/post.t: -------------------------------------------------------------------------------- 1 | use Test::Nginx::Socket::Lua; 2 | use Cwd qw(cwd); 3 | 4 | repeat_each(2); 5 | 6 | plan tests => repeat_each() * (blocks() * 2); 7 | 8 | my $pwd = cwd(); 9 | 10 | our $HttpConfig = <<"_EOC_"; 11 | lua_package_path "$pwd/lib/?.lua;;"; 12 | _EOC_ 13 | 14 | no_long_string(); 15 | no_diff(); 16 | run_tests(); 17 | 18 | __DATA__ 19 | 20 | === TEST 1: simple post 21 | --- http_config eval: $::HttpConfig 22 | --- config 23 | location /t { 24 | content_by_lua_block { 25 | local cjson = require 'cjson' 26 | local post = require 'resty.post':new() 27 | local m = post:read() 28 | ngx.say(cjson.encode(m)) 29 | } 30 | } 31 | --- request 32 | POST /t 33 | a=3&b=4&c 34 | --- response_body 35 | {"b":"4","a":"3","c":true} 36 | 37 | 38 | === TEST 2: array post 39 | --- http_config eval: $::HttpConfig 40 | --- config 41 | location /t { 42 | content_by_lua_block { 43 | local cjson = require 'cjson' 44 | local post = require 'resty.post':new() 45 | local m = post:read() 46 | ngx.say(cjson.encode(m)) 47 | } 48 | } 49 | --- request 50 | POST /t 51 | a=1&a=2&b=1&c=1&c=2 52 | --- response_body 53 | {"b":"1","a":["1","2"],"c":["1","2"]} 54 | 55 | 56 | === TEST 3: json 57 | --- http_config eval: $::HttpConfig 58 | --- config 59 | location /t { 60 | content_by_lua_block { 61 | local cjson = require "cjson" 62 | local post = require "resty.post":new() 63 | local m = post:read() 64 | ngx.say(cjson.encode(m)) 65 | } 66 | } 67 | --- more_headers 68 | Content-Type: application/json 69 | --- request 70 | POST /t 71 | {"a":3,"b":4,"c":true} 72 | --- response_body 73 | {"b":4,"a":3,"c":true} 74 | --- error_log 75 | 76 | 77 | === TEST 4: post with formdata file 78 | --- http_config eval: $::HttpConfig 79 | --- config 80 | location /t { 81 | content_by_lua_block { 82 | local cjson = require 'cjson' 83 | local post = require 'resty.post':new() 84 | local m = post:read() 85 | m.files.file1.tmp_name = nil 86 | ngx.say(cjson.encode(m)) 87 | } 88 | } 89 | --- more_headers 90 | Content-Type: multipart/form-data; boundary=---------------------------820127721219505131303151179 91 | --- request eval 92 | qq{POST /t\n-----------------------------820127721219505131303151179\r 93 | Content-Disposition: form-data; name="file1"; filename="a.txt"\r 94 | Content-Type: text/plain\r 95 | \r 96 | Hello, world\r\n-----------------------------820127721219505131303151179\r 97 | Content-Disposition: form-data; name="test"\r 98 | \r 99 | value\r 100 | \r\n-----------------------------820127721219505131303151179--\r 101 | } 102 | --- response_body 103 | {"files":{"file1":{"type":"text\/plain","size":12,"name":"a.txt"}},"test":"value\r\n"} 104 | 105 | 106 | === TEST 5: post with formdata array input 107 | --- http_config eval: $::HttpConfig 108 | --- config 109 | location /t { 110 | content_by_lua_block { 111 | local cjson = require 'cjson' 112 | local post = require 'resty.post':new() 113 | local m = post:read() 114 | m.files = nil 115 | ngx.say(cjson.encode(m.name)) 116 | ngx.say(cjson.encode(m.user)) 117 | ngx.say(cjson.encode(m.friend)) 118 | } 119 | } 120 | --- more_headers 121 | Content-Type: multipart/form-data; boundary=---------------------------820127721219505131303151179 122 | --- request eval 123 | qq{POST /t\n-----------------------------820127721219505131303151179\r 124 | Content-Disposition: form-data; name="name[1]"\r 125 | \r 126 | Foo\r 127 | -----------------------------820127721219505131303151179\r 128 | Content-Disposition: form-data; name="name[0]"\r 129 | \r 130 | Bar\r 131 | -----------------------------820127721219505131303151179\r 132 | Content-Disposition: form-data; name="user.name"\r 133 | \r 134 | Foo Bar\r 135 | -----------------------------820127721219505131303151179\r 136 | Content-Disposition: form-data; name="user[title]"\r 137 | \r 138 | Mr.\r 139 | -----------------------------820127721219505131303151179\r 140 | Content-Disposition: form-data; name="friend[0].title"\r 141 | \r 142 | Ms.\r 143 | -----------------------------820127721219505131303151179\r 144 | Content-Disposition: form-data; name="friend[0].name"\r 145 | \r 146 | Jane Doo\r 147 | -----------------------------820127721219505131303151179\r 148 | Content-Disposition: form-data; name="friend[1][title]"\r 149 | \r 150 | Mr.\r 151 | -----------------------------820127721219505131303151179\r 152 | Content-Disposition: form-data; name="friend[1][name]"\r 153 | \r 154 | John Doo\r 155 | -----------------------------820127721219505131303151179--\r 156 | } 157 | --- response_body 158 | ["Bar","Foo"] 159 | {"name":"Foo Bar","title":"Mr."} 160 | [{"title":"Ms.","name":"Jane Doo"},{"title":"Mr.","name":"John Doo"}] 161 | 162 | --------------------------------------------------------------------------------