diff --git a/http-scm-0.rockspec b/http-scm-0.rockspec index f00e3a64..5516d5a4 100644 --- a/http-scm-0.rockspec +++ b/http-scm-0.rockspec @@ -44,5 +44,6 @@ build = { ["http.websocket"] = "http/websocket.lua"; ["http.zlib"] = "http/zlib.lua"; ["http.compat.prosody"] = "http/compat/prosody.lua"; + ["http.compat.socket"] = "http/compat/socket.lua"; }; } diff --git a/http/compat/socket.lua b/http/compat/socket.lua new file mode 100644 index 00000000..ba4e728b --- /dev/null +++ b/http/compat/socket.lua @@ -0,0 +1,179 @@ +--[[ +Compatibility layer with luasocket's socket.http module +Documentation: http://w3.impa.br/~diego/software/luasocket/http.html + +This module a few key differences: + - The `.create` member is not supported + - The user-agent will be from lua-http + - lua-http features (such as HTTPS and HTTP2) will be used where possible + - trailers are currently discarded + - error messages are differents +]] + +local monotime = require "cqueues".monotime +local ce = require "cqueues.errno" +local request = require "http.request" +local version = require "http.version" +local reason_phrases = require "http.h1_reason_phrases" + +local _M = { + PROXY = nil; -- default proxy used for connections; + TIMEOUT = 60; -- timeout for all I/O operations; + -- default user agent reported to server. + USERAGENT = string.format("%s/%s (luasocket compatibility layer)", + version.name, version.version); +} + +local function ltn12_pump_step(src, snk) + local chunk, src_err = src() + local ret, snk_err = snk(chunk, src_err) + if chunk and ret then return 1 + else return nil, src_err or snk_err end +end + +local function get_body_as_string(stream, deadline) + local body, err = stream:get_body_as_string(deadline and deadline-monotime()) + if not body then + if err == ce.EPIPE then + return nil + else + return nil, err + end + end + return body +end + +local function returns_1() + return 1 +end + +function _M.request(reqt, b) + local deadline = _M.TIMEOUT and (monotime()+_M.TIMEOUT) + local req, proxy, user_headers, get_body + if type(reqt) == "string" then + req = request.new_from_uri(reqt) + proxy = _M.PROXY + if b ~= nil then + assert(type(b) == "string", "body must be nil or string") + req.headers:upsert(":method", "POST") + req:set_body(b) + req.headers:upsert("content-type", "application/x-www-form-urlencoded") + end + get_body = get_body_as_string + else + assert(reqt.create == nil, "'create' option not supported") + req = request.new_from_uri(reqt.url) + proxy = reqt.proxy or _M.PROXY + if reqt.host ~= nil then + req.host = reqt.host + end + if reqt.port ~= nil then + req.port = reqt.port + end + if reqt.method ~= nil then + assert(type(reqt.method) == "string", "'method' option must be nil or string") + req.headers:upsert(":method", reqt.method) + end + if reqt.redirect == false then + req.follow_redirects = false + else + req.max_redirects = 5 - (reqt.nredirects or 0) + end + user_headers = reqt.headers + local step = reqt.step or ltn12_pump_step + local src = reqt.source + if src ~= nil then + local co = coroutine.create(function() + while true do + assert(step(src, coroutine.yield)) + end + end) + req:set_body(function() + -- Pass true through to coroutine to indicate success of last write + local ok, chunk, err = coroutine.resume(co, true) + if not ok then + error(chunk) + elseif err then + error(err) + else + return chunk + end + end) + end + local sink = reqt.sink + -- luasocket returns `1` when using a request table + if sink ~= nil then + get_body = function(stream, deadline) -- luacheck: ignore 431 + local function res_body_source() + local chunk, err = stream:get_next_chunk(deadline and deadline-monotime()) + if not chunk then + if err == ce.EPIPE then + return nil + else + return nil, err + end + end + return chunk + end + -- This loop is the same as ltn12.pump.all + while true do + local ok, err = step(res_body_source, sink) + if not ok then + if err then + return nil, err + else + return 1 + end + end + end + end + else + get_body = returns_1 + end + end + req.headers:upsert("user-agent", _M.USERAGENT) + if proxy then + error("PROXYs are not currently supported by lua-http") + end + if user_headers then + for name, field in pairs(user_headers) do + if name == "host" then + req.headers:upsert(":authority", field) + else + req.headers:append(name:lower(), field) + end + end + end + local res_headers, stream = req:go(deadline and deadline-monotime()) + if not res_headers then + if stream == ce.EPIPE then + return nil, "closed" + elseif stream == ce.ETIMEDOUT then + return nil, "timeout" + else + return nil, stream + end + end + local code = res_headers:get(":status") + local status = reason_phrases[code] + -- In luasocket, status codes are returned as numbers + code = tonumber(code, 10) or code + local headers = {} + for name in res_headers:each() do + if name ~= ":status" and headers[name] == nil then + headers[name] = res_headers:get_comma_separated(name) + end + end + local body, err = get_body(stream, deadline) + stream:shutdown() + if not body then + if err == ce.ETIMEDOUT then + return nil, "timeout" + else + return nil, err + end + end + return body, code, headers, status +end + +return _M diff --git a/spec/compat_socket_spec.lua b/spec/compat_socket_spec.lua new file mode 100644 index 00000000..c5ccb459 --- /dev/null +++ b/spec/compat_socket_spec.lua @@ -0,0 +1,147 @@ +local TEST_TIMEOUT = 2 +describe("http.compat.socket module", function() + local http = require "http.compat.socket" + local new_headers = require "http.headers".new + local server = require "http.server" + local util = require "http.util" + local cqueues = require "cqueues" + local function assert_loop(cq, timeout) + local ok, err, _, thd = cq:loop(timeout) + if not ok then + if thd then + err = debug.traceback(thd, err) + end + error(err, 2) + end + end + it("fails safely on an invalid host", function() + -- in the luasocket example they use 'wrong.host', but 'host' is now a valid TLD. + -- use 'wrong.invalid' instead for this test. + local r, e = http.request("http://wrong.invalid/") + assert.same(r, nil) + -- in luasocket, the error is documented as "host not found", but we allow something else + assert.same("string", type(e)) + end) + it("works against builtin server with GET request", function() + local cq = cqueues.new() + local s = server.listen { + host = "localhost"; + port = 0; + } + assert(s:listen()) + local _, host, port = s:localname() + local authority = util.to_authority(host, port, "http") + cq:wrap(function() + s:run(function (stream) + local request_headers = assert(stream:get_headers()) + s:pause() + assert.same("http", request_headers:get ":scheme") + assert.same("GET", request_headers:get ":method") + assert.same("/foo", request_headers:get ":path") + assert.same(authority, request_headers:get ":authority") + local headers = new_headers() + headers:append(":status", "200") + headers:append("connection", "close") + assert(stream:write_headers(headers, false)) + assert(stream:write_chunk("hello world", true)) + end) + s:close() + end) + cq:wrap(function() + local r, e = http.request("http://"..authority.."/foo") + assert.same("hello world", r) + assert.same(200, e) + end) + assert_loop(cq, TEST_TIMEOUT) + assert.truthy(cq:empty()) + end) + it("works against builtin server with POST request", function() + local cq = cqueues.new() + local s = server.listen { + host = "localhost"; + port = 0; + } + assert(s:listen()) + local _, host, port = s:localname() + local authority = util.to_authority(host, port, "http") + cq:wrap(function() + s:run(function (stream) + s:pause() + local request_headers = assert(stream:get_headers()) + assert.same("http", request_headers:get ":scheme") + assert.same("POST", request_headers:get ":method") + assert.same("/foo", request_headers:get ":path") + assert.same(authority, request_headers:get ":authority") + local body = assert(stream:get_body_as_string()) + assert.same("a body", body) + local headers = new_headers() + headers:append(":status", "201") + headers:append("connection", "close") + assert(stream:write_headers(headers, false)) + assert(stream:write_chunk("hello world", true)) + end) + s:close() + end) + cq:wrap(function() + local r, e = http.request("http://"..authority.."/foo", "a body") + assert.same("hello world", r) + assert.same(201, e) + end) + assert_loop(cq, TEST_TIMEOUT) + assert.truthy(cq:empty()) + end) + it("works against builtin server with complex request", function() + local cq = cqueues.new() + local s = server.listen { + host = "localhost"; + port = 0; + } + assert(s:listen()) + local _, host, port = s:localname() + cq:wrap(function() + s:run(function (stream) + s:pause() + local request_headers = assert(stream:get_headers()) + assert.same("http", request_headers:get ":scheme") + assert.same("PUT", request_headers:get ":method") + assert.same("/path?query", request_headers:get ":path") + assert.same("otherhost.com:8080", request_headers:get ":authority") + assert.same("fun", request_headers:get "myheader") + assert.same("normalised", request_headers:get "camelcase") + assert(stream:write_continue()) + local body = assert(stream:get_body_as_string()) + assert.same("a body", body) + local headers = new_headers() + headers:append(":status", "404") + headers:append("connection", "close") + assert(stream:write_headers(headers, false)) + assert(stream:write_chunk("hello world", true)) + end) + s:close() + end) + cq:wrap(function() + local r, e = assert(http.request { + url = "http://example.com/path?query"; + host = host; + port = port; + method = "PUT"; + headers = { + host = "otherhost.com:8080"; + myheader = "fun"; + CamelCase = "normalised"; + }; + source = coroutine.wrap(function() + coroutine.yield("a body") + end); + sink = coroutine.wrap(function(b) + assert.same("hello world", b) + assert.same(nil, coroutine.yield(true)) + end); + }) + assert.same(1, r) + assert.same(404, e) + end) + assert_loop(cq, TEST_TIMEOUT) + assert.truthy(cq:empty()) + end) +end)