From 28f16d7db9d5c23122a991777fdb1eb4f2b96450 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Tue, 19 Mar 2019 14:36:48 +0300 Subject: [PATCH 01/10] Initial commit; partially failing tests --- http/nginx_server.lua | 136 +++++ http/router.lua | 354 +++++++++++ http/router/fs.lua | 258 ++++++++ http/router/request.lua | 207 +++++++ http/router/response.lua | 75 +++ http/server.lua | 1080 +++------------------------------ http/tsgi.lua | 122 ++++ http/utils.lua | 83 +++ rockspecs/http-scm-1.rockspec | 6 + test/http.test.lua | 107 ++-- test/nginx.conf | 84 +++ 11 files changed, 1490 insertions(+), 1022 deletions(-) create mode 100644 http/nginx_server.lua create mode 100644 http/router.lua create mode 100644 http/router/fs.lua create mode 100644 http/router/request.lua create mode 100644 http/router/response.lua create mode 100644 http/tsgi.lua create mode 100644 http/utils.lua create mode 100644 test/nginx.conf diff --git a/http/nginx_server.lua b/http/nginx_server.lua new file mode 100644 index 0000000..6f6c214 --- /dev/null +++ b/http/nginx_server.lua @@ -0,0 +1,136 @@ +local tsgi = require('http.tsgi') + +local json = require('json') +local log = require('log') + +local KEY_BODY = 'tsgi.http.nginx_server.body' + +local self + +local function noop() end + +local function convert_headername(name) + return 'HEADER_' .. string.upper(name) +end + +local function tsgi_input_read(env) + return env[KEY_BODY] +end + +local function make_env(req) + -- in nginx dont provide `parse_query` for this to work + local uriparts = string.split(req.uri, '?') -- luacheck: ignore + local path_info, query_string = uriparts[1], uriparts[2] + + local body = '' + if type(req.body) == 'string' then + body = json.decode(req.body).params + end + + local env = { + ['tsgi.version'] = '1', + ['tsgi.url_scheme'] = 'http', -- no support for https + ['tsgi.input'] = { + read = tsgi_input_read, + rewind = nil, -- TODO + }, + ['tsgi.errors'] = { + write = noop, + flush = noop, + }, + ['tsgi.hijack'] = nil, -- no hijack with nginx + ['REQUEST_METHOD'] = string.upper(req.method), + ['SERVER_NAME'] = self.host, + ['SERVER_PORT'] = self.port, + ['SCRIPT_NAME'] = '', -- TODO: what do we put here? + ['PATH_INFO'] = path_info, + ['QUERY_STRING'] = query_string, + ['SERVER_PROTOCOL'] = req.proto, + + [tsgi.KEY_PEER] = { + host = self.host, + port = self.port, + }, + + [KEY_BODY] = body, -- http body string; used in `tsgi_input_read` + } + + for name, value in pairs(req.headers) do + env[convert_headername(name)] = value + end + + return env +end + +function nginx_entrypoint(req, ...) -- luacheck: ignore + local env = make_env(req, ...) + + local ok, resp = pcall(self.router, env) + + local status = resp.status or 200 + local headers = resp.headers or {} + local body = resp.body or '' + + if not ok then + status = 500 + headers = {} + local trace = debug.traceback() + local p = 'TODO_REQUEST_DESCRIPTION' -- TODO + + log.error('unhandled error: %s\n%s\nrequest:\n%s', + tostring(resp), trace, tostring(p)) -- TODO: tostring(p) + + if self.display_errors then + body = + "Unhandled error: " .. tostring(resp) .. "\n" + .. trace .. "\n\n" + .. "\n\nRequest:\n" + .. tostring(p) -- TODO: tostring(p) + else + body = "Internal Error" + end + end + + -- handle iterable body + local gen, param, state + + if type(body) == 'function' then + -- Generating function + gen = body + elseif type(body) == 'table' and body.gen then + -- Iterator + gen, param, state = body.gen, body.param, body.state + end + + if gen ~= nil then + body = '' + for _, part in gen, param, state do + body = body .. tostring(part) + end + end + + return status, headers, body +end + +local function ngxserver_set_router(_, router) + self.router = router +end + +local function init(opts) + if not self then + self = { + host = opts.host, + port = opts.port, + display_errors = opts.display_errors or true, + + set_router = ngxserver_set_router, + start = noop, -- TODO: fix + stop = noop -- TODO: fix + } + end + return self +end + +return { + init = init, +} diff --git a/http/router.lua b/http/router.lua new file mode 100644 index 0000000..99dae45 --- /dev/null +++ b/http/router.lua @@ -0,0 +1,354 @@ +-- http.server + +local fs = require('http.router.fs') +local request_metatable = require('http.router.request').metatable +local utils = require('http.utils') + +local function uri_file_extension(s, default) + -- cut from last dot till the end + local ext = string.match(s, '[.]([^.]+)$') + if ext ~= nil then + return ext + end + return default +end + +-- TODO: move to router.request? +local function url_for_helper(tx, name, args, query) + return tx:url_for(name, args, query) +end + +local function request_from_env(env, router) -- luacheck: ignore + local tsgi = require('http.tsgi') + + local request = {} + request.router = router + request.env = env + request.peer = env[tsgi.KEY_PEER] -- TODO: delete + + return setmetatable(request, request_metatable) +end + +local function handler(self, env) + local request = request_from_env(env, self) + + if self.hooks.before_dispatch ~= nil then + self.hooks.before_dispatch(self, request) + end + + local format = uri_file_extension(request.env['PATH_INFO'], 'html') + + -- Try to find matching route, + -- if failed, try static file. + -- + -- `r` is route-info (TODO: ???), this is dispatching at its glory + + local r = self:match(request.env['REQUEST_METHOD'], request.env['PATH_INFO']) + if r == nil then + return fs.static_file(self, request, format) + end + + local stash = utils.extend(r.stash, { format = format }) + + request.endpoint = r.endpoint -- THIS IS ROUTE, BUT IS NAMED `ENDPOINT`! OH-MY-GOD! + request.tstash = stash + + -- execute user-specified request handler + local resp = r.endpoint.sub(request) + + if self.hooks.after_dispatch ~= nil then + self.hooks.after_dispatch(request, resp) + end + return resp +end + +-- TODO: `route` is not route, but path... +local function match_route(self, method, route) + -- route must have '/' at the begin and end + if string.match(route, '.$') ~= '/' then + route = route .. '/' + end + if string.match(route, '^.') ~= '/' then + route = '/' .. route + end + + method = string.upper(method) + + local fit + local stash = {} + + for k, r in pairs(self.routes) do + if r.method == method or r.method == 'ANY' then + local m = { string.match(route, r.match) } + local nfit + if #m > 0 then + if #r.stash > 0 then + if #r.stash == #m then + nfit = r + end + else + nfit = r + end + + if nfit ~= nil then + if fit == nil then + fit = nfit + stash = m + else + if #fit.stash > #nfit.stash then + fit = nfit + stash = m + elseif r.method ~= fit.method then + if fit.method == 'ANY' then + fit = nfit + stash = m + end + end + end + end + end + end + end + + if fit == nil then + return fit + end + local resstash = {} + for i = 1, #fit.stash do + resstash[ fit.stash[ i ] ] = stash[ i ] + end + return { endpoint = fit, stash = resstash } +end + +local function set_helper(self, name, sub) + if sub == nil or type(sub) == 'function' then + self.helpers[ name ] = sub + return self + end + utils.errorf("Wrong type for helper function: %s", type(sub)) +end + +local function set_hook(self, name, sub) + if sub == nil or type(sub) == 'function' then + self.hooks[ name ] = sub + return self + end + utils.errorf("Wrong type for hook function: %s", type(sub)) +end + +local function url_for_route(r, args, query) + if args == nil then + args = {} + end + local name = r.path + for i, sn in pairs(r.stash) do + local sv = args[sn] + if sv == nil then + sv = '' + end + name = string.gsub(name, '[*:]' .. sn, sv, 1) + end + + if query ~= nil then + if type(query) == 'table' then + local sep = '?' + for k, v in pairs(query) do + name = name .. sep .. utils.uri_escape(k) .. '=' .. utils.uri_escape(v) + sep = '&' + end + else + name = name .. '?' .. query + end + end + + if string.match(name, '^/') == nil then + return '/' .. name + else + return name + end +end + +local possible_methods = { + GET = 'GET', + HEAD = 'HEAD', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', +} + +local function add_route(self, opts, sub) + if type(opts) ~= 'table' or type(self) ~= 'table' then + error("Usage: router:route({ ... }, function(cx) ... end)") + end + + opts = utils.extend({method = 'ANY'}, opts, false) + + local ctx + local action + + if sub == nil then + sub = fs.render + elseif type(sub) == 'string' then + + ctx, action = string.match(sub, '(.+)#(.*)') + + if ctx == nil or action == nil then + utils.errorf("Wrong controller format '%s', must be 'module#action'", sub) + end + + sub = fs.ctx_action + + elseif type(sub) ~= 'function' then + utils.errorf("wrong argument: expected function, but received %s", + type(sub)) + end + + opts.method = possible_methods[string.upper(opts.method)] or 'ANY' + + if opts.path == nil then + error("path is not defined") + end + + opts.controller = ctx + opts.action = action + opts.match = opts.path + opts.match = string.gsub(opts.match, '[-]', "[-]") + + -- convert user-specified route URL to regexp, + -- and initialize stashes + local estash = { } + local stash = { } + while true do + local name = string.match(opts.match, ':([%a_][%w_]*)') + if name == nil then + break + end + if estash[name] then + utils.errorf("duplicate stash: %s", name) + end + estash[name] = true + opts.match = string.gsub(opts.match, ':[%a_][%w_]*', '([^/]-)', 1) + + table.insert(stash, name) + end + while true do + local name = string.match(opts.match, '[*]([%a_][%w_]*)') + if name == nil then + break + end + if estash[name] then + utils.errorf("duplicate stash: %s", name) + end + estash[name] = true + opts.match = string.gsub(opts.match, '[*][%a_][%w_]*', '(.-)', 1) + + table.insert(stash, name) + end + + -- ensure opts.match is like '^/xxx/$' + do + if string.match(opts.match, '.$') ~= '/' then + opts.match = opts.match .. '/' + end + if string.match(opts.match, '^.') ~= '/' then + opts.match = '/' .. opts.match + end + opts.match = '^' .. opts.match .. '$' + end + + estash = nil + + opts.stash = stash + opts.sub = sub + opts.url_for = url_for_route + + -- register new route in a router + if opts.name ~= nil then + if opts.name == 'current' then + error("Route can not have name 'current'") + end + if self.iroutes[ opts.name ] ~= nil then + utils.errorf("Route with name '%s' is already exists", opts.name) + end + table.insert(self.routes, opts) + self.iroutes[ opts.name ] = #self.routes + else + table.insert(self.routes, opts) + end + return self +end + +local function url_for(self, name, args, query) + local idx = self.iroutes[ name ] + if idx ~= nil then + return self.routes[ idx ]:url_for(args, query) + end + + if string.match(name, '^/') == nil then + if string.match(name, '^https?://') ~= nil then + return name + else + return '/' .. name + end + else + return name + end +end + +local exports = { + new = function(httpd, options) + if options == nil then + options = {} + end + if type(options) ~= 'table' then + utils.errorf("options must be table not '%s'", type(options)) + end + + local default = { + max_header_size = 4096, + header_timeout = 100, + app_dir = '.', + charset = 'utf-8', + cache_templates = true, + cache_controllers = true, + cache_static = true, + } + + local self = { + options = utils.extend(default, options, true), + + routes = { }, -- routes array + iroutes = { }, -- routes by name + helpers = { -- for use in templates + url_for = url_for_helper, + }, + hooks = { }, -- middleware + + -- methods + route = add_route, -- add route + helper = set_helper, -- for use in templates + hook = set_hook, -- middleware + url_for = url_for, + + -- private + match = match_route, + + -- caches + cache = { + tpl = {}, + ctx = {}, + static = {}, + }, + } + + -- make router object itself callable + httpd:set_router(function (env) + return handler(self, env) + end) + + return self + end +} + +return exports diff --git a/http/router/fs.lua b/http/router/fs.lua new file mode 100644 index 0000000..1e07225 --- /dev/null +++ b/http/router/fs.lua @@ -0,0 +1,258 @@ +local lib = require('http.lib') +local utils = require('http.utils') +local mime_types = require('http.mime_types') +local response = require('http.router.response') + +local json = require('json') + +local function type_by_format(fmt) + if fmt == nil then + return 'application/octet-stream' + end + + local t = mime_types[ fmt ] + + if t ~= nil then + return t + end + + return 'application/octet-stream' +end + +local function catfile(...) + local sp = { ... } + + local path + + if #sp == 0 then + return + end + + for i, pe in pairs(sp) do + if path == nil then + path = pe + elseif string.match(path, '.$') ~= '/' then + if string.match(pe, '^.') ~= '/' then + path = path .. '/' .. pe + else + path = path .. pe + end + else + if string.match(pe, '^.') == '/' then + path = path .. string.gsub(pe, '^/', '', 1) + else + path = path .. pe + end + end + end + + return path +end + +local function static_file(self, request, format) + local file = catfile(self.options.app_dir, 'public', request.env['PATH_INFO']) + + if self.options.cache_static and self.cache.static[ file ] ~= nil then + return { + code = 200, + headers = { + [ 'content-type'] = type_by_format(format), + }, + body = self.cache.static[ file ] + } + end + + local s, fh = pcall(io.input, file) + + if not s then + return { status = 404 } + end + + local body = fh:read('*a') + io.close(fh) + + if self.options.cache_static then + self.cache.static[ file ] = body + end + + return { + status = 200, + headers = { + [ 'content-type'] = type_by_format(format), + }, + body = body + } +end + + +local function ctx_action(tx) + local ctx = tx.endpoint.controller + local action = tx.endpoint.action + if tx.router.options.cache_controllers then + if tx.router.cache[ ctx ] ~= nil then + if type(tx.router.cache[ ctx ][ action ]) ~= 'function' then + utils.errorf("Controller '%s' doesn't contain function '%s'", + ctx, action) + end + return tx.router.cache[ ctx ][ action ](tx) + end + end + + local ppath = package.path + package.path = catfile(tx.router.options.app_dir, 'controllers', '?.lua') + .. ';' + .. catfile(tx.router.options.app_dir, + 'controllers', '?/init.lua') + if ppath ~= nil then + package.path = package.path .. ';' .. ppath + end + + local st, mod = pcall(require, ctx) + package.path = ppath + package.loaded[ ctx ] = nil + + if not st then + utils.errorf("Can't load module '%s': %s'", ctx, tostring(mod)) + end + + if type(mod) ~= 'table' then + utils.errorf("require '%s' didn't return table", ctx) + end + + if type(mod[ action ]) ~= 'function' then + utils.errorf("Controller '%s' doesn't contain function '%s'", ctx, action) + end + + if tx.router.options.cache_controllers then + tx.router.cache[ ctx ] = mod + end + + return mod[action](tx) +end + +local function load_template(self, r, format) + if r.template ~= nil then + return + end + + if format == nil then + format = 'html' + end + + local file + if r.file ~= nil then + file = r.file + elseif r.controller ~= nil and r.action ~= nil then + file = catfile( + string.gsub(r.controller, '[.]', '/'), + r.action .. '.' .. format .. '.el') + else + utils.errorf("Can not find template for '%s'", r.path) + end + + if self.options.cache_templates then + if self.cache.tpl[ file ] ~= nil then + return self.cache.tpl[ file ] + end + end + + + local tpl = catfile(self.options.app_dir, 'templates', file) + local fh = io.input(tpl) + local template = fh:read('*a') + fh:close() + + if self.options.cache_templates then + self.cache.tpl[ file ] = template + end + return template +end + + +local function render(tx, opts) + if tx == nil then + error("Usage: self:render({ ... })") + end + + local resp = setmetatable({ headers = {} }, response.metatable) + local vars = {} + if opts ~= nil then + if opts.text ~= nil then + if tx.router.options.charset ~= nil then + resp.headers['content-type'] = + utils.sprintf("text/plain; charset=%s", + tx.router.options.charset + ) + else + resp.headers['content-type'] = 'text/plain' + end + resp.body = tostring(opts.text) + return resp + end + + -- TODO + if opts.json ~= nil then + if tx.router.options.charset ~= nil then + resp.headers['content-type'] = + utils.sprintf('application/json; charset=%s', + tx.router.options.charset + ) + else + resp.headers['content-type'] = 'application/json' + end + resp.body = json.encode(opts.json) + return resp + end + + if opts.data ~= nil then + resp.body = tostring(opts.data) + return resp + end + + vars = utils.extend(tx.tstash, opts, false) + end + + local tpl + + local format = tx.tstash.format + if format == nil then + format = 'html' + end + + if tx.endpoint.template ~= nil then + tpl = tx.endpoint.template + else + tpl = load_template(tx.router, tx.endpoint, format) + if tpl == nil then + utils.errorf('template is not defined for the route') + end + end + + if type(tpl) == 'function' then + tpl = tpl() + end + + for hname, sub in pairs(tx.router.helpers) do + vars[hname] = function(...) return sub(tx, ...) end + end + vars.action = tx.endpoint.action + vars.controller = tx.endpoint.controller + vars.format = format + + resp.body = lib.template(tpl, vars) + resp.headers['content-type'] = type_by_format(format) + + if tx.router.options.charset ~= nil then + if format == 'html' or format == 'js' or format == 'json' then + resp.headers['content-type'] = resp.headers['content-type'] + .. '; charset=' .. tx.router.options.charset + end + end + return resp +end + +return { + render = render, + ctx_action = ctx_action, + static_file = static_file, +} diff --git a/http/router/request.lua b/http/router/request.lua new file mode 100644 index 0000000..dd5cf48 --- /dev/null +++ b/http/router/request.lua @@ -0,0 +1,207 @@ +local fs = require('http.router.fs') +local response = require('http.router.response') +local utils = require('http.utils') +local lib = require('http.lib') +local tsgi = require('http.tsgi') + +local json = require('json') + +local function cached_query_param(self, name) + if name == nil then + return self.query_params + end + return self.query_params[ name ] +end + +local function cached_post_param(self, name) + if name == nil then + return self.post_params + end + return self.post_params[ name ] +end + +local function request_tostring(self) + local res = self:request_line() .. "\r\n" + + for hn, hv in pairs(tsgi.headers(self.env)) do + res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) + end + + return utils.sprintf("%s\r\n%s", res, self:read_cached()) +end + +local function request_line(self) + local rstr = self.env['PATH_INFO'] + if string.len(self.env['QUERY_STRING']) then + rstr = rstr .. '?' .. self.env['QUERY_STRING'] + end + return utils.sprintf("%s %s %s", + self.env['REQUEST_METHOD'], + rstr, + self.env['SERVER_PROTOCOL'] or 'HTTP/?') +end + +local function query_param(self, name) + if self.env['QUERY_STRING'] == nil and string.len(self.env['QUERY_STRING']) == 0 then + rawset(self, 'query_params', {}) + else + local params = lib.params(self.env['QUERY_STRING']) + local pres = {} + for k, v in pairs(params) do + pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v) + end + rawset(self, 'query_params', pres) + end + + rawset(self, 'query_param', cached_query_param) + return self:query_param(name) +end + +local function request_content_type(self) + -- returns content type without encoding string + if self.env['HEADER_CONTENT-TYPE'] == nil then + return nil + end + + return string.match(self.env['HEADER_CONTENT-TYPE'], + '^([^;]*)$') or + string.match(self.env['HEADER_CONTENT-TYPE'], + '^(.*);.*') +end + +local function post_param(self, name) + local content_type = self:content_type() + if self:content_type() == 'multipart/form-data' then + -- TODO: do that! + rawset(self, 'post_params', {}) + elseif self:content_type() == 'application/json' then + local params = self:json() + rawset(self, 'post_params', params) + elseif self:content_type() == 'application/x-www-form-urlencoded' then + local params = lib.params(self:read_cached()) + local pres = {} + for k, v in pairs(params) do + pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v, true) + end + rawset(self, 'post_params', pres) + else + local params = lib.params(self:read_cached()) + local pres = {} + for k, v in pairs(params) do + pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v) + end + rawset(self, 'post_params', pres) + end + + rawset(self, 'post_param', cached_post_param) + return self:post_param(name) +end + + +local function param(self, name) + if name ~= nil then + local v = self:post_param(name) + if v ~= nil then + return v + end + return self:query_param(name) + end + + local post = self:post_param() + local query = self:query_param() + return utils.extend(post, query, false) +end + +local function cookie(self, cookiename) + if self.env['HEADER_COOKIE'] == nil then + return nil + end + for k, v in string.gmatch( + self.env['HEADER_COOKIE'], "([^=,; \t]+)=([^,; \t]+)") do + if k == cookiename then + return utils.uri_unescape(v) + end + end + return nil +end + +local function iterate(self, gen, param, state) + return setmetatable({ body = { gen = gen, param = param, state = state } }, + response.metatable) +end + +local function redirect_to(self, name, args, query) + local location = self:url_for(name, args, query) + return setmetatable({ status = 302, headers = { location = location } }, + response.metatable) +end + +local function access_stash(self, name, ...) + if type(self) ~= 'table' then + error("usage: request:stash('name'[, 'value'])") + end + if select('#', ...) > 0 then + self.tstash[ name ] = select(1, ...) + end + + return self.tstash[ name ] +end + +local function url_for_request(self, name, args, query) + if name == 'current' then + return self.endpoint:url_for(args, query) + end + return self.router:url_for(name, args, query) +end + + +local function request_json(req) + local data = req:read_cached() + local s, json = pcall(json.decode, data) + if s then + return json + else + error(utils.sprintf("Can't decode json in request '%s': %s", + data, tostring(json))) + return nil + end +end + +local function request_read(self, opts, timeout) + local env = self.env + return env['tsgi.input'].read(env, opts, timeout) +end + +local function request_read_cached(self) + if self.cached_data == nil then + local env = self.env + local data = env['tsgi.input'].read(env) + rawset(self, 'cached_data', data) + return data + else + return self.cached_data + end +end + +local metatable = { + __index = { + render = fs.render, + cookie = cookie, + redirect_to = redirect_to, + iterate = iterate, + stash = access_stash, + url_for = url_for_request, + content_type= request_content_type, + request_line= request_line, + read_cached = request_read_cached, + + query_param = query_param, + post_param = post_param, + param = param, + + read = request_read, + json = request_json + }, + __tostring = request_tostring; +} +return {metatable = metatable} diff --git a/http/router/response.lua b/http/router/response.lua new file mode 100644 index 0000000..6b9b4d9 --- /dev/null +++ b/http/router/response.lua @@ -0,0 +1,75 @@ +local utils = require('http.utils') + +local function expires_str(str) + local now = os.time() + local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now))) + local fmt = '%a, %d-%b-%Y %H:%M:%S GMT' + + if str == 'now' or str == 0 or str == '0' then + return os.date(fmt, gmtnow) + end + + local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$') + if period == nil then + return str + end + + diff = tonumber(diff) + if period == 'h' then + diff = diff * 3600 + elseif period == 'd' then + diff = diff * 86400 + elseif period == 'm' then + diff = diff * 86400 * 30 + else + diff = diff * 86400 * 365 + end + + return os.date(fmt, gmtnow + diff) +end + +local function setcookie(resp, cookie) + local name = cookie.name + local value = cookie.value + + if name == nil then + error('cookie.name is undefined') + end + if value == nil then + error('cookie.value is undefined') + end + + local str = utils.sprintf('%s=%s', name, utils.uri_escape(value)) + if cookie.path ~= nil then + str = utils.sprintf('%s;path=%s', str, utils.uri_escape(cookie.path)) + end + if cookie.domain ~= nil then + str = utils.sprintf('%s;domain=%s', str, cookie.domain) + end + + if cookie.expires ~= nil then + str = utils.sprintf('%s;expires="%s"', str, expires_str(cookie.expires)) + end + + if not resp.headers then + resp.headers = {} + end + if resp.headers['set-cookie'] == nil then + resp.headers['set-cookie'] = { str } + elseif type(resp.headers['set-cookie']) == 'string' then + resp.headers['set-cookie'] = { + resp.headers['set-cookie'], + str + } + else + table.insert(resp.headers['set-cookie'], str) + end + return resp +end + +local metatable = { + __index = { + setcookie = setcookie; + } +} +return {metatable = metatable} diff --git a/http/server.lua b/http/server.lua index ea014f2..57ac2a6 100644 --- a/http/server.lua +++ b/http/server.lua @@ -1,623 +1,17 @@ --- http.server - local lib = require('http.lib') - -local io = io -local require = require -local package = package -local mime_types = require('http.mime_types') -local codes = require('http.codes') +local tsgi = require('http.tsgi') +local utils = require('http.utils') local log = require('log') + local socket = require('socket') -local json = require('json') -local errno = require 'errno' +local errno = require('errno') local DETACHED = 101 -local function errorf(fmt, ...) - error(string.format(fmt, ...)) -end - -local function sprintf(fmt, ...) - return string.format(fmt, ...) -end - -local function uri_escape(str) - local res = {} - if type(str) == 'table' then - for _, v in pairs(str) do - table.insert(res, uri_escape(v)) - end - else - res = string.gsub(str, '[^a-zA-Z0-9_]', - function(c) - return string.format('%%%02X', string.byte(c)) - end - ) - end - return res -end - -local function uri_unescape(str, unescape_plus_sign) - local res = {} - if type(str) == 'table' then - for _, v in pairs(str) do - table.insert(res, uri_unescape(v)) - end - else - if unescape_plus_sign ~= nil then - str = string.gsub(str, '+', ' ') - end - - res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', - function(c) - return string.char(tonumber(c, 16)) - end - ) - end - return res -end - -local function extend(tbl, tblu, raise) - local res = {} - for k, v in pairs(tbl) do - res[ k ] = v - end - for k, v in pairs(tblu) do - if raise then - if res[ k ] == nil then - errorf("Unknown option '%s'", k) - end - end - res[ k ] = v - end - return res -end - -local function type_by_format(fmt) - if fmt == nil then - return 'application/octet-stream' - end - - local t = mime_types[ fmt ] - - if t ~= nil then - return t - end - - return 'application/octet-stream' -end - -local function reason_by_code(code) - code = tonumber(code) - if codes[code] ~= nil then - return codes[code] - end - return sprintf('Unknown code %d', code) -end - -local function ucfirst(str) - return str:gsub("^%l", string.upper, 1) -end - -local function cached_query_param(self, name) - if name == nil then - return self.query_params - end - return self.query_params[ name ] -end - -local function cached_post_param(self, name) - if name == nil then - return self.post_params - end - return self.post_params[ name ] -end - -local function request_tostring(self) - local res = self:request_line() .. "\r\n" - - for hn, hv in pairs(self.headers) do - res = sprintf("%s%s: %s\r\n", res, ucfirst(hn), hv) - end - - return sprintf("%s\r\n%s", res, self.body) -end - -local function request_line(self) - local rstr = self.path - if string.len(self.query) then - rstr = rstr .. '?' .. self.query - end - return sprintf("%s %s HTTP/%d.%d", - self.method, rstr, self.proto[1], self.proto[2]) -end - -local function query_param(self, name) - if self.query == nil and string.len(self.query) == 0 then - rawset(self, 'query_params', {}) - else - local params = lib.params(self.query) - local pres = {} - for k, v in pairs(params) do - pres[ uri_unescape(k) ] = uri_unescape(v) - end - rawset(self, 'query_params', pres) - end - - rawset(self, 'query_param', cached_query_param) - return self:query_param(name) -end - -local function request_content_type(self) - -- returns content type without encoding string - if self.headers['content-type'] == nil then - return nil - end - - return string.match(self.headers['content-type'], - '^([^;]*)$') or - string.match(self.headers['content-type'], - '^(.*);.*') -end - -local function post_param(self, name) - local content_type = self:content_type() - if self:content_type() == 'multipart/form-data' then - -- TODO: do that! - rawset(self, 'post_params', {}) - elseif self:content_type() == 'application/json' then - local params = self:json() - rawset(self, 'post_params', params) - elseif self:content_type() == 'application/x-www-form-urlencoded' then - local params = lib.params(self:read_cached()) - local pres = {} - for k, v in pairs(params) do - pres[ uri_unescape(k) ] = uri_unescape(v, true) - end - rawset(self, 'post_params', pres) - else - local params = lib.params(self:read_cached()) - local pres = {} - for k, v in pairs(params) do - pres[ uri_unescape(k) ] = uri_unescape(v) - end - rawset(self, 'post_params', pres) - end - - rawset(self, 'post_param', cached_post_param) - return self:post_param(name) -end - -local function param(self, name) - if name ~= nil then - local v = self:post_param(name) - if v ~= nil then - return v - end - return self:query_param(name) - end - - local post = self:post_param() - local query = self:query_param() - return extend(post, query, false) -end - -local function catfile(...) - local sp = { ... } - - local path - - if #sp == 0 then - return - end - - for i, pe in pairs(sp) do - if path == nil then - path = pe - elseif string.match(path, '.$') ~= '/' then - if string.match(pe, '^.') ~= '/' then - path = path .. '/' .. pe - else - path = path .. pe - end - else - if string.match(pe, '^.') == '/' then - path = path .. string.gsub(pe, '^/', '', 1) - else - path = path .. pe - end - end - end - - return path -end - -local response_mt -local request_mt - -local function expires_str(str) - - local now = os.time() - local gmtnow = now - os.difftime(now, os.time(os.date("!*t", now))) - local fmt = '%a, %d-%b-%Y %H:%M:%S GMT' - - if str == 'now' or str == 0 or str == '0' then - return os.date(fmt, gmtnow) - end - - local diff, period = string.match(str, '^[+]?(%d+)([hdmy])$') - if period == nil then - return str - end - - diff = tonumber(diff) - if period == 'h' then - diff = diff * 3600 - elseif period == 'd' then - diff = diff * 86400 - elseif period == 'm' then - diff = diff * 86400 * 30 - else - diff = diff * 86400 * 365 - end - - return os.date(fmt, gmtnow + diff) -end - -local function setcookie(resp, cookie) - local name = cookie.name - local value = cookie.value - - if name == nil then - error('cookie.name is undefined') - end - if value == nil then - error('cookie.value is undefined') - end - - local str = sprintf('%s=%s', name, uri_escape(value)) - if cookie.path ~= nil then - str = sprintf('%s;path=%s', str, uri_escape(cookie.path)) - end - if cookie.domain ~= nil then - str = sprintf('%s;domain=%s', str, cookie.domain) - end - - if cookie.expires ~= nil then - str = sprintf('%s;expires="%s"', str, expires_str(cookie.expires)) - end - - if not resp.headers then - resp.headers = {} - end - if resp.headers['set-cookie'] == nil then - resp.headers['set-cookie'] = { str } - elseif type(resp.headers['set-cookie']) == 'string' then - resp.headers['set-cookie'] = { - resp.headers['set-cookie'], - str - } - else - table.insert(resp.headers['set-cookie'], str) - end - return resp -end - -local function cookie(tx, cookie) - if tx.headers.cookie == nil then - return nil - end - for k, v in string.gmatch( - tx.headers.cookie, "([^=,; \t]+)=([^,; \t]+)") do - if k == cookie then - return uri_unescape(v) - end - end - return nil -end - -local function url_for_helper(tx, name, args, query) - return tx:url_for(name, args, query) -end - -local function load_template(self, r, format) - if r.template ~= nil then - return - end - - if format == nil then - format = 'html' - end - - local file - if r.file ~= nil then - file = r.file - elseif r.controller ~= nil and r.action ~= nil then - file = catfile( - string.gsub(r.controller, '[.]', '/'), - r.action .. '.' .. format .. '.el') - else - errorf("Can not find template for '%s'", r.path) - end - - if self.options.cache_templates then - if self.cache.tpl[ file ] ~= nil then - return self.cache.tpl[ file ] - end - end - - - local tpl = catfile(self.options.app_dir, 'templates', file) - local fh = io.input(tpl) - local template = fh:read('*a') - fh:close() - - if self.options.cache_templates then - self.cache.tpl[ file ] = template - end - return template -end - -local function render(tx, opts) - if tx == nil then - error("Usage: self:render({ ... })") - end - - local resp = setmetatable({ headers = {} }, response_mt) - local vars = {} - if opts ~= nil then - if opts.text ~= nil then - if tx.httpd.options.charset ~= nil then - resp.headers['content-type'] = - sprintf("text/plain; charset=%s", - tx.httpd.options.charset - ) - else - resp.headers['content-type'] = 'text/plain' - end - resp.body = tostring(opts.text) - return resp - end - - if opts.json ~= nil then - if tx.httpd.options.charset ~= nil then - resp.headers['content-type'] = - sprintf('application/json; charset=%s', - tx.httpd.options.charset - ) - else - resp.headers['content-type'] = 'application/json' - end - resp.body = json.encode(opts.json) - return resp - end - - if opts.data ~= nil then - resp.body = tostring(opts.data) - return resp - end - - vars = extend(tx.tstash, opts, false) - end - - local tpl - - local format = tx.tstash.format - if format == nil then - format = 'html' - end - - if tx.endpoint.template ~= nil then - tpl = tx.endpoint.template - else - tpl = load_template(tx.httpd, tx.endpoint, format) - if tpl == nil then - errorf('template is not defined for the route') - end - end - - if type(tpl) == 'function' then - tpl = tpl() - end - - for hname, sub in pairs(tx.httpd.helpers) do - vars[hname] = function(...) return sub(tx, ...) end - end - vars.action = tx.endpoint.action - vars.controller = tx.endpoint.controller - vars.format = format - - resp.body = lib.template(tpl, vars) - resp.headers['content-type'] = type_by_format(format) - - if tx.httpd.options.charset ~= nil then - if format == 'html' or format == 'js' or format == 'json' then - resp.headers['content-type'] = resp.headers['content-type'] - .. '; charset=' .. tx.httpd.options.charset - end - end - return resp -end - -local function iterate(tx, gen, param, state) - return setmetatable({ body = { gen = gen, param = param, state = state } }, - response_mt) -end - -local function redirect_to(tx, name, args, query) - local location = tx:url_for(name, args, query) - return setmetatable({ status = 302, headers = { location = location } }, - response_mt) -end - -local function access_stash(tx, name, ...) - if type(tx) ~= 'table' then - error("usage: ctx:stash('name'[, 'value'])") - end - if select('#', ...) > 0 then - tx.tstash[ name ] = select(1, ...) - end - - return tx.tstash[ name ] -end - -local function url_for_tx(tx, name, args, query) - if name == 'current' then - return tx.endpoint:url_for(args, query) - end - return tx.httpd:url_for(name, args, query) -end - -local function request_json(req) - local data = req:read_cached() - local s, json = pcall(json.decode, data) - if s then - return json - else - error(sprintf("Can't decode json in request '%s': %s", - data, tostring(json))) - return nil - end -end - -local function request_read(req, opts, timeout) - local remaining = req._remaining - if not remaining then - remaining = tonumber(req.headers['content-length']) - if not remaining then - return '' - end - end - - if opts == nil then - opts = remaining - elseif type(opts) == 'number' then - if opts > remaining then - opts = remaining - end - elseif type(opts) == 'string' then - opts = { size = remaining, delimiter = opts } - elseif type(opts) == 'table' then - local size = opts.size or opts.chunk - if size and size > remaining then - opts.size = remaining - opts.chunk = nil - end - end - - local buf = req.s:read(opts, timeout) - if buf == nil then - req._remaining = 0 - return '' - end - remaining = remaining - #buf - assert(remaining >= 0) - req._remaining = remaining - return buf -end - -local function request_read_cached(self) - if self.cached_data == nil then - local data = self:read() - rawset(self, 'cached_data', data) - return data - else - return self.cached_data - end -end - -local function static_file(self, request, format) - local file = catfile(self.options.app_dir, 'public', request.path) - - if self.options.cache_static and self.cache.static[ file ] ~= nil then - return { - code = 200, - headers = { - [ 'content-type'] = type_by_format(format), - }, - body = self.cache.static[ file ] - } - end - - local s, fh = pcall(io.input, file) - - if not s then - return { status = 404 } - end - - local body = fh:read('*a') - io.close(fh) - - if self.options.cache_static then - self.cache.static[ file ] = body - end - - return { - status = 200, - headers = { - [ 'content-type'] = type_by_format(format), - }, - body = body - } -end - -request_mt = { - __index = { - render = render, - cookie = cookie, - redirect_to = redirect_to, - iterate = iterate, - stash = access_stash, - url_for = url_for_tx, - content_type= request_content_type, - request_line= request_line, - read_cached = request_read_cached, - query_param = query_param, - post_param = post_param, - param = param, - read = request_read, - json = request_json - }, - __tostring = request_tostring; -} - -response_mt = { - __index = { - setcookie = setcookie; - } -} - -local function handler(self, request) - if self.hooks.before_dispatch ~= nil then - self.hooks.before_dispatch(self, request) - end - - local format = 'html' - - local pformat = string.match(request.path, '[.]([^.]+)$') - if pformat ~= nil then - format = pformat - end - - - local r = self:match(request.method, request.path) - if r == nil then - return static_file(self, request, format) - end - - local stash = extend(r.stash, { format = format }) - - request.endpoint = r.endpoint - request.tstash = stash - - local resp = r.endpoint.sub(request) - if self.hooks.after_dispatch ~= nil then - self.hooks.after_dispatch(request, resp) - end - return resp -end +--------- +-- Utils +--------- local function normalize_headers(hdrs) local res = {} @@ -627,12 +21,21 @@ local function normalize_headers(hdrs) return res end +local function headers_ended(hdrs) + return string.endswith(hdrs, "\n\n") -- luacheck: ignore + or string.endswith(hdrs, "\r\n\r\n") -- luacheck: ignore +end + +---------- +-- Server +---------- + local function parse_request(req) local p = lib._parse_request(req) if p.error then return p end - p.path = uri_unescape(p.path) + p.path = utils.uri_unescape(p.path) if p.path:sub(1, 1) ~= "/" or p.path:find("./", nil, true) ~= nil then p.error = "invalid uri" return p @@ -642,6 +45,7 @@ end local function process_client(self, s, peer) while true do + -- read headers, until double CRLF local hdrs = '' local is_eof = false @@ -660,7 +64,7 @@ local function process_client(self, s, peer) hdrs = hdrs .. chunk - if string.endswith(hdrs, "\n\n") or string.endswith(hdrs, "\r\n\r\n") then + if headers_ended(hdrs) then break end end @@ -669,19 +73,23 @@ local function process_client(self, s, peer) break end + -- parse headers log.debug("request:\n%s", hdrs) local p = parse_request(hdrs) if p.error ~= nil then log.error('failed to parse request: %s', p.error) - s:write(sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error)) + s:write(utils.sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error)) break end - p.httpd = self - p.s = s - p.peer = peer - setmetatable(p, request_mt) - if p.headers['expect'] == '100-continue' then + local env = tsgi.make_env({ + parsed_request = p, + sock = s, + httpd = self, + peer = peer, + }) + + if env['HEADER_EXPECT'] == '100-continue' then s:write('HTTP/1.0 100 Continue\r\n\r\n') end @@ -689,53 +97,60 @@ local function process_client(self, s, peer) logreq("%s %s%s", p.method, p.path, p.query ~= "" and "?"..p.query or "") - local res, reason = pcall(self.options.handler, self, p) - p:read() -- skip remaining bytes of request body - local status, hdrs, body + local ok, resp = pcall(self.options.handler, env) + env['tsgi.input']:read() -- skip remaining bytes of request body + local status, body + + -- DETACHED: dont close socket, but quit processing HTTP + if self.is_hijacked then + break + end - if not res then + -- set response headers + if not ok then status = 500 hdrs = {} local trace = debug.traceback() local logerror = self.options.log_errors and log.error or log.debug logerror('unhandled error: %s\n%s\nrequest:\n%s', - tostring(reason), trace, tostring(p)) + tostring(resp), trace, tostring(p)) -- TODO: tostring(p) if self.options.display_errors then body = - "Unhandled error: " .. tostring(reason) .. "\n" + "Unhandled error: " .. tostring(resp) .. "\n" .. trace .. "\n\n" .. "\n\nRequest:\n" - .. tostring(p) + .. tostring(p) -- TODO: tostring(p) else body = "Internal Error" end - elseif type(reason) == 'table' then - if reason.status == nil then + elseif type(resp) == 'table' then + if resp.status == nil then status = 200 - elseif type(reason.status) == 'number' then - status = reason.status + elseif type(resp.status) == 'number' then + status = resp.status else error('response.status must be a number') end - if reason.headers == nil then + if resp.headers == nil then hdrs = {} - elseif type(reason.headers) == 'table' then - hdrs = normalize_headers(reason.headers) + elseif type(resp.headers) == 'table' then + hdrs = normalize_headers(resp.headers) else error('response.headers must be a table') end - body = reason.body - elseif reason == nil then + body = resp.body + elseif resp == nil then status = 200 hdrs = {} - elseif type(reason) == 'number' then - if reason == DETACHED then + elseif type(resp) == 'number' then + if resp == DETACHED then break end else error('invalid response') end + -- set more response headers local gen, param, state if type(body) == 'string' then -- Plain string @@ -757,9 +172,10 @@ local function process_client(self, s, peer) end if hdrs.server == nil then - hdrs.server = sprintf('Tarantool http (tarantool v%s)', _TARANTOOL) + hdrs.server = utils.sprintf('Tarantool http (tarantool v%s)', _TARANTOOL) -- luacheck: ignore end + -- handle even more response headers if p.proto[1] ~= 1 then hdrs.connection = 'close' elseif p.broken then @@ -784,20 +200,21 @@ local function process_client(self, s, peer) end end + -- generate response {{{ local response = { "HTTP/1.1 "; status; " "; - reason_by_code(status); + utils.reason_by_code(status); "\r\n"; }; for k, v in pairs(hdrs) do if type(v) == 'table' then - for i, sv in pairs(v) do - table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), sv)) + for _, sv in pairs(v) do + table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), sv)) end else - table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), v)) + table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), v)) end end table.insert(response, "\r\n") @@ -813,11 +230,11 @@ local function process_client(self, s, peer) if not s:write(response) then break end - response = nil + response = nil -- luacheck: ignore -- Transfer-Encoding: chunked for _, part in gen, param, state do part = tostring(part) - if not s:write(sprintf("%x\r\n%s\r\n", #part, part)) then + if not s:write(utils.sprintf("%x\r\n%s\r\n", #part, part)) then break end end @@ -830,6 +247,7 @@ local function process_client(self, s, peer) break end end + -- }}} if p.proto[1] ~= 1 then break @@ -842,8 +260,8 @@ local function process_client(self, s, peer) end local function httpd_stop(self) - if type(self) ~= 'table' then - error("httpd: usage: httpd:stop()") + if type(self) ~= 'table' then + error("httpd: usage: httpd:stop()") end if self.is_run then self.is_run = false @@ -858,279 +276,6 @@ local function httpd_stop(self) return self end -local function match_route(self, method, route) - -- route must have '/' at the begin and end - if string.match(route, '.$') ~= '/' then - route = route .. '/' - end - if string.match(route, '^.') ~= '/' then - route = '/' .. route - end - - method = string.upper(method) - - local fit - local stash = {} - - for k, r in pairs(self.routes) do - if r.method == method or r.method == 'ANY' then - local m = { string.match(route, r.match) } - local nfit - if #m > 0 then - if #r.stash > 0 then - if #r.stash == #m then - nfit = r - end - else - nfit = r - end - - if nfit ~= nil then - if fit == nil then - fit = nfit - stash = m - else - if #fit.stash > #nfit.stash then - fit = nfit - stash = m - elseif r.method ~= fit.method then - if fit.method == 'ANY' then - fit = nfit - stash = m - end - end - end - end - end - end - end - - if fit == nil then - return fit - end - local resstash = {} - for i = 1, #fit.stash do - resstash[ fit.stash[ i ] ] = stash[ i ] - end - return { endpoint = fit, stash = resstash } -end - -local function set_helper(self, name, sub) - if sub == nil or type(sub) == 'function' then - self.helpers[ name ] = sub - return self - end - errorf("Wrong type for helper function: %s", type(sub)) -end - -local function set_hook(self, name, sub) - if sub == nil or type(sub) == 'function' then - self.hooks[ name ] = sub - return self - end - errorf("Wrong type for hook function: %s", type(sub)) -end - -local function url_for_route(r, args, query) - if args == nil then - args = {} - end - local name = r.path - for i, sn in pairs(r.stash) do - local sv = args[sn] - if sv == nil then - sv = '' - end - name = string.gsub(name, '[*:]' .. sn, sv, 1) - end - - if query ~= nil then - if type(query) == 'table' then - local sep = '?' - for k, v in pairs(query) do - name = name .. sep .. uri_escape(k) .. '=' .. uri_escape(v) - sep = '&' - end - else - name = name .. '?' .. query - end - end - - if string.match(name, '^/') == nil then - return '/' .. name - else - return name - end -end - -local function ctx_action(tx) - local ctx = tx.endpoint.controller - local action = tx.endpoint.action - if tx.httpd.options.cache_controllers then - if tx.httpd.cache[ ctx ] ~= nil then - if type(tx.httpd.cache[ ctx ][ action ]) ~= 'function' then - errorf("Controller '%s' doesn't contain function '%s'", - ctx, action) - end - return tx.httpd.cache[ ctx ][ action ](tx) - end - end - - local ppath = package.path - package.path = catfile(tx.httpd.options.app_dir, 'controllers', '?.lua') - .. ';' - .. catfile(tx.httpd.options.app_dir, - 'controllers', '?/init.lua') - if ppath ~= nil then - package.path = package.path .. ';' .. ppath - end - - local st, mod = pcall(require, ctx) - package.path = ppath - package.loaded[ ctx ] = nil - - if not st then - errorf("Can't load module '%s': %s'", ctx, tostring(mod)) - end - - if type(mod) ~= 'table' then - errorf("require '%s' didn't return table", ctx) - end - - if type(mod[ action ]) ~= 'function' then - errorf("Controller '%s' doesn't contain function '%s'", ctx, action) - end - - if tx.httpd.options.cache_controllers then - tx.httpd.cache[ ctx ] = mod - end - - return mod[action](tx) -end - -local possible_methods = { - GET = 'GET', - HEAD = 'HEAD', - POST = 'POST', - PUT = 'PUT', - DELETE = 'DELETE', - PATCH = 'PATCH', -} - -local function add_route(self, opts, sub) - if type(opts) ~= 'table' or type(self) ~= 'table' then - error("Usage: httpd:route({ ... }, function(cx) ... end)") - end - - opts = extend({method = 'ANY'}, opts, false) - - local ctx - local action - - if sub == nil then - sub = render - elseif type(sub) == 'string' then - - ctx, action = string.match(sub, '(.+)#(.*)') - - if ctx == nil or action == nil then - errorf("Wrong controller format '%s', must be 'module#action'", sub) - end - - sub = ctx_action - - elseif type(sub) ~= 'function' then - errorf("wrong argument: expected function, but received %s", - type(sub)) - end - - opts.method = possible_methods[string.upper(opts.method)] or 'ANY' - - if opts.path == nil then - error("path is not defined") - end - - opts.controller = ctx - opts.action = action - opts.match = opts.path - opts.match = string.gsub(opts.match, '[-]', "[-]") - - local estash = { } - local stash = { } - while true do - local name = string.match(opts.match, ':([%a_][%w_]*)') - if name == nil then - break - end - if estash[name] then - errorf("duplicate stash: %s", name) - end - estash[name] = true - opts.match = string.gsub(opts.match, ':[%a_][%w_]*', '([^/]-)', 1) - - table.insert(stash, name) - end - while true do - local name = string.match(opts.match, '[*]([%a_][%w_]*)') - if name == nil then - break - end - if estash[name] then - errorf("duplicate stash: %s", name) - end - estash[name] = true - opts.match = string.gsub(opts.match, '[*][%a_][%w_]*', '(.-)', 1) - - table.insert(stash, name) - end - - if string.match(opts.match, '.$') ~= '/' then - opts.match = opts.match .. '/' - end - if string.match(opts.match, '^.') ~= '/' then - opts.match = '/' .. opts.match - end - - opts.match = '^' .. opts.match .. '$' - - estash = nil - - opts.stash = stash - opts.sub = sub - opts.url_for = url_for_route - - if opts.name ~= nil then - if opts.name == 'current' then - error("Route can not have name 'current'") - end - if self.iroutes[ opts.name ] ~= nil then - errorf("Route with name '%s' is already exists", opts.name) - end - table.insert(self.routes, opts) - self.iroutes[ opts.name ] = #self.routes - else - table.insert(self.routes, opts) - end - return self -end - -local function url_for_httpd(httpd, name, args, query) - - local idx = httpd.iroutes[ name ] - if idx ~= nil then - return httpd.routes[ idx ]:url_for(args, query) - end - - if string.match(name, '^/') == nil then - if string.match(name, '^https?://') ~= nil then - return name - else - return '/' .. name - end - else - return name - end -end local function httpd_start(self) if type(self) ~= 'table' then @@ -1138,12 +283,12 @@ local function httpd_start(self) end local server = socket.tcp_server(self.host, self.port, - { name = 'http', - handler = function(...) - local res = process_client(self, ...) - end}) + { name = 'http', + handler = function(...) + local _ = process_client(self, ...) + end}) if server == nil then - error(sprintf("Can't create tcp_server: %s", errno.strerror())) + error(utils.sprintf("Can't create tcp_server: %s", errno.strerror())) end rawset(self, 'is_run', true) @@ -1153,62 +298,39 @@ local function httpd_start(self) return self end -local exports = { - DETACHED = DETACHED, - - new = function(host, port, options) - if options == nil then - options = {} - end - if type(options) ~= 'table' then - errorf("options must be table not '%s'", type(options)) - end - local default = { - max_header_size = 4096, - header_timeout = 100, - handler = handler, - app_dir = '.', - charset = 'utf-8', - cache_templates = true, - cache_controllers = true, - cache_static = true, - log_requests = true, - log_errors = true, - display_errors = true, - } +local function httpd_set_router(self, router) + self.options.handler = router +end - local self = { - host = host, - port = port, - is_run = false, - stop = httpd_stop, - start = httpd_start, - options = extend(default, options, true), +local new = function(host, port, options) + if options == nil then + options = {} + end + if type(options) ~= 'table' then + utils.errorf("options must be table not '%s'", type(options)) + end - routes = { }, - iroutes = { }, - helpers = { - url_for = url_for_helper, - }, - hooks = { }, + local default = { + handler = nil, -- TODO + log_requests = true, + log_errors = true, + display_errors = true, + } - -- methods - route = add_route, - match = match_route, - helper = set_helper, - hook = set_hook, - url_for = url_for_httpd, + local self = { + host = host, + port = port, + is_run = false, + stop = httpd_stop, + start = httpd_start, + set_router = httpd_set_router, + options = utils.extend(default, options, true), + } - -- caches - cache = { - tpl = {}, - ctx = {}, - static = {}, - }, - } + return self +end - return self - end +return { + DETACHED = DETACHED, + new = new, } - -return exports diff --git a/http/tsgi.lua b/http/tsgi.lua new file mode 100644 index 0000000..0c1f02a --- /dev/null +++ b/http/tsgi.lua @@ -0,0 +1,122 @@ +local log = require('log') + +local KEY_HTTPD = 'tarantool.http.httpd' +local KEY_SOCK = 'tarantool.http.sock' +local KEY_REMAINING = 'tarantool.http.sock_remaining_len' +local KEY_PARSED_REQUEST = 'tarantool.http.parsed_request' +local KEY_PEER = 'tarantool.http.peer' + +-- helpers + +-- XXX: do it with lua-iterators +local function headers(env) + local map = {} + for name, value in pairs(env) do + if string.startswith(name, 'HEADER_') then -- luacheck: ignore + map[name] = value + end + end + return map +end + +--- + +local function noop() end + +local function tsgi_errors_write(env, msg) -- luacheck: ignore + log.error(msg) +end + +local function tsgi_hijack(env) + local httpd = env[KEY_HTTPD] + local sock = env[KEY_SOCK] + + httpd.is_hijacked = true + return sock +end + +local function tsgi_input_read(env, opts, timeout) + local remaining = env[KEY_REMAINING] + if not remaining then + remaining = tonumber(env['HEADER_CONTENT-LENGTH']) -- TODO: hyphen + if not remaining then + return '' + end + end + + if opts == nil then + opts = remaining + elseif type(opts) == 'number' then + if opts > remaining then + opts = remaining + end + elseif type(opts) == 'string' then + opts = { size = remaining, delimiter = opts } + elseif type(opts) == 'table' then + local size = opts.size or opts.chunk + if size and size > remaining then + opts.size = remaining + opts.chunk = nil + end + end + + local buf = env[KEY_SOCK]:read(opts, timeout) + if buf == nil then + env[KEY_REMAINING] = 0 + return '' + end + remaining = remaining - #buf + assert(remaining >= 0) + env[KEY_REMAINING] = remaining + return buf +end + +local function convert_headername(name) + return 'HEADER_' .. string.upper(name) -- TODO: hyphens +end + +local function make_env(opts) + local p = opts.parsed_request + + local env = { + [KEY_SOCK] = opts.sock, + [KEY_HTTPD] = opts.httpd, + [KEY_PARSED_REQUEST] = p, -- TODO: delete? + [KEY_PEER] = opts.peer, -- TODO: delete? + + ['tsgi.version'] = '1', + ['tsgi.url_scheme'] = 'http', -- no support for https yet + ['tsgi.input'] = { + read = tsgi_input_read, + rewind = nil, -- TODO + }, + ['tsgi.errors'] = { + write = tsgi_errors_write, + flush = noop, + }, + ['tsgi.hijack'] = tsgi_hijack, + + ['REQUEST_METHOD'] = p.method, + ['SCRIPT_NAME'] = '', -- TODO: what the heck is this? + ['PATH_INFO'] = p.path, + ['QUERY_STRING'] = p.query, + ['SERVER_NAME'] = opts.httpd.host, + ['SERVER_PORT'] = opts.httpd.port, + ['SERVER_PROTOCOL'] = string.format('HTTP/%d.%d', p.proto[1], p.proto[2]), + } + + -- set headers + for name, value in pairs(p.headers) do + env[convert_headername(name)] = value + end + + return env +end + +return { + make_env = make_env, + headers = headers, + KEY_HTTPD = KEY_HTTPD, + KEY_PARSED_REQUEST = KEY_PARSED_REQUEST, + KEY_PEER = KEY_PEER, +} diff --git a/http/utils.lua b/http/utils.lua new file mode 100644 index 0000000..c4a4245 --- /dev/null +++ b/http/utils.lua @@ -0,0 +1,83 @@ +local codes = require('http.codes') + +local function errorf(fmt, ...) + error(string.format(fmt, ...)) +end + +local function sprintf(fmt, ...) + return string.format(fmt, ...) +end + +local function ucfirst(str) + return str:gsub("^%l", string.upper, 1) +end + +local function reason_by_code(code) + code = tonumber(code) + if codes[code] ~= nil then + return codes[code] + end + return sprintf('Unknown code %d', code) +end + +local function extend(tbl, tblu, raise) + local res = {} + for k, v in pairs(tbl) do + res[ k ] = v + end + for k, v in pairs(tblu) do + if raise then + if res[ k ] == nil then + errorf("Unknown option '%s'", k) + end + end + res[ k ] = v + end + return res +end + +local function uri_unescape(str, unescape_plus_sign) + local res = {} + if type(str) == 'table' then + for _, v in pairs(str) do + table.insert(res, uri_unescape(v)) + end + else + if unescape_plus_sign ~= nil then + str = string.gsub(str, '+', ' ') + end + + res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', + function(c) + return string.char(tonumber(c, 16)) + end + ) + end + return res +end + +local function uri_escape(str) + local res = {} + if type(str) == 'table' then + for _, v in pairs(str) do + table.insert(res, uri_escape(v)) + end + else + res = string.gsub(str, '[^a-zA-Z0-9_]', + function(c) + return string.format('%%%02X', string.byte(c)) + end + ) + end + return res +end + +return { + errorf = errorf, + sprintf = sprintf, + ucfirst = ucfirst, + reason_by_code = reason_by_code, + extend = extend, + uri_unescape = uri_unescape, + uri_escape = uri_escape, +} diff --git a/rockspecs/http-scm-1.rockspec b/rockspecs/http-scm-1.rockspec index 3713e0a..3fdf26c 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/rockspecs/http-scm-1.rockspec @@ -28,6 +28,12 @@ build = { } }, ['http.server'] = 'http/server.lua', + ['http.router.fs'] = 'http/router/fs.lua', + ['http.router.request'] = 'http/router/request.lua', + ['http.router.response'] = 'http/router/response.lua', + ['http.router'] = 'http/router.lua', + ['http.tsgi'] = 'http/tsgi.lua', + ['http.utils'] = 'http/utils.lua', ['http.mime_types'] = 'http/mime_types.lua', ['http.codes'] = 'http/codes.lua', } diff --git a/test/http.test.lua b/test/http.test.lua index ff78338..572b91e 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -5,10 +5,16 @@ local fio = require('fio') local http_lib = require('http.lib') local http_client = require('http.client') local http_server = require('http.server') +local ngx_server = require('http.nginx_server') +local http_router = require('http.router') local json = require('json') -local yaml = require 'yaml' local urilib = require('uri') +box.cfg{listen = '127.0.0.1:3301'} -- luacheck: ignore +box.schema.user.grant( -- luacheck: ignore + 'guest', 'read,write,execute', 'universe', nil, {if_not_exists = true} +) + local test = tap.test("http") test:plan(7) test:test("split_uri", function(test) @@ -42,7 +48,7 @@ test:test("split_uri", function(test) query = 'query'}) check('https://google.com:443/abc?query', { scheme = 'https', host = 'google.com', service = '443', path = '/abc', query = 'query'}) -end) + end) test:test("template", function(test) test:plan(5) @@ -131,8 +137,23 @@ end) local function cfgserv() local path = os.getenv('LUA_SOURCE_DIR') or './' path = fio.pathjoin(path, 'test') - local httpd = http_server.new('127.0.0.1', 12345, { app_dir = path, - log_requests = false, log_errors = false }) + + -- TODO + --[[ + local httpd = http_server.new('127.0.0.1', 12345, { + log_requests = false, + log_errors = false + })]] + + -- host and port are for SERVER_NAME, SERVER_PORT only. + -- TODO: are they required? + + local httpd = ngx_server.init({ + host = '127.0.0.1', + port = 12345, + }) + + local router = http_router.new(httpd, {app_dir = path}) :route({path = '/abc/:cde/:def', name = 'test'}, function() end) :route({path = '/abc'}, function() end) :route({path = '/ctxaction'}, 'module.controller#action') @@ -148,60 +169,60 @@ local function cfgserv() :route({path = '/helper', file = 'helper.html.el'}) :route({ path = '/test', file = 'test.html.el' }, function(cx) return cx:render({ title = 'title: 123' }) end) - return httpd + return httpd, router end test:test("server url match", function(test) test:plan(18) - local httpd = cfgserv() + local httpd, router = cfgserv() test:istable(httpd, "httpd object") - test:isnil(httpd:match('GET', '/')) - test:is(httpd:match('GET', '/abc').endpoint.path, "/abc", "/abc") - test:is(#httpd:match('GET', '/abc').stash, 0, "/abc") - test:is(httpd:match('GET', '/abc/123').endpoint.path, "/abc/:cde", "/abc/123") - test:is(httpd:match('GET', '/abc/123').stash.cde, "123", "/abc/123") - test:is(httpd:match('GET', '/abc/123/122').endpoint.path, "/abc/:cde/:def", + test:isnil(router:match('GET', '/')) + test:is(router:match('GET', '/abc').endpoint.path, "/abc", "/abc") + test:is(#router:match('GET', '/abc').stash, 0, "/abc") + test:is(router:match('GET', '/abc/123').endpoint.path, "/abc/:cde", "/abc/123") + test:is(router:match('GET', '/abc/123').stash.cde, "123", "/abc/123") + test:is(router:match('GET', '/abc/123/122').endpoint.path, "/abc/:cde/:def", "/abc/123/122") - test:is(httpd:match('GET', '/abc/123/122').stash.def, "122", + test:is(router:match('GET', '/abc/123/122').stash.def, "122", "/abc/123/122") - test:is(httpd:match('GET', '/abc/123/122').stash.cde, "123", + test:is(router:match('GET', '/abc/123/122').stash.cde, "123", "/abc/123/122") - test:is(httpd:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", + test:is(router:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", "/abc_123-122") - test:is(httpd:match('GET', '/abc_123-122').stash.cde_def, "123-122", + test:is(router:match('GET', '/abc_123-122').stash.cde_def, "123-122", "/abc_123-122") - test:is(httpd:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", + test:is(router:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", "/abc-123-def") - test:is(httpd:match('GET', '/abc-123-def').stash.cde, "123", + test:is(router:match('GET', '/abc-123-def').stash.cde, "123", "/abc-123-def") - test:is(httpd:match('GET', '/aba-123-dea/1/2/3').endpoint.path, + test:is(router:match('GET', '/aba-123-dea/1/2/3').endpoint.path, "/aba*def", '/aba-123-dea/1/2/3') - test:is(httpd:match('GET', '/aba-123-dea/1/2/3').stash.def, + test:is(router:match('GET', '/aba-123-dea/1/2/3').stash.def, "-123-dea/1/2/3", '/aba-123-dea/1/2/3') - test:is(httpd:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, + test:is(router:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, "/abb*def/cde", '/abb-123-dea/1/2/3/cde') - test:is(httpd:match('GET', '/abb-123-dea/1/2/3/cde').stash.def, + test:is(router:match('GET', '/abb-123-dea/1/2/3/cde').stash.def, "-123-dea/1/2/3", '/abb-123-dea/1/2/3/cde') - test:is(httpd:match('GET', '/banners/1wulc.z8kiy.6p5e3').stash.token, + test:is(router:match('GET', '/banners/1wulc.z8kiy.6p5e3').stash.token, '1wulc.z8kiy.6p5e3', "stash with dots") end) test:test("server url_for", function(test) test:plan(5) - local httpd = cfgserv() - test:is(httpd:url_for('abcdef'), '/abcdef', '/abcdef') - test:is(httpd:url_for('test'), '/abc//', '/abc//') - test:is(httpd:url_for('test', { cde = 'cde_v', def = 'def_v' }), + local httpd, router = cfgserv() + test:is(router:url_for('abcdef'), '/abcdef', '/abcdef') + test:is(router:url_for('test'), '/abc//', '/abc//') + test:is(router:url_for('test', { cde = 'cde_v', def = 'def_v' }), '/abc/cde_v/def_v', '/abc/cde_v/def_v') - test:is(httpd:url_for('star', { def = '/def_v' }), + test:is(router:url_for('star', { def = '/def_v' }), '/abb/def_v/cde', '/abb/def_v/cde') - test:is(httpd:url_for('star', { def = '/def_v' }, { a = 'b', c = 'd' }), + test:is(router:url_for('star', { def = '/def_v' }, { a = 'b', c = 'd' }), '/abb/def_v/cde?a=b&c=d', '/abb/def_v/cde?a=b&c=d') -end) + end) test:test("server requests", function(test) test:plan(36) - local httpd = cfgserv() + local httpd, router = cfgserv() httpd:start() local r = http_client.get('http://127.0.0.1:12345/test') @@ -258,21 +279,21 @@ test:test("server requests", function(test) test:is(r.reason, 'Ok', 'helper?abc reason') test:is(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') - httpd:route({path = '/die', file = 'helper.html.el'}, + router:route({path = '/die', file = 'helper.html.el'}, function() error(123) end ) local r = http_client.get('http://127.0.0.1:12345/die') test:is(r.status, 500, 'die 500') --test:is(r.reason, 'Internal server error', 'die reason') - httpd:route({ path = '/info' }, function(cx) + router:route({ path = '/info' }, function(cx) return cx:render({ json = cx.peer }) end) local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) test:is(r.host, '127.0.0.1', 'peer.host') test:isnumber(r.port, 'peer.port') - local r = httpd:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + local r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, function(tx) return tx:render({text = 'POST = ' .. tx:read()}) end) @@ -282,25 +303,25 @@ test:test("server requests", function(test) test:test('GET/POST at one route', function(test) test:plan(8) - r = httpd:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, function(tx) return tx:render({text = 'POST = ' .. tx:read()}) end) test:istable(r, 'add POST method') - r = httpd:route({method = 'GET', path = '/dit', file = 'helper.html.el'}, + r = router:route({method = 'GET', path = '/dit', file = 'helper.html.el'}, function(tx) return tx:render({text = 'GET = ' .. tx:read()}) end ) test:istable(r, 'add GET method') - r = httpd:route({method = 'DELETE', path = '/dit', file = 'helper.html.el'}, + r = router:route({method = 'DELETE', path = '/dit', file = 'helper.html.el'}, function(tx) return tx:render({text = 'DELETE = ' .. tx:read()}) end ) test:istable(r, 'add DELETE method') - r = httpd:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, + r = router:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, function(tx) return tx:render({text = 'PATCH = ' .. tx:read()}) end ) @@ -319,7 +340,7 @@ test:test("server requests", function(test) test:is(r.body, 'PATCH = test2', 'PATCH reply') end) - httpd:route({path = '/chunked'}, function(self) + router:route({path = '/chunked'}, function(self) return self:iterate(ipairs({'chunked', 'encoding', 't\r\nest'})) end) @@ -331,7 +352,7 @@ test:test("server requests", function(test) test:test('get cookie', function(test) test:plan(2) - httpd:route({path = '/receive_cookie'}, function(req) + router:route({path = '/receive_cookie'}, function(req) local foo = req:cookie('foo') local baz = req:cookie('baz') return req:render({ @@ -349,7 +370,7 @@ test:test("server requests", function(test) test:test('cookie', function(test) test:plan(2) - httpd:route({path = '/cookie'}, function(req) + router:route({path = '/cookie'}, function(req) local resp = req:render({text = ''}) resp:setcookie({ name = 'test', value = 'tost', expires = '+1y', path = '/abc' }) @@ -363,7 +384,7 @@ test:test("server requests", function(test) test:test('post body', function(test) test:plan(2) - httpd:route({ path = '/post', method = 'POST'}, function(req) + router:route({ path = '/post', method = 'POST'}, function(req) local t = { #req:read("\n"); #req:read(10); @@ -383,7 +404,7 @@ test:test("server requests", function(test) test:is(r.status, 200, 'status') test:is_deeply(json.decode(r.body), { 541,10,10,458,1375,0,0 }, 'req:read() results') - end) + end) httpd:stop() end) diff --git a/test/nginx.conf b/test/nginx.conf new file mode 100644 index 0000000..abacd20 --- /dev/null +++ b/test/nginx.conf @@ -0,0 +1,84 @@ +worker_processes 8; + +events { + worker_connections 4096; +} + +http { +access_log /dev/stdout; +error_log /dev/stderr debug; + +upstream tnt_backend { + server 127.0.0.1:3301 max_fails=5 fail_timeout=60s; + keepalive 32; +} + +server { + listen 127.0.0.1:12345; + server_name localhost; + + location /tnt_proxy { + internal; + tnt_method "nginx_entrypoint"; + tnt_http_methods all; + tnt_buffer_size 100k; + tnt_pass_http_request on pass_body; # parse_args; + tnt_pass tnt_backend; + } + + location / { + rewrite_by_lua ' + local cjson = require("cjson") + local map = { + GET = ngx.HTTP_GET, + POST = ngx.HTTP_POST, + PUT = ngx.HTTP_PUT, + PATCH = ngx.HTTP_PATCH, + DELETE = ngx.HTTP_DELETE, + } + + -- hide `{"params": [...]}` from a user + + ngx.req.read_body() + local body = ngx.req.get_body_data() + + -- TODO: escape double-quotes in body + if body then + body = "{\\"params\\": \\"" .. body .. "\\"}" + end + + local res = ngx.location.capture("/tnt_proxy", { + args = ngx.var.args, + method = map[ngx.var.request_method], + body = body + }) + if res.status == ngx.HTTP_OK then + local answ = cjson.decode(res.body) + -- Read reply + local result = answ["result"] + if result ~= nil then + ngx.status = result[1] + for k, v in pairs(result[2]) do + ngx.header[k] = v + end + ngx.print(result[3]) + else + ngx.status = 502 + ngx.say("Tarantool does not work") + end + -- Finalize execution + ngx.exit(ngx.OK) + else + ngx.status = res.status + ngx.say(res.body) + end + '; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root html; + } +} + +} From 1e6c256c897daae3ee60895b66080f14f62a4557 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Sun, 21 Apr 2019 18:36:38 +0300 Subject: [PATCH 02/10] Major rewrite --- README.md | 3 +- http/nginx_server.lua | 156 +++++++++++++++++++++++++++++++--------- http/router.lua | 31 ++++++-- http/router/request.lua | 13 ++-- http/server.lua | 38 +++++++++- http/tsgi.lua | 54 +++++++++++--- test/http.test.lua | 102 ++++++++++++++++++++++++-- test/nginx.conf | 4 +- 8 files changed, 334 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 0e3bfab..e1eb32d 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,8 @@ end * `tostring(req)` - returns a string representation of the request. * `req:request_line()` - returns the request body. * `req:read(delimiter|chunk|{delimiter = x, chunk = x}, timeout)` - reads the - raw request body as a stream (see `socket:read()`). + raw request body as a stream (see `socket:read()`). **NOTE**: When using + NGINX TSGI adapter, only `req:read(chunk)` is available. * `req:json()` - returns a Lua table from a JSON request. * `req:post_param(name)` - returns a single POST request a parameter value. If `name` is `nil`, returns all parameters as a Lua table. diff --git a/http/nginx_server.lua b/http/nginx_server.lua index 6f6c214..aaee160 100644 --- a/http/nginx_server.lua +++ b/http/nginx_server.lua @@ -1,24 +1,68 @@ local tsgi = require('http.tsgi') +local utils = require('http.utils') +require('checks') local json = require('json') local log = require('log') local KEY_BODY = 'tsgi.http.nginx_server.body' -local self - local function noop() end local function convert_headername(name) return 'HEADER_' .. string.upper(name) end -local function tsgi_input_read(env) - return env[KEY_BODY] +local function tsgi_input_read(self, n) + checks('table', '?number') -- luacheck: ignore + + local start = self._pos + local last + + if n ~= nil then + last = start + n + self._pos = last + else + last = #self._env[KEY_BODY] + self._pos = last + end + + return self._env[KEY_BODY]:sub(start, last) +end + +local function tsgi_input_rewind(self) + self._pos = 0 +end + +local function serialize_request(env) + -- {{{ + -- TODO: copypaste from router/request.lua. + -- maybe move it to tsgi.lua. + + local res = env['PATH_INFO'] + local query_string = env['QUERY_STRING'] + if query_string ~= nil and query_string ~= '' then + res = res .. '?' .. query_string + end + + res = utils.sprintf("%s %s %s", + env['REQUEST_METHOD'], + res, + env['SERVER_PROTOCOL'] or 'HTTP/?') + res = res .. "\r\n" + -- }}} end of request_line copypaste + + for hn, hv in pairs(tsgi.headers(env)) do + res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) + end + + -- return utils.sprintf("%s\r\n%s", res, self:read_cached()) + -- NOTE: no body is logged. + return res end -local function make_env(req) - -- in nginx dont provide `parse_query` for this to work +local function make_env(server, req) + -- NGINX Tarantool Upstream `parse_query` option must NOT be set. local uriparts = string.split(req.uri, '?') -- luacheck: ignore local path_info, query_string = uriparts[1], uriparts[2] @@ -31,41 +75,62 @@ local function make_env(req) ['tsgi.version'] = '1', ['tsgi.url_scheme'] = 'http', -- no support for https ['tsgi.input'] = { + _pos = 0, -- last unread char in body read = tsgi_input_read, - rewind = nil, -- TODO + rewind = tsgi_input_rewind, }, ['tsgi.errors'] = { write = noop, flush = noop, }, - ['tsgi.hijack'] = nil, -- no hijack with nginx + ['tsgi.hijack'] = nil, -- no support for hijack with nginx ['REQUEST_METHOD'] = string.upper(req.method), - ['SERVER_NAME'] = self.host, - ['SERVER_PORT'] = self.port, - ['SCRIPT_NAME'] = '', -- TODO: what do we put here? + ['SERVER_NAME'] = server.host, + ['SERVER_PORT'] = server.port, ['PATH_INFO'] = path_info, ['QUERY_STRING'] = query_string, ['SERVER_PROTOCOL'] = req.proto, [tsgi.KEY_PEER] = { - host = self.host, - port = self.port, + host = server.host, + port = server.port, }, [KEY_BODY] = body, -- http body string; used in `tsgi_input_read` } + -- Pass through `env` to env['tsgi.*']:read() functions + env['tsgi.input']._env = env + env['tsgi.errors']._env = env + for name, value in pairs(req.headers) do env[convert_headername(name)] = value end + -- SCRIPT_NAME is a virtual location of your app. + -- + -- Imagine you want to serve your HTTP API under prefix /test + -- and later move it to /. + -- + -- Instead of rewriting endpoints to your application, you do: + -- + -- location /test/ { + -- proxy_pass http://127.0.0.1:8001/test/; + -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/; + -- proxy_set_header SCRIPT_NAME /test; + -- } + -- + -- Application source code is not touched. + env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or '' + env['HTTP_SCRIPT_NAME'] = nil + return env end -function nginx_entrypoint(req, ...) -- luacheck: ignore - local env = make_env(req, ...) +local function generic_entrypoint(server, req, ...) -- luacheck: ignore + local env = make_env(server, req, ...) - local ok, resp = pcall(self.router, env) + local ok, resp = pcall(server.router, env) local status = resp.status or 200 local headers = resp.headers or {} @@ -75,17 +140,18 @@ function nginx_entrypoint(req, ...) -- luacheck: ignore status = 500 headers = {} local trace = debug.traceback() - local p = 'TODO_REQUEST_DESCRIPTION' -- TODO + -- TODO: copypaste + -- TODO: env could be changed. we need to save a copy of it log.error('unhandled error: %s\n%s\nrequest:\n%s', - tostring(resp), trace, tostring(p)) -- TODO: tostring(p) + tostring(resp), trace, serialize_request(env)) - if self.display_errors then + if server.display_errors then body = "Unhandled error: " .. tostring(resp) .. "\n" .. trace .. "\n\n" .. "\n\nRequest:\n" - .. tostring(p) -- TODO: tostring(p) + .. serialize_request(env) else body = "Internal Error" end @@ -112,25 +178,47 @@ function nginx_entrypoint(req, ...) -- luacheck: ignore return status, headers, body end -local function ngxserver_set_router(_, router) +local function ngxserver_set_router(self, router) + checks('table', 'function') -- luacheck: ignore + self.router = router end -local function init(opts) - if not self then - self = { - host = opts.host, - port = opts.port, - display_errors = opts.display_errors or true, - - set_router = ngxserver_set_router, - start = noop, -- TODO: fix - stop = noop -- TODO: fix - } - end +local function ngxserver_start(self) + checks('table') -- luacheck: ignore + + rawset(_G, self.tnt_method, function(...) + return generic_entrypoint(self, ...) + end) +end + +local function ngxserver_stop(self) + checks('table') -- luacheck: ignore + + rawset(_G, self.tnt_method, nil) +end + +local function new(opts) + checks({ -- luacheck: ignore + host = 'string', + port = 'number', + tnt_method = 'string', + display_errors = '?boolean', + }) + + local self = { + host = opts.host, + port = opts.port, + tnt_method = opts.tnt_method, + display_errors = opts.display_errors or true, + + set_router = ngxserver_set_router, + start = ngxserver_start, + stop = ngxserver_stop, + } return self end return { - init = init, + new = new, } diff --git a/http/router.lua b/http/router.lua index 99dae45..1360d8f 100644 --- a/http/router.lua +++ b/http/router.lua @@ -19,12 +19,35 @@ local function url_for_helper(tx, name, args, query) end local function request_from_env(env, router) -- luacheck: ignore + -- TODO: khm... what if we have nginx tsgi? + -- we need to restrict ourselves to generic TSGI + -- methods and properties! + local tsgi = require('http.tsgi') - local request = {} - request.router = router - request.env = env - request.peer = env[tsgi.KEY_PEER] -- TODO: delete + local request = { + router = router, + env = env, + peer = env[tsgi.KEY_PEER], -- TODO: delete + method = env['REQUEST_METHOD'], + path = env['PATH_INFO'], + query = env['QUERY_STRING'], + } + + -- parse SERVER_PROTOCOL which is 'HTTP/.' + local maj = env['SERVER_PROTOCOL']:sub(-3, -3) + local min = env['SERVER_PROTOCOL']:sub(-1, -1) + request.proto = { + [1] = tonumber(maj), + [2] = tonumber(min), + } + + request.headers = {} + for name, value in pairs(tsgi.headers(env)) do + -- strip HEADER_ part and convert to lowercase + local converted_name = name:sub(8):lower() + request.headers[converted_name] = value + end return setmetatable(request, request_metatable) end diff --git a/http/router/request.lua b/http/router/request.lua index dd5cf48..801457a 100644 --- a/http/router/request.lua +++ b/http/router/request.lua @@ -32,9 +32,12 @@ end local function request_line(self) local rstr = self.env['PATH_INFO'] - if string.len(self.env['QUERY_STRING']) then - rstr = rstr .. '?' .. self.env['QUERY_STRING'] + + local query_string = self.env['QUERY_STRING'] + if query_string ~= nil and query_string ~= '' then + rstr = rstr .. '?' .. query_string end + return utils.sprintf("%s %s %s", self.env['REQUEST_METHOD'], rstr, @@ -42,7 +45,7 @@ local function request_line(self) end local function query_param(self, name) - if self.env['QUERY_STRING'] == nil and string.len(self.env['QUERY_STRING']) == 0 then + if self.env['QUERY_STRING'] ~= nil and string.len(self.env['QUERY_STRING']) == 0 then rawset(self, 'query_params', {}) else local params = lib.params(self.env['QUERY_STRING']) @@ -169,13 +172,13 @@ end local function request_read(self, opts, timeout) local env = self.env - return env['tsgi.input'].read(env, opts, timeout) + return env['tsgi.input']:read(opts, timeout) -- TODO: TSGI spec is violated end local function request_read_cached(self) if self.cached_data == nil then local env = self.env - local data = env['tsgi.input'].read(env) + local data = env['tsgi.input']:read() rawset(self, 'cached_data', data) return data else diff --git a/http/server.lua b/http/server.lua index 57ac2a6..3ac4b35 100644 --- a/http/server.lua +++ b/http/server.lua @@ -43,6 +43,33 @@ local function parse_request(req) return p end +local function serialize_request(env) + -- {{{ + -- TODO: copypaste from router/request.lua. + -- maybe move it to tsgi.lua. + + local res = env['PATH_INFO'] + local query_string = env['QUERY_STRING'] + if query_string ~= nil and query_string ~= '' then + res = res .. '?' .. query_string + end + + res = utils.sprintf("%s %s %s", + env['REQUEST_METHOD'], + res, + env['SERVER_PROTOCOL'] or 'HTTP/?') + res = res .. "\r\n" + -- }}} end of request_line copypaste + + for hn, hv in pairs(tsgi.headers(env)) do + res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) + end + + -- return utils.sprintf("%s\r\n%s", res, self:read_cached()) + -- NOTE: no body is logged. + return res +end + local function process_client(self, s, peer) while true do -- read headers, until double CRLF @@ -112,14 +139,17 @@ local function process_client(self, s, peer) hdrs = {} local trace = debug.traceback() local logerror = self.options.log_errors and log.error or log.debug + + -- TODO: copypaste logerror('unhandled error: %s\n%s\nrequest:\n%s', - tostring(resp), trace, tostring(p)) -- TODO: tostring(p) + tostring(resp), trace, serialize_request(env)) if self.options.display_errors then + -- TODO: env could be changed. we need to save a copy of it body = "Unhandled error: " .. tostring(resp) .. "\n" .. trace .. "\n\n" .. "\n\nRequest:\n" - .. tostring(p) -- TODO: tostring(p) + .. serialize_request(env) else body = "Internal Error" end @@ -282,6 +312,8 @@ local function httpd_start(self) error("httpd: usage: httpd:start()") end + assert(self.options.handler ~= nil, 'Router must be set before calling server:start()') + local server = socket.tcp_server(self.host, self.port, { name = 'http', handler = function(...) @@ -311,7 +343,7 @@ local new = function(host, port, options) end local default = { - handler = nil, -- TODO + handler = nil, -- no router set-up initially log_requests = true, log_errors = true, display_errors = true, diff --git a/http/tsgi.lua b/http/tsgi.lua index 0c1f02a..f8c46cb 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -23,7 +23,7 @@ end local function noop() end -local function tsgi_errors_write(env, msg) -- luacheck: ignore +local function tsgi_errors_write(self, msg) -- luacheck: ignore log.error(msg) end @@ -35,7 +35,15 @@ local function tsgi_hijack(env) return sock end -local function tsgi_input_read(env, opts, timeout) +-- TODO: understand this. Maybe rewrite it to only follow +-- TSGI logic, and not router logic. +-- +-- if opts is number, it specifies number of bytes to be read +-- if opts is a table, it specifies options +local function tsgi_input_read(self, opts, timeout) + checks('table', '?number|string|table', '?number') -- luacheck: ignore + local env = self._env + local remaining = env[KEY_REMAINING] if not remaining then remaining = tonumber(env['HEADER_CONTENT-LENGTH']) -- TODO: hyphen @@ -81,23 +89,24 @@ local function make_env(opts) local env = { [KEY_SOCK] = opts.sock, [KEY_HTTPD] = opts.httpd, - [KEY_PARSED_REQUEST] = p, -- TODO: delete? - [KEY_PEER] = opts.peer, -- TODO: delete? + [KEY_PARSED_REQUEST] = p, -- TODO: delete? + [KEY_PEER] = opts.peer, -- TODO: delete? ['tsgi.version'] = '1', - ['tsgi.url_scheme'] = 'http', -- no support for https yet + ['tsgi.url_scheme'] = 'http', -- no support for https yet ['tsgi.input'] = { read = tsgi_input_read, - rewind = nil, -- TODO + rewind = nil, -- non-rewindable by default }, ['tsgi.errors'] = { write = tsgi_errors_write, - flush = noop, + flush = noop, -- TODO: implement }, - ['tsgi.hijack'] = tsgi_hijack, + ['tsgi.hijack'] = setmetatable({}, { + __call = tsgi_hijack, + }), ['REQUEST_METHOD'] = p.method, - ['SCRIPT_NAME'] = '', -- TODO: what the heck is this? ['PATH_INFO'] = p.path, ['QUERY_STRING'] = p.query, ['SERVER_NAME'] = opts.httpd.host, @@ -105,18 +114,41 @@ local function make_env(opts) ['SERVER_PROTOCOL'] = string.format('HTTP/%d.%d', p.proto[1], p.proto[2]), } + -- Pass through `env` to env['tsgi.*']:*() functions + env['tsgi.input']._env = env + env['tsgi.errors']._env = env + env['tsgi.hijack']._env = env + -- set headers for name, value in pairs(p.headers) do env[convert_headername(name)] = value end + -- SCRIPT_NAME is a virtual location of your app. + -- + -- Imagine you want to serve your HTTP API under prefix /test + -- and later move it to /. + -- + -- Instead of rewriting endpoints to your application, you do: + -- + -- location /test/ { + -- proxy_pass http://127.0.0.1:8001/test/; + -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/; + -- proxy_set_header SCRIPT_NAME /test; + -- } + -- + -- Application source code is not touched. + env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or '' + env['HTTP_SCRIPT_NAME'] = nil + return env end return { - make_env = make_env, - headers = headers, KEY_HTTPD = KEY_HTTPD, KEY_PARSED_REQUEST = KEY_PARSED_REQUEST, KEY_PEER = KEY_PEER, + + make_env = make_env, + headers = headers, } diff --git a/test/http.test.lua b/test/http.test.lua index 572b91e..25146a8 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -139,8 +139,7 @@ local function cfgserv() path = fio.pathjoin(path, 'test') -- TODO - --[[ - local httpd = http_server.new('127.0.0.1', 12345, { + --[[local httpd = http_server.new('127.0.0.1', 12345, { log_requests = false, log_errors = false })]] @@ -148,9 +147,12 @@ local function cfgserv() -- host and port are for SERVER_NAME, SERVER_PORT only. -- TODO: are they required? - local httpd = ngx_server.init({ + local httpd = ngx_server.new({ host = '127.0.0.1', port = 12345, + tnt_method = 'nginx_entrypoint', + log_requests = false, + log_errors = false, }) local router = http_router.new(httpd, {app_dir = path}) @@ -168,7 +170,7 @@ local function cfgserv() :helper('helper_title', function(self, a) return 'Hello, ' .. a end) :route({path = '/helper', file = 'helper.html.el'}) :route({ path = '/test', file = 'test.html.el' }, - function(cx) return cx:render({ title = 'title: 123' }) end) + function(cx) return cx:render({ title = 'title: 123' }) end) return httpd, router end @@ -207,6 +209,7 @@ test:test("server url match", function(test) '1wulc.z8kiy.6p5e3', "stash with dots") end) + test:test("server url_for", function(test) test:plan(5) local httpd, router = cfgserv() @@ -218,10 +221,10 @@ test:test("server url_for", function(test) '/abb/def_v/cde', '/abb/def_v/cde') test:is(router:url_for('star', { def = '/def_v' }, { a = 'b', c = 'd' }), '/abb/def_v/cde?a=b&c=d', '/abb/def_v/cde?a=b&c=d') - end) +end) test:test("server requests", function(test) - test:plan(36) + test:plan(38) local httpd, router = cfgserv() httpd:start() @@ -299,7 +302,6 @@ test:test("server requests", function(test) end) test:istable(r, ':route') - test:test('GET/POST at one route', function(test) test:plan(8) @@ -327,6 +329,7 @@ test:test("server requests", function(test) end ) test:istable(r, 'add PATCH method') + -- TODO r = http_client.request('POST', 'http://127.0.0.1:12345/dit', 'test') test:is(r.body, 'POST = test', 'POST reply') @@ -382,6 +385,91 @@ test:test("server requests", function(test) test:ok(r.headers['set-cookie'] ~= nil, "header") end) + test:test('request object with GET method', function(test) + test:plan(7) + router:route({path = '/check_req_properties'}, function(req) + return { + headers = {}, + body = json.encode({ + headers = req.headers, + method = req.method, + path = req.path, + query = req.query, + proto = req.proto, + query_param_bar = req:query_param('bar'), + }), + status = 200, + } + end) + local r = http_client.get( + 'http://127.0.0.1:12345/check_req_properties?foo=1&bar=2', { + headers = { + ['X-test-header'] = 'test-value' + } + }) + test:is(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + test:is(parsed_body.headers['x-test-header'], 'test-value', 'req.headers') + test:is(parsed_body.method, 'GET', 'req.method') + test:is(parsed_body.path, '/check_req_properties', 'req.path') + test:is(parsed_body.query, 'foo=1&bar=2', 'req.query') + test:is(parsed_body.query_param_bar, '2', 'req:query_param()') + test:is_deeply(parsed_body.proto, {1, 1}, 'req.proto') + end) + + test:test('request object methods', function(test) + test:plan(7) + router:route({path = '/check_req_methods_for_json', method = 'POST'}, function(req) + return { + headers = {}, + body = json.encode({ + request_line = req:request_line(), + read_cached = req:read_cached(), + json = req:json(), + post_param_for_kind = req:post_param('kind'), + }), + status = 200, + } + end) + router:route({path = '/check_req_methods', method = 'POST'}, function(req) + return { + headers = {}, + body = json.encode({ + request_line = req:request_line(), + read_cached = req:read_cached(), + }), + status = 200, + } + end) + + r = http_client.post( + 'http://127.0.0.1:12345/check_req_methods_for_json', + '{"kind": "json"}', { + headers = { + ['Content-type'] = 'application/json', + ['X-test-header'] = 'test-value' + } + }) + test:is(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + test:is(parsed_body.request_line, 'POST /check_req_methods_for_json HTTP/1.1', 'req.request_line') + test:is(parsed_body.read_cached, '{"kind": "json"}', 'json req:read_cached()') + test:is_deeply(parsed_body.json, {kind = "json"}, 'req:json()') + test:is(parsed_body.post_param_for_kind, "json", 'req:post_param()') + + r = http_client.post( + 'http://127.0.0.1:12345/check_req_methods', + 'hello mister' + ) + test:is(r.status, 200, 'status') + parsed_body = json.decode(r.body) + test:is(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') + end) + + assert(false) + test:test('post body', function(test) test:plan(2) router:route({ path = '/post', method = 'POST'}, function(req) diff --git a/test/nginx.conf b/test/nginx.conf index abacd20..7090cd7 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -42,9 +42,9 @@ server { ngx.req.read_body() local body = ngx.req.get_body_data() - -- TODO: escape double-quotes in body + -- cjson.encode is needed to json-escape the body if body then - body = "{\\"params\\": \\"" .. body .. "\\"}" + body = "{\\"params\\": " .. cjson.encode(body) .. "}" end local res = ngx.location.capture("/tnt_proxy", { From 66c9d7139d8ecfb49fa4d2d8640c496d5a80affd Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Thu, 25 Apr 2019 14:27:23 +0300 Subject: [PATCH 03/10] Forbid req.peer on nginx tsgi --- README.md | 5 +++-- http/nginx_server.lua | 9 ++++----- http/router.lua | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e1eb32d..2be68a8 100644 --- a/README.md +++ b/README.md @@ -208,11 +208,12 @@ end * `req.headers` - normalized request headers. A normalized header is in the lower case, all headers joined together into a single string. * `req.peer` - a Lua table with information about the remote peer - (like `socket:peer()`). + (like `socket:peer()`). **NOTE**: not available when using NGINX TSGI + adapter. * `tostring(req)` - returns a string representation of the request. * `req:request_line()` - returns the request body. * `req:read(delimiter|chunk|{delimiter = x, chunk = x}, timeout)` - reads the - raw request body as a stream (see `socket:read()`). **NOTE**: When using + raw request body as a stream (see `socket:read()`). **NOTE**: when using NGINX TSGI adapter, only `req:read(chunk)` is available. * `req:json()` - returns a Lua table from a JSON request. * `req:post_param(name)` - returns a single POST request a parameter value. diff --git a/http/nginx_server.lua b/http/nginx_server.lua index aaee160..c9c3651 100644 --- a/http/nginx_server.lua +++ b/http/nginx_server.lua @@ -91,11 +91,6 @@ local function make_env(server, req) ['QUERY_STRING'] = query_string, ['SERVER_PROTOCOL'] = req.proto, - [tsgi.KEY_PEER] = { - host = server.host, - port = server.port, - }, - [KEY_BODY] = body, -- http body string; used in `tsgi_input_read` } @@ -204,6 +199,8 @@ local function new(opts) port = 'number', tnt_method = 'string', display_errors = '?boolean', + log_errors = '?boolean', + log_requests = '?boolean', }) local self = { @@ -211,6 +208,8 @@ local function new(opts) port = opts.port, tnt_method = opts.tnt_method, display_errors = opts.display_errors or true, + log_errors = opts.log_errors or true, + log_requests = opts.log_requests or true, set_router = ngxserver_set_router, start = ngxserver_start, diff --git a/http/router.lua b/http/router.lua index 1360d8f..b67f638 100644 --- a/http/router.lua +++ b/http/router.lua @@ -28,7 +28,7 @@ local function request_from_env(env, router) -- luacheck: ignore local request = { router = router, env = env, - peer = env[tsgi.KEY_PEER], -- TODO: delete + peer = env[tsgi.KEY_PEER], -- only for builtin server method = env['REQUEST_METHOD'], path = env['PATH_INFO'], query = env['QUERY_STRING'], From daac83b7b5acc69753dd550e6361f6fe976497bb Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Thu, 25 Apr 2019 14:36:28 +0300 Subject: [PATCH 04/10] Add nginx tsgi tests --- test/Procfile.test.nginx | 2 + test/http.test.lua | 128 +++++++++++++++++++++++++-------------- test_locally.sh | 9 +++ 3 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 test/Procfile.test.nginx create mode 100755 test_locally.sh diff --git a/test/Procfile.test.nginx b/test/Procfile.test.nginx new file mode 100644 index 0000000..1de79fd --- /dev/null +++ b/test/Procfile.test.nginx @@ -0,0 +1,2 @@ +nginx: nginx -g "daemon off;" -c $PWD/test/nginx.conf +nginx_tsgi_test: SERVER_TYPE=nginx ./test/http.test.lua 2>&1 diff --git a/test/http.test.lua b/test/http.test.lua index 25146a8..73b6ae3 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -10,6 +10,22 @@ local http_router = require('http.router') local json = require('json') local urilib = require('uri') +-- fix tap and http logs interleaving. +-- +-- tap module writes to stdout, +-- http-server logs to stderr. +-- this results in non-synchronized output. +-- +-- somehow redirecting stdout to stderr doesn't +-- remove buffering of tap logs (at least on OSX). +-- Monkeypatching to the rescue! + +local orig_iowrite = io.write +package.loaded['io'].write = function(...) + orig_iowrite(...) + io.flush() +end + box.cfg{listen = '127.0.0.1:3301'} -- luacheck: ignore box.schema.user.grant( -- luacheck: ignore 'guest', 'read,write,execute', 'universe', nil, {if_not_exists = true} @@ -134,27 +150,40 @@ test:test('params', function(test) {a = { 'b', '1' }, b = 'cde'}, 'array') end) -local function cfgserv() - local path = os.getenv('LUA_SOURCE_DIR') or './' - path = fio.pathjoin(path, 'test') +local function is_nginx_test() + local server_type = os.getenv('SERVER_TYPE') or 'builtin' + return server_type:lower() == 'nginx' +end - -- TODO - --[[local httpd = http_server.new('127.0.0.1', 12345, { - log_requests = false, - log_errors = false - })]] +local function is_builtin_test() + return not is_nginx_test() +end - -- host and port are for SERVER_NAME, SERVER_PORT only. - -- TODO: are they required? +local function choose_server() + if is_nginx_test() then + -- host and port are for SERVER_NAME, SERVER_PORT only. + -- TODO: are they required? + + return ngx_server.new({ + host = '127.0.0.1', + port = 12345, + tnt_method = 'nginx_entrypoint', + log_requests = false, + log_errors = false, + }) + end - local httpd = ngx_server.new({ - host = '127.0.0.1', - port = 12345, - tnt_method = 'nginx_entrypoint', + return http_server.new('127.0.0.1', 12345, { log_requests = false, - log_errors = false, + log_errors = false }) +end +local function cfgserv() + local path = os.getenv('LUA_SOURCE_DIR') or './' + path = fio.pathjoin(path, 'test') + + local httpd = choose_server() local router = http_router.new(httpd, {app_dir = path}) :route({path = '/abc/:cde/:def', name = 'test'}, function() end) :route({path = '/abc'}, function() end) @@ -289,12 +318,18 @@ test:test("server requests", function(test) test:is(r.status, 500, 'die 500') --test:is(r.reason, 'Internal server error', 'die reason') - router:route({ path = '/info' }, function(cx) - return cx:render({ json = cx.peer }) - end) - local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) - test:is(r.host, '127.0.0.1', 'peer.host') - test:isnumber(r.port, 'peer.port') + -- request.peer is not supported in NGINX TSGI + if is_builtin_test() then + router:route({ path = '/info' }, function(cx) + return cx:render({ json = cx.peer }) + end) + local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) + test:is(r.host, '127.0.0.1', 'peer.host') + test:isnumber(r.port, 'peer.port') + else + test:ok(true, 'peer.host - ignore on NGINX') + test:ok(true, 'peer.port - ignore on NGINX') + end local r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, function(tx) @@ -468,31 +503,34 @@ test:test("server requests", function(test) test:is(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') end) - assert(false) - test:test('post body', function(test) - test:plan(2) - router:route({ path = '/post', method = 'POST'}, function(req) - local t = { - #req:read("\n"); - #req:read(10); - #req:read({ size = 10, delimiter = "\n"}); - #req:read("\n"); - #req:read(); - #req:read(); - #req:read(); - } - return req:render({json = t}) - end) - local bodyf = os.getenv('LUA_SOURCE_DIR') or './' - bodyf = io.open(fio.pathjoin(bodyf, 'test/public/lorem.txt')) - local body = bodyf:read('*a') - bodyf:close() - local r = http_client.post('http://127.0.0.1:12345/post', body) - test:is(r.status, 200, 'status') - test:is_deeply(json.decode(r.body), { 541,10,10,458,1375,0,0 }, - 'req:read() results') - end) + if is_builtin_test() then + test:test('post body', function(test) + test:plan(2) + router:route({ path = '/post', method = 'POST'}, function(req) + local t = { + #req:read("\n"); + #req:read(10); + #req:read({ size = 10, delimiter = "\n"}); + #req:read("\n"); + #req:read(); + #req:read(); + #req:read(); + } + return req:render({json = t}) + end) + local bodyf = os.getenv('LUA_SOURCE_DIR') or './' + bodyf = io.open(fio.pathjoin(bodyf, 'test/public/lorem.txt')) + local body = bodyf:read('*a') + bodyf:close() + local r = http_client.post('http://127.0.0.1:12345/post', body) + test:is(r.status, 200, 'status') + test:is_deeply(json.decode(r.body), { 541,10,10,458,1375,0,0 }, + 'req:read() results') + end) + else + test:ok(true, 'post body - ignore on NGINX') + end httpd:stop() end) diff --git a/test_locally.sh b/test_locally.sh new file mode 100755 index 0000000..fabadd5 --- /dev/null +++ b/test_locally.sh @@ -0,0 +1,9 @@ +echo "Builtin server" +echo "--------------------" +echo "" +SERVER_TYPE=builtin ./test/http.test.lua + +echo "Nginx server" +echo "--------------------" +echo "" +honcho start -f ./test/Procfile.test.nginx From 013937bc0c3ea69b02a4ac393b5bae14c6854e05 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Fri, 26 Apr 2019 12:34:28 +0300 Subject: [PATCH 05/10] Move router.lua to router/init.lua --- http/{router.lua => router/init.lua} | 0 rockspecs/http-scm-1.rockspec | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename http/{router.lua => router/init.lua} (100%) diff --git a/http/router.lua b/http/router/init.lua similarity index 100% rename from http/router.lua rename to http/router/init.lua diff --git a/rockspecs/http-scm-1.rockspec b/rockspecs/http-scm-1.rockspec index 3fdf26c..f74a031 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/rockspecs/http-scm-1.rockspec @@ -31,7 +31,7 @@ build = { ['http.router.fs'] = 'http/router/fs.lua', ['http.router.request'] = 'http/router/request.lua', ['http.router.response'] = 'http/router/response.lua', - ['http.router'] = 'http/router.lua', + ['http.router'] = 'http/router/init.lua', ['http.tsgi'] = 'http/tsgi.lua', ['http.utils'] = 'http/utils.lua', ['http.mime_types'] = 'http/mime_types.lua', From ef3992d11eb83f32bab2705c62e87ac0b6343828 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Fri, 10 May 2019 21:23:48 +0300 Subject: [PATCH 06/10] Rearrange code 1. Distinct subfolders nginx_server/ server/ for NGINX and builtin servers. 2. Extract common (not specific to particular tsgi adapter) code to http/tsgi.lua. --- .../init.lua} | 0 http/router/init.lua | 6 +- http/{server.lua => server/init.lua} | 9 +- http/server/tsgi_adapter.lua | 131 +++++++++++++++++ http/tsgi.lua | 132 +----------------- rockspecs/http-scm-1.rockspec | 4 +- 6 files changed, 144 insertions(+), 138 deletions(-) rename http/{nginx_server.lua => nginx_server/init.lua} (100%) rename http/{server.lua => server/init.lua} (98%) create mode 100644 http/server/tsgi_adapter.lua diff --git a/http/nginx_server.lua b/http/nginx_server/init.lua similarity index 100% rename from http/nginx_server.lua rename to http/nginx_server/init.lua diff --git a/http/router/init.lua b/http/router/init.lua index b67f638..59cc1c5 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -1,8 +1,8 @@ --- http.server - local fs = require('http.router.fs') local request_metatable = require('http.router.request').metatable + local utils = require('http.utils') +local tsgi = require('http.tsgi') local function uri_file_extension(s, default) -- cut from last dot till the end @@ -23,8 +23,6 @@ local function request_from_env(env, router) -- luacheck: ignore -- we need to restrict ourselves to generic TSGI -- methods and properties! - local tsgi = require('http.tsgi') - local request = { router = router, env = env, diff --git a/http/server.lua b/http/server/init.lua similarity index 98% rename from http/server.lua rename to http/server/init.lua index 3ac4b35..3a49606 100644 --- a/http/server.lua +++ b/http/server/init.lua @@ -1,9 +1,10 @@ -local lib = require('http.lib') +local tsgi_adapter = require('http.server.tsgi_adapter') + local tsgi = require('http.tsgi') +local lib = require('http.lib') local utils = require('http.utils') local log = require('log') - local socket = require('socket') local errno = require('errno') @@ -46,7 +47,7 @@ end local function serialize_request(env) -- {{{ -- TODO: copypaste from router/request.lua. - -- maybe move it to tsgi.lua. + -- maybe move it to tsgi_adapter.lua. local res = env['PATH_INFO'] local query_string = env['QUERY_STRING'] @@ -109,7 +110,7 @@ local function process_client(self, s, peer) break end - local env = tsgi.make_env({ + local env = tsgi_adapter.make_env({ parsed_request = p, sock = s, httpd = self, diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua new file mode 100644 index 0000000..8d34391 --- /dev/null +++ b/http/server/tsgi_adapter.lua @@ -0,0 +1,131 @@ +local tsgi = require('http.tsgi') + +local log = require('log') + + +local function noop() end + +local function tsgi_errors_write(self, msg) -- luacheck: ignore + log.error(msg) +end + +local function tsgi_hijack(env) + local httpd = env[tsgi.KEY_HTTPD] + local sock = env[tsgi.KEY_SOCK] + + httpd.is_hijacked = true + return sock +end + +-- TODO: understand this. Maybe rewrite it to only follow +-- TSGI logic, and not router logic. +-- +-- if opts is number, it specifies number of bytes to be read +-- if opts is a table, it specifies options +local function tsgi_input_read(self, opts, timeout) + checks('table', '?number|string|table', '?number') -- luacheck: ignore + local env = self._env + + local remaining = env[tsgi.KEY_REMAINING] + if not remaining then + remaining = tonumber(env['HEADER_CONTENT-LENGTH']) -- TODO: hyphen + if not remaining then + return '' + end + end + + if opts == nil then + opts = remaining + elseif type(opts) == 'number' then + if opts > remaining then + opts = remaining + end + elseif type(opts) == 'string' then + opts = { size = remaining, delimiter = opts } + elseif type(opts) == 'table' then + local size = opts.size or opts.chunk + if size and size > remaining then + opts.size = remaining + opts.chunk = nil + end + end + + local buf = env[tsgi.KEY_SOCK]:read(opts, timeout) + if buf == nil then + env[tsgi.KEY_REMAINING] = 0 + return '' + end + remaining = remaining - #buf + assert(remaining >= 0) + env[tsgi.KEY_REMAINING] = remaining + return buf +end + +local function convert_headername(name) + return 'HEADER_' .. string.upper(name) -- TODO: hyphens +end + +local function make_env(opts) + local p = opts.parsed_request + + local env = { + [tsgi.KEY_SOCK] = opts.sock, + [tsgi.KEY_HTTPD] = opts.httpd, + [tsgi.KEY_PARSED_REQUEST] = p, -- TODO: delete? + [tsgi.KEY_PEER] = opts.peer, -- TODO: delete? + + ['tsgi.version'] = '1', + ['tsgi.url_scheme'] = 'http', -- no support for https yet + ['tsgi.input'] = { + read = tsgi_input_read, + rewind = nil, -- non-rewindable by default + }, + ['tsgi.errors'] = { + write = tsgi_errors_write, + flush = noop, -- TODO: implement + }, + ['tsgi.hijack'] = setmetatable({}, { + __call = tsgi_hijack, + }), + + ['REQUEST_METHOD'] = p.method, + ['PATH_INFO'] = p.path, + ['QUERY_STRING'] = p.query, + ['SERVER_NAME'] = opts.httpd.host, + ['SERVER_PORT'] = opts.httpd.port, + ['SERVER_PROTOCOL'] = string.format('HTTP/%d.%d', p.proto[1], p.proto[2]), + } + + -- Pass through `env` to env['tsgi.*']:*() functions + env['tsgi.input']._env = env + env['tsgi.errors']._env = env + env['tsgi.hijack']._env = env + + -- set headers + for name, value in pairs(p.headers) do + env[convert_headername(name)] = value + end + + -- SCRIPT_NAME is a virtual location of your app. + -- + -- Imagine you want to serve your HTTP API under prefix /test + -- and later move it to /. + -- + -- Instead of rewriting endpoints to your application, you do: + -- + -- location /test/ { + -- proxy_pass http://127.0.0.1:8001/test/; + -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/; + -- proxy_set_header SCRIPT_NAME /test; + -- } + -- + -- Application source code is not touched. + env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or '' + env['HTTP_SCRIPT_NAME'] = nil + + return env +end + +return { + make_env = make_env, +} diff --git a/http/tsgi.lua b/http/tsgi.lua index f8c46cb..8db6b20 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -1,4 +1,4 @@ -local log = require('log') +-- TSGI helper functions local KEY_HTTPD = 'tarantool.http.httpd' local KEY_SOCK = 'tarantool.http.sock' @@ -6,8 +6,6 @@ local KEY_REMAINING = 'tarantool.http.sock_remaining_len' local KEY_PARSED_REQUEST = 'tarantool.http.parsed_request' local KEY_PEER = 'tarantool.http.peer' --- helpers - -- XXX: do it with lua-iterators local function headers(env) local map = {} @@ -19,136 +17,12 @@ local function headers(env) return map end ---- - -local function noop() end - -local function tsgi_errors_write(self, msg) -- luacheck: ignore - log.error(msg) -end - -local function tsgi_hijack(env) - local httpd = env[KEY_HTTPD] - local sock = env[KEY_SOCK] - - httpd.is_hijacked = true - return sock -end - --- TODO: understand this. Maybe rewrite it to only follow --- TSGI logic, and not router logic. --- --- if opts is number, it specifies number of bytes to be read --- if opts is a table, it specifies options -local function tsgi_input_read(self, opts, timeout) - checks('table', '?number|string|table', '?number') -- luacheck: ignore - local env = self._env - - local remaining = env[KEY_REMAINING] - if not remaining then - remaining = tonumber(env['HEADER_CONTENT-LENGTH']) -- TODO: hyphen - if not remaining then - return '' - end - end - - if opts == nil then - opts = remaining - elseif type(opts) == 'number' then - if opts > remaining then - opts = remaining - end - elseif type(opts) == 'string' then - opts = { size = remaining, delimiter = opts } - elseif type(opts) == 'table' then - local size = opts.size or opts.chunk - if size and size > remaining then - opts.size = remaining - opts.chunk = nil - end - end - - local buf = env[KEY_SOCK]:read(opts, timeout) - if buf == nil then - env[KEY_REMAINING] = 0 - return '' - end - remaining = remaining - #buf - assert(remaining >= 0) - env[KEY_REMAINING] = remaining - return buf -end - -local function convert_headername(name) - return 'HEADER_' .. string.upper(name) -- TODO: hyphens -end - -local function make_env(opts) - local p = opts.parsed_request - - local env = { - [KEY_SOCK] = opts.sock, - [KEY_HTTPD] = opts.httpd, - [KEY_PARSED_REQUEST] = p, -- TODO: delete? - [KEY_PEER] = opts.peer, -- TODO: delete? - - ['tsgi.version'] = '1', - ['tsgi.url_scheme'] = 'http', -- no support for https yet - ['tsgi.input'] = { - read = tsgi_input_read, - rewind = nil, -- non-rewindable by default - }, - ['tsgi.errors'] = { - write = tsgi_errors_write, - flush = noop, -- TODO: implement - }, - ['tsgi.hijack'] = setmetatable({}, { - __call = tsgi_hijack, - }), - - ['REQUEST_METHOD'] = p.method, - ['PATH_INFO'] = p.path, - ['QUERY_STRING'] = p.query, - ['SERVER_NAME'] = opts.httpd.host, - ['SERVER_PORT'] = opts.httpd.port, - ['SERVER_PROTOCOL'] = string.format('HTTP/%d.%d', p.proto[1], p.proto[2]), - } - - -- Pass through `env` to env['tsgi.*']:*() functions - env['tsgi.input']._env = env - env['tsgi.errors']._env = env - env['tsgi.hijack']._env = env - - -- set headers - for name, value in pairs(p.headers) do - env[convert_headername(name)] = value - end - - -- SCRIPT_NAME is a virtual location of your app. - -- - -- Imagine you want to serve your HTTP API under prefix /test - -- and later move it to /. - -- - -- Instead of rewriting endpoints to your application, you do: - -- - -- location /test/ { - -- proxy_pass http://127.0.0.1:8001/test/; - -- proxy_redirect http://127.0.0.1:8001/test/ http://$host/test/; - -- proxy_set_header SCRIPT_NAME /test; - -- } - -- - -- Application source code is not touched. - env['SCRIPT_NAME'] = env['HTTP_SCRIPT_NAME'] or '' - env['HTTP_SCRIPT_NAME'] = nil - - return env -end - return { KEY_HTTPD = KEY_HTTPD, + KEY_SOCK = KEY_SOCK, + KEY_REMAINING = KEY_REMAINING, KEY_PARSED_REQUEST = KEY_PARSED_REQUEST, KEY_PEER = KEY_PEER, - make_env = make_env, headers = headers, } diff --git a/rockspecs/http-scm-1.rockspec b/rockspecs/http-scm-1.rockspec index f74a031..6089241 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/rockspecs/http-scm-1.rockspec @@ -27,7 +27,9 @@ build = { "$(TARANTOOL_INCDIR)" } }, - ['http.server'] = 'http/server.lua', + ['http.server'] = 'http/server/init.lua', + ['http.server.tsgi_adapter'] = 'http/server/tsgi_adapter.lua', + ['http.nginx_server'] = 'http/nginx_server/init.lua', ['http.router.fs'] = 'http/router/fs.lua', ['http.router.request'] = 'http/router/request.lua', ['http.router.response'] = 'http/router/response.lua', From 5b1e00276f81a54c42e5df0519c8b32c9c04ec0d Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Fri, 10 May 2019 21:26:34 +0300 Subject: [PATCH 07/10] Make builtin-server test fail explicitely --- test_locally.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test_locally.sh b/test_locally.sh index fabadd5..04bc087 100755 --- a/test_locally.sh +++ b/test_locally.sh @@ -1,3 +1,5 @@ +set -e + echo "Builtin server" echo "--------------------" echo "" From 3f6a2bb4f5bba4c56a5ffebbb6c3c7580a57bd84 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Fri, 10 May 2019 22:06:36 +0300 Subject: [PATCH 08/10] Fix duplication of serialize_request() As it is used in both builtin- and nginx- servers, its extracted to common http/tsgi module. --- http/nginx_server/init.lua | 32 ++------------------------------ http/server/init.lua | 31 ++----------------------------- http/tsgi.lua | 30 ++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 59 deletions(-) diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index c9c3651..c88cd70 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -1,5 +1,4 @@ local tsgi = require('http.tsgi') -local utils = require('http.utils') require('checks') local json = require('json') @@ -34,33 +33,6 @@ local function tsgi_input_rewind(self) self._pos = 0 end -local function serialize_request(env) - -- {{{ - -- TODO: copypaste from router/request.lua. - -- maybe move it to tsgi.lua. - - local res = env['PATH_INFO'] - local query_string = env['QUERY_STRING'] - if query_string ~= nil and query_string ~= '' then - res = res .. '?' .. query_string - end - - res = utils.sprintf("%s %s %s", - env['REQUEST_METHOD'], - res, - env['SERVER_PROTOCOL'] or 'HTTP/?') - res = res .. "\r\n" - -- }}} end of request_line copypaste - - for hn, hv in pairs(tsgi.headers(env)) do - res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) - end - - -- return utils.sprintf("%s\r\n%s", res, self:read_cached()) - -- NOTE: no body is logged. - return res -end - local function make_env(server, req) -- NGINX Tarantool Upstream `parse_query` option must NOT be set. local uriparts = string.split(req.uri, '?') -- luacheck: ignore @@ -139,14 +111,14 @@ local function generic_entrypoint(server, req, ...) -- luacheck: ignore -- TODO: copypaste -- TODO: env could be changed. we need to save a copy of it log.error('unhandled error: %s\n%s\nrequest:\n%s', - tostring(resp), trace, serialize_request(env)) + tostring(resp), trace, tsgi.serialize_request(env)) if server.display_errors then body = "Unhandled error: " .. tostring(resp) .. "\n" .. trace .. "\n\n" .. "\n\nRequest:\n" - .. serialize_request(env) + .. tsgi.serialize_request(env) else body = "Internal Error" end diff --git a/http/server/init.lua b/http/server/init.lua index 3a49606..28ff41f 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -44,33 +44,6 @@ local function parse_request(req) return p end -local function serialize_request(env) - -- {{{ - -- TODO: copypaste from router/request.lua. - -- maybe move it to tsgi_adapter.lua. - - local res = env['PATH_INFO'] - local query_string = env['QUERY_STRING'] - if query_string ~= nil and query_string ~= '' then - res = res .. '?' .. query_string - end - - res = utils.sprintf("%s %s %s", - env['REQUEST_METHOD'], - res, - env['SERVER_PROTOCOL'] or 'HTTP/?') - res = res .. "\r\n" - -- }}} end of request_line copypaste - - for hn, hv in pairs(tsgi.headers(env)) do - res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) - end - - -- return utils.sprintf("%s\r\n%s", res, self:read_cached()) - -- NOTE: no body is logged. - return res -end - local function process_client(self, s, peer) while true do -- read headers, until double CRLF @@ -143,14 +116,14 @@ local function process_client(self, s, peer) -- TODO: copypaste logerror('unhandled error: %s\n%s\nrequest:\n%s', - tostring(resp), trace, serialize_request(env)) + tostring(resp), trace, tsgi.serialize_request(env)) if self.options.display_errors then -- TODO: env could be changed. we need to save a copy of it body = "Unhandled error: " .. tostring(resp) .. "\n" .. trace .. "\n\n" .. "\n\nRequest:\n" - .. serialize_request(env) + .. tsgi.serialize_request(env) else body = "Internal Error" end diff --git a/http/tsgi.lua b/http/tsgi.lua index 8db6b20..01955b1 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -1,5 +1,7 @@ -- TSGI helper functions +local utils = require('http.utils') + local KEY_HTTPD = 'tarantool.http.httpd' local KEY_SOCK = 'tarantool.http.sock' local KEY_REMAINING = 'tarantool.http.sock_remaining_len' @@ -17,6 +19,33 @@ local function headers(env) return map end +local function serialize_request(env) + -- {{{ + -- TODO: copypaste from router/request.lua. + -- maybe move it to tsgi.lua. + + local res = env['PATH_INFO'] + local query_string = env['QUERY_STRING'] + if query_string ~= nil and query_string ~= '' then + res = res .. '?' .. query_string + end + + res = utils.sprintf("%s %s %s", + env['REQUEST_METHOD'], + res, + env['SERVER_PROTOCOL'] or 'HTTP/?') + res = res .. "\r\n" + -- }}} end of request_line copypaste + + for hn, hv in pairs(headers(env)) do + res = utils.sprintf("%s%s: %s\r\n", res, utils.ucfirst(hn), hv) + end + + -- return utils.sprintf("%s\r\n%s", res, self:read_cached()) + -- NOTE: no body is logged. + return res +end + return { KEY_HTTPD = KEY_HTTPD, KEY_SOCK = KEY_SOCK, @@ -25,4 +54,5 @@ return { KEY_PEER = KEY_PEER, headers = headers, + serialize_request = serialize_request, } From 45b33d92be0d5c9db052352d9c6ca98213979c93 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Sat, 11 May 2019 15:22:11 +0300 Subject: [PATCH 09/10] Add require("checks") to tsgi_adapter.lua --- http/server/tsgi_adapter.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua index 8d34391..f006d79 100644 --- a/http/server/tsgi_adapter.lua +++ b/http/server/tsgi_adapter.lua @@ -1,5 +1,6 @@ local tsgi = require('http.tsgi') +require('checks') local log = require('log') From 05e661d7238ed77783cdbc2706f41d8bab9e9b49 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Thu, 16 May 2019 19:17:48 +0300 Subject: [PATCH 10/10] Add request_object:peer() for NGINX adapter host and port are extracted via box.session.peer(). family, type and protocol are hardcoded. --- README.md | 6 ++++-- http/nginx_server/init.lua | 11 +++++++++++ http/router/init.lua | 2 +- test/http.test.lua | 18 ++++++------------ 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2be68a8..34f9487 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,10 @@ end * `req.headers` - normalized request headers. A normalized header is in the lower case, all headers joined together into a single string. * `req.peer` - a Lua table with information about the remote peer - (like `socket:peer()`). **NOTE**: not available when using NGINX TSGI - adapter. + (like `socket:peer()`). + **NOTE**: when router is being used with + nginx adapter, `req.peer` contains information on iproto connection with + nginx, not the original HTTP user-agent. * `tostring(req)` - returns a string representation of the request. * `req:request_line()` - returns the request body. * `req:read(delimiter|chunk|{delimiter = x, chunk = x}, timeout)` - reads the diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index c88cd70..42537ff 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -43,6 +43,10 @@ local function make_env(server, req) body = json.decode(req.body).params end + local hostport = box.session.peer(box.session.id()) -- luacheck: ignore + local hostport_parts = string.split(hostport, ':') -- luacheck: ignore + local peer_host, peer_port = hostport_parts[1], tonumber(hostport_parts[2]) + local env = { ['tsgi.version'] = '1', ['tsgi.url_scheme'] = 'http', -- no support for https @@ -62,6 +66,13 @@ local function make_env(server, req) ['PATH_INFO'] = path_info, ['QUERY_STRING'] = query_string, ['SERVER_PROTOCOL'] = req.proto, + [tsgi.KEY_PEER] = { + host = peer_host, + port = peer_port, + family = 'AF_INET', + type = 'SOCK_STREAM', + protocol = 'tcp', + }, [KEY_BODY] = body, -- http body string; used in `tsgi_input_read` } diff --git a/http/router/init.lua b/http/router/init.lua index 59cc1c5..2127e3f 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -26,7 +26,7 @@ local function request_from_env(env, router) -- luacheck: ignore local request = { router = router, env = env, - peer = env[tsgi.KEY_PEER], -- only for builtin server + peer = env[tsgi.KEY_PEER], method = env['REQUEST_METHOD'], path = env['PATH_INFO'], query = env['QUERY_STRING'], diff --git a/test/http.test.lua b/test/http.test.lua index 73b6ae3..a56a8bb 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -318,18 +318,12 @@ test:test("server requests", function(test) test:is(r.status, 500, 'die 500') --test:is(r.reason, 'Internal server error', 'die reason') - -- request.peer is not supported in NGINX TSGI - if is_builtin_test() then - router:route({ path = '/info' }, function(cx) - return cx:render({ json = cx.peer }) - end) - local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) - test:is(r.host, '127.0.0.1', 'peer.host') - test:isnumber(r.port, 'peer.port') - else - test:ok(true, 'peer.host - ignore on NGINX') - test:ok(true, 'peer.port - ignore on NGINX') - end + router:route({ path = '/info' }, function(cx) + return cx:render({ json = cx.peer }) + end) + local r = json.decode(http_client.get('http://127.0.0.1:12345/info').body) + test:is(r.host, '127.0.0.1', 'peer.host') + test:isnumber(r.port, 'peer.port') local r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, function(tx)