├── .gitignore ├── README.md ├── lib └── resty │ └── http │ └── simple.lua └── t ├── headers.t ├── maximumsize.t └── simple-get.t /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *~ 4 | go 5 | t/servroot/ 6 | reindex 7 | *.t_ 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Name 2 | ==== 3 | 4 | lua-resty-http -simple- Simple Lua HTTP client driver for ngx_lua 5 | 6 | Example 7 | ======= 8 | 9 | server { 10 | location /test { 11 | content_by_lua ' 12 | local http = require "resty.http.simple" 13 | 14 | local res, err = http.request("checkip.amazonaws.com", 80, { 15 | headers = { Cookie = "foo=bar"} }) 16 | if not res then 17 | ngx.say("http failure: ", err) 18 | return 19 | end 20 | 21 | if res.status >= 200 and res.status < 300 then 22 | ngx.say("My IP is: " .. res.body) 23 | else 24 | ngx.say("Query returned a non-200 response: " .. res.status) 25 | end 26 | '; 27 | } 28 | } 29 | 30 | API 31 | === 32 | 33 | request 34 | --- 35 | `syntax: local res, err = http.request(host, port, options?)` 36 | 37 | Perform an http request. 38 | 39 | Before actually resolving the host name and connecting to the remote 40 | backend, this method will always look up the connection pool for 41 | matched idle connections created by previous calls of this 42 | method. This allows the module to handle HTTP keep alives. 43 | 44 | An optional Lua `options` table can be specified to declare various options: 45 | 46 | * `method` 47 | : Specifies the request method, defaults to `GET`. 48 | * `path` 49 | : Specifies the path, defaults to `'/'`. 50 | * `query` 51 | : Specifies query parameters. Accepts either a string or a Lua table. 52 | * `headers` 53 | : Specifies request headers. Accepts a Lua table. 54 | * `body` 55 | : Specifies request body for POST method. Only accepts a string. 56 | * `timeout` 57 | : Sets the timeout in milliseconds for network operations. Defaults to `5000`. 58 | * `version` 59 | : Sets the HTTP version. Use `0` for HTTP/1.0 and `1` for 60 | HTTP/1.1. Defaults to `1`. 61 | * `maxsize` 62 | : Sets the maximum size in bytes to fetch. A response body larger than 63 | this will cause the fucntion to return a `exceeds maxsize` 64 | error. Defaults to `nil` which means no limit. 65 | 66 | 67 | Returns a `res` object containing three attributes: 68 | 69 | * `res.status` (number) 70 | : The resonse status, e.g. 200 71 | * `res.headers` (table) 72 | : A Lua table with response headers. 73 | * `res.body` (string) 74 | : The plain response body 75 | 76 | **Note** All headers (request and response) are noramlized for 77 | capitalization - e.g., Accept-Encoding, ETag, Foo-Bar, Baz - in the 78 | normal HTTP "standard." 79 | 80 | Licence 81 | ======= 82 | 83 | Started life as a fork of 84 | [lua-resty-http](https://github.com/bsm/lua-resty-http) - Copyright (c) 2013 Black Square Media Ltd 85 | 86 | This code is covered by MIT License. 87 | 88 | Copyright (C) 2013, by Brian Akins . 89 | 90 | Permission is hereby granted, free of charge, to any person obtaining 91 | a copy of this software and associated documentation files (the 92 | 'Software'), to deal in the Software without restriction, including 93 | without limitation the rights to use, copy, modify, merge, publish, 94 | distribute, sublicense, and/or sell copies of the Software, and to 95 | permit persons to whom the Software is furnished to do so, subject to 96 | the following conditions: 97 | 98 | The above copyright notice and this permission notice shall be 99 | included in all copies or substantial portions of the Software. 100 | 101 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 102 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 103 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 104 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 105 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 106 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 107 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 108 | -------------------------------------------------------------------------------- /lib/resty/http/simple.lua: -------------------------------------------------------------------------------- 1 | local pairs = pairs 2 | local type = type 3 | local tonumber = tonumber 4 | local tostring = tostring 5 | local setmetatable = setmetatable 6 | local encode_args = ngx.encode_args 7 | local tcp = ngx.socket.tcp 8 | local concat = table.concat 9 | local insert = table.insert 10 | local upper = string.upper 11 | local lower = string.lower 12 | local sub = string.sub 13 | local sfind = string.find 14 | local gmatch = string.gmatch 15 | local gsub = string.gsub 16 | local ipairs = ipairs 17 | local rawset = rawset 18 | local rawget = rawget 19 | local min = math.min 20 | local ngx = ngx 21 | 22 | module(...) 23 | 24 | _VERSION = "0.1.0" 25 | 26 | -------------------------------------- 27 | -- LOCAL CONSTANTS -- 28 | -------------------------------------- 29 | local HTTP_1_1 = " HTTP/1.1\r\n" 30 | local HTTP_1_0 = " HTTP/1.0\r\n" 31 | 32 | local USER_AGENT = "Resty/HTTP-Simple " .. _VERSION .. " (Lua)" 33 | 34 | -- canonical names for common headers 35 | local common_headers = { 36 | "Cache-Control", 37 | "Content-Length", 38 | "Content-Type", 39 | "Date", 40 | "ETag", 41 | "Expires", 42 | "Host", 43 | "Location", 44 | "User-Agent" 45 | } 46 | 47 | for _,key in ipairs(common_headers) do 48 | rawset(common_headers, key, key) 49 | rawset(common_headers, lower(key), key) 50 | end 51 | 52 | function normalize_header(key) 53 | local val = common_headers[key] 54 | if val then 55 | return val 56 | end 57 | key = lower(key) 58 | val = common_headers[lower(key)] 59 | if val then 60 | return val 61 | end 62 | -- normalize it ourselves. do not cache it as we could explode our memory usage 63 | key = gsub(key, "^%l", upper) 64 | key = gsub(key, "-%l", upper) 65 | return key 66 | end 67 | 68 | 69 | -------------------------------------- 70 | -- LOCAL HELPERS -- 71 | -------------------------------------- 72 | 73 | local function _req_header(self, opts) 74 | -- Initialize request 75 | local req = { 76 | upper(opts.method or "GET"), 77 | " " 78 | } 79 | 80 | -- Append path 81 | local path = opts.path 82 | if type(path) ~= "string" then 83 | path = "/" 84 | elseif sub(path, 1, 1) ~= "/" then 85 | path = "/" .. path 86 | end 87 | insert(req, path) 88 | 89 | -- Normalize query string 90 | if type(opts.query) == "table" then 91 | opts.query = encode_args(opts.query) 92 | end 93 | 94 | -- Append query string 95 | if type(opts.query) == "string" then 96 | insert(req, "?" .. opts.query) 97 | end 98 | 99 | -- Close first line 100 | if opts.version == 1 then 101 | insert(req, HTTP_1_1) 102 | else 103 | insert(req, HTTP_1_0) 104 | end 105 | 106 | -- Normalize headers 107 | opts.headers = opts.headers or {} 108 | local headers = {} 109 | for k,v in pairs(opts.headers) do 110 | headers[normalize_header(k)] = v 111 | end 112 | 113 | if opts.body then 114 | headers['Content-Length'] = #opts.body 115 | end 116 | if not headers['Host'] then 117 | headers['Host'] = self.host 118 | end 119 | if not headers['User-Agent'] then 120 | headers['User-Agent'] = USER_AGENT 121 | end 122 | if not headers['Accept'] then 123 | headers['Accept'] = "*/*" 124 | end 125 | if version == 0 and not headers['Connection'] then 126 | headers['Connection'] = "Keep-Alive" 127 | end 128 | 129 | -- Append headers 130 | for key, values in pairs(headers) do 131 | if type(values) ~= "table" then 132 | values = {values} 133 | end 134 | 135 | key = tostring(key) 136 | for _, value in pairs(values) do 137 | insert(req, key .. ": " .. tostring(value) .. "\r\n") 138 | end 139 | end 140 | 141 | -- Close headers 142 | insert(req, "\r\n") 143 | 144 | return concat(req) 145 | end 146 | 147 | local function _parse_headers(sock) 148 | local headers = {} 149 | local mode = nil 150 | 151 | repeat 152 | local line = sock:receive() 153 | 154 | for key, val in gmatch(line, "([%w%-]+)%s*:%s*(.+)") do 155 | key = normalize_header(key) 156 | if headers[key] then 157 | local delimiter = ", " 158 | if key == "Set-Cookie" then 159 | delimiter = "; " 160 | end 161 | headers[key] = headers[key] .. delimiter .. tostring(val) 162 | else 163 | headers[key] = tostring(val) 164 | end 165 | end 166 | until sfind(line, "^%s*$") 167 | 168 | return headers, nil 169 | end 170 | 171 | local function _receive_length(sock, length) 172 | local chunks = {} 173 | 174 | local chunk, err = sock:receive(length) 175 | if not chunk then 176 | return nil, err 177 | end 178 | 179 | return chunk, nil 180 | end 181 | 182 | 183 | local function _receive_chunked(sock, maxsize) 184 | local chunks = {} 185 | 186 | local size = 0 187 | local done = false 188 | repeat 189 | local str, err = sock:receive("*l") 190 | if not str then 191 | return nil, err 192 | end 193 | 194 | local length = tonumber(str, 16) 195 | 196 | if not length then 197 | return nil, "unable to read chunksize" 198 | end 199 | 200 | size = size + length 201 | if maxsize and size > maxsize then 202 | return nil, 'exceeds maxsize' 203 | end 204 | 205 | if length > 0 then 206 | local str, err = sock:receive(length) 207 | if not str then 208 | return nil, err 209 | end 210 | insert(chunks, str) 211 | else 212 | done = true 213 | end 214 | -- read the \r\n 215 | sock:receive(2) 216 | until done 217 | 218 | return concat(chunks), nil 219 | end 220 | 221 | local function _receive_all(sock, maxsize) 222 | -- we read maxsize +1 for the corner case where the upstream wants to write 223 | -- exactly maxsize bytes 224 | local arg = maxsize and (maxsize + 1) or "*a" 225 | local chunk, err, partial = sock:receive(arg) 226 | if maxsize then 227 | -- if we didn't get an error, it means that the upstream still had data to write 228 | -- which means it exceeded maxsize 229 | if not err then 230 | return nil, 'exceeds maxsize' 231 | else 232 | -- you read to closed in this situation so, if upstream did not close 233 | -- then its an error 234 | if err ~= "closed" then 235 | return nil, err 236 | else 237 | -- this seems odd but is correct, bcs of how ngx_lua 238 | -- handled the rror case, which is actually a success 239 | -- in this scenerio 240 | chunk = partial 241 | end 242 | end 243 | end 244 | 245 | -- in the case of reading all til closed, closed is not a "valid" error 246 | if not chunk then 247 | return nil, err 248 | end 249 | return chunk, nil 250 | end 251 | 252 | local function _receive(self, sock) 253 | local line, err = sock:receive() 254 | if not line then 255 | return nil, err 256 | end 257 | 258 | local status = tonumber(sub(line, 10, 12)) 259 | 260 | local headers, err = _parse_headers(sock) 261 | if not headers then 262 | return nil, err 263 | end 264 | 265 | local maxsize = self.opts.maxsize 266 | 267 | local length = tonumber(headers["Content-Length"]) 268 | local body 269 | local err 270 | 271 | local keepalive = true 272 | 273 | if length then 274 | if maxsize and length > maxsize then 275 | body, err = nil, 'exceeds maxsize' 276 | else 277 | body, err = _receive_length(sock, length) 278 | end 279 | else 280 | local encoding = headers["Transfer-Encoding"] 281 | if encoding and lower(encoding) == "chunked" then 282 | body, err = _receive_chunked(sock, maxsize) 283 | else 284 | body, err = _receive_all(sock, maxsize) 285 | keepalive = false 286 | end 287 | end 288 | 289 | if not body then 290 | if err then 291 | return nil, err 292 | end 293 | keepalive = false 294 | end 295 | 296 | if keepalive then 297 | local connection = headers["Connection"] 298 | connection = connection and lower(connection) or nil 299 | if connection then 300 | if connection == "close" then 301 | keepalive = false 302 | end 303 | else 304 | if self.opts.version == 0 then 305 | keepalive = false 306 | end 307 | end 308 | end 309 | 310 | if keepalive then 311 | sock:setkeepalive() 312 | else 313 | sock:close() 314 | end 315 | 316 | return { status = status, headers = headers, body = body } 317 | end 318 | 319 | 320 | -------------------------------------- 321 | -- PUBLIC API -- 322 | -------------------------------------- 323 | 324 | function request(host, port, opts) 325 | opts = opts or {} 326 | local sock, err = tcp() 327 | if not sock then 328 | return nil, err 329 | end 330 | 331 | sock:settimeout(opts.timeout or 5000) 332 | 333 | local rc, err = sock:connect(host, port) 334 | if not rc then 335 | return nil, err 336 | end 337 | 338 | local version = opts.version 339 | if version then 340 | if version ~= 0 and version ~= 1 then 341 | return nil, "unknown HTTP version" 342 | end 343 | else 344 | opts.version = 1 345 | end 346 | 347 | local self = { 348 | host = host, 349 | port = port, 350 | sock = sock, 351 | opts = opts 352 | } 353 | 354 | -- Build and send request header 355 | local header = _req_header(self, opts) 356 | local bytes, err = sock:send(header) 357 | if not bytes then 358 | return nil, err 359 | end 360 | 361 | -- Send the body if there is one 362 | if opts and type(opts.body) == "string" then 363 | local bytes, err = sock:send(opts.body) 364 | if not bytes then 365 | return nil, err 366 | end 367 | end 368 | 369 | return _receive(self, sock) 370 | end 371 | 372 | -------------------------------------------------------------------------------- /t/headers.t: -------------------------------------------------------------------------------- 1 | use lib 'lib'; 2 | use Test::Nginx::Socket; 3 | use Cwd qw(cwd); 4 | 5 | repeat_each(2); 6 | 7 | plan tests => repeat_each() * (3 * blocks()); 8 | 9 | my $pwd = cwd(); 10 | 11 | our $HttpConfig = qq{ 12 | lua_package_path "$pwd/lib/?.lua;;"; 13 | }; 14 | 15 | $ENV{TEST_NGINX_RESOLVER} = '8.8.8.8'; 16 | 17 | no_long_string(); 18 | 19 | run_tests(); 20 | 21 | __DATA__ 22 | 23 | === TEST 1: Header normalization 24 | --- http_config eval: $::HttpConfig 25 | --- config 26 | resolver $TEST_NGINX_RESOLVER; 27 | location /foo { 28 | content_by_lua ' 29 | ngx.header["foo-bar"] = "Foo-Bar" 30 | ngx.header["foo-baz"] = "Foo-Baz" 31 | ngx.header["etag"] = "ETag" 32 | ngx.header["X-FOO-BAR"] = "X-Foo-Bar" 33 | ngx.say("meh") 34 | '; 35 | } 36 | 37 | location /t { 38 | content_by_lua ' 39 | local http = require "resty.http.simple" 40 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo" }) 41 | headers = res.headers 42 | ngx.say(headers["Foo-Bar"]) 43 | ngx.say(headers["Foo-Baz"]) 44 | ngx.say(headers["ETag"]) 45 | ngx.say(headers["X-Foo-Bar"]) 46 | '; 47 | } 48 | --- request 49 | GET /t 50 | --- response_body 51 | Foo-Bar 52 | Foo-Baz 53 | ETag 54 | X-Foo-Bar 55 | --- no_error_log 56 | [error] 57 | 58 | 59 | -------------------------------------------------------------------------------- /t/maximumsize.t: -------------------------------------------------------------------------------- 1 | use lib 'lib'; 2 | use Test::Nginx::Socket; 3 | use Cwd qw(cwd); 4 | 5 | repeat_each(2); 6 | 7 | plan tests => repeat_each() * (3 * blocks()); 8 | 9 | my $pwd = cwd(); 10 | 11 | our $HttpConfig = qq{ 12 | lua_package_path "$pwd/lib/?.lua;;"; 13 | }; 14 | 15 | $ENV{TEST_NGINX_RESOLVER} = '8.8.8.8'; 16 | 17 | no_long_string(); 18 | 19 | run_tests(); 20 | 21 | __DATA__ 22 | 23 | === TEST 1: Content Length greater than maximum size 24 | --- http_config eval: $::HttpConfig 25 | --- config 26 | resolver $TEST_NGINX_RESOLVER; 27 | location /foo { 28 | content_by_lua ' 29 | local len = 1024 * 1024 30 | ngx.header.content_length = len 31 | local t = {} 32 | for i=1,len do 33 | t[i] = 0 34 | end 35 | ngx.print(table.concat(t)) 36 | '; 37 | } 38 | 39 | location /t { 40 | content_by_lua ' 41 | local http = require "resty.http.simple" 42 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo", maxsize = 1024 }) 43 | ngx.say(err) 44 | '; 45 | } 46 | --- request 47 | GET /t 48 | --- response_body 49 | exceeds maxsize 50 | --- no_error_log 51 | [error] 52 | 53 | 54 | 55 | === TEST 2: Chunked with length greater than maximum size 56 | --- http_config eval: $::HttpConfig 57 | --- config 58 | resolver $TEST_NGINX_RESOLVER; 59 | location /foo { 60 | content_by_lua ' 61 | local len = 1024 * 1024 62 | local t = {} 63 | for i=1,len do 64 | t[i] = 0 65 | end 66 | ngx.print(table.concat(t)) 67 | '; 68 | } 69 | 70 | location /t { 71 | content_by_lua ' 72 | local http = require "resty.http.simple" 73 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo", maxsize = 1024 }) 74 | ngx.say(err) 75 | '; 76 | } 77 | --- request 78 | GET /t 79 | --- response_body 80 | exceeds maxsize 81 | --- no_error_log 82 | [error] 83 | 84 | 85 | 86 | === TEST 3: HTTP/1.0 with length greater than maximum size 87 | --- http_config eval: $::HttpConfig 88 | --- config 89 | lua_http10_buffering off; 90 | resolver $TEST_NGINX_RESOLVER; 91 | location /foo { 92 | content_by_lua ' 93 | local len = 1024 * 1024 94 | local t = {} 95 | for i=1,len do 96 | t[i] = 0 97 | end 98 | ngx.print(table.concat(t)) 99 | '; 100 | } 101 | 102 | location /t { 103 | content_by_lua ' 104 | local http = require "resty.http.simple" 105 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo", maxsize = 1024, version = 0 }) 106 | ngx.say(err) 107 | '; 108 | } 109 | --- request 110 | GET /t 111 | --- response_body 112 | exceeds maxsize 113 | --- no_error_log 114 | [error] 115 | 116 | 117 | === TEST 4: HTTP/1.0 with length less than maximum size 118 | --- http_config eval: $::HttpConfig 119 | --- config 120 | lua_http10_buffering off; 121 | resolver $TEST_NGINX_RESOLVER; 122 | location /foo { 123 | content_by_lua ' 124 | local len = 1023 125 | local t = {} 126 | for i=1,len do 127 | t[i] = "a" 128 | end 129 | ngx.print(table.concat(t)) 130 | '; 131 | } 132 | 133 | location /t { 134 | content_by_lua ' 135 | local http = require "resty.http.simple" 136 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo", maxsize = 1024, version = 0 }) 137 | ngx.say(err) 138 | ngx.say(string.len(res.body)) 139 | '; 140 | } 141 | --- request 142 | GET /t 143 | --- response_body 144 | nil 145 | 1023 146 | --- no_error_log 147 | [error] 148 | -------------------------------------------------------------------------------- /t/simple-get.t: -------------------------------------------------------------------------------- 1 | use lib 'lib'; 2 | use Test::Nginx::Socket; 3 | use Cwd qw(cwd); 4 | 5 | repeat_each(2); 6 | 7 | plan tests => repeat_each() * (3 * blocks()); 8 | 9 | my $pwd = cwd(); 10 | 11 | our $HttpConfig = qq{ 12 | lua_package_path "$pwd/lib/?.lua;;"; 13 | }; 14 | 15 | $ENV{TEST_NGINX_RESOLVER} = '8.8.8.8'; 16 | 17 | no_long_string(); 18 | 19 | run_tests(); 20 | 21 | __DATA__ 22 | 23 | === TEST 1: basic with content length 24 | --- http_config eval: $::HttpConfig 25 | --- config 26 | resolver $TEST_NGINX_RESOLVER; 27 | location /foo { 28 | content_by_lua ' 29 | ngx.header.content_length = 7 30 | ngx.say("foobar") 31 | '; 32 | } 33 | 34 | location /t { 35 | content_by_lua ' 36 | local http = require "resty.http.simple" 37 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo" }) 38 | ngx.say(err) 39 | ngx.say(res.body) 40 | ngx.say(res.status) 41 | ngx.say(res.headers["Content-Length"]) 42 | '; 43 | } 44 | --- request 45 | GET /t 46 | --- response_body 47 | nil 48 | foobar 49 | 50 | 200 51 | 7 52 | --- no_error_log 53 | [error] 54 | 55 | 56 | === TEST 2: basic without content length 57 | --- http_config eval: $::HttpConfig 58 | --- config 59 | resolver $TEST_NGINX_RESOLVER; 60 | location /foo { 61 | content_by_lua ' 62 | ngx.say("foobar") 63 | '; 64 | } 65 | 66 | location /t { 67 | content_by_lua ' 68 | local http = require "resty.http.simple" 69 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo" }) 70 | ngx.say(err) 71 | ngx.say(res.body) 72 | ngx.say(res.status) 73 | ngx.say(res.headers["Content-Length"]) 74 | '; 75 | } 76 | --- request 77 | GET /t 78 | --- response_body 79 | nil 80 | foobar 81 | 82 | 200 83 | nil 84 | --- no_error_log 85 | [error] 86 | 87 | 88 | 89 | === TEST 3: basic without content length and HTTP/1.0 90 | --- http_config eval: $::HttpConfig 91 | --- config 92 | lua_http10_buffering off; 93 | resolver $TEST_NGINX_RESOLVER; 94 | location /foo { 95 | content_by_lua ' 96 | ngx.say("foobar") 97 | '; 98 | } 99 | 100 | location /t { 101 | content_by_lua ' 102 | local http = require "resty.http.simple" 103 | local res, err = http.request("127.0.0.1", 1984, { path = "/foo", version = 0 }) 104 | ngx.say(res.body) 105 | ngx.say(res.status) 106 | ngx.say(string.len(res.body)) 107 | '; 108 | } 109 | --- request 110 | GET /t 111 | --- response_body 112 | foobar 113 | 114 | 200 115 | 7 116 | --- no_error_log 117 | [error] --------------------------------------------------------------------------------