From 01004d7d63cd7253248fb4a9613229e08fa98fd6 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Sun, 19 May 2019 00:07:19 +0300 Subject: [PATCH 01/25] Add TSGI Layer Between Router and Server (#81) Changes done: 1. decompose the code into modular pieces 2. decouple router and server parts 3. introduce nginx as possible Web Server via TSGI adapter 4. enable router tests on NGINX Note: this change breaks connection stealing. --- README.md | 6 +- http/nginx_server/init.lua | 206 ++++++ http/router/fs.lua | 258 +++++++ http/router/init.lua | 375 ++++++++++ http/router/request.lua | 210 ++++++ http/router/response.lua | 75 ++ http/server.lua | 1270 --------------------------------- http/server/init.lua | 342 +++++++++ http/server/tsgi_adapter.lua | 132 ++++ http/tsgi.lua | 58 ++ http/utils.lua | 83 +++ rockspecs/http-scm-1.rockspec | 10 +- test/Procfile.test.nginx | 2 + test/http.test.lua | 267 +++++-- test/nginx.conf | 84 +++ test_locally.sh | 11 + 16 files changed, 2054 insertions(+), 1335 deletions(-) create mode 100644 http/nginx_server/init.lua create mode 100644 http/router/fs.lua create mode 100644 http/router/init.lua create mode 100644 http/router/request.lua create mode 100644 http/router/response.lua delete mode 100644 http/server.lua create mode 100644 http/server/init.lua create mode 100644 http/server/tsgi_adapter.lua create mode 100644 http/tsgi.lua create mode 100644 http/utils.lua create mode 100644 test/Procfile.test.nginx create mode 100644 test/nginx.conf create mode 100755 test_locally.sh diff --git a/README.md b/README.md index d39d5e5..f0dbc9c 100644 --- a/README.md +++ b/README.md @@ -215,10 +215,14 @@ end 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**: 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 - 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/init.lua b/http/nginx_server/init.lua new file mode 100644 index 0000000..42537ff --- /dev/null +++ b/http/nginx_server/init.lua @@ -0,0 +1,206 @@ +local tsgi = require('http.tsgi') + +require('checks') +local json = require('json') +local log = require('log') + +local KEY_BODY = 'tsgi.http.nginx_server.body' + +local function noop() end + +local function convert_headername(name) + return 'HEADER_' .. string.upper(name) +end + +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 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] + + local body = '' + if type(req.body) == 'string' then + 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 + ['tsgi.input'] = { + _pos = 0, -- last unread char in body + read = tsgi_input_read, + rewind = tsgi_input_rewind, + }, + ['tsgi.errors'] = { + write = noop, + flush = noop, + }, + ['tsgi.hijack'] = nil, -- no support for hijack with nginx + ['REQUEST_METHOD'] = string.upper(req.method), + ['SERVER_NAME'] = server.host, + ['SERVER_PORT'] = server.port, + ['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` + } + + -- 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 + +local function generic_entrypoint(server, req, ...) -- luacheck: ignore + local env = make_env(server, req, ...) + + local ok, resp = pcall(server.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() + + -- 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, tsgi.serialize_request(env)) + + if server.display_errors then + body = + "Unhandled error: " .. tostring(resp) .. "\n" + .. trace .. "\n\n" + .. "\n\nRequest:\n" + .. tsgi.serialize_request(env) + 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(self, router) + checks('table', 'function') -- luacheck: ignore + + self.router = router +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', + log_errors = '?boolean', + log_requests = '?boolean', + }) + + local self = { + host = opts.host, + 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, + stop = ngxserver_stop, + } + return self +end + +return { + new = new, +} 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/init.lua b/http/router/init.lua new file mode 100644 index 0000000..2127e3f --- /dev/null +++ b/http/router/init.lua @@ -0,0 +1,375 @@ +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 + 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 + -- TODO: khm... what if we have nginx tsgi? + -- we need to restrict ourselves to generic TSGI + -- methods and properties! + + local request = { + router = router, + env = env, + peer = env[tsgi.KEY_PEER], + 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 + +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/request.lua b/http/router/request.lua new file mode 100644 index 0000000..801457a --- /dev/null +++ b/http/router/request.lua @@ -0,0 +1,210 @@ +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'] + + 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, + 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(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() + 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 deleted file mode 100644 index e7dac9a..0000000 --- a/http/server.lua +++ /dev/null @@ -1,1270 +0,0 @@ --- 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 log = require('log') -local socket = require('socket') -local json = require('json') -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, 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 is_function(obj) - return type(obj) == 'function' -end - -local function get_request_logger(server_opts, route_opts) - if route_opts and route_opts.endpoint.log_requests ~= nil then - if is_function(route_opts.endpoint.log_requests) then - return route_opts.endpoint.log_requests - elseif route_opts.endpoint.log_requests == false then - return log.debug - end - end - - if server_opts.log_requests then - if is_function(server_opts.log_requests) then - return server_opts.log_requests - end - - return log.info - end - - return log.debug -end - -local function get_error_logger(server_opts, route_opts) - if route_opts and route_opts.endpoint.log_errors ~= nil then - if is_function(route_opts.endpoint.log_errors) then - return route_opts.endpoint.log_errors - elseif route_opts.endpoint.log_errors == false then - return log.debug - end - end - - if server_opts.log_errors then - if is_function(server_opts.log_errors) then - return server_opts.log_errors - end - - return log.error - end - - return log.debug -end - -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 - -local function normalize_headers(hdrs) - local res = {} - for h, v in pairs(hdrs) do - res[ string.lower(h) ] = v - end - return res -end - -local function parse_request(req) - local p = lib._parse_request(req) - if p.error then - return p - end - p.path = uri_unescape(p.path) - if p.path:sub(1, 1) ~= "/" or p.path:find("./", nil, true) ~= nil then - p.error = "invalid uri" - return p - end - return p -end - -local function process_client(self, s, peer) - while true do - local hdrs = '' - - local is_eof = false - while true do - local chunk = s:read{ - delimiter = { "\n\n", "\r\n\r\n" } - } - - if chunk == '' then - is_eof = true - break -- eof - elseif chunk == nil then - log.error('failed to read request: %s', errno.strerror()) - return - end - - hdrs = hdrs .. chunk - - if string.endswith(hdrs, "\n\n") or string.endswith(hdrs, "\r\n\r\n") then - break - end - end - - if is_eof then - break - end - - 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)) - break - end - p.httpd = self - p.s = s - p.peer = peer - setmetatable(p, request_mt) - - if p.headers['expect'] == '100-continue' then - s:write('HTTP/1.0 100 Continue\r\n\r\n') - end - - local route = self:match(p.method, p.path) - local logreq = get_request_logger(self.options, route) - 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 - - if not res then - status = 500 - hdrs = {} - local trace = debug.traceback() - local logerror = get_error_logger(self.options, route) - logerror('unhandled error: %s\n%s\nrequest:\n%s', - tostring(reason), trace, tostring(p)) - if self.options.display_errors then - body = - "Unhandled error: " .. tostring(reason) .. "\n" - .. trace .. "\n\n" - .. "\n\nRequest:\n" - .. tostring(p) - else - body = "Internal Error" - end - elseif type(reason) == 'table' then - if reason.status == nil then - status = 200 - elseif type(reason.status) == 'number' then - status = reason.status - else - error('response.status must be a number') - end - if reason.headers == nil then - hdrs = {} - elseif type(reason.headers) == 'table' then - hdrs = normalize_headers(reason.headers) - else - error('response.headers must be a table') - end - body = reason.body - elseif reason == nil then - status = 200 - hdrs = {} - elseif type(reason) == 'number' then - if reason == DETACHED then - break - end - else - error('invalid response') - end - - local gen, param, state - if type(body) == 'string' then - -- Plain string - hdrs['content-length'] = #body - elseif type(body) == 'function' then - -- Generating function - gen = body - hdrs['transfer-encoding'] = 'chunked' - elseif type(body) == 'table' and body.gen then - -- Iterator - gen, param, state = body.gen, body.param, body.state - hdrs['transfer-encoding'] = 'chunked' - elseif body == nil then - -- Empty body - hdrs['content-length'] = 0 - else - body = tostring(body) - hdrs['content-length'] = #body - end - - if hdrs.server == nil then - hdrs.server = sprintf('Tarantool http (tarantool v%s)', _TARANTOOL) - end - - if p.proto[1] ~= 1 then - hdrs.connection = 'close' - elseif p.broken then - hdrs.connection = 'close' - elseif rawget(p, 'body') == nil then - hdrs.connection = 'close' - elseif p.proto[2] == 1 then - if p.headers.connection == nil then - hdrs.connection = 'keep-alive' - elseif string.lower(p.headers.connection) ~= 'keep-alive' then - hdrs.connection = 'close' - else - hdrs.connection = 'keep-alive' - end - elseif p.proto[2] == 0 then - if p.headers.connection == nil then - hdrs.connection = 'close' - elseif string.lower(p.headers.connection) == 'keep-alive' then - hdrs.connection = 'keep-alive' - else - hdrs.connection = 'close' - end - end - - local response = { - "HTTP/1.1 "; - status; - " "; - 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)) - end - else - table.insert(response, sprintf("%s: %s\r\n", ucfirst(k), v)) - end - end - table.insert(response, "\r\n") - - if type(body) == 'string' then - table.insert(response, body) - response = table.concat(response) - if not s:write(response) then - break - end - elseif gen then - response = table.concat(response) - if not s:write(response) then - break - end - response = nil - -- 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 - break - end - end - if not s:write("0\r\n\r\n") then - break - end - else - response = table.concat(response) - if not s:write(response) then - break - end - end - - if p.proto[1] ~= 1 then - break - end - - if hdrs.connection ~= 'keep-alive' then - break - end - end -end - -local function httpd_stop(self) - if type(self) ~= 'table' then - error("httpd: usage: httpd:stop()") - end - if self.is_run then - self.is_run = false - else - error("server is already stopped") - end - - if self.tcp_server ~= nil then - self.tcp_server:close() - self.tcp_server = nil - end - 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.log_requests ~= nil then - if type(opts.log_requests) ~= 'function' and type(opts.log_requests) ~= 'boolean' then - error("'log_requests' option should be a function or a boolean") - end - end - - if opts.log_errors ~= nil then - if type(opts.log_errors) ~= 'function' and type(opts.log_errors) ~= 'boolean' then - error("'log_errors' option should be a function or a boolean") - end - end - - 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 - error("httpd: usage: httpd:start()") - end - - local server = socket.tcp_server(self.host, self.port, - { name = 'http', - handler = function(...) - local res = process_client(self, ...) - end}) - if server == nil then - error(sprintf("Can't create tcp_server: %s", errno.strerror())) - end - - rawset(self, 'is_run', true) - rawset(self, 'tcp_server', server) - rawset(self, 'stop', httpd_stop) - - 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 self = { - host = host, - port = port, - is_run = false, - stop = httpd_stop, - start = httpd_start, - options = extend(default, options, true), - - routes = { }, - iroutes = { }, - helpers = { - url_for = url_for_helper, - }, - hooks = { }, - - -- methods - route = add_route, - match = match_route, - helper = set_helper, - hook = set_hook, - url_for = url_for_httpd, - - -- caches - cache = { - tpl = {}, - ctx = {}, - static = {}, - }, - } - - return self - end -} - -return exports diff --git a/http/server/init.lua b/http/server/init.lua new file mode 100644 index 0000000..28ff41f --- /dev/null +++ b/http/server/init.lua @@ -0,0 +1,342 @@ +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') + +local DETACHED = 101 + +--------- +-- Utils +--------- + +local function normalize_headers(hdrs) + local res = {} + for h, v in pairs(hdrs) do + res[ string.lower(h) ] = v + end + 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 = 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 + end + return p +end + +local function process_client(self, s, peer) + while true do + -- read headers, until double CRLF + local hdrs = '' + + local is_eof = false + while true do + local chunk = s:read{ + delimiter = { "\n\n", "\r\n\r\n" } + } + + if chunk == '' then + is_eof = true + break -- eof + elseif chunk == nil then + log.error('failed to read request: %s', errno.strerror()) + return + end + + hdrs = hdrs .. chunk + + if headers_ended(hdrs) then + break + end + end + + if is_eof then + 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(utils.sprintf("HTTP/1.0 400 Bad request\r\n\r\n%s", p.error)) + break + end + + local env = tsgi_adapter.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 + + local logreq = self.options.log_requests and log.info or log.debug + logreq("%s %s%s", p.method, p.path, + p.query ~= "" and "?"..p.query or "") + + 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 + + -- 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 + + -- TODO: copypaste + logerror('unhandled error: %s\n%s\nrequest:\n%s', + 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" + .. tsgi.serialize_request(env) + else + body = "Internal Error" + end + elseif type(resp) == 'table' then + if resp.status == nil then + status = 200 + elseif type(resp.status) == 'number' then + status = resp.status + else + error('response.status must be a number') + end + if resp.headers == nil then + hdrs = {} + elseif type(resp.headers) == 'table' then + hdrs = normalize_headers(resp.headers) + else + error('response.headers must be a table') + end + body = resp.body + elseif resp == nil then + status = 200 + hdrs = {} + 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 + hdrs['content-length'] = #body + elseif type(body) == 'function' then + -- Generating function + gen = body + hdrs['transfer-encoding'] = 'chunked' + elseif type(body) == 'table' and body.gen then + -- Iterator + gen, param, state = body.gen, body.param, body.state + hdrs['transfer-encoding'] = 'chunked' + elseif body == nil then + -- Empty body + hdrs['content-length'] = 0 + else + body = tostring(body) + hdrs['content-length'] = #body + end + + if hdrs.server == nil then + 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 + hdrs.connection = 'close' + elseif rawget(p, 'body') == nil then + hdrs.connection = 'close' + elseif p.proto[2] == 1 then + if p.headers.connection == nil then + hdrs.connection = 'keep-alive' + elseif string.lower(p.headers.connection) ~= 'keep-alive' then + hdrs.connection = 'close' + else + hdrs.connection = 'keep-alive' + end + elseif p.proto[2] == 0 then + if p.headers.connection == nil then + hdrs.connection = 'close' + elseif string.lower(p.headers.connection) == 'keep-alive' then + hdrs.connection = 'keep-alive' + else + hdrs.connection = 'close' + end + end + + -- generate response {{{ + local response = { + "HTTP/1.1 "; + status; + " "; + utils.reason_by_code(status); + "\r\n"; + }; + for k, v in pairs(hdrs) do + if type(v) == 'table' then + for _, sv in pairs(v) do + table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), sv)) + end + else + table.insert(response, utils.sprintf("%s: %s\r\n", utils.ucfirst(k), v)) + end + end + table.insert(response, "\r\n") + + if type(body) == 'string' then + table.insert(response, body) + response = table.concat(response) + if not s:write(response) then + break + end + elseif gen then + response = table.concat(response) + if not s:write(response) then + break + end + response = nil -- luacheck: ignore + -- Transfer-Encoding: chunked + for _, part in gen, param, state do + part = tostring(part) + if not s:write(utils.sprintf("%x\r\n%s\r\n", #part, part)) then + break + end + end + if not s:write("0\r\n\r\n") then + break + end + else + response = table.concat(response) + if not s:write(response) then + break + end + end + -- }}} + + if p.proto[1] ~= 1 then + break + end + + if hdrs.connection ~= 'keep-alive' then + break + end + end +end + +local function httpd_stop(self) + if type(self) ~= 'table' then + error("httpd: usage: httpd:stop()") + end + if self.is_run then + self.is_run = false + else + error("server is already stopped") + end + + if self.tcp_server ~= nil then + self.tcp_server:close() + self.tcp_server = nil + end + return self +end + + +local function httpd_start(self) + if type(self) ~= 'table' then + 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(...) + local _ = process_client(self, ...) + end}) + if server == nil then + error(utils.sprintf("Can't create tcp_server: %s", errno.strerror())) + end + + rawset(self, 'is_run', true) + rawset(self, 'tcp_server', server) + rawset(self, 'stop', httpd_stop) + + return self +end + +local function httpd_set_router(self, router) + self.options.handler = router +end + +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 + + local default = { + handler = nil, -- no router set-up initially + log_requests = true, + log_errors = true, + display_errors = true, + } + + 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), + } + + return self +end + +return { + DETACHED = DETACHED, + new = new, +} diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua new file mode 100644 index 0000000..f006d79 --- /dev/null +++ b/http/server/tsgi_adapter.lua @@ -0,0 +1,132 @@ +local tsgi = require('http.tsgi') + +require('checks') +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 new file mode 100644 index 0000000..01955b1 --- /dev/null +++ b/http/tsgi.lua @@ -0,0 +1,58 @@ +-- 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' +local KEY_PARSED_REQUEST = 'tarantool.http.parsed_request' +local KEY_PEER = 'tarantool.http.peer' + +-- 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 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, + KEY_REMAINING = KEY_REMAINING, + KEY_PARSED_REQUEST = KEY_PARSED_REQUEST, + KEY_PEER = KEY_PEER, + + headers = headers, + serialize_request = serialize_request, +} 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..6089241 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/rockspecs/http-scm-1.rockspec @@ -27,7 +27,15 @@ 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', + ['http.router'] = 'http/router/init.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/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 396cbaf..e4e2fee 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -5,10 +5,32 @@ 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') +-- 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} +) + local test = tap.test("http") test:plan(8) @@ -43,7 +65,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) @@ -129,11 +151,41 @@ test:test('params', function(test) {a = { 'b', '1' }, b = 'cde'}, 'array') end) +local function is_nginx_test() + local server_type = os.getenv('SERVER_TYPE') or 'builtin' + return server_type:lower() == 'nginx' +end + +local function is_builtin_test() + return not is_nginx_test() +end + +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 + + return http_server.new('127.0.0.1', 12345, { + log_requests = false, + log_errors = false + }) +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 }) + + 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) :route({path = '/ctxaction'}, 'module.controller#action') @@ -148,61 +200,62 @@ 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) - return httpd + function(cx) return cx:render({ title = 'title: 123' }) end) + 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) test:test("server requests", function(test) - test:plan(36) - local httpd = cfgserv() + test:plan(38) + local httpd, router = cfgserv() httpd:start() local r = http_client.get('http://127.0.0.1:12345/test') @@ -259,54 +312,54 @@ 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) - return cx:render({ json = cx.peer }) + 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) test:istable(r, ':route') - 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 ) 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') @@ -320,7 +373,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) @@ -332,7 +385,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({ @@ -350,7 +403,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' }) @@ -362,30 +415,118 @@ test:test("server requests", function(test) test:ok(r.headers['set-cookie'] ~= nil, "header") end) - test:test('post body', function(test) - test:plan(2) - httpd: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(); + 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, } - 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) + 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') - test:is_deeply(json.decode(r.body), { 541,10,10,458,1375,0,0 }, - 'req:read() results') + parsed_body = json.decode(r.body) + test:is(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') 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/nginx.conf b/test/nginx.conf new file mode 100644 index 0000000..7090cd7 --- /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() + + -- cjson.encode is needed to json-escape the body + if body then + body = "{\\"params\\": " .. cjson.encode(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; + } +} + +} diff --git a/test_locally.sh b/test_locally.sh new file mode 100755 index 0000000..04bc087 --- /dev/null +++ b/test_locally.sh @@ -0,0 +1,11 @@ +set -e + +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 6e01b6afb7ea0a22686efa4d40a4b2848fca10b4 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 27 May 2019 17:05:36 +0300 Subject: [PATCH 02/25] Remove httpd parameter from router init Now you don't have to pass server object on router creation, but router has to register itself in server object manually: httpd:set_router(router) --- http/nginx_server/init.lua | 2 +- http/router/init.lua | 15 +++++++++------ test/http.test.lua | 3 ++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index 42537ff..b530d9f 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -157,7 +157,7 @@ local function generic_entrypoint(server, req, ...) -- luacheck: ignore end local function ngxserver_set_router(self, router) - checks('table', 'function') -- luacheck: ignore + checks('table', 'function|table') -- luacheck: ignore self.router = router end diff --git a/http/router/init.lua b/http/router/init.lua index 2127e3f..b92e52a 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -318,7 +318,7 @@ local function url_for(self, name, args, query) end local exports = { - new = function(httpd, options) + new = function(options) if options == nil then options = {} end @@ -364,11 +364,14 @@ local exports = { } -- make router object itself callable - httpd:set_router(function (env) - return handler(self, env) - end) - - return self + -- + -- BE AWARE: + -- 1) router(env) is valid, but + -- 2) type(router) == 'table': + -- + return setmetatable(self, { + __call = handler, + }) end } diff --git a/test/http.test.lua b/test/http.test.lua index e4e2fee..944eb76 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -185,7 +185,7 @@ local function cfgserv() path = fio.pathjoin(path, 'test') local httpd = choose_server() - local router = http_router.new(httpd, {app_dir = path}) + local router = http_router.new({app_dir = path}) :route({path = '/abc/:cde/:def', name = 'test'}, function() end) :route({path = '/abc'}, function() end) :route({path = '/ctxaction'}, 'module.controller#action') @@ -201,6 +201,7 @@ 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) + httpd:set_router(router) return httpd, router end From 6313f88d37f3f3b1c32c6c7274c4ba70c7c65e51 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 27 May 2019 21:24:08 +0300 Subject: [PATCH 03/25] Remove tsgi.errors --- http/nginx_server/init.lua | 7 ------- http/server/tsgi_adapter.lua | 13 ------------- 2 files changed, 20 deletions(-) diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index b530d9f..bfe9078 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -6,8 +6,6 @@ local log = require('log') local KEY_BODY = 'tsgi.http.nginx_server.body' -local function noop() end - local function convert_headername(name) return 'HEADER_' .. string.upper(name) end @@ -55,10 +53,6 @@ local function make_env(server, req) read = tsgi_input_read, rewind = tsgi_input_rewind, }, - ['tsgi.errors'] = { - write = noop, - flush = noop, - }, ['tsgi.hijack'] = nil, -- no support for hijack with nginx ['REQUEST_METHOD'] = string.upper(req.method), ['SERVER_NAME'] = server.host, @@ -79,7 +73,6 @@ local function make_env(server, req) -- 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 diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua index f006d79..f805b2a 100644 --- a/http/server/tsgi_adapter.lua +++ b/http/server/tsgi_adapter.lua @@ -1,14 +1,6 @@ local tsgi = require('http.tsgi') require('checks') -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] @@ -81,10 +73,6 @@ local function make_env(opts) 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, }), @@ -99,7 +87,6 @@ local function make_env(opts) -- Pass through `env` to env['tsgi.*']:*() functions env['tsgi.input']._env = env - env['tsgi.errors']._env = env env['tsgi.hijack']._env = env -- set headers From 3802b50575f2835763f10c660b55c58ecd46753a Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Wed, 29 May 2019 16:24:30 +0300 Subject: [PATCH 04/25] Implement Partial-Order Approach to Middleware --- examples/middleware.lua | 81 +++++++++++ http/router/init.lua | 272 ++++++++++++++++++------------------- http/router/matching.lua | 116 ++++++++++++++++ http/router/middleware.lua | 144 ++++++++++++++++++++ http/tsgi.lua | 33 +++++ test/http.test.lua | 221 ++++++++++++++---------------- test/middleware.test.lua | 81 +++++++++++ 7 files changed, 690 insertions(+), 258 deletions(-) create mode 100755 examples/middleware.lua create mode 100644 http/router/matching.lua create mode 100644 http/router/middleware.lua create mode 100755 test/middleware.test.lua diff --git a/examples/middleware.lua b/examples/middleware.lua new file mode 100755 index 0000000..be6902e --- /dev/null +++ b/examples/middleware.lua @@ -0,0 +1,81 @@ +#!/usr/bin/env tarantool +local http_router = require('http.router') +local http_server = require('http.server') +local tsgi = require('http.tsgi') +local json = require('json') +local log = require('log') + +box.cfg{} -- luacheck: ignore + +local httpd = http_server.new('127.0.0.1', 12345, { + log_requests = true, + log_errors = true +}) + +local function swap_orange_and_apple(env) + local path_info = env['PATH_INFO'] + log.info('swap_orange_and_apple: path_info = %s', path_info) + if path_info == '/fruits/orange' then + env['PATH_INFO'] = '/fruits/apple' + elseif path_info == '/fruits/apple' then + env['PATH_INFO'] = '/fruits/orange' + end + + return tsgi.next(env) +end + +local function add_helloworld_to_response(env) + local resp = tsgi.next(env) + if resp.body == nil then + return resp + end + + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world!' + resp.body = json.encode(lua_body) + + return resp +end + +local function apple_handler(_) + return {status = 200, body = json.encode({kind = 'apple'})} +end + +local function orange_handler(_) + return {status = 200, body = json.encode({kind = 'orange'})} +end + +local router = http_router.new() + :route({ + method = 'GET', + path = '/fruits/apple', + }, + apple_handler + ) + :route({ + method = 'GET', + path = '/fruits/orange', + }, + orange_handler + ) + +local ok = router:use({ + preroute = true, + name = 'swap_orange_and_apple', + method = 'GET', + path = '/fruits/.*', + handler = swap_orange_and_apple, +}) +assert(ok, 'no conflict on adding swap_orange_and_apple') + +ok = router:use({ + name = 'hello_world', + method = 'GET', + path = '/fruits/.*', + handler = add_helloworld_to_response, +}) +assert(ok, 'no conflict on adding hello_world middleware') + + +httpd:set_router(router) +httpd:start() diff --git a/http/router/init.lua b/http/router/init.lua index b92e52a..62b281e 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -1,4 +1,6 @@ local fs = require('http.router.fs') +local middleware = require('http.router.middleware') +local matching = require('http.router.matching') local request_metatable = require('http.router.request').metatable local utils = require('http.utils') @@ -50,111 +52,101 @@ local function request_from_env(env, router) -- luacheck: ignore return setmetatable(request, request_metatable) end -local function handler(self, env) +local function main_endpoint_middleware(env) + local self = env[tsgi.KEY_ROUTER] + local format = uri_file_extension(env['PATH_INFO'], 'html') + local r = env[tsgi.KEY_ROUTE] local request = request_from_env(env, self) + 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 + return r.endpoint.handler(request) +end - if self.hooks.before_dispatch ~= nil then - self.hooks.before_dispatch(self, request) +local function populate_chain_with_middleware(env, middleware_obj) + local filter = matching.transform_filter({ + path = env['PATH_INFO'], + method = env['REQUEST_METHOD'] + }) + for _, m in pairs(middleware_obj:ordered()) do + if matching.matches(m, filter) then + tsgi.push_back_handler(env, m.handler) + end end +end - local format = uri_file_extension(request.env['PATH_INFO'], 'html') +local function dispatch_middleware(env) + local self = env[tsgi.KEY_ROUTER] - -- 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(env['REQUEST_METHOD'], env['PATH_INFO']) + env[tsgi.KEY_ROUTE] = r - local r = self:match(request.env['REQUEST_METHOD'], request.env['PATH_INFO']) - if r == nil then - return fs.static_file(self, request, format) - end + populate_chain_with_middleware(env, self.middleware) - local stash = utils.extend(r.stash, { format = format }) + -- finally, add user specified handler + tsgi.push_back_handler(env, main_endpoint_middleware) - request.endpoint = r.endpoint -- THIS IS ROUTE, BUT IS NAMED `ENDPOINT`! OH-MY-GOD! - request.tstash = stash + return tsgi.next(env) +end - -- execute user-specified request handler - local resp = r.endpoint.sub(request) +local function router_handler(self, env) + env[tsgi.KEY_ROUTER] = self - if self.hooks.after_dispatch ~= nil then - self.hooks.after_dispatch(request, resp) - end - return resp + -- set-up middleware chain + tsgi.init_handlers(env) + + populate_chain_with_middleware(env, self.preroute_middleware) + + -- add routing + tsgi.push_back_handler(env, dispatch_middleware) + + -- execute middleware chain from first + return tsgi.next(env) 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 + local filter = matching.transform_filter({ + method = method, + path = route + }) + + local best_match = nil + for _, r in pairs(self.routes) do + local ok, match = matching.matches(r, filter) + if ok and matching.better_than(match, best_match) then + best_match = match end end - if fit == nil then - return fit + if best_match == nil or best_match.route == nil then + return nil end + local resstash = {} - for i = 1, #fit.stash do - resstash[ fit.stash[ i ] ] = stash[ i ] + for i = 1, #best_match.route.stash do + resstash[best_match.route.stash[i]] = best_match.stash[i] end - return { endpoint = fit, stash = resstash } + return {endpoint = best_match.route, stash = resstash} end -local function set_helper(self, name, sub) - if sub == nil or type(sub) == 'function' then - self.helpers[ name ] = sub +local function set_helper(self, name, handler) + if handler == nil or type(handler) == 'function' then + self.helpers[ name ] = handler return self end - utils.errorf("Wrong type for helper function: %s", type(sub)) + utils.errorf("Wrong type for helper function: %s", type(handler)) end -local function set_hook(self, name, sub) - if sub == nil or type(sub) == 'function' then - self.hooks[ name ] = sub +local function set_hook(self, name, handler) + if handler == nil or type(handler) == 'function' then + self.hooks[ name ] = handler return self end - utils.errorf("Wrong type for hook function: %s", type(sub)) + utils.errorf("Wrong type for hook function: %s", type(handler)) end local function url_for_route(r, args, query) @@ -198,7 +190,46 @@ local possible_methods = { PATCH = 'PATCH', } -local function add_route(self, opts, sub) +local function use_middleware(self, opts) + local opts = table.deepcopy(opts) -- luacheck: ignore + + if type(opts) ~= 'table' or type(self) ~= 'table' then + error("Usage: router:route({ ... }, function(cx) ... end)") + end + + assert(type(opts.name) == 'string') + assert(type(opts.handler) == 'function') + + opts.path = opts.path or '/.*' + assert(type(opts.path) == 'string') + + opts.method = opts.method or 'ANY' + + opts.before = opts.before or {} + opts.after = opts.after or {} + for _, order_key in ipairs({'before', 'after'}) do + local opt = opts[order_key] + assert(type(opt) ~= 'string' or type(opt) ~= 'table', + ('%s must be a table of strings or a string'):format(order_key)) + if type(opt) == 'table' then + for _, name in ipairs(opt) do + local fmt = ('%s of table type, must contain strings, got %s') + :format(order_key, type(opt[name])) + assert(type(opt[name]) == 'string', fmt) + end + end + end + + -- helpers for matching and retrieving pattern words + opts.match, opts.stash = matching.transform_pattern(opts.path) + + if opts.preroute == true then + return self.preroute_middleware:use(opts) + end + return self.middleware:use(opts) +end + +local function add_route(self, opts, handler) if type(opts) ~= 'table' or type(self) ~= 'table' then error("Usage: router:route({ ... }, function(cx) ... end)") end @@ -208,21 +239,21 @@ local function add_route(self, opts, sub) local ctx local action - if sub == nil then - sub = fs.render - elseif type(sub) == 'string' then + if handler == nil then + handler = fs.render + elseif type(handler) == 'string' then - ctx, action = string.match(sub, '(.+)#(.*)') + ctx, action = string.match(handler, '(.+)#(.*)') if ctx == nil or action == nil then - utils.errorf("Wrong controller format '%s', must be 'module#action'", sub) + utils.errorf("Wrong controller format '%s', must be 'module#action'", handler) end - sub = fs.ctx_action + handler = fs.ctx_action - elseif type(sub) ~= 'function' then + elseif type(handler) ~= 'function' then utils.errorf("wrong argument: expected function, but received %s", - type(sub)) + type(handler)) end opts.method = possible_methods[string.upper(opts.method)] or 'ANY' @@ -233,55 +264,9 @@ local function add_route(self, opts, sub) 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.match, opts.stash = matching.transform_pattern(opts.path) + opts.handler = handler opts.url_for = url_for_route -- register new route in a router @@ -339,17 +324,20 @@ local exports = { local self = { options = utils.extend(default, options, true), - routes = { }, -- routes array - iroutes = { }, -- routes by name - helpers = { -- for use in templates + routes = { }, -- routes array + iroutes = { }, -- routes by name + middleware = middleware.new(), -- new middleware + preroute_middleware = middleware.new(), -- new middleware (preroute) + helpers = { -- for use in templates url_for = url_for_helper, }, - hooks = { }, -- middleware + hooks = { }, -- middleware -- methods - route = add_route, -- add route - helper = set_helper, -- for use in templates - hook = set_hook, -- middleware + use = use_middleware, -- new middleware + route = add_route, -- add route + helper = set_helper, -- for use in templates + hook = set_hook, -- middleware url_for = url_for, -- private @@ -370,7 +358,7 @@ local exports = { -- 2) type(router) == 'table': -- return setmetatable(self, { - __call = handler, + __call = router_handler, }) end } diff --git a/http/router/matching.lua b/http/router/matching.lua new file mode 100644 index 0000000..78180b0 --- /dev/null +++ b/http/router/matching.lua @@ -0,0 +1,116 @@ +local utils = require('http.utils') + +local function transform_filter(filter) + local path = filter.path -- luacheck: ignore + -- route must have '/' at the begin and end + if string.match(path, '.$') ~= '/' then + path = path .. '/' + end + if string.match(path, '^.') ~= '/' then + path = '/' .. path + end + + return { + path = path, + method = string.upper(filter.method) + } +end + +-- converts user-defined path pattern to a matcher string. +-- used on adding new route. +local function transform_pattern(path) + local match = path + match = string.gsub(match, '[-]', "[-]") + + -- convert user-specified route URL to regexp, + -- and initialize stashes + + local estash = { } -- helper table, name -> boolean + local stash = { } -- i -> word + + while true do + local name = string.match(match, ':([%a_][%w_]*)') + if name == nil then + break + end + if estash[name] then + utils.errorf("duplicate stash: %s", name) + end + estash[name] = true + match = string.gsub(match, ':[%a_][%w_]*', '([^/]-)', 1) + + table.insert(stash, name) + end + + while true do + local name = string.match(match, '[*]([%a_][%w_]*)') + if name == nil then + break + end + if estash[name] then + utils.errorf("duplicate stash: %s", name) + end + estash[name] = true + match = string.gsub(match, '[*][%a_][%w_]*', '(.-)', 1) + + table.insert(stash, name) + end + + -- ensure match is like '^/xxx/$' + do + if string.match(match, '.$') ~= '/' then + match = match .. '/' + end + if string.match(match, '^.') ~= '/' then + match = '/' .. match + end + match = '^' .. match .. '$' + end + + return match, stash +end + +local function matches(r, filter) + local methods_match = r.method == filter.method or r.method == 'ANY' + if not methods_match then + return false + end + + local regex_groups_matched = {string.match(filter.path, r.match)} + if #regex_groups_matched == 0 then + return false + end + if #r.stash > 0 and #r.stash ~= #regex_groups_matched then + return false + end + + return true, { + route = r, + stash = regex_groups_matched, + } +end + +local function better_than(newmatch, oldmatch) + if newmatch == nil then + return false + end + if oldmatch == nil then + return true + end + + -- current match (route) is prioritized iff: + -- 1. it has less matched words, or + -- 2. if current match (route) has more specific method filter + if #oldmatch.stash > #newmatch.stash then + return true + end + return newmatch.route.method ~= oldmatch.route.method and + oldmatch.method == 'ANY' +end + +return { + matches = matches, + better_than = better_than, + transform_filter = transform_filter, + transform_pattern = transform_pattern, +} diff --git a/http/router/middleware.lua b/http/router/middleware.lua new file mode 100644 index 0000000..151a9d7 --- /dev/null +++ b/http/router/middleware.lua @@ -0,0 +1,144 @@ +local COLOR_NOT_VISITED = 0 +local COLOR_IN_PROGRESS = 1 +local COLOR_VISITED = 2 + +local function dfs(self, v, colors, reverse_order) + if colors[v] == COLOR_VISITED then + -- already traversed v + return true + elseif colors[v] == COLOR_IN_PROGRESS then + -- loop detected + return false + end + colors[v] = COLOR_IN_PROGRESS + + for _, to in ipairs(self.edges[v]) do + local ok = self:dfs(to, colors, reverse_order) + if not ok then + return false + end + end + + table.insert(reverse_order, v) + colors[v] = COLOR_VISITED + return true +end + +local function prepare_graph(self) + local numvertices = #self.nodes + + self.edges = {} + for v = 1, numvertices do + self.edges[v] = {} + end + + for v, node in pairs(self.nodes) do + for _, from_name in pairs(node.after) do + local from = self.id_by_name[from_name] + if from ~= nil then + table.insert(self.edges[from], v) + end + end + + for _, to_name in pairs(node.before) do + local to = self.id_by_name[to_name] + if to ~= nil then + table.insert(self.edges[v], to) + end + end + end +end + +local function find_order(self) + self:prepare_graph() + + local numvertices = #self.nodes + + local reverse_order = {} + local colors = {} + for v = 1, numvertices do + colors[v] = COLOR_NOT_VISITED + end + + local conflict = false + for v = 1, numvertices do + if colors[v] == COLOR_NOT_VISITED then + local ok = self:dfs(v, colors, reverse_order) + if not ok then + conflict = true + break + end + end + end + if conflict then + return false + end + + assert(#reverse_order, numvertices, 'ordered every node') + + self.order = {} + for i = numvertices, 1, -1 do + table.insert(self.order, reverse_order[i]) + end + + return true +end + +local function listify(val) + return type(val) == 'table' and val or {val} +end + +local function ordered(self) + local ret = {} + for _, v in ipairs(self.order) do + table.insert(ret, self.nodes[v]) + end + return ret +end + +-- TODO: error-handling +local function use(self, m) + m.after = listify(m.after) + m.before = listify(m.before) + + table.insert(self.nodes, m) + self.id_by_name[m.name] = #self.nodes + + local ok = self:find_order() + if not ok then + -- rollback + table.remove(self.nodes) + + ok = self:find_order() + assert(ok, 'rollback failed!') + return false + end + return true +end + +local function clear(self) + self.nodes = {} + self.id_by_name = {} + self.order = {} +end + +local function new() + return { + nodes = {}, + id_by_name = {}, + order = {}, + + use = use, + clear = clear, + ordered = ordered, + + -- private + prepare_graph = prepare_graph, + find_order = find_order, + dfs = dfs, + } +end + +return { + new = new, +} diff --git a/http/tsgi.lua b/http/tsgi.lua index 01955b1..61f7653 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -7,6 +7,11 @@ 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' +local KEY_ROUTE = 'tarantool.http.route' +local KEY_ROUTER = 'tarantool.http.router' + +local KEY_MIDDLEWARE_CALLCHAIN_CURRENT = 'tarantool.middleware.callchain_current' +local KEY_MIDDLEWARE_CALLCHAIN_TABLE = 'tarantool.middleware.callchain_table' -- XXX: do it with lua-iterators local function headers(env) @@ -46,6 +51,24 @@ local function serialize_request(env) return res end +local function middleware_init_handlers(env) + env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] = 0 + env[KEY_MIDDLEWARE_CALLCHAIN_TABLE] = {} +end + +local function middleware_invoke_next_handler(env) + local callchain = env[KEY_MIDDLEWARE_CALLCHAIN_TABLE] + local next_handler_id = env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] + 1 + local next_handler = callchain[next_handler_id] + env[KEY_MIDDLEWARE_CALLCHAIN_CURRENT] = next_handler_id + return next_handler(env) +end + +local function middleware_push_back_handler(env, f) + local callchain = env[KEY_MIDDLEWARE_CALLCHAIN_TABLE] + table.insert(callchain, f) +end + return { KEY_HTTPD = KEY_HTTPD, KEY_SOCK = KEY_SOCK, @@ -55,4 +78,14 @@ return { headers = headers, serialize_request = serialize_request, + + -- middleware support + KEY_MIDDLEWARE_CALLCHAIN_CURRENT = KEY_MIDDLEWARE_CALLCHAIN_CURRENT, + KEY_MIDDLEWARE_CALLCHAIN_TABLE = KEY_MIDDLEWARE_CALLCHAIN_TABLE, + KEY_ROUTE = KEY_ROUTE, + KEY_ROUTER = KEY_ROUTER, + + init_handlers = middleware_init_handlers, + next = middleware_invoke_next_handler, + push_back_handler = middleware_push_back_handler, } diff --git a/test/http.test.lua b/test/http.test.lua index 944eb76..29e4099 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -33,7 +33,6 @@ box.schema.user.grant( -- luacheck: ignore local test = tap.test("http") test:plan(8) - test:test("split_uri", function(test) test:plan(65) local function check(uri, rhs) @@ -161,6 +160,9 @@ local function is_builtin_test() end local function choose_server() + local log_requests = true + local log_errors = true + if is_nginx_test() then -- host and port are for SERVER_NAME, SERVER_PORT only. -- TODO: are they required? @@ -169,14 +171,14 @@ local function choose_server() host = '127.0.0.1', port = 12345, tnt_method = 'nginx_entrypoint', - log_requests = false, - log_errors = false, + log_requests = log_requests, + log_errors = log_errors, }) end return http_server.new('127.0.0.1', 12345, { - log_requests = false, - log_errors = false + log_requests = log_requests, + log_errors = log_errors }) end @@ -531,136 +533,123 @@ test:test("server requests", function(test) httpd:stop() end) -local log_queue = {} - -local custom_logger = { - debug = function() end, - verbose = function(...) - table.insert(log_queue, { log_lvl = 'verbose', }) - end, - info = function(...) - table.insert(log_queue, { log_lvl = 'info', msg = string.format(...)}) - end, - warn = function(...) - table.insert(log_queue, { log_lvl = 'warn', msg = string.format(...)}) - end, - error = function(...) - table.insert(log_queue, { log_lvl = 'error', msg = string.format(...)}) - end -} - -local function find_msg_in_log_queue(msg, strict) - for _, log in ipairs(log_queue) do - if not strict then - if log.msg:match(msg) then - return log - end - else - if log.msg == msg then - return log - end - end - end -end - -local function clear_log_queue() - log_queue = {} -end - -test:test("Custom log functions for route", function(test) - test:plan(5) +test:test("middleware", function(test) + test:plan(12) + local httpd, router = cfgserv() - test:test("Setting log option for server instance", function(test) - test:plan(2) + local add_helloworld_before_to_response = function(env) + local tsgi = require('http.tsgi') + local resp = tsgi.next(env) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = custom_logger.info, log_errors = custom_logger.error }) - httpd:route({ path='/' }, function(_) end) - httpd:route({ path='/error' }, function(_) error('Some error...') end) - httpd:start() + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world! (before)' + resp.body = json.encode(lua_body) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging requests in custom logger if it's presents") - clear_log_queue() + return resp + end - http_client.get("127.0.0.1:12345/error") - test:ok(find_msg_in_log_queue("Some error...", false), "Route should logging error in custom logger if it's presents") - clear_log_queue() + local add_helloworld_to_response = function(env) + local tsgi = require('http.tsgi') + local resp = tsgi.next(env) - httpd:stop() - end) + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world!' + resp.body = json.encode(lua_body) - test:test("Setting log options for route", function(test) - test:plan(8) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = true, log_errors = false }) - local dummy_logger = function() end - - local ok, err = pcall(httpd.route, httpd, { path = '/', log_requests = 3 }) - test:is(ok, false, "Route logger can't be a log_level digit") - test:like(err, "'log_requests' option should be a function", "route() should return error message in case of incorrect logger option") + return resp + end - ok, err = pcall(httpd.route, httpd, { path = '/', log_requests = { info = dummy_logger } }) - test:is(ok, false, "Route logger can't be a table") - test:like(err, "'log_requests' option should be a function", "route() should return error message in case of incorrect logger option") + local ok = router:use({ + name = 'hello_world', + path = '/.*', + method = {'GET', 'POST'}, + handler = add_helloworld_to_response + }) + test:ok(ok, 'hello_world middleware added successfully') - local ok, err = pcall(httpd.route, httpd, { path = '/', log_errors = 3 }) - test:is(ok, false, "Route error logger can't be a log_level digit") - test:like(err, "'log_errors' option should be a function", "route() should return error message in case of incorrect logger option") + local middlewares_ordered = router.middleware:ordered() + test:is(#middlewares_ordered, 1, 'one middleware is registered') - ok, err = pcall(httpd.route, httpd, { path = '/', log_errors = { error = dummy_logger } }) - test:is(ok, false, "Route error logger can't be a table") - test:like(err, "'log_errors' option should be a function", "route() should return error message in case of incorrect log_errors option") - end) + ok = router:use({ + name = 'hello_world_before', + path = '/.*', + method = 'ANY', + before = 'hello_world', + handler = add_helloworld_before_to_response + }) + test:ok(ok, 'hello_world_before middleware added successfully') - test:test("Log output with custom loggers on route", function(test) - test:plan(3) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = true, log_errors = true }) - httpd:start() + middlewares_ordered = router.middleware:ordered() + test:is(#middlewares_ordered, 2, 'both middlewares are registered') + test:is(middlewares_ordered[1].name, 'hello_world_before', + 'hello_world_before is first') + test:is(middlewares_ordered[2].name, 'hello_world', + 'hello_world is last') - httpd:route({ path = '/', log_requests = custom_logger.info, log_errors = custom_logger.error }, function(_) end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging requests in custom logger if it's presents") - clear_log_queue() + local apple_handler = function() + return {status = 200, body = json.encode({kind = 'apple'})} + end - httpd.routes = {} - httpd:route({ path = '/', log_requests = custom_logger.info, log_errors = custom_logger.error }, function(_) - error("User business logic exception...") - end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route should logging request and error in case of route exception") - test:ok(find_msg_in_log_queue("User business logic exception...", false), - "Route should logging error custom logger if it's presents in case of route exception") - clear_log_queue() + local orange_handler = function() + return {status = 200, body = json.encode({kind = 'orange'})} + end - httpd:stop() - end) + router:route( + { + method = 'GET', + path = '/fruits/apple', + }, + apple_handler + ) + router:route( + { + method = 'GET', + path = '/fruits/orange', + }, + orange_handler + ) - test:test("Log route requests with turned off 'log_requests' option", function(test) - test:plan(1) - local httpd = http_server.new("127.0.0.1", 12345, { log_requests = false }) - httpd:start() + httpd:start() - httpd:route({ path = '/', log_requests = custom_logger.info }, function(_) end) - http_client.get("127.0.0.1:12345") - test:is_deeply(find_msg_in_log_queue("GET /"), { log_lvl = 'info', msg = 'GET /' }, "Route can override logging requests if the http server have turned off 'log_requests' option") - clear_log_queue() + local r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + test:is(r.status, 200, 'status') + require('log').info('DEBUG: /fruits/apple response: %s', r.body) + local parsed_body = json.decode(r.body) + test:is(parsed_body.kind, 'apple', 'body is correct') + test:is(parsed_body.message, 'hello world! (before)', 'hello_world middleware invoked last') + + local function swap_orange_and_apple(env) + local path_info = env['PATH_INFO'] + local log = require('log') + log.info('swap_orange_and_apple: path_info = %s', path_info) + + if path_info == '/fruits/orange' then + env['PATH_INFO'] = '/fruits/apple' + elseif path_info == '/fruits/apple' then + env['PATH_INFO'] = '/fruits/orange' + end - httpd:stop() - end) + local tsgi = require('http.tsgi') + return tsgi.next(env) + end - test:test("Log route requests with turned off 'log_errors' option", function(test) - test:plan(1) - local httpd = http_server.new("127.0.0.1", 12345, { log_errors = false }) - httpd:start() + ok = router:use({ + preroute = true, + name = 'swap_orange_and_apple', + handler = swap_orange_and_apple, + }) + test:ok(ok, 'swap_orange_and_apple middleware added successfully') - httpd:route({ path = '/', log_errors = custom_logger.error }, function(_) - error("User business logic exception...") - end) - http_client.get("127.0.0.1:12345") - test:ok(find_msg_in_log_queue("User business logic exception...", false), "Route can override logging requests if the http server have turned off 'log_errors' option") - clear_log_queue() + r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + test:is(r.status, 200, 'status') + parsed_body = json.decode(r.body) + test:is(parsed_body.kind, 'orange', 'route swapped from apple handler to orange') - httpd:stop() - end) + httpd:stop() end) os.exit(test:check() == true and 0 or 1) diff --git a/test/middleware.test.lua b/test/middleware.test.lua new file mode 100755 index 0000000..5825339 --- /dev/null +++ b/test/middleware.test.lua @@ -0,0 +1,81 @@ +#!/usr/bin/env tarantool + +local tap = require('tap') +local middleware_module = require('http.router.middleware') + +-- 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 + +local test = tap.test("http") +test:plan(1) + +test:test("ordering", function(test) -- luacheck: ignore + test:plan(7) + + local middleware = middleware_module.new() + + local add = function(opts, add_opts) + local should_be_ok = not (add_opts or {}).must_fail + + local msg = ('adding middleware %s is successful'):format(opts.name) + if not should_be_ok then + msg = ('adding middleware %s must fail'):format(opts.name) + end + + local ok = middleware:use(opts) + test:is(ok, should_be_ok, msg) + end + + local ensure_before = function(mwname1, mwname2) + local msg = ('%s must be ordered before %s'):format(mwname1, mwname2) + for _, mw in ipairs(middleware:ordered()) do + if mw.name == mwname1 then + test:ok(true, msg) + return + elseif mw.name == mwname2 then + test:fail(msg) + return + end + end + end + + add({ + name = 'a' + }) + + add({ + name = 'b', + after = 'a', + before = 'c' + }) + add({ + name = 'c', + }) + + ensure_before('a', 'b') + ensure_before('b', 'c') + ensure_before('b', 'c') + + add({ + name = 'd', + before = 'a', + after = 'c' + }, { + must_fail = true + }) + + middleware:clear() +end) From 247138e5106cac97ab2fd2cba77995ada97ef109 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Thu, 13 Jun 2019 00:01:24 +0300 Subject: [PATCH 05/25] Make socket hijacking work again --- http/server/init.lua | 2 +- http/server/tsgi_adapter.lua | 12 +++++------ http/tsgi.lua | 2 ++ test/http.test.lua | 41 +++++++++++++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/http/server/init.lua b/http/server/init.lua index 28ff41f..d2db2c5 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -103,7 +103,7 @@ local function process_client(self, s, peer) local status, body -- DETACHED: dont close socket, but quit processing HTTP - if self.is_hijacked then + if env[tsgi.KEY_IS_HIJACKED] == true then break end diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua index f805b2a..45775b1 100644 --- a/http/server/tsgi_adapter.lua +++ b/http/server/tsgi_adapter.lua @@ -3,10 +3,9 @@ local tsgi = require('http.tsgi') require('checks') local function tsgi_hijack(env) - local httpd = env[tsgi.KEY_HTTPD] - local sock = env[tsgi.KEY_SOCK] + env[tsgi.KEY_IS_HIJACKED] = true - httpd.is_hijacked = true + local sock = env[tsgi.KEY_SOCK] return sock end @@ -73,9 +72,6 @@ local function make_env(opts) read = tsgi_input_read, rewind = nil, -- non-rewindable by default }, - ['tsgi.hijack'] = setmetatable({}, { - __call = tsgi_hijack, - }), ['REQUEST_METHOD'] = p.method, ['PATH_INFO'] = p.path, @@ -87,7 +83,9 @@ local function make_env(opts) -- Pass through `env` to env['tsgi.*']:*() functions env['tsgi.input']._env = env - env['tsgi.hijack']._env = env + env['tsgi.hijack'] = setmetatable(env, { + __call = tsgi_hijack, + }) -- set headers for name, value in pairs(p.headers) do diff --git a/http/tsgi.lua b/http/tsgi.lua index 61f7653..838ea0c 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -9,6 +9,7 @@ local KEY_PARSED_REQUEST = 'tarantool.http.parsed_request' local KEY_PEER = 'tarantool.http.peer' local KEY_ROUTE = 'tarantool.http.route' local KEY_ROUTER = 'tarantool.http.router' +local KEY_IS_HIJACKED = 'tarantool.http.server.is_hijacked' local KEY_MIDDLEWARE_CALLCHAIN_CURRENT = 'tarantool.middleware.callchain_current' local KEY_MIDDLEWARE_CALLCHAIN_TABLE = 'tarantool.middleware.callchain_table' @@ -75,6 +76,7 @@ return { KEY_REMAINING = KEY_REMAINING, KEY_PARSED_REQUEST = KEY_PARSED_REQUEST, KEY_PEER = KEY_PEER, + KEY_IS_HIJACKED = KEY_IS_HIJACKED, headers = headers, serialize_request = serialize_request, diff --git a/test/http.test.lua b/test/http.test.lua index 29e4099..a1fc1e9 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -257,7 +257,7 @@ test:test("server url_for", function(test) end) test:test("server requests", function(test) - test:plan(38) + test:plan(42) local httpd, router = cfgserv() httpd:start() @@ -530,6 +530,45 @@ test:test("server requests", function(test) test:ok(true, 'post body - ignore on NGINX') end + -- hijacking + if is_builtin_test() then + -- 0. create a route (simplest) in which env:hijack() is called, + -- and then do ping-pong. + router:route({method = 'POST', path = '/upgrade'}, function(req) + local env = req.env + + -- intercept raw socket connection + local sock = env['tsgi.hijack']() + assert(sock ~= nil, 'hijacked socket is not empty') + + -- receive ping, send pong + sock:write('ready') + local ping = sock:read(4) + assert(ping == 'ping') + sock:write('pong') + end) + + -- 1. set-up socket + local socket = require('socket') + local sock = socket.tcp_connect('127.0.0.1', 12345) + test:ok(sock ~= nil, 'HTTP client connection established') + + -- 2. over raw-socket send HTTP POST (to get it routed to route) + local upgrade_request = 'POST /upgrade HTTP/1.1\r\nConnection: upgrade\r\n\r\n' + local bytessent = sock:write(upgrade_request) + test:is(bytessent, #upgrade_request, 'upgrade request sent fully') + + -- 3. send ping, receive pong + test:is(sock:read(5), 'ready', 'server is ready') + sock:write('ping') + test:is(sock:read(4), 'pong', 'pong receieved') + else + test:ok(true, 'HTTP client connection established - ignored on NGINX') + test:ok(true, 'upgrade request sent fully - ignored on NGINX') + test:ok(true, 'server is ready - ignored on NGINX') + test:ok(true, 'pong received - ignored on NGINX') + end + httpd:stop() end) From 5a483bf493e8bfe0d51534363912314736af3b15 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 17 Jun 2019 15:48:26 +0300 Subject: [PATCH 06/25] Update rockspec --- rockspecs/http-scm-1.rockspec | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rockspecs/http-scm-1.rockspec b/rockspecs/http-scm-1.rockspec index 6089241..25b5c7b 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/rockspecs/http-scm-1.rockspec @@ -30,10 +30,13 @@ build = { ['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'] = 'http/router/init.lua', ['http.router.fs'] = 'http/router/fs.lua', + ['http.router.matching'] = 'http/router/matching.lua', + ['http.router.middleware'] = 'http/router/middleware.lua', ['http.router.request'] = 'http/router/request.lua', ['http.router.response'] = 'http/router/response.lua', - ['http.router'] = 'http/router/init.lua', + ['http.router.trie'] = 'http/router/trie.lua', ['http.tsgi'] = 'http/tsgi.lua', ['http.utils'] = 'http/utils.lua', ['http.mime_types'] = 'http/mime_types.lua', From ed8107c848d2ceb7c290aef38ad4d7ab548303f2 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 1 Jul 2019 20:53:24 +0300 Subject: [PATCH 07/25] router: remove duplicate code in matching.lua --- http/router/matching.lua | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/http/router/matching.lua b/http/router/matching.lua index 78180b0..322c29c 100644 --- a/http/router/matching.lua +++ b/http/router/matching.lua @@ -28,33 +28,26 @@ local function transform_pattern(path) local estash = { } -- helper table, name -> boolean local stash = { } -- i -> word - while true do - local name = string.match(match, ':([%a_][%w_]*)') + -- when no such pattern is found, returns false + local find_and_replace_stash_pattern = function(pattern_regex, replace_with) + local name = string.match(match, pattern_regex) if name == nil then - break + return false end if estash[name] then utils.errorf("duplicate stash: %s", name) end estash[name] = true - match = string.gsub(match, ':[%a_][%w_]*', '([^/]-)', 1) + match = string.gsub(match, pattern_regex, replace_with, 1) table.insert(stash, name) + return true end - while true do - local name = string.match(match, '[*]([%a_][%w_]*)') - if name == nil then - break - end - if estash[name] then - utils.errorf("duplicate stash: %s", name) - end - estash[name] = true - match = string.gsub(match, '[*][%a_][%w_]*', '(.-)', 1) - - table.insert(stash, name) - end + -- patterns starting with : + while find_and_replace_stash_pattern(':([%a_][%w_]*)', '([^/]-)') do end + -- extended patterns starting with * + while find_and_replace_stash_pattern('[*]([%a_][%w_]*)', '(.-)') do end -- ensure match is like '^/xxx/$' do From da4cb2f90aac299e3d958b8ebd08888fc76044d5 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Tue, 2 Jul 2019 00:37:30 +0300 Subject: [PATCH 08/25] router: route specificity by symbols known Now we prioritize route (a) over (b) for some request that matches both: 1. if it has more "constant" symbols (that is symbols hardcoded in the path and not matched in regexp group generated from : or * stashes), or, if this number is equal, 2. if (a) has more specific filter on HTTP method --- http/router/matching.lua | 18 ++++++++++++++---- test/http.test.lua | 31 +++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/http/router/matching.lua b/http/router/matching.lua index 322c29c..2733779 100644 --- a/http/router/matching.lua +++ b/http/router/matching.lua @@ -77,9 +77,19 @@ local function matches(r, filter) return false end + -- how many symbols were not known (were hidden behind : and * patterns) + local symbols_didnt_know = 0 + for _, matched_part in ipairs(regex_groups_matched) do + symbols_didnt_know = symbols_didnt_know + #matched_part + end + return true, { route = r, stash = regex_groups_matched, + + -- the more symbols were known in advance by route, + -- the more priority we give the route + specificity = -symbols_didnt_know, } end @@ -91,10 +101,10 @@ local function better_than(newmatch, oldmatch) return true end - -- current match (route) is prioritized iff: - -- 1. it has less matched words, or - -- 2. if current match (route) has more specific method filter - if #oldmatch.stash > #newmatch.stash then + -- newmatch route is prioritized over oldmatch iff: + -- 1. its' path is more specific (see matches() function), or + -- 2. if current route has more specific method filter + if newmatch.specificity > oldmatch.specificity then return true end return newmatch.route.method ~= oldmatch.route.method and diff --git a/test/http.test.lua b/test/http.test.lua index a1fc1e9..4324c89 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -257,7 +257,7 @@ test:test("server url_for", function(test) end) test:test("server requests", function(test) - test:plan(42) + test:plan(43) local httpd, router = cfgserv() httpd:start() @@ -501,7 +501,6 @@ test:test("server requests", function(test) test:is(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') end) - if is_builtin_test() then test:test('post body', function(test) test:plan(2) @@ -569,6 +568,34 @@ test:test("server requests", function(test) test:ok(true, 'pong received - ignored on NGINX') end + test:test('prioritization of more specific routes', function(test) + test:plan(4) + + router:route({method = 'GET', path = '*stashname'}, function(_) + return { + status = 200, + body = 'GET *', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + test:is(r.status, 200, '/a/b/c request returns 200') + test:is(r.body, 'GET *', 'GET * matches') + + router:route({method = 'ANY', path = '/a/:foo/:bar'}, function(_) + return { + status = 200, + body = 'ANY /a/:foo/:bar', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + test:is(r.status, 200, '/a/b/c request returns 200') + test:is( + r.body, + 'ANY /a/:foo/:bar', + '# of stashes matched doesnt matter - only # of known symbols by the route matters' + ) + end) + httpd:stop() end) From 3601aee478fd70c2d2b07a9ad245cb87ec755285 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Sat, 13 Jul 2019 18:32:55 +0300 Subject: [PATCH 09/25] Rename server :set_router -> :set_handler --- examples/middleware.lua | 2 +- http/nginx_server/init.lua | 4 ++-- http/server/init.lua | 4 ++-- test/http.test.lua | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/middleware.lua b/examples/middleware.lua index be6902e..0092f62 100755 --- a/examples/middleware.lua +++ b/examples/middleware.lua @@ -77,5 +77,5 @@ ok = router:use({ assert(ok, 'no conflict on adding hello_world middleware') -httpd:set_router(router) +httpd:set_handler(router) httpd:start() diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index bfe9078..efbfb59 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -149,7 +149,7 @@ local function generic_entrypoint(server, req, ...) -- luacheck: ignore return status, headers, body end -local function ngxserver_set_router(self, router) +local function ngxserver_set_handler(self, router) checks('table', 'function|table') -- luacheck: ignore self.router = router @@ -187,7 +187,7 @@ local function new(opts) log_errors = opts.log_errors or true, log_requests = opts.log_requests or true, - set_router = ngxserver_set_router, + set_handler = ngxserver_set_handler, start = ngxserver_start, stop = ngxserver_stop, } diff --git a/http/server/init.lua b/http/server/init.lua index d2db2c5..21a9b53 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -304,7 +304,7 @@ local function httpd_start(self) return self end -local function httpd_set_router(self, router) +local function httpd_set_handler(self, router) self.options.handler = router end @@ -329,7 +329,7 @@ local new = function(host, port, options) is_run = false, stop = httpd_stop, start = httpd_start, - set_router = httpd_set_router, + set_handler = httpd_set_handler, options = utils.extend(default, options, true), } diff --git a/test/http.test.lua b/test/http.test.lua index 4324c89..623f2cd 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -203,7 +203,7 @@ 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) - httpd:set_router(router) + httpd:set_handler(router) return httpd, router end From 40850b09d84c788b7fc8ef738ef581f07fac5fb0 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 15 Jul 2019 21:21:54 +0300 Subject: [PATCH 10/25] router:use(): make handler option a positional argument --- http/router/init.lua | 26 +++++++++++++++----------- test/http.test.lua | 9 +++------ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/http/router/init.lua b/http/router/init.lua index 62b281e..07f08d9 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -6,6 +6,8 @@ local request_metatable = require('http.router.request').metatable local utils = require('http.utils') local tsgi = require('http.tsgi') +require('checks') + local function uri_file_extension(s, default) -- cut from last dot till the end local ext = string.match(s, '[.]([^.]+)$') @@ -190,23 +192,25 @@ local possible_methods = { PATCH = 'PATCH', } -local function use_middleware(self, opts) +local function use_middleware(self, handler, opts) + checks('table', 'function', { -- luacheck: ignore + path = '?string', + method = '?string|table', + name = '?string', + preroute = '?boolean', + before = '?string|table', + after = '?string|table', + }) local opts = table.deepcopy(opts) -- luacheck: ignore + opts.handler = handler - if type(opts) ~= 'table' or type(self) ~= 'table' then - error("Usage: router:route({ ... }, function(cx) ... end)") - end - - assert(type(opts.name) == 'string') - assert(type(opts.handler) == 'function') - + local uuid = require('uuid') opts.path = opts.path or '/.*' - assert(type(opts.path) == 'string') - opts.method = opts.method or 'ANY' - + opts.name = opts.name or uuid.str() opts.before = opts.before or {} opts.after = opts.after or {} + for _, order_key in ipairs({'before', 'after'}) do local opt = opts[order_key] assert(type(opt) ~= 'string' or type(opt) ~= 'table', diff --git a/test/http.test.lua b/test/http.test.lua index 623f2cd..29bf590 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -625,23 +625,21 @@ test:test("middleware", function(test) return resp end - local ok = router:use({ + local ok = router:use(add_helloworld_to_response, { name = 'hello_world', path = '/.*', method = {'GET', 'POST'}, - handler = add_helloworld_to_response }) test:ok(ok, 'hello_world middleware added successfully') local middlewares_ordered = router.middleware:ordered() test:is(#middlewares_ordered, 1, 'one middleware is registered') - ok = router:use({ + ok = router:use(add_helloworld_before_to_response, { name = 'hello_world_before', path = '/.*', method = 'ANY', before = 'hello_world', - handler = add_helloworld_before_to_response }) test:ok(ok, 'hello_world_before middleware added successfully') @@ -701,10 +699,9 @@ test:test("middleware", function(test) return tsgi.next(env) end - ok = router:use({ + ok = router:use(swap_orange_and_apple, { preroute = true, name = 'swap_orange_and_apple', - handler = swap_orange_and_apple, }) test:ok(ok, 'swap_orange_and_apple middleware added successfully') From 6f398b6604702aa939c17534abe2d4d82dfa7078 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Wed, 21 Aug 2019 17:16:01 +0300 Subject: [PATCH 11/25] Bless env with request metatable --- http/nginx_server/init.lua | 13 ++-- http/router/fs.lua | 34 +++++----- http/router/init.lua | 76 ++++++++--------------- http/router/request.lua | 124 ++++++++++++++++++++++++++++++++----- http/server/init.lua | 29 +++++---- test/http.test.lua | 40 ++++++------ 6 files changed, 193 insertions(+), 123 deletions(-) diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index efbfb59..c1213d3 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -101,7 +101,7 @@ end local function generic_entrypoint(server, req, ...) -- luacheck: ignore local env = make_env(server, req, ...) - local ok, resp = pcall(server.router, env) + local ok, resp = pcall(server.router_obj, env) local status = resp.status or 200 local headers = resp.headers or {} @@ -149,10 +149,14 @@ local function generic_entrypoint(server, req, ...) -- luacheck: ignore return status, headers, body end -local function ngxserver_set_handler(self, router) +local function ngxserver_set_router(self, router) checks('table', 'function|table') -- luacheck: ignore - self.router = router + self.router_obj = router +end + +local function ngxserver_router(self) + return self.router_obj end local function ngxserver_start(self) @@ -187,7 +191,8 @@ local function new(opts) log_errors = opts.log_errors or true, log_requests = opts.log_requests or true, - set_handler = ngxserver_set_handler, + set_router = ngxserver_set_router, + router = ngxserver_router, start = ngxserver_start, stop = ngxserver_stop, } diff --git a/http/router/fs.lua b/http/router/fs.lua index 1e07225..a635c20 100644 --- a/http/router/fs.lua +++ b/http/router/fs.lua @@ -50,7 +50,7 @@ local function catfile(...) end local function static_file(self, request, format) - local file = catfile(self.options.app_dir, 'public', request.env['PATH_INFO']) + local file = catfile(self.options.app_dir, 'public', request:path()) if self.options.cache_static and self.cache.static[ file ] ~= nil then return { @@ -88,20 +88,20 @@ 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 + 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) + return tx:router().cache[ ctx ][ action ](tx) end end local ppath = package.path - package.path = catfile(tx.router.options.app_dir, 'controllers', '?.lua') + package.path = catfile(tx:router().options.app_dir, 'controllers', '?.lua') .. ';' - .. catfile(tx.router.options.app_dir, + .. catfile(tx:router().options.app_dir, 'controllers', '?/init.lua') if ppath ~= nil then package.path = package.path .. ';' .. ppath @@ -123,8 +123,8 @@ local function ctx_action(tx) utils.errorf("Controller '%s' doesn't contain function '%s'", ctx, action) end - if tx.router.options.cache_controllers then - tx.router.cache[ ctx ] = mod + if tx:router().options.cache_controllers then + tx:router().cache[ ctx ] = mod end return mod[action](tx) @@ -178,10 +178,10 @@ local function render(tx, opts) local vars = {} if opts ~= nil then if opts.text ~= nil then - if tx.router.options.charset ~= nil then + if tx:router().options.charset ~= nil then resp.headers['content-type'] = utils.sprintf("text/plain; charset=%s", - tx.router.options.charset + tx:router().options.charset ) else resp.headers['content-type'] = 'text/plain' @@ -192,10 +192,10 @@ local function render(tx, opts) -- TODO if opts.json ~= nil then - if tx.router.options.charset ~= nil then + if tx:router().options.charset ~= nil then resp.headers['content-type'] = utils.sprintf('application/json; charset=%s', - tx.router.options.charset + tx:router().options.charset ) else resp.headers['content-type'] = 'application/json' @@ -222,7 +222,7 @@ local function render(tx, opts) if tx.endpoint.template ~= nil then tpl = tx.endpoint.template else - tpl = load_template(tx.router, tx.endpoint, format) + tpl = load_template(tx:router(), tx.endpoint, format) if tpl == nil then utils.errorf('template is not defined for the route') end @@ -232,7 +232,7 @@ local function render(tx, opts) tpl = tpl() end - for hname, sub in pairs(tx.router.helpers) do + for hname, sub in pairs(tx:router().helpers) do vars[hname] = function(...) return sub(tx, ...) end end vars.action = tx.endpoint.action @@ -242,10 +242,10 @@ local function render(tx, opts) resp.body = lib.template(tpl, vars) resp.headers['content-type'] = type_by_format(format) - if tx.router.options.charset ~= nil then + 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 + .. '; charset=' .. tx:router().options.charset end end return resp diff --git a/http/router/init.lua b/http/router/init.lua index 07f08d9..bcf8282 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -1,7 +1,8 @@ local fs = require('http.router.fs') local middleware = require('http.router.middleware') local matching = require('http.router.matching') -local request_metatable = require('http.router.request').metatable + +local bless_request = require('http.router.request').bless local utils = require('http.utils') local tsgi = require('http.tsgi') @@ -22,43 +23,11 @@ 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 - -- TODO: khm... what if we have nginx tsgi? - -- we need to restrict ourselves to generic TSGI - -- methods and properties! - - local request = { - router = router, - env = env, - peer = env[tsgi.KEY_PEER], - 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 +local function main_endpoint_middleware(request) + local self = request:router() + local format = uri_file_extension(request:path(), 'html') + local r = request[tsgi.KEY_ROUTE] - return setmetatable(request, request_metatable) -end - -local function main_endpoint_middleware(env) - local self = env[tsgi.KEY_ROUTER] - local format = uri_file_extension(env['PATH_INFO'], 'html') - local r = env[tsgi.KEY_ROUTE] - local request = request_from_env(env, self) if r == nil then return fs.static_file(self, request, format) end @@ -70,8 +39,8 @@ end local function populate_chain_with_middleware(env, middleware_obj) local filter = matching.transform_filter({ - path = env['PATH_INFO'], - method = env['REQUEST_METHOD'] + path = env:path(), + method = env:method() }) for _, m in pairs(middleware_obj:ordered()) do if matching.matches(m, filter) then @@ -80,33 +49,38 @@ local function populate_chain_with_middleware(env, middleware_obj) end end -local function dispatch_middleware(env) - local self = env[tsgi.KEY_ROUTER] +local function dispatch_middleware(req) + local self = req:router() - local r = self:match(env['REQUEST_METHOD'], env['PATH_INFO']) - env[tsgi.KEY_ROUTE] = r + local r = self:match(req:method(), req:path()) + req[tsgi.KEY_ROUTE] = r - populate_chain_with_middleware(env, self.middleware) + populate_chain_with_middleware(req, self.middleware) -- finally, add user specified handler - tsgi.push_back_handler(env, main_endpoint_middleware) + tsgi.push_back_handler(req, main_endpoint_middleware) - return tsgi.next(env) + return req:next() end local function router_handler(self, env) - env[tsgi.KEY_ROUTER] = self + -- attach a metatable with helper methods + -- to otherwise raw TSGI table + -- (e.g. to be able to write request:path() instead of request['PATH_INFO']) + local request = bless_request(env, self) + + request:set_router(self) -- set-up middleware chain - tsgi.init_handlers(env) + tsgi.init_handlers(request) - populate_chain_with_middleware(env, self.preroute_middleware) + populate_chain_with_middleware(request, self.preroute_middleware) -- add routing - tsgi.push_back_handler(env, dispatch_middleware) + tsgi.push_back_handler(request, dispatch_middleware) -- execute middleware chain from first - return tsgi.next(env) + return request:next() end -- TODO: `route` is not route, but path... diff --git a/http/router/request.lua b/http/router/request.lua index 801457a..1ba4dc5 100644 --- a/http/router/request.lua +++ b/http/router/request.lua @@ -6,6 +6,14 @@ local tsgi = require('http.tsgi') local json = require('json') +local function request_set_router(self, router) + self[tsgi.KEY_ROUTER] = router +end + +local function request_router(self) + return self[tsgi.KEY_ROUTER] +end + local function cached_query_param(self, name) if name == nil then return self.query_params @@ -31,24 +39,24 @@ local function request_tostring(self) end local function request_line(self) - local rstr = self.env['PATH_INFO'] + local rstr = self:path() - local query_string = self.env['QUERY_STRING'] + local query_string = self:query() if query_string ~= nil and query_string ~= '' then rstr = rstr .. '?' .. query_string end return utils.sprintf("%s %s %s", - self.env['REQUEST_METHOD'], + self['REQUEST_METHOD'], rstr, - self.env['SERVER_PROTOCOL'] or 'HTTP/?') + self['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 + if self:query() ~= nil and string.len(self:query()) == 0 then rawset(self, 'query_params', {}) else - local params = lib.params(self.env['QUERY_STRING']) + local params = lib.params(self['QUERY_STRING']) local pres = {} for k, v in pairs(params) do pres[ utils.uri_unescape(k) ] = utils.uri_unescape(v) @@ -62,13 +70,13 @@ end local function request_content_type(self) -- returns content type without encoding string - if self.env['HEADER_CONTENT-TYPE'] == nil then + if self['HEADER_CONTENT-TYPE'] == nil then return nil end - return string.match(self.env['HEADER_CONTENT-TYPE'], + return string.match(self['HEADER_CONTENT-TYPE'], '^([^;]*)$') or - string.match(self.env['HEADER_CONTENT-TYPE'], + string.match(self['HEADER_CONTENT-TYPE'], '^(.*);.*') end @@ -116,11 +124,11 @@ local function param(self, name) end local function cookie(self, cookiename) - if self.env['HEADER_COOKIE'] == nil then + if self:header('cookie') == nil then return nil end for k, v in string.gmatch( - self.env['HEADER_COOKIE'], "([^=,; \t]+)=([^,; \t]+)") do + self:header('cookie'), "([^=,; \t]+)=([^,; \t]+)") do if k == cookiename then return utils.uri_unescape(v) end @@ -171,14 +179,12 @@ local function request_json(req) end local function request_read(self, opts, timeout) - local env = self.env - return env['tsgi.input']:read(opts, timeout) -- TODO: TSGI spec is violated + return self['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() + local data = self['tsgi.input']:read() rawset(self, 'cached_data', data) return data else @@ -186,8 +192,63 @@ local function request_read_cached(self) end end +------------------------------------- +local function request_peer(self) + return self[tsgi.KEY_PEER] +end + +local function request_method(self) + return self['REQUEST_METHOD'] +end + +local function request_path(self) + return self['PATH_INFO'] +end + +local function request_query(self) + return self['QUERY_STRING'] +end + +local function request_proto(self) + -- parse SERVER_PROTOCOL which is 'HTTP/.' + local maj = self['SERVER_PROTOCOL']:sub(-3, -3) + local min = self['SERVER_PROTOCOL']:sub(-1, -1) + return { + [1] = tonumber(maj), + [2] = tonumber(min), + } +end + +local function request_headers(self) + local headers = {} + for name, value in pairs(tsgi.headers(self)) do + -- strip HEADER_ part and convert to lowercase + local converted_name = name:sub(8):lower() + headers[converted_name] = value + end + return headers +end + +local function request_header(self, name) + name = 'HEADER_' .. name:upper() + return self[name] +end + +---------------------------------- + +local function request_next(self) + return tsgi.next(self) +end + +local function request_hijack(self) + return self['tsgi.hijack']() +end + local metatable = { __index = { + router = request_router, + set_router = request_set_router, + render = fs.render, cookie = cookie, redirect_to = redirect_to, @@ -203,8 +264,37 @@ local metatable = { param = param, read = request_read, - json = request_json + json = request_json, + + peer = request_peer, + method = request_method, + path = request_path, + query = request_query, + proto = request_proto, + headers = request_headers, + header = request_header, + + next = request_next, + hijack = request_hijack, }, __tostring = request_tostring; } -return {metatable = metatable} + +local function bless(request) + local mt = getmetatable(request) + if mt == nil then + return setmetatable(request, metatable) + end + + -- merge to existing metatable + for name, value in pairs(metatable) do + if mt[name] ~= nil then + require('log').info('merge_metatable: name already set: ' .. name) + end + assert(mt[name] == nil) + mt[name] = value + end + return request +end + +return {bless = bless} diff --git a/http/server/init.lua b/http/server/init.lua index 21a9b53..37fa8f3 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -98,7 +98,7 @@ local function process_client(self, s, peer) logreq("%s %s%s", p.method, p.path, p.query ~= "" and "?"..p.query or "") - local ok, resp = pcall(self.options.handler, env) + local ok, resp = pcall(self.options.router, env) env['tsgi.input']:read() -- skip remaining bytes of request body local status, body @@ -286,7 +286,7 @@ local function httpd_start(self) error("httpd: usage: httpd:start()") end - assert(self.options.handler ~= nil, 'Router must be set before calling server:start()') + assert(self.options.router ~= nil, 'Router must be set before calling server:start()') local server = socket.tcp_server(self.host, self.port, { name = 'http', @@ -304,8 +304,12 @@ local function httpd_start(self) return self end -local function httpd_set_handler(self, router) - self.options.handler = router +local function httpd_set_router(self, router) + self.options.router = router +end + +local function httpd_router(self) + return self.options.router end local new = function(host, port, options) @@ -317,20 +321,21 @@ local new = function(host, port, options) end local default = { - handler = nil, -- no router set-up initially + router = nil, -- no router set-up initially log_requests = true, log_errors = true, display_errors = true, } local self = { - host = host, - port = port, - is_run = false, - stop = httpd_stop, - start = httpd_start, - set_handler = httpd_set_handler, - options = utils.extend(default, options, true), + host = host, + port = port, + is_run = false, + stop = httpd_stop, + start = httpd_start, + set_router = httpd_set_router, + router = httpd_router, + options = utils.extend(default, options, true), } return self diff --git a/test/http.test.lua b/test/http.test.lua index 29bf590..3b718ea 100755 --- a/test/http.test.lua +++ b/test/http.test.lua @@ -203,7 +203,7 @@ 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) - httpd:set_handler(router) + httpd:set_router(router) return httpd, router end @@ -323,8 +323,9 @@ test:test("server requests", function(test) --test:is(r.reason, 'Internal server error', 'die reason') router:route({ path = '/info' }, function(cx) - return cx:render({ json = cx.peer }) + 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') @@ -424,11 +425,11 @@ test:test("server requests", function(test) return { headers = {}, body = json.encode({ - headers = req.headers, - method = req.method, - path = req.path, - query = req.query, - proto = req.proto, + headers = req:headers(), + method = req:method(), + path = req:path(), + query = req:query(), + proto = req:proto(), query_param_bar = req:query_param('bar'), }), status = 200, @@ -534,10 +535,8 @@ test:test("server requests", function(test) -- 0. create a route (simplest) in which env:hijack() is called, -- and then do ping-pong. router:route({method = 'POST', path = '/upgrade'}, function(req) - local env = req.env - -- intercept raw socket connection - local sock = env['tsgi.hijack']() + local sock = req:hijack() assert(sock ~= nil, 'hijacked socket is not empty') -- receive ping, send pong @@ -603,9 +602,8 @@ test:test("middleware", function(test) test:plan(12) local httpd, router = cfgserv() - local add_helloworld_before_to_response = function(env) - local tsgi = require('http.tsgi') - local resp = tsgi.next(env) + local add_helloworld_before_to_response = function(req) + local resp = req:next() local lua_body = json.decode(resp.body) lua_body.message = 'hello world! (before)' @@ -614,9 +612,8 @@ test:test("middleware", function(test) return resp end - local add_helloworld_to_response = function(env) - local tsgi = require('http.tsgi') - local resp = tsgi.next(env) + local add_helloworld_to_response = function(req) + local resp = req:next() local lua_body = json.decode(resp.body) lua_body.message = 'hello world!' @@ -684,19 +681,18 @@ test:test("middleware", function(test) test:is(parsed_body.kind, 'apple', 'body is correct') test:is(parsed_body.message, 'hello world! (before)', 'hello_world middleware invoked last') - local function swap_orange_and_apple(env) - local path_info = env['PATH_INFO'] + local function swap_orange_and_apple(req) + local path_info = req['PATH_INFO'] local log = require('log') log.info('swap_orange_and_apple: path_info = %s', path_info) if path_info == '/fruits/orange' then - env['PATH_INFO'] = '/fruits/apple' + req['PATH_INFO'] = '/fruits/apple' elseif path_info == '/fruits/apple' then - env['PATH_INFO'] = '/fruits/orange' + req['PATH_INFO'] = '/fruits/orange' end - local tsgi = require('http.tsgi') - return tsgi.next(env) + return req:next() end ok = router:use(swap_orange_and_apple, { From 42a661f8d5e7a85015bf83c99e653d78d0bd2359 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Wed, 21 Aug 2019 17:16:15 +0300 Subject: [PATCH 12/25] Update README --- README.md | 341 ++++++++++++++++++++++++------------------------------ 1 file changed, 154 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index f0dbc9c..f601e21 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ align="right"> [![Build Status](https://travis-ci.org/tarantool/http.png?branch=tarantool-1.7)](https://travis-ci.org/tarantool/http) +> **DISCLAIMER:** Any functionality not described here is subject to change +> in backward incompatible fashion at any time. Don't rely on source code +> internals. + > **Note:** In Tarantool 1.7.5+, a full-featured HTTP client is available aboard. > For Tarantool 1.6.5+, both HTTP server and client are available > [here](https://github.com/tarantool/http/tree/tarantool-1.6). @@ -18,21 +22,17 @@ align="right"> * [Usage](#usage) * [Creating a server](#creating-a-server) * [Using routes](#using-routes) -* [Contents of app\_dir](#contents-of-app_dir) * [Route handlers](#route-handlers) * [Fields and methods of the Request object](#fields-and-methods-of-the-request-object) * [Fields and methods of the Response object](#fields-and-methods-of-the-response-object) * [Examples](#examples) * [Working with stashes](#working-with-stashes) - * [Special stash names](#special-stash-names) * [Working with cookies](#working-with-cookies) -* [Rendering a template](#rendering-a-template) -* [Template helpers](#template-helpers) -* [Hooks](#hooks) - * [handler(httpd, req)](#handlerhttpd-req) - * [before\_dispatch(httpd, req)](#before_dispatchhttpd-req) - * [after\_dispatch(cx, resp)](#after_dispatchcx-resp) -* [See also](#see-also) +* [Middleware](#middleware) + * [router:use(f, opts)](#routerusef-opts) + * [f](#f) + * [Ordering](#ordering) + * [Example](#example) ## Prerequisites @@ -67,26 +67,38 @@ You can: ## Usage -The server is an object which is configured with HTTP request -handlers, routes (paths), templates, and a port to bind to. +There are 4 main logical objects you can operate with: +1. **server**, which can be nginx or built-in +2. **router**, where you define routes and middleware +3. **route**, a function processing HTTP requests +4. **middleware**, a function invoked before route handler is invoked + +The **server** is an object which implements HTTP protocol and handles all +lower level stuff like TCP connection. Unless Tarantool is running under a superuser, port numbers below 1024 may be unavailable. The server can be started and stopped anytime. Multiple servers can be created. -To start a server: +The **router** is where you define how and who will handle your requests +configured with HTTP request handlers, routes (paths), templates, +and a port to bind to. You need to set the router to a server, for it to be used. + +To start a server with a router: -1. [Create it](#creating-a-server) with `httpd = require('http.server').new(...)`. -2. [Configure routing](#using-routes) with `httpd:route(...)`. -3. Start it with `httpd:start()`. +1. [Create a server](#creating-a-server) with `server = require('http.server').new(...)`. +2. [Create a router](#creating-a-router) with `router = require('http.router').new(...)`. +3. Set a router to server with `server:set_router(router)`. +4. [Configure routing](#using-routes) with `router:route(...)`. +5. Start serving HTTP requests it with `server:start()`. -To stop the server, use `httpd:stop()`. +To stop the server, use `server:stop()`. ## Creating a server ```lua -httpd = require('http.server').new(host, port[, { options } ]) +server = require('http.server').new(host, port[, { options } ]) ``` `host` and `port` must contain: @@ -95,19 +107,9 @@ httpd = require('http.server').new(host, port[, { options } ]) `options` may contain: -* `max_header_size` (default is 4096 bytes) - a limit for - HTTP request header size. -* `header_timeout` (default: 100 seconds) - a timeout until - the server stops reading HTTP headers sent by the client. - The server closes the client connection if the client doesn't - send its headers within the given amount of time. -* `app_dir` (default is '.', the server working directory) - - a path to the directory with HTML templates and controllers. * `handler` - a Lua function to handle HTTP requests (this is a handler to use if the module "routing" functionality is not needed). -* `charset` - the character set for server responses of - type `text/html`, `text/plain` and `application/json`. * `display_errors` - return application errors and backtraces to the client (like PHP). * `log_requests` - log incoming requests. This parameter can receive: @@ -117,6 +119,18 @@ httpd = require('http.server').new(host, port[, { options } ]) By default uses `log.info` function for requests logging. * `log_errors` - same as the `log_requests` option but is used for error messages logging. By default uses `log.error()` function. +## Creating a router + +```lua +router = require('http.router').new(options) +server:set_router(router) +``` + +`options` may contain: + +* `charset` - the character set for server responses of + type `text/html`, `text/plain` and `application/json`. + ## Using routes It is possible to automatically route requests between different @@ -142,53 +156,21 @@ Route examples: To configure a route, use the `route()` method of the `httpd` object: ```lua -httpd:route({ path = '/path/to' }, 'controller#action') -httpd:route({ path = '/', template = 'Hello <%= var %>' }, handle1) -httpd:route({ path = '/:abc/cde', file = 'users.html.el' }, handle2) httpd:route({ path = '/objects', method = 'GET' }, handle3) ... ``` The first argument for `route()` is a Lua table with one or more keys: -* `file` - a template file name (can be relative to. - `{app_dir}/templates`, where `app_dir` is the path set when creating the - server). If no template file name extension is provided, the extension is - set to ".html.el", meaning HTML with embedded Lua. -* `template` - template Lua variable name, in case the template - is a Lua variable. If `template` is a function, it's called on every - request to get template body. This is useful if template body must be - taken from a database. -* `path` - route path, as described earlier. -* `name` - route name. -* `method` - method on the route like `POST`, `GET`, `PUT`, `DELETE` -* `log_requests` - option that overrides the server parameter of the same name but only for current route. -* `log_errors` - option that overrides the server parameter of the same name but only for current route. +| key | description | +| --- | ----------- | +| `path` | route path, as described earlier. | +| `name` | route name. | +| `method` | method on the route like `POST`, `GET`, `PUT`, `DELETE` | The second argument is the route handler to be used to produce a response to the request. -The typical usage is to avoid passing `file` and `template` arguments, -since they take time to evaluate, but these arguments are useful -for writing tests or defining HTTP servers with just one "route". - -The handler can also be passed as a string of the form 'filename#functionname'. -In that case, the handler body is taken from a file in the -`{app_dir}/controllers` directory. - -## Contents of `app_dir` - -* `public` - a path to static content. Everything stored on this path - defines a route which matches the file name, and the HTTP server serves this - file automatically, as is. Notice that the server doesn't use `sendfile()`, - and it reads the entire content of the file into the memory before passing - it to the client. ??? Caching is not used, unless turned on. So this is not - suitable for large files, use nginx instead. -* `templates` - a path to templates. -* `controllers` - a path to *.lua files with Lua controllers. For example, - the controller name 'module.submodule#foo' is mapped to - `{app_dir}/controllers/module.submodule.lua`. - ## Route handlers A route handler is a function which accepts one argument (**Request**) and @@ -207,42 +189,36 @@ end ### Fields and methods of the Request object -* `req.method` - HTTP request type (`GET`, `POST` etc). -* `req.path` - request path. -* `req.query` - request arguments. -* `req.proto` - HTTP version (for example, `{ 1, 1 }` is `HTTP/1.1`). -* `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**: 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 - 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. -* `req:query_param(name)` - returns a single GET request parameter value. - If `name` is `nil`, returns a Lua table with all arguments. -* `req:param(name)` - any request parameter, either GET or POST. -* `req:cookie(name)` - to get a cookie in the request. -* `req:stash(name[, value])` - get or set a variable "stashed" - when dispatching a route. -* `req:url_for(name, args, query)` - returns the route's exact URL. -* `req:render({})` - create a **Response** object with a rendered template. -* `req:redirect_to` - create a **Response** object with an HTTP redirect. +| method | description | +| ------ | ----------- | +| `req:method()` | HTTP request type (`GET`, `POST` etc). | +| `req:path()` | request path. | +| `req:query()` | request arguments. | +| `req:proto()` | HTTP version (for example, `{ 1, 1 }` is `HTTP/1.1`). | +| `req:headers()` | normalized request headers. A normalized header. | +| `req:header(name)` | value of header `name`. | +| `req:peer()` | a Lua table with information about the remote peer (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 raw request body as a stream (see `socket:read()`). **NOTE**: when using NGINX TSGI adapter, only `req:read(chunk)` is available. | +| `req:post_param(name)` | returns a single POST request a parameter value. If `name` is `nil`, returns all parameters as a Lua table. | +| `req:query_param(name)` | returns a single GET request parameter value. If `name` is `nil`, returns a Lua table with all arguments. | +| `req:param(name)` | any request parameter, either GET or POST. | +| `req:cookie(name)` | to get a cookie in the request. | +| `req:stash(name[, value])` | **NOTE**: currently not supported inside middleware handlers. Get or set a variable "stashed" when dispatching a route. | +| `req:url_for(name, args, query)` | returns the route's exact URL. +| `req:redirect_to` | create a **Response** object with an HTTP redirect. +| `req:next()` | in middleware invokes remaining middleware chain and route handler and returns the response | +| `req:hijack()` | terminates HTTP connection. Open TCP connection object is returned | ### Fields and methods of the Response object -* `resp.status` - HTTP response code. -* `resp.headers` - a Lua table with normalized headers. -* `resp.body` - response body (string|table|wrapped\_iterator). -* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` - - adds `Set-Cookie` headers to `resp.headers`. +| method | description | +| ------ | ----------- | +| `resp.status` | HTTP response code. +| `resp.headers` | a Lua table with normalized headers. +| `resp.body` | response body (string|table|wrapped\_iterator). +| `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` | adds `Set-Cookie` headers to `resp.headers`. ### Examples @@ -278,15 +254,6 @@ httpd:route( httpd:start() ``` -### Special stash names - -* `controller` - the controller name. -* `action` - the handler name in the controller. -* `format` - the current output format (e.g. `html`, `txt`). Is - detected automatically based on the request's `path` (for example, `/abc.js` - sets `format` to `js`). When producing a response, `format` is used - to serve the response's 'Content-type:'. - ## Working with cookies To get a cookie, use: @@ -338,107 +305,107 @@ The table must contain the following fields: * `+1m` - 1 month (30 days) * `+1y` - 1 year (365 days) -## Rendering a template - -Lua can be used inside a response template, for example: - -```html - - - <%= title %> - - -
    - % for i = 1, 10 do -
  • <%= item[i].key %>: <%= item[i].value %>
  • - % end -
- - -``` - -To embed Lua code into a template, use: +## Middleware -* `<% lua-here %>` - insert any Lua code, including multi-line. - Can be used anywhere in the template. -* `% lua-here` - a single-line Lua substitution. Can only be - present at the beginning of a line (with optional preceding spaces - and tabs, which are ignored). +tarantool/http v2 comes with improved middleware support: +1. middleware functions control both HTTP request arrival and HTTP response +return in the same function. As opposed to v1 functions `before_dispatch()`, `after_dispatch()`. +2. filters on path and method: if request doesn't match path pattern or +method, the middleware won't be invoked for this particular request. +3. you can modify order of middleware execution by specifying relations +between middlewares via optional `opts.after` / `opts.before` arrays on +middleware creation (see below). -A few control characters may follow `%`: +### `router:use(f, opts)` -* `=` (e.g., `<%= value + 1 %>`) - runs the embedded Lua code - and inserts the result into HTML. Special HTML characters, - such as `<`, `>`, `&`, `"`, are escaped. -* `==` (e.g., `<%== value + 10 %>`) - the same, but without - escaping. +#### Parameters -A Lua statement inside the template has access to the following -environment: +| parameter | type | description | +| ----------- | ------ | ---------------- | +| `f` | response = function(req) | see explanation below | +| `opts.path` | string | as in `route(f)` | +| `opts.method` | string | as in `route()` | +| `opts.preroute` | bool | when true, middleware will be invoked before routing | +| `opts.name` | string | middleware name that is referred to when defining order between middleware. +| `opts.before` | array of strings | middleware names that must be invoked before this middleware | +| `opts.after` | array of strings | middleware names that must be invoked after this middleware | +| `return-value` | bool | true, if middleware is added successfully, false otherwise | -1. Lua variables defined in the template, -1. stashed variables, -1. variables standing for keys in the `render` table. +#### f +`f` has the same signature as route handler. -## Template helpers +Inside `f` use `req:next()` to call next function, which can be +another middleware handler or a terminating route handler. -Helpers are special functions that are available in all HTML -templates. These functions must be defined when creating an `httpd` object. +**NOTE**: `req:stash()` is currently not working inside middleware handlers. -Setting or deleting a helper: - -```lua --- setting a helper -httpd:helper('time', function(self, ...) return box.time() end) --- deleting a helper -httpd:helper('some_name', nil) -``` - -Using a helper inside an HTML template: - -```html -
- Current timestamp: <%= time() %> -
-``` +Alternatively, you can return response from `f` before calling +`req:next()` (early exit), in this case the request will not be +dispatched to the route handler. -A helper function can receive arguments. The first argument is -always the current controller. The rest is whatever is -passed to the helper from the template. +This is convenient for example in authorization middleware functions, +where you can exit with 403 Forbidden on authorization failure. -## Hooks +#### Ordering -It is possible to define additional functions invoked at various -stages of request processing. +By default, if you don't specify `before`/`after` options in `router:use()`, +the order of invokation for any request is the **definition order** (of +course, if some middleware is filtered-out, it won't be executed) -### `handler(httpd, req)` +If you need more complex order of execution between middleware handlers, +you can do so by providing local execution order: -If `handler` is present in `httpd` options, it gets -involved on every HTTP request, and the built-in routing -mechanism is unused (no other hooks are called in this case). +```lua +local ok_b = router:use(b, {name = 'c', before = 'a'}) +local ok_a = router:use(a, {name = 'a'}) +local ok_c = router:use(c, {name = 'b', after = 'a'}) +-- order is a -> b -> c -> route-handler (where "->" is invokation via `req:next()`) -### `before_dispatch(httpd, req)` +local ok_d = router:use(d, {before = 'a', after = 'c'}) +assert(ok_d) -- FAIL: cannot satisfy order without breaking specified dependencies +``` -Is invoked before a request is routed to a handler. The first -argument of the hook is the HTTP request to be handled. -The return value of the hook is ignored. +Internally, in any time, a total-order of middleware is maintained. +If upon adding new middleware such total-order becomes non-existing, +the middleware addition is rejected. -This hook could be used to log a request, or modify request headers. +#### Example +```lua +local users = require('myproject.users') -### `after_dispatch(cx, resp)` +local json = require('json') +local digest = require('digest') -Is invoked after a handler for a route is executed. +local function basic_auth_handler(req) + local auth = req:header('authorization') + if not auth or not auth:find('Basic ') then + return { + status = 401, + body = json.encode({message = 'Missing Authorization Header'}) + } + end -The arguments of the hook are the request passed into the handler, -and the response produced by the handler. + local base64_credentials = auth:split(' ')[2] + local credentials = digest.base64_decode(base64_credentials) + local username = credentials:split(':')[1] + local password = credentials:split(':')[2] -This hook can be used to modify the response. -The return value of the hook is ignored. + local user = users.authenticate(username, password) + if not user then + return { + status = 401, + body = json.encode({message = 'Invalid Authentication Credentials'}) + } + end -## See also + req.user = user - * [Tarantool project][Tarantool] on GitHub - * [Tests][] for the `http` module + return req:next() +end -[Tarantool]: http://github.com/tarantool/tarantool -[Tests]: https://github.com/tarantool/http/tree/master/test +local ok = router:use(basic_auth_handler, { + path = '/api/v1', -- e.g. in API v2 a different + -- authentication mechanism is used + method = 'ANY', +}) +``` From c59e10413d5d7e621f8e64fc8e75cb0b3b9bb910 Mon Sep 17 00:00:00 2001 From: Albert Sverdlov Date: Mon, 26 Aug 2019 23:14:36 +0300 Subject: [PATCH 13/25] Fix cookies formatting (#82) Stop url-encoding cookie path quoting cookie expire date From RFC 6265 (4.1.1 section): expires-av = "Expires=" sane-cookie-date sane-cookie-date = --- http/router/response.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/http/router/response.lua b/http/router/response.lua index 6b9b4d9..ace53d3 100644 --- a/http/router/response.lua +++ b/http/router/response.lua @@ -41,14 +41,14 @@ local function setcookie(resp, cookie) 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)) + str = utils.sprintf('%s;path=%s', str, 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)) + str = utils.sprintf('%s;expires=%s', str, expires_str(cookie.expires)) end if not resp.headers then From 4ffd6965753d4fc1ad6adf8d6a0444ce60d33f97 Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 19:38:20 +0300 Subject: [PATCH 14/25] Update rockspecs according to new release --- ...http-scm-1.rockspec => http-scm-1.rockspec | 4 +-- rockspecs/http-1.0.1-1.rockspec | 36 ------------------- rockspecs/http-1.0.2-1.rockspec | 36 ------------------- 3 files changed, 2 insertions(+), 74 deletions(-) rename rockspecs/http-scm-1.rockspec => http-scm-1.rockspec (94%) delete mode 100644 rockspecs/http-1.0.1-1.rockspec delete mode 100644 rockspecs/http-1.0.2-1.rockspec diff --git a/rockspecs/http-scm-1.rockspec b/http-scm-1.rockspec similarity index 94% rename from rockspecs/http-scm-1.rockspec rename to http-scm-1.rockspec index 25b5c7b..12b5854 100644 --- a/rockspecs/http-scm-1.rockspec +++ b/http-scm-1.rockspec @@ -10,7 +10,8 @@ description = { license = 'BSD', } dependencies = { - 'lua >= 5.1' + 'lua >= 5.1', + 'checks >= 3.0.1' } external_dependencies = { TARANTOOL = { @@ -36,7 +37,6 @@ build = { ['http.router.middleware'] = 'http/router/middleware.lua', ['http.router.request'] = 'http/router/request.lua', ['http.router.response'] = 'http/router/response.lua', - ['http.router.trie'] = 'http/router/trie.lua', ['http.tsgi'] = 'http/tsgi.lua', ['http.utils'] = 'http/utils.lua', ['http.mime_types'] = 'http/mime_types.lua', diff --git a/rockspecs/http-1.0.1-1.rockspec b/rockspecs/http-1.0.1-1.rockspec deleted file mode 100644 index e328c41..0000000 --- a/rockspecs/http-1.0.1-1.rockspec +++ /dev/null @@ -1,36 +0,0 @@ -package = 'http' -version = '1.0.1-1' -source = { - url = 'git://github.com/tarantool/http.git', - tag = '1.0.1', -} -description = { - summary = "HTTP server for Tarantool", - homepage = 'https://github.com/tarantool/http/', - license = 'BSD', -} -dependencies = { - 'lua >= 5.1' -} -external_dependencies = { - TARANTOOL = { - header = "tarantool/module.h" - } -} -build = { - type = 'builtin', - - modules = { - ['http.lib'] = { - sources = 'http/lib.c', - incdirs = { - "$(TARANTOOL_INCDIR)" - } - }, - ['http.server'] = 'http/server.lua', - ['http.mime_types'] = 'http/mime_types.lua', - ['http.codes'] = 'http/codes.lua', - } -} - --- vim: syntax=lua diff --git a/rockspecs/http-1.0.2-1.rockspec b/rockspecs/http-1.0.2-1.rockspec deleted file mode 100644 index 31b5ed7..0000000 --- a/rockspecs/http-1.0.2-1.rockspec +++ /dev/null @@ -1,36 +0,0 @@ -package = 'http' -version = '1.0.2-1' -source = { - url = 'git://github.com/tarantool/http.git', - tag = '1.0.2', -} -description = { - summary = "HTTP server for Tarantool", - homepage = 'https://github.com/tarantool/http/', - license = 'BSD', -} -dependencies = { - 'lua >= 5.1' -} -external_dependencies = { - TARANTOOL = { - header = "tarantool/module.h" - } -} -build = { - type = 'builtin', - - modules = { - ['http.lib'] = { - sources = 'http/lib.c', - incdirs = { - "$(TARANTOOL_INCDIR)" - } - }, - ['http.server'] = 'http/server.lua', - ['http.mime_types'] = 'http/mime_types.lua', - ['http.codes'] = 'http/codes.lua', - } -} - --- vim: syntax=lua From 549058d8d3cfd2bd48555ece47fd9a6e8a167626 Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 20:20:47 +0300 Subject: [PATCH 15/25] Port from tap to luatest --- CMakeLists.txt | 2 +- http-scm-1.rockspec | 3 +- test/Procfile.test.nginx | 2 +- test/http.test.lua | 714 --------------------------------------- test/http_test.lua | 692 +++++++++++++++++++++++++++++++++++++ test_locally.sh | 2 +- 6 files changed, 697 insertions(+), 718 deletions(-) delete mode 100755 test/http.test.lua create mode 100755 test/http_test.lua diff --git a/CMakeLists.txt b/CMakeLists.txt index 9457d19..ef96e35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,7 @@ enable_testing() set (LUA_PATH "LUA_PATH=${PROJECT_SOURCE_DIR}/?.lua\\;${PROJECT_SOURCE_DIR}/?/init.lua\\;\\;") set (LUA_SOURCE_DIR "LUA_SOURCE_DIR=${PROJECT_SOURCE_DIR}") -add_test(http ${CMAKE_SOURCE_DIR}/test/http.test.lua) +add_test(http ${CMAKE_SOURCE_DIR}/.rocks/bin/luatest) set_tests_properties(http PROPERTIES ENVIRONMENT "${LUA_PATH};${LUA_SOURCE_DIR}") diff --git a/http-scm-1.rockspec b/http-scm-1.rockspec index 12b5854..db27942 100644 --- a/http-scm-1.rockspec +++ b/http-scm-1.rockspec @@ -11,7 +11,8 @@ description = { } dependencies = { 'lua >= 5.1', - 'checks >= 3.0.1' + 'checks >= 3.0.1', + 'luatest >= 0.2.2' } external_dependencies = { TARANTOOL = { diff --git a/test/Procfile.test.nginx b/test/Procfile.test.nginx index 1de79fd..47a0db7 100644 --- a/test/Procfile.test.nginx +++ b/test/Procfile.test.nginx @@ -1,2 +1,2 @@ nginx: nginx -g "daemon off;" -c $PWD/test/nginx.conf -nginx_tsgi_test: SERVER_TYPE=nginx ./test/http.test.lua 2>&1 +nginx_tsgi_test: SERVER_TYPE=nginx ./.rocks/bin/luatest 2>&1 diff --git a/test/http.test.lua b/test/http.test.lua deleted file mode 100755 index 3b718ea..0000000 --- a/test/http.test.lua +++ /dev/null @@ -1,714 +0,0 @@ -#!/usr/bin/env tarantool - -local tap = require('tap') -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 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} -) - -local test = tap.test("http") -test:plan(8) -test:test("split_uri", function(test) - test:plan(65) - local function check(uri, rhs) - local lhs = urilib.parse(uri) - local extra = { lhs = lhs, rhs = rhs } - if lhs.query == '' then - lhs.query = nil - end - test:is(lhs.scheme, rhs.scheme, uri.." scheme", extra) - test:is(lhs.host, rhs.host, uri.." host", extra) - test:is(lhs.service, rhs.service, uri.." service", extra) - test:is(lhs.path, rhs.path, uri.." path", extra) - test:is(lhs.query, rhs.query, uri.." query", extra) - end - check('http://abc', { scheme = 'http', host = 'abc'}) - check('http://abc/', { scheme = 'http', host = 'abc', path ='/'}) - check('http://abc?', { scheme = 'http', host = 'abc'}) - check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'}) - check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'}) - check('http://abc:123', { scheme = 'http', host = 'abc', service = '123' }) - check('http://abc:123?', { scheme = 'http', host = 'abc', service = '123'}) - check('http://abc:123?query', { scheme = 'http', host = 'abc', - service = '123', query = 'query'}) - check('http://domain.subdomain.com:service?query', { scheme = 'http', - host = 'domain.subdomain.com', service = 'service', query = 'query'}) - check('google.com', { host = 'google.com'}) - check('google.com?query', { host = 'google.com', query = 'query'}) - check('google.com/abc?query', { host = 'google.com', path = '/abc', - query = 'query'}) - check('https://google.com:443/abc?query', { scheme = 'https', - host = 'google.com', service = '443', path = '/abc', query = 'query'}) - end) - -test:test("template", function(test) - test:plan(5) - test:is(http_lib.template("<% for i = 1, cnt do %> <%= abc %> <% end %>", - {abc = '1 <3>&" ', cnt = 3}), - ' 1 <3>&" 1 <3>&" 1 <3>&" ', - "tmpl1") - test:is(http_lib.template("<% for i = 1, cnt do %> <%= ab %> <% end %>", - {abc = '1 <3>&" ', cnt = 3}), - ' nil nil nil ', "tmpl2") - local r, msg = pcall(http_lib.template, "<% ab() %>", {ab = '1'}) - test:ok(r == false and msg:match("call local 'ab'") ~= nil, "bad template") - - -- gh-18: rendered tempate is truncated - local template = [[ - - - - % for i,v in pairs(t) do - - - - - % end -
<%= i %><%= v %>
- - -]] - - local t = {} - for i=1, 100 do - t[i] = string.rep('#', i) - end - - local rendered, code = http_lib.template(template, { t = t }) - test:ok(#rendered > 10000, "rendered size") - test:is(rendered:sub(#rendered - 7, #rendered - 1), "", "rendered eof") -end) - -test:test('parse_request', function(test) - test:plan(6) - - test:is_deeply(http_lib._parse_request('abc'), - { error = 'Broken request line', headers = {} }, 'broken request') - - - - test:is( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").path, - '/', - 'path' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").proto, - {1,1}, - 'proto' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").headers, - {host = 's.com'}, - 'host' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").method, - 'GET', - 'method' - ) - test:is_deeply( - http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").query, - '', - 'query' - ) -end) - -test:test('params', function(test) - test:plan(6) - test:is_deeply(http_lib.params(), {}, 'nil string') - test:is_deeply(http_lib.params(''), {}, 'empty string') - test:is_deeply(http_lib.params('a'), {a = ''}, 'separate literal') - test:is_deeply(http_lib.params('a=b'), {a = 'b'}, 'one variable') - test:is_deeply(http_lib.params('a=b&b=cde'), {a = 'b', b = 'cde'}, 'some') - test:is_deeply(http_lib.params('a=b&b=cde&a=1'), - {a = { 'b', '1' }, b = 'cde'}, 'array') -end) - -local function is_nginx_test() - local server_type = os.getenv('SERVER_TYPE') or 'builtin' - return server_type:lower() == 'nginx' -end - -local function is_builtin_test() - return not is_nginx_test() -end - -local function choose_server() - local log_requests = true - local log_errors = true - - 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 = log_requests, - log_errors = log_errors, - }) - end - - return http_server.new('127.0.0.1', 12345, { - log_requests = log_requests, - log_errors = log_errors - }) -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({app_dir = path}) - :route({path = '/abc/:cde/:def', name = 'test'}, function() end) - :route({path = '/abc'}, function() end) - :route({path = '/ctxaction'}, 'module.controller#action') - :route({path = '/absentaction'}, 'module.controller#absent') - :route({path = '/absent'}, 'module.absent#action') - :route({path = '/abc/:cde'}, function() end) - :route({path = '/abc_:cde_def'}, function() end) - :route({path = '/abc-:cde-def'}, function() end) - :route({path = '/aba*def'}, function() end) - :route({path = '/abb*def/cde', name = 'star'}, function() end) - :route({path = '/banners/:token'}) - :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) - httpd:set_router(router) - return httpd, router -end - -test:test("server url match", function(test) - test:plan(18) - local httpd, router = cfgserv() - test:istable(httpd, "httpd object") - 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(router:match('GET', '/abc/123/122').stash.def, "122", - "/abc/123/122") - test:is(router:match('GET', '/abc/123/122').stash.cde, "123", - "/abc/123/122") - test:is(router:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", - "/abc_123-122") - test:is(router:match('GET', '/abc_123-122').stash.cde_def, "123-122", - "/abc_123-122") - test:is(router:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", - "/abc-123-def") - test:is(router:match('GET', '/abc-123-def').stash.cde, "123", - "/abc-123-def") - test:is(router:match('GET', '/aba-123-dea/1/2/3').endpoint.path, - "/aba*def", '/aba-123-dea/1/2/3') - 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(router:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, - "/abb*def/cde", '/abb-123-dea/1/2/3/cde') - 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(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, 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(router:url_for('star', { def = '/def_v' }), - '/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) - -test:test("server requests", function(test) - test:plan(43) - local httpd, router = cfgserv() - httpd:start() - - local r = http_client.get('http://127.0.0.1:12345/test') - test:is(r.status, 200, '/test code') - test:is(r.proto[1], 1, '/test http 1.1') - test:is(r.proto[2], 1, '/test http 1.1') - test:is(r.reason, 'Ok', '/test reason') - test:is(string.match(r.body, 'title: 123'), 'title: 123', '/test body') - - local r = http_client.get('http://127.0.0.1:12345/test404') - test:is(r.status, 404, '/test404 code') - -- broken in built-in tarantool/http - --test:is(r.reason, 'Not found', '/test404 reason') - - local r = http_client.get('http://127.0.0.1:12345/absent') - test:is(r.status, 500, '/absent code') - --test:is(r.reason, 'Internal server error', '/absent reason') - test:is(string.match(r.body, 'load module'), 'load module', '/absent body') - - local r = http_client.get('http://127.0.0.1:12345/ctxaction') - test:is(r.status, 200, '/ctxaction code') - test:is(r.reason, 'Ok', '/ctxaction reason') - test:is(string.match(r.body, 'Hello, Tarantool'), 'Hello, Tarantool', - '/ctxaction body') - test:is(string.match(r.body, 'action: action'), 'action: action', - '/ctxaction body action') - test:is(string.match(r.body, 'controller: module[.]controller'), - 'controller: module.controller', '/ctxaction body controller') - - local r = http_client.get('http://127.0.0.1:12345/ctxaction.invalid') - test:is(r.status, 404, '/ctxaction.invalid code') -- WTF? - --test:is(r.reason, 'Not found', '/ctxaction.invalid reason') - --test:is(r.body, '', '/ctxaction.invalid body') - - local r = http_client.get('http://127.0.0.1:12345/hello.html') - test:is(r.status, 200, '/hello.html code') - test:is(r.reason, 'Ok', '/hello.html reason') - test:is(string.match(r.body, 'static html'), 'static html', - '/hello.html body') - - local r = http_client.get('http://127.0.0.1:12345/absentaction') - test:is(r.status, 500, '/absentaction 500') - --test:is(r.reason, 'Internal server error', '/absentaction reason') - test:is(string.match(r.body, 'contain function'), 'contain function', - '/absentaction body') - - local r = http_client.get('http://127.0.0.1:12345/helper') - test:is(r.status, 200, 'helper 200') - test:is(r.reason, 'Ok', 'helper reason') - test:is(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') - - local r = http_client.get('http://127.0.0.1:12345/helper?abc') - test:is(r.status, 200, 'helper?abc 200') - test:is(r.reason, 'Ok', 'helper?abc reason') - test:is(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') - - 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') - - 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) - return tx:render({text = 'POST = ' .. tx:read()}) - end) - test:istable(r, ':route') - - test:test('GET/POST at one route', function(test) - test:plan(8) - - 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 = 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 = 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 = router:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, - function(tx) - return tx:render({text = 'PATCH = ' .. tx:read()}) - 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') - - r = http_client.request('GET', 'http://127.0.0.1:12345/dit') - test:is(r.body, 'GET = ', 'GET reply') - - r = http_client.request('DELETE', 'http://127.0.0.1:12345/dit', 'test1') - test:is(r.body, 'DELETE = test1', 'DELETE reply') - - r = http_client.request('PATCH', 'http://127.0.0.1:12345/dit', 'test2') - test:is(r.body, 'PATCH = test2', 'PATCH reply') - end) - - router:route({path = '/chunked'}, function(self) - return self:iterate(ipairs({'chunked', 'encoding', 't\r\nest'})) - end) - - -- http client currently doesn't support chunked encoding - local r = http_client.get('http://127.0.0.1:12345/chunked') - test:is(r.status, 200, 'chunked 200') - test:is(r.headers['transfer-encoding'], 'chunked', 'chunked headers') - test:is(r.body, 'chunkedencodingt\r\nest', 'chunked body') - - test:test('get cookie', function(test) - test:plan(2) - router:route({path = '/receive_cookie'}, function(req) - local foo = req:cookie('foo') - local baz = req:cookie('baz') - return req:render({ - text = ('foo=%s; baz=%s'):format(foo, baz) - }) - end) - local r = http_client.get('http://127.0.0.1:12345/receive_cookie', { - headers = { - cookie = 'foo=bar; baz=feez', - } - }) - test:is(r.status, 200, 'status') - test:is(r.body, 'foo=bar; baz=feez', 'body') - end) - - test:test('cookie', function(test) - test:plan(2) - router:route({path = '/cookie'}, function(req) - local resp = req:render({text = ''}) - resp:setcookie({ name = 'test', value = 'tost', - expires = '+1y', path = '/abc' }) - resp:setcookie({ name = 'xxx', value = 'yyy' }) - return resp - end) - local r = http_client.get('http://127.0.0.1:12345/cookie') - test:is(r.status, 200, 'status') - 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) - - 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 - - -- hijacking - if is_builtin_test() then - -- 0. create a route (simplest) in which env:hijack() is called, - -- and then do ping-pong. - router:route({method = 'POST', path = '/upgrade'}, function(req) - -- intercept raw socket connection - local sock = req:hijack() - assert(sock ~= nil, 'hijacked socket is not empty') - - -- receive ping, send pong - sock:write('ready') - local ping = sock:read(4) - assert(ping == 'ping') - sock:write('pong') - end) - - -- 1. set-up socket - local socket = require('socket') - local sock = socket.tcp_connect('127.0.0.1', 12345) - test:ok(sock ~= nil, 'HTTP client connection established') - - -- 2. over raw-socket send HTTP POST (to get it routed to route) - local upgrade_request = 'POST /upgrade HTTP/1.1\r\nConnection: upgrade\r\n\r\n' - local bytessent = sock:write(upgrade_request) - test:is(bytessent, #upgrade_request, 'upgrade request sent fully') - - -- 3. send ping, receive pong - test:is(sock:read(5), 'ready', 'server is ready') - sock:write('ping') - test:is(sock:read(4), 'pong', 'pong receieved') - else - test:ok(true, 'HTTP client connection established - ignored on NGINX') - test:ok(true, 'upgrade request sent fully - ignored on NGINX') - test:ok(true, 'server is ready - ignored on NGINX') - test:ok(true, 'pong received - ignored on NGINX') - end - - test:test('prioritization of more specific routes', function(test) - test:plan(4) - - router:route({method = 'GET', path = '*stashname'}, function(_) - return { - status = 200, - body = 'GET *', - } - end) - local r = http_client.get('http://127.0.0.1:12345/a/b/c') - test:is(r.status, 200, '/a/b/c request returns 200') - test:is(r.body, 'GET *', 'GET * matches') - - router:route({method = 'ANY', path = '/a/:foo/:bar'}, function(_) - return { - status = 200, - body = 'ANY /a/:foo/:bar', - } - end) - local r = http_client.get('http://127.0.0.1:12345/a/b/c') - test:is(r.status, 200, '/a/b/c request returns 200') - test:is( - r.body, - 'ANY /a/:foo/:bar', - '# of stashes matched doesnt matter - only # of known symbols by the route matters' - ) - end) - - httpd:stop() -end) - -test:test("middleware", function(test) - test:plan(12) - local httpd, router = cfgserv() - - local add_helloworld_before_to_response = function(req) - local resp = req:next() - - local lua_body = json.decode(resp.body) - lua_body.message = 'hello world! (before)' - resp.body = json.encode(lua_body) - - return resp - end - - local add_helloworld_to_response = function(req) - local resp = req:next() - - local lua_body = json.decode(resp.body) - lua_body.message = 'hello world!' - resp.body = json.encode(lua_body) - - return resp - end - - local ok = router:use(add_helloworld_to_response, { - name = 'hello_world', - path = '/.*', - method = {'GET', 'POST'}, - }) - test:ok(ok, 'hello_world middleware added successfully') - - local middlewares_ordered = router.middleware:ordered() - test:is(#middlewares_ordered, 1, 'one middleware is registered') - - ok = router:use(add_helloworld_before_to_response, { - name = 'hello_world_before', - path = '/.*', - method = 'ANY', - before = 'hello_world', - }) - test:ok(ok, 'hello_world_before middleware added successfully') - - middlewares_ordered = router.middleware:ordered() - test:is(#middlewares_ordered, 2, 'both middlewares are registered') - test:is(middlewares_ordered[1].name, 'hello_world_before', - 'hello_world_before is first') - test:is(middlewares_ordered[2].name, 'hello_world', - 'hello_world is last') - - local apple_handler = function() - return {status = 200, body = json.encode({kind = 'apple'})} - end - - local orange_handler = function() - return {status = 200, body = json.encode({kind = 'orange'})} - end - - router:route( - { - method = 'GET', - path = '/fruits/apple', - }, - apple_handler - ) - router:route( - { - method = 'GET', - path = '/fruits/orange', - }, - orange_handler - ) - - httpd:start() - - local r = http_client.get( - 'http://127.0.0.1:12345/fruits/apple' - ) - test:is(r.status, 200, 'status') - require('log').info('DEBUG: /fruits/apple response: %s', r.body) - local parsed_body = json.decode(r.body) - test:is(parsed_body.kind, 'apple', 'body is correct') - test:is(parsed_body.message, 'hello world! (before)', 'hello_world middleware invoked last') - - local function swap_orange_and_apple(req) - local path_info = req['PATH_INFO'] - local log = require('log') - log.info('swap_orange_and_apple: path_info = %s', path_info) - - if path_info == '/fruits/orange' then - req['PATH_INFO'] = '/fruits/apple' - elseif path_info == '/fruits/apple' then - req['PATH_INFO'] = '/fruits/orange' - end - - return req:next() - end - - ok = router:use(swap_orange_and_apple, { - preroute = true, - name = 'swap_orange_and_apple', - }) - test:ok(ok, 'swap_orange_and_apple middleware added successfully') - - r = http_client.get( - 'http://127.0.0.1:12345/fruits/apple' - ) - test:is(r.status, 200, 'status') - parsed_body = json.decode(r.body) - test:is(parsed_body.kind, 'orange', 'route swapped from apple handler to orange') - - httpd:stop() -end) - -os.exit(test:check() == true and 0 or 1) diff --git a/test/http_test.lua b/test/http_test.lua new file mode 100755 index 0000000..19d6832 --- /dev/null +++ b/test/http_test.lua @@ -0,0 +1,692 @@ +#!/usr/bin/env tarantool + +local t = require('luatest') +local g = t.group('http') +local tap = require('tap') +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 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 + +g.before_all = function() + 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} + ) +end + +g.test_split_uri = function() + local function check(uri, rhs) + local lhs = urilib.parse(uri) + local extra = { lhs = lhs, rhs = rhs } + if lhs.query == '' then + lhs.query = nil + end + t.assertEquals(lhs.scheme, rhs.scheme, uri.." scheme", extra) + t.assertEquals(lhs.host, rhs.host, uri.." host", extra) + t.assertEquals(lhs.service, rhs.service, uri.." service", extra) + t.assertEquals(lhs.path, rhs.path, uri.." path", extra) + t.assertEquals(lhs.query, rhs.query, uri.." query", extra) + end + check('http://abc', { scheme = 'http', host = 'abc'}) + check('http://abc/', { scheme = 'http', host = 'abc', path ='/'}) + check('http://abc?', { scheme = 'http', host = 'abc'}) + check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'}) + check('http://abc/?', { scheme = 'http', host = 'abc', path ='/'}) + check('http://abc:123', { scheme = 'http', host = 'abc', service = '123' }) + check('http://abc:123?', { scheme = 'http', host = 'abc', service = '123'}) + check('http://abc:123?query', { scheme = 'http', host = 'abc', + service = '123', query = 'query'}) + check('http://domain.subdomain.com:service?query', { scheme = 'http', + host = 'domain.subdomain.com', service = 'service', query = 'query'}) + check('google.com', { host = 'google.com'}) + check('google.com?query', { host = 'google.com', query = 'query'}) + check('google.com/abc?query', { host = 'google.com', path = '/abc', + query = 'query'}) + check('https://google.com:443/abc?query', { scheme = 'https', + host = 'google.com', service = '443', path = '/abc', query = 'query'}) +end + +g.test_template = function() + t.assertEquals(http_lib.template("<% for i = 1, cnt do %> <%= abc %> <% end %>", + {abc = '1 <3>&" ', cnt = 3}), + ' 1 <3>&" 1 <3>&" 1 <3>&" ', + "tmpl1") + t.assertEquals(http_lib.template("<% for i = 1, cnt do %> <%= ab %> <% end %>", + {abc = '1 <3>&" ', cnt = 3}), + ' nil nil nil ', "tmpl2") + local r, msg = pcall(http_lib.template, "<% ab() %>", {ab = '1'}) + t.assertTrue(r == false and msg:match("call local 'ab'") ~= nil, "bad template") + + -- gh-18: rendered tempate is truncated + local template = [[ + + + + % for i,v in pairs(t) do + + + + + % end +
<%= i %><%= v %>
+ + +]] + + local tt = {} + for i=1, 100 do + tt[i] = string.rep('#', i) + end + + local rendered, code = http_lib.template(template, { t = tt }) + t.assertTrue(#rendered > 10000, "rendered size") + t.assertEquals(rendered:sub(#rendered - 7, #rendered - 1), "", "rendered eof") +end + +g.test_parse_request = function(test) + + t.assertEquals(http_lib._parse_request('abc'), + { error = 'Broken request line', headers = {} }, 'broken request') + + + + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").path, + '/', + 'path' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").proto, + {1,1}, + 'proto' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").headers, + {host = 's.com'}, + 'host' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").method, + 'GET', + 'method' + ) + t.assertEquals( + http_lib._parse_request("GET / HTTP/1.1\nHost: s.com\r\n\r\n").query, + '', + 'query' + ) +end + +g.test_params = function(test) + t.assertEquals(http_lib.params(), {}, 'nil string') + t.assertEquals(http_lib.params(''), {}, 'empty string') + t.assertEquals(http_lib.params('a'), {a = ''}, 'separate literal') + t.assertEquals(http_lib.params('a=b'), {a = 'b'}, 'one variable') + t.assertEquals(http_lib.params('a=b&b=cde'), {a = 'b', b = 'cde'}, 'some') + t.assertEquals(http_lib.params('a=b&b=cde&a=1'), + {a = { 'b', '1' }, b = 'cde'}, 'array') +end + +local function is_nginx_test() + local server_type = os.getenv('SERVER_TYPE') or 'builtin' + return server_type:lower() == 'nginx' +end + +local function is_builtin_test() + return not is_nginx_test() +end + +local function choose_server() + local log_requests = true + local log_errors = true + + 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 = log_requests, + log_errors = log_errors, + }) + end + + return http_server.new('127.0.0.1', 12345, { + log_requests = log_requests, + log_errors = log_errors + }) +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({app_dir = path}) + :route({path = '/abc/:cde/:def', name = 'test'}, function() end) + :route({path = '/abc'}, function() end) + :route({path = '/ctxaction'}, 'module.controller#action') + :route({path = '/absentaction'}, 'module.controller#absent') + :route({path = '/absent'}, 'module.absent#action') + :route({path = '/abc/:cde'}, function() end) + :route({path = '/abc_:cde_def'}, function() end) + :route({path = '/abc-:cde-def'}, function() end) + :route({path = '/aba*def'}, function() end) + :route({path = '/abb*def/cde', name = 'star'}, function() end) + :route({path = '/banners/:token'}) + :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) + httpd:set_router(router) + return httpd, router +end + +g.test_server_url_match = function(test) + local httpd, router = cfgserv() + t.assertIsTable(httpd, "httpd object") + t.assertIsNil(router:match('GET', '/')) + t.assertEquals(router:match('GET', '/abc').endpoint.path, "/abc", "/abc") + t.assertEquals(#router:match('GET', '/abc').stash, 0, "/abc") + t.assertEquals(router:match('GET', '/abc/123').endpoint.path, "/abc/:cde", "/abc/123") + t.assertEquals(router:match('GET', '/abc/123').stash.cde, "123", "/abc/123") + t.assertEquals(router:match('GET', '/abc/123/122').endpoint.path, "/abc/:cde/:def", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc/123/122').stash.def, "122", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc/123/122').stash.cde, "123", + "/abc/123/122") + t.assertEquals(router:match('GET', '/abc_123-122').endpoint.path, "/abc_:cde_def", + "/abc_123-122") + t.assertEquals(router:match('GET', '/abc_123-122').stash.cde_def, "123-122", + "/abc_123-122") + t.assertEquals(router:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", + "/abc-123-def") + t.assertEquals(router:match('GET', '/abc-123-def').stash.cde, "123", + "/abc-123-def") + t.assertEquals(router:match('GET', '/aba-123-dea/1/2/3').endpoint.path, + "/aba*def", '/aba-123-dea/1/2/3') + t.assertEquals(router:match('GET', '/aba-123-dea/1/2/3').stash.def, + "-123-dea/1/2/3", '/aba-123-dea/1/2/3') + t.assertEquals(router:match('GET', '/abb-123-dea/1/2/3/cde').endpoint.path, + "/abb*def/cde", '/abb-123-dea/1/2/3/cde') + t.assertEquals(router:match('GET', '/abb-123-dea/1/2/3/cde').stash.def, + "-123-dea/1/2/3", '/abb-123-dea/1/2/3/cde') + t.assertEquals(router:match('GET', '/banners/1wulc.z8kiy.6p5e3').stash.token, + '1wulc.z8kiy.6p5e3', "stash with dots") +end + + +g.test_server_url_for = function() + local httpd, router = cfgserv() + t.assertEquals(router:url_for('abcdef'), '/abcdef', '/abcdef') + t.assertEquals(router:url_for('test'), '/abc//', '/abc//') + t.assertEquals(router:url_for('test', { cde = 'cde_v', def = 'def_v' }), + '/abc/cde_v/def_v', '/abc/cde_v/def_v') + t.assertEquals(router:url_for('star', { def = '/def_v' }), + '/abb/def_v/cde', '/abb/def_v/cde') + t.assertEquals(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 + +g.test_server_requests = function() + local httpd, router = cfgserv() + httpd:start() + + local r = http_client.get('http://127.0.0.1:12345/test') + t.assertEquals(r.status, 200, '/test code') + + t.assertEquals(r.proto[1], 1, '/test http 1.1') + t.assertEquals(r.proto[2], 1, '/test http 1.1') + t.assertEquals(r.reason, 'Ok', '/test reason') + t.assertEquals(string.match(r.body, 'title: 123'), 'title: 123', '/test body') + + local r = http_client.get('http://127.0.0.1:12345/test404') + t.assertEquals(r.status, 404, '/test404 code') + -- broken in built-in tarantool/http + --t.assertEquals(r.reason, 'Not found', '/test404 reason') + + local r = http_client.get('http://127.0.0.1:12345/absent') + t.assertEquals(r.status, 500, '/absent code') + --t.assertEquals(r.reason, 'Internal server error', '/absent reason') + t.assertEquals(string.match(r.body, 'load module'), 'load module', '/absent body') + + local r = http_client.get('http://127.0.0.1:12345/ctxaction') + t.assertEquals(r.status, 200, '/ctxaction code') + t.assertEquals(r.reason, 'Ok', '/ctxaction reason') + t.assertEquals(string.match(r.body, 'Hello, Tarantool'), 'Hello, Tarantool', + '/ctxaction body') + t.assertEquals(string.match(r.body, 'action: action'), 'action: action', + '/ctxaction body action') + t.assertEquals(string.match(r.body, 'controller: module[.]controller'), + 'controller: module.controller', '/ctxaction body controller') + + local r = http_client.get('http://127.0.0.1:12345/ctxaction.invalid') + t.assertEquals(r.status, 404, '/ctxaction.invalid code') -- WTF? + --t.assertEquals(r.reason, 'Not found', '/ctxaction.invalid reason') + --t.assertEquals(r.body, '', '/ctxaction.invalid body') + + local r = http_client.get('http://127.0.0.1:12345/hello.html') + t.assertEquals(r.status, 200, '/hello.html code') + t.assertEquals(r.reason, 'Ok', '/hello.html reason') + t.assertEquals(string.match(r.body, 'static html'), 'static html', + '/hello.html body') + + local r = http_client.get('http://127.0.0.1:12345/absentaction') + t.assertEquals(r.status, 500, '/absentaction 500') + --t.assertEquals(r.reason, 'Internal server error', '/absentaction reason') + t.assertEquals(string.match(r.body, 'contain function'), 'contain function', + '/absentaction body') + + local r = http_client.get('http://127.0.0.1:12345/helper') + t.assertEquals(r.status, 200, 'helper 200') + t.assertEquals(r.reason, 'Ok', 'helper reason') + t.assertEquals(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') + + local r = http_client.get('http://127.0.0.1:12345/helper?abc') + t.assertEquals(r.status, 200, 'helper?abc 200') + t.assertEquals(r.reason, 'Ok', 'helper?abc reason') + t.assertEquals(string.match(r.body, 'Hello, world'), 'Hello, world', 'helper body') + + router:route({path = '/die', file = 'helper.html.el'}, + function() error(123) end ) + + local r = http_client.get('http://127.0.0.1:12345/die') + t.assertEquals(r.status, 500, 'die 500') + --t.assertEquals(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) + t.assertEquals(r.host, '127.0.0.1', 'peer.host') + t.assertIsNumber(r.port, 'peer.port') + + local r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'POST = ' .. tx:read()}) + end) + t.assertIsTable(r, ':route') + + + -- GET/POST at one route + r = router:route({method = 'POST', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'POST = ' .. tx:read()}) + end) + t.assertIsTable(r, 'add POST method') + + r = router:route({method = 'GET', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'GET = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add GET method') + + r = router:route({method = 'DELETE', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'DELETE = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add DELETE method') + + r = router:route({method = 'PATCH', path = '/dit', file = 'helper.html.el'}, + function(tx) + return tx:render({text = 'PATCH = ' .. tx:read()}) + end ) + t.assertIsTable(r, 'add PATCH method') + + -- TODO + r = http_client.request('POST', 'http://127.0.0.1:12345/dit', 'test') + t.assertEquals(r.body, 'POST = test', 'POST reply') + + r = http_client.request('GET', 'http://127.0.0.1:12345/dit') + t.assertEquals(r.body, 'GET = ', 'GET reply') + + r = http_client.request('DELETE', 'http://127.0.0.1:12345/dit', 'test1') + t.assertEquals(r.body, 'DELETE = test1', 'DELETE reply') + + r = http_client.request('PATCH', 'http://127.0.0.1:12345/dit', 'test2') + t.assertEquals(r.body, 'PATCH = test2', 'PATCH reply') + + router:route({path = '/chunked'}, function(self) + return self:iterate(ipairs({'chunked', 'encoding', 't\r\nest'})) + end) + + -- http client currently doesn't support chunked encoding + local r = http_client.get('http://127.0.0.1:12345/chunked') + t.assertEquals(r.status, 200, 'chunked 200') + t.assertEquals(r.headers['transfer-encoding'], 'chunked', 'chunked headers') + t.assertEquals(r.body, 'chunkedencodingt\r\nest', 'chunked body') + + -- get cookie + router:route({path = '/receive_cookie'}, function(req) + local foo = req:cookie('foo') + local baz = req:cookie('baz') + return req:render({ + text = ('foo=%s; baz=%s'):format(foo, baz) + }) + end) + local r = http_client.get('http://127.0.0.1:12345/receive_cookie', { + headers = { + cookie = 'foo=bar; baz=feez', + } + }) + t.assertEquals(r.status, 200, 'status') + t.assertEquals(r.body, 'foo=bar; baz=feez', 'body') + + -- cookie + router:route({path = '/cookie'}, function(req) + local resp = req:render({text = ''}) + resp:setcookie({ name = 'test', value = 'tost', + expires = '+1y', path = '/abc' }) + resp:setcookie({ name = 'xxx', value = 'yyy' }) + return resp + end) + local r = http_client.get('http://127.0.0.1:12345/cookie') + t.assertEquals(r.status, 200, 'status') + t.assertTrue(r.headers['set-cookie'] ~= nil, "header") + + + -- request object with GET method + 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' + } + }) + t.assertEquals(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.headers['x-test-header'], 'test-value', 'req.headers') + t.assertEquals(parsed_body.method, 'GET', 'req.method') + t.assertEquals(parsed_body.path, '/check_req_properties', 'req.path') + t.assertEquals(parsed_body.query, 'foo=1&bar=2', 'req.query') + t.assertEquals(parsed_body.query_param_bar, '2', 'req:query_param()') + t.assertEquals(parsed_body.proto, {1, 1}, 'req.proto') + + -- request object methods + 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' + } + }) + t.assertEquals(r.status, 200, 'status') + + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.request_line, 'POST /check_req_methods_for_json HTTP/1.1', 'req.request_line') + t.assertEquals(parsed_body.read_cached, '{"kind": "json"}', 'json req:read_cached()') + t.assertEquals(parsed_body.json, {kind = "json"}, 'req:json()') + t.assertEquals(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' + ) + t.assertEquals(r.status, 200, 'status') + parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.read_cached, 'hello mister', 'non-json req:read_cached()') + + if is_builtin_test() then + 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) + t.assertEquals(r.status, 200, 'status') + t.assertEquals(json.decode(r.body), { 541,10,10,458,1375,0,0 }, + 'req:read() results') + else + t.assertTrue(true, 'post body - ignore on NGINX') + end + + -- hijacking + if is_builtin_test() then + -- 0. create a route (simplest) in which env:hijack() is called, + -- and then do ping-pong. + router:route({method = 'POST', path = '/upgrade'}, function(req) + -- intercept raw socket connection + local sock = req:hijack() + assert(sock ~= nil, 'hijacked socket is not empty') + + -- receive ping, send pong + sock:write('ready') + local ping = sock:read(4) + assert(ping == 'ping') + sock:write('pong') + end) + + -- 1. set-up socket + local socket = require('socket') + local sock = socket.tcp_connect('127.0.0.1', 12345) + t.assertTrue(sock ~= nil, 'HTTP client connection established') + + -- 2. over raw-socket send HTTP POST (to get it routed to route) + local upgrade_request = 'POST /upgrade HTTP/1.1\r\nConnection: upgrade\r\n\r\n' + local bytessent = sock:write(upgrade_request) + t.assertEquals(bytessent, #upgrade_request, 'upgrade request sent fully') + + -- 3. send ping, receive pong + t.assertEquals(sock:read(5), 'ready', 'server is ready') + sock:write('ping') + t.assertEquals(sock:read(4), 'pong', 'pong receieved') + else + t.assertTrue(true, 'HTTP client connection established - ignored on NGINX') + t.assertTrue(true, 'upgrade request sent fully - ignored on NGINX') + t.assertTrue(true, 'server is ready - ignored on NGINX') + t.assertTrue(true, 'pong received - ignored on NGINX') + end + + -- prioritization of more specific routes + router:route({method = 'GET', path = '*stashname'}, function(_) + return { + status = 200, + body = 'GET *', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + t.assertEquals(r.status, 200, '/a/b/c request returns 200') + t.assertEquals(r.body, 'GET *', 'GET * matches') + + router:route({method = 'ANY', path = '/a/:foo/:bar'}, function(_) + return { + status = 200, + body = 'ANY /a/:foo/:bar', + } + end) + local r = http_client.get('http://127.0.0.1:12345/a/b/c') + t.assertEquals(r.status, 200, '/a/b/c request returns 200') + t.assertEquals( + r.body, + 'ANY /a/:foo/:bar', + '# of stashes matched doesnt matter - only # of known symbols by the route matters' + ) + + httpd:stop() +end + +g.test_middleware = function() + local httpd, router = cfgserv() + + local add_helloworld_before_to_response = function(req) + local resp = req:next() + + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world! (before)' + resp.body = json.encode(lua_body) + + return resp + end + + local add_helloworld_to_response = function(req) + local resp = req:next() + + local lua_body = json.decode(resp.body) + lua_body.message = 'hello world!' + resp.body = json.encode(lua_body) + + return resp + end + + local ok = router:use(add_helloworld_to_response, { + name = 'hello_world', + path = '/.*', + method = {'GET', 'POST'}, + }) + t.assertTrue(ok, 'hello_world middleware added successfully') + + local middlewares_ordered = router.middleware:ordered() + t.assertEquals(#middlewares_ordered, 1, 'one middleware is registered') + + ok = router:use(add_helloworld_before_to_response, { + name = 'hello_world_before', + path = '/.*', + method = 'ANY', + before = 'hello_world', + }) + t.assertTrue(ok, 'hello_world_before middleware added successfully') + + middlewares_ordered = router.middleware:ordered() + t.assertEquals(#middlewares_ordered, 2, 'both middlewares are registered') + t.assertEquals(middlewares_ordered[1].name, 'hello_world_before', + 'hello_world_before is first') + t.assertEquals(middlewares_ordered[2].name, 'hello_world', + 'hello_world is last') + + local apple_handler = function() + return {status = 200, body = json.encode({kind = 'apple'})} + end + + local orange_handler = function() + return {status = 200, body = json.encode({kind = 'orange'})} + end + + router:route( + { + method = 'GET', + path = '/fruits/apple', + }, + apple_handler + ) + router:route( + { + method = 'GET', + path = '/fruits/orange', + }, + orange_handler + ) + + httpd:start() + + local r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + t.assertEquals(r.status, 200, 'status') + require('log').info('DEBUG: /fruits/apple response: %s', r.body) + local parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.kind, 'apple', 'body is correct') + t.assertEquals(parsed_body.message, 'hello world! (before)', 'hello_world middleware invoked last') + + local function swap_orange_and_apple(req) + local path_info = req['PATH_INFO'] + local log = require('log') + log.info('swap_orange_and_apple: path_info = %s', path_info) + + if path_info == '/fruits/orange' then + req['PATH_INFO'] = '/fruits/apple' + elseif path_info == '/fruits/apple' then + req['PATH_INFO'] = '/fruits/orange' + end + + return req:next() + end + + ok = router:use(swap_orange_and_apple, { + preroute = true, + name = 'swap_orange_and_apple', + }) + t.assertTrue(ok, 'swap_orange_and_apple middleware added successfully') + + r = http_client.get( + 'http://127.0.0.1:12345/fruits/apple' + ) + t.assertEquals(r.status, 200, 'status') + parsed_body = json.decode(r.body) + t.assertEquals(parsed_body.kind, 'orange', 'route swapped from apple handler to orange') + + httpd:stop() +end diff --git a/test_locally.sh b/test_locally.sh index 04bc087..64754cb 100755 --- a/test_locally.sh +++ b/test_locally.sh @@ -3,7 +3,7 @@ set -e echo "Builtin server" echo "--------------------" echo "" -SERVER_TYPE=builtin ./test/http.test.lua +SERVER_TYPE=builtin ./.rocks/bin/luatest echo "Nginx server" echo "--------------------" From 886976aebf0a33523536e7a8772616f9d4be2deb Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 20:20:57 +0300 Subject: [PATCH 16/25] Add editorconfig to configure indents --- .editorconfig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e0d2af0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +[CMakeLists.txt] +indent_style = space +indent_size = 4 + +[*.cmake] +indent_style = space +indent_size = 4 + +[*.lua] +indent_style = space +indent_size = 4 + +[*.{h,c,cc}] +indent_style = tab +tab_width = 8 From caffb0e6079dffbd9a2bc643579a60b86721992a Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 20:30:54 +0300 Subject: [PATCH 17/25] Add changelog for v2 --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d25fa5..d85114c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Added options `log_requests` and `log_errors` to `route()` method for customizing request log output and error log output respectively. +### Added +- Major rewrite since version 1.x +- Ability to be used with internal http server and an nginx upstream module + (without modifying the backend code) +- Standardized request object (similar to WSGI) +- A new router with route priorities inspired by Mojolicious +- Middleware support (for e.g. for centrally handling authorization) ## [1.0.3] - 2018-06-29 ### Added From 28f3afe78a1c037ccda271e0a00f58d5ee018889 Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 20:54:01 +0300 Subject: [PATCH 18/25] Fix CMake build and add version information to release --- .gitignore | 4 ++++ CMakeLists.txt | 29 +++++++++++++++++++++++++++++ http-scm-1.rockspec | 27 ++++++--------------------- http/CMakeLists.txt | 16 +++++++++++++--- http/VERSION.lua.in | 3 +++ http/server/init.lua | 6 ++++++ 6 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 http/VERSION.lua.in diff --git a/.gitignore b/.gitignore index 9fa6709..0924cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ install_manifest.txt VERSION Testing CTestTestfile.cmake +*.snap +*.xlog +VERSION.lua +.rocks diff --git a/CMakeLists.txt b/CMakeLists.txt index ef96e35..67fa1c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,3 +29,32 @@ set_tests_properties(http PROPERTIES ENVIRONMENT "${LUA_PATH};${LUA_SOURCE_DIR}" add_custom_target(check WORKING_DIRECTORY ${PROJECT_BUILD_DIR} COMMAND ctest -V) + +## VERSION #################################################################### +############################################################################### + +execute_process( + COMMAND git describe --tags --always + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + OUTPUT_STRIP_TRAILING_WHITESPACE + OUTPUT_VARIABLE GIT_DESCRIBE + ERROR_QUIET +) + +if (NOT GIT_DESCRIBE) + set(GIT_DESCRIBE "unknown") +endif() + +configure_file ( + "${PROJECT_SOURCE_DIR}/http/VERSION.lua.in" + "${CMAKE_CURRENT_BINARY_DIR}/VERSION.lua" +) + + +## Install #################################################################### +############################################################################### + +install( + FILES ${CMAKE_CURRENT_BINARY_DIR}/VERSION.lua + DESTINATION ${TARANTOOL_INSTALL_LUADIR}/${PROJECT_NAME}/ +) diff --git a/http-scm-1.rockspec b/http-scm-1.rockspec index db27942..7d37e79 100644 --- a/http-scm-1.rockspec +++ b/http-scm-1.rockspec @@ -20,28 +20,13 @@ external_dependencies = { } } build = { - type = 'builtin', + type = 'cmake', - modules = { - ['http.lib'] = { - sources = 'http/lib.c', - incdirs = { - "$(TARANTOOL_INCDIR)" - } - }, - ['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'] = 'http/router/init.lua', - ['http.router.fs'] = 'http/router/fs.lua', - ['http.router.matching'] = 'http/router/matching.lua', - ['http.router.middleware'] = 'http/router/middleware.lua', - ['http.router.request'] = 'http/router/request.lua', - ['http.router.response'] = 'http/router/response.lua', - ['http.tsgi'] = 'http/tsgi.lua', - ['http.utils'] = 'http/utils.lua', - ['http.mime_types'] = 'http/mime_types.lua', - ['http.codes'] = 'http/codes.lua', + variables = { + version = 'scm-1', + TARANTOOL_DIR = '$(TARANTOOL_DIR)', + TARANTOOL_INSTALL_LIBDIR = '$(LIBDIR)', + TARANTOOL_INSTALL_LUADIR = '$(LUADIR)', } } diff --git a/http/CMakeLists.txt b/http/CMakeLists.txt index 441aeb7..d24e1ec 100644 --- a/http/CMakeLists.txt +++ b/http/CMakeLists.txt @@ -12,6 +12,16 @@ set_target_properties(httpd # Install install(TARGETS httpd LIBRARY DESTINATION ${TARANTOOL_INSTALL_LIBDIR}/http) -install(FILES server.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http) -install(FILES mime_types.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http) -install(FILES codes.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http) +install(FILES server/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/server/init.lua) +install(FILES server/tsgi_adapter.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/server/tsgi_adapter.lua) +install(FILES nginx_server/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/nginx_server/init.lua) +install(FILES router/init.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/init.lua) +install(FILES router/fs.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/fs.lua) +install(FILES router/matching.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/matching.lua) +install(FILES router/middleware.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/middleware.lua) +install(FILES router/request.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/request.lua) +install(FILES router/response.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/router/response.lua) +install(FILES tsgi.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/tsgi.lua) +install(FILES utils.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/utils.lua) +install(FILES mime_types.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/mime_types.lua) +install(FILES codes.lua DESTINATION ${TARANTOOL_INSTALL_LUADIR}/http/codes.lu) diff --git a/http/VERSION.lua.in b/http/VERSION.lua.in new file mode 100644 index 0000000..9baca9a --- /dev/null +++ b/http/VERSION.lua.in @@ -0,0 +1,3 @@ +#!/usr/bin/env tarantool + +return "@GIT_DESCRIBE@" diff --git a/http/server/init.lua b/http/server/init.lua index 37fa8f3..039433a 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -10,6 +10,11 @@ local errno = require('errno') local DETACHED = 101 +local ok, VERSION = pcall(require, 'http.VERSION') +if not ok then + VERSION = 'unknown' +end + --------- -- Utils --------- @@ -342,6 +347,7 @@ local new = function(host, port, options) end return { + VERSION = VERSION, DETACHED = DETACHED, new = new, } From 9b5ff23d2fc68a506cfff4c664a6a81934dedb39 Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Wed, 2 Oct 2019 21:07:42 +0300 Subject: [PATCH 19/25] Fix RPM spec --- rpm/tarantool-http.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rpm/tarantool-http.spec b/rpm/tarantool-http.spec index 45690e1..a0a1835 100644 --- a/rpm/tarantool-http.spec +++ b/rpm/tarantool-http.spec @@ -11,6 +11,7 @@ BuildRequires: gcc >= 4.5 BuildRequires: tarantool-devel >= 1.7.5.0 BuildRequires: /usr/bin/prove Requires: tarantool >= 1.7.5.0 +Requires: tarantool-checks %description This package provides a HTTP server for Tarantool. @@ -19,6 +20,7 @@ This package provides a HTTP server for Tarantool. %setup -q -n %{name}-%{version} %build +tarantoolctl rocks install luatest %cmake . -DCMAKE_BUILD_TYPE=RelWithDebInfo make %{?_smp_mflags} From 19e30497a8c5c88331ba0f4ae79f358e9518ea49 Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Thu, 3 Oct 2019 16:01:34 +0300 Subject: [PATCH 20/25] Add check dependency in debian build rules --- debian/control | 7 +++++-- debian/rules | 1 - rpm/tarantool-http.spec | 3 --- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/debian/control b/debian/control index 6e6db23..6c87625 100644 --- a/debian/control +++ b/debian/control @@ -4,7 +4,8 @@ Section: database Maintainer: Roman Tsisyk Build-Depends: debhelper (>= 9), cdbs, cmake (>= 2.8), - tarantool-dev (>= 1.7.5.0) + tarantool-dev (>= 1.7.5.0), + tarantool-checks (>= 3.0.1) Standards-Version: 3.9.6 Homepage: https://github.com/tarantool/http Vcs-Git: git://github.com/tarantool/http.git @@ -12,7 +13,9 @@ Vcs-Browser: https://github.com/tarantool/http Package: tarantool-http Architecture: i386 amd64 armhf arm64 -Depends: tarantool (>= 1.7.5.0), ${shlibs:Depends}, ${misc:Depends} +Depends: tarantool (>= 1.7.5.0), + tarantool-checks (>= 3.0.1), + ${shlibs:Depends}, ${misc:Depends} Pre-Depends: ${misc:Pre-Depends} Description: HTTP server for Tarantool This package provides a HTTP server for Tarantool. diff --git a/debian/rules b/debian/rules index d5825ab..821d511 100755 --- a/debian/rules +++ b/debian/rules @@ -2,7 +2,6 @@ DEB_CMAKE_EXTRA_FLAGS := -DCMAKE_INSTALL_LIBDIR=lib/$(DEB_HOST_MULTIARCH) \ -DCMAKE_BUILD_TYPE=RelWithDebInfo -DEB_MAKE_CHECK_TARGET := check include /usr/share/cdbs/1/rules/debhelper.mk include /usr/share/cdbs/1/class/cmake.mk diff --git a/rpm/tarantool-http.spec b/rpm/tarantool-http.spec index a0a1835..38251fc 100644 --- a/rpm/tarantool-http.spec +++ b/rpm/tarantool-http.spec @@ -24,9 +24,6 @@ tarantoolctl rocks install luatest %cmake . -DCMAKE_BUILD_TYPE=RelWithDebInfo make %{?_smp_mflags} -%check -make %{?_smp_mflags} check - %install %make_install From e3d35f7b6c85e03274e38c03cb208ec17306e465 Mon Sep 17 00:00:00 2001 From: Max Melentiev Date: Thu, 3 Oct 2019 19:17:35 +0300 Subject: [PATCH 21/25] Run test before packpack on ci --- .travis.yml | 206 +++++++++++++++++++--------------------------------- 1 file changed, 76 insertions(+), 130 deletions(-) diff --git a/.travis.yml b/.travis.yml index 68a8c2a..fa786ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,146 +2,92 @@ sudo: false language: C services: - docker +dist: xenial cache: - directories: - - $HOME/.cache + directories: + - $HOME/.cache git: - depth: 100500 + depth: 100500 env: - global: - - PRODUCT=tarantool-http + global: + - PRODUCT=tarantool-http - matrix: - - OS=el DIST=6 - - OS=el DIST=7 - - OS=fedora DIST=26 - - OS=fedora DIST=27 - - OS=fedora DIST=28 - - OS=fedora DIST=29 - - OS=ubuntu DIST=trusty - - OS=ubuntu DIST=xenial - - OS=ubuntu DIST=bionic - - OS=ubuntu DIST=cosmic - - OS=debian DIST=jessie - - OS=debian DIST=stretch +_test: &test + before_install: + - curl http://download.tarantool.org/tarantool/$TARANTOOL_VERSION/gpgkey | sudo apt-key add - + - echo "deb http://download.tarantool.org/tarantool/$TARANTOOL_VERSION/ubuntu/ xenial main" | + sudo tee /etc/apt/sources.list.d/tarantool.list + - sudo apt-get -y update + - sudo apt-get install -y tarantool tarantool-dev + - tarantoolctl rocks make + - tarantoolctl rocks install luatest 0.2.2 + script: .rocks/bin/luatest -script: - - git describe --long - - git clone https://github.com/packpack/packpack.git packpack - - packpack/packpack +_deploy: &deploy + provider: packagecloud + username: tarantool + token: ${PACKAGECLOUD_TOKEN} + dist: ${OS}/${DIST} + package_glob: build/*.{deb,rpm} + skip_cleanup: true + on: + branch: master + condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" -before_deploy: - - ls -l build/ +_packpack: &packpack + stage: deploy + script: + - git describe --long + - git clone https://github.com/packpack/packpack.git packpack + - packpack/packpack + - ls -l build/ + deploy: + # Deploy packages to PackageCloud + - <<: *deploy + repository: "1_7" + - <<: *deploy + repository: "1_9" + - <<: *deploy + repository: "1_10" + - <<: *deploy + repository: "2x" + - <<: *deploy + repository: "2_2" -deploy: - # Deploy packages to PackageCloud from master branch - - provider: packagecloud - username: tarantool - repository: "1_7" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - branch: master - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "1_9" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - branch: master - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "1_10" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - branch: master - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "2x" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - branch: master - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "2_2" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - branch: master - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - # Deploy packages to PackageCloud from tags - # see: - # * https://github.com/tarantool/tarantool/issues/3745 - # * https://github.com/travis-ci/travis-ci/issues/7780#issuecomment-302389370 - - provider: packagecloud - username: tarantool - repository: "1_7" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - tags: true - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "1_9" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - tags: true - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "1_10" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - tags: true - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "2x" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - tags: true - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" - - provider: packagecloud - username: tarantool - repository: "2_2" - token: ${PACKAGECLOUD_TOKEN} - dist: ${OS}/${DIST} - package_glob: build/*.{deb,rpm} - skip_cleanup: true - on: - tags: true - condition: -n "${OS}" && -n "${DIST}" && -n "${PACKAGECLOUD_TOKEN}" +jobs: + include: + - <<: *test + env: TARANTOOL_VERSION=1.10 + - <<: *test + env: TARANTOOL_VERSION=2x + + - <<: *packpack + env: OS=el DIST=6 + - <<: *packpack + env: OS=el DIST=7 + - <<: *packpack + env: OS=fedora DIST=26 + - <<: *packpack + env: OS=fedora DIST=27 + - <<: *packpack + env: OS=fedora DIST=28 + - <<: *packpack + env: OS=fedora DIST=29 + - <<: *packpack + env: OS=ubuntu DIST=trusty + - <<: *packpack + env: OS=ubuntu DIST=xenial + - <<: *packpack + env: OS=ubuntu DIST=bionic + - <<: *packpack + env: OS=ubuntu DIST=cosmic + - <<: *packpack + env: OS=debian DIST=jessie + - <<: *packpack + env: OS=debian DIST=stretch notifications: email: From c6bd906e0432b39b3fab193a934d96bb0263bf17 Mon Sep 17 00:00:00 2001 From: Yaroslav Dynnikov Date: Thu, 3 Oct 2019 21:04:24 +0300 Subject: [PATCH 22/25] Fix FindTarantool.cmake script See https://github.com/tarantool/modulekit/issues/2 --- cmake/FindTarantool.cmake | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/cmake/FindTarantool.cmake b/cmake/FindTarantool.cmake index c9a9ba3..a938158 100644 --- a/cmake/FindTarantool.cmake +++ b/cmake/FindTarantool.cmake @@ -9,7 +9,8 @@ macro(extract_definition name output input) endmacro() find_path(TARANTOOL_INCLUDE_DIR tarantool/module.h - HINTS ENV TARANTOOL_DIR + HINTS ${TARANTOOL_DIR} ENV TARANTOOL_DIR + PATH_SUFFIXES include ) if(TARANTOOL_INCLUDE_DIR) @@ -26,22 +27,14 @@ include(FindPackageHandleStandardArgs) find_package_handle_standard_args(TARANTOOL REQUIRED_VARS TARANTOOL_INCLUDE_DIR VERSION_VAR TARANTOOL_VERSION) if(TARANTOOL_FOUND) - set(TARANTOOL_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/tarantool") - set(TARANTOOL_INSTALL_LUADIR "${CMAKE_INSTALL_DATADIR}/tarantool") set(TARANTOOL_INCLUDE_DIRS "${TARANTOOL_INCLUDE_DIR}" - "${TARANTOOL_INCLUDE_DIR}/tarantool/") + "${TARANTOOL_INCLUDE_DIR}/tarantool/" + CACHE PATH "Include directories for Tarantool") + set(TARANTOOL_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/tarantool" + CACHE PATH "Directory for storing Lua modules written in Lua") + set(TARANTOOL_INSTALL_LUADIR "${CMAKE_INSTALL_DATADIR}/tarantool" + CACHE PATH "Directory for storing Lua modules written in C") - if (NOT "${CMAKE_INSTALL_PREFIX}" STREQUAL "/usr/local" AND - NOT "${CMAKE_INSTALL_PREFIX}" STREQUAL "${_install_prefix}") - message(WARNING "Provided CMAKE_INSTALL_PREFIX is different from " - "CMAKE_INSTALL_PREFIX of Tarantool. You might need to set " - "corrent package.path/package.cpath to load this module or " - "change your build prefix:" - "\n" - "cmake . -DCMAKE_INSTALL_PREFIX=${_install_prefix}" - "\n" - ) - endif () if (NOT TARANTOOL_FIND_QUIETLY AND NOT FIND_TARANTOOL_DETAILS) set(FIND_TARANTOOL_DETAILS ON CACHE INTERNAL "Details about TARANTOOL") message(STATUS "Tarantool LUADIR is ${TARANTOOL_INSTALL_LUADIR}") From e79db205070e0a34f7c6271e765062bed651bee8 Mon Sep 17 00:00:00 2001 From: Yaroslav Dynnikov Date: Thu, 3 Oct 2019 21:10:27 +0300 Subject: [PATCH 23/25] Fix some linter warnings * Add luacheckrc config * Don't ignore warnings blindly * Don't export checks globally --- .luacheckrc | 36 ++++++++++++++++++++++++++++++++++++ http/nginx_server/init.lua | 20 ++++++++++---------- http/router/init.lua | 8 ++++---- http/router/matching.lua | 2 +- http/server/init.lua | 8 ++++---- http/server/tsgi_adapter.lua | 4 ++-- http/tsgi.lua | 2 +- test/http_test.lua | 4 ++-- test/middleware.test.lua | 2 +- 9 files changed, 61 insertions(+), 25 deletions(-) create mode 100644 .luacheckrc diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..9320391 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,36 @@ +redefined = false +include_files = {"**/*.lua", "*.rockspec", "*.luacheckrc"} +exclude_files = {"lua_modules", ".luarocks", ".rocks", "luatest/luaunit.lua", "build"} +new_read_globals = { + 'box', + '_TARANTOOL', + 'tonumber64', + os = { + fields = { + 'environ', + } + }, + string = { + fields = { + 'split', + 'startswith', + }, + }, + table = { + fields = { + 'maxn', + 'copy', + 'new', + 'clear', + 'move', + 'foreach', + 'sort', + 'remove', + 'foreachi', + 'deepcopy', + 'getn', + 'concat', + 'insert', + }, + }, +} diff --git a/http/nginx_server/init.lua b/http/nginx_server/init.lua index c1213d3..28aaf8f 100644 --- a/http/nginx_server/init.lua +++ b/http/nginx_server/init.lua @@ -1,6 +1,6 @@ local tsgi = require('http.tsgi') -require('checks') +local checks = require('checks') local json = require('json') local log = require('log') @@ -11,7 +11,7 @@ local function convert_headername(name) end local function tsgi_input_read(self, n) - checks('table', '?number') -- luacheck: ignore + checks('table', '?number') local start = self._pos local last @@ -33,7 +33,7 @@ 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 + local uriparts = string.split(req.uri, '?') local path_info, query_string = uriparts[1], uriparts[2] local body = '' @@ -41,8 +41,8 @@ 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 hostport = box.session.peer(box.session.id()) + local hostport_parts = string.split(hostport, ':') local peer_host, peer_port = hostport_parts[1], tonumber(hostport_parts[2]) local env = { @@ -98,7 +98,7 @@ local function make_env(server, req) return env end -local function generic_entrypoint(server, req, ...) -- luacheck: ignore +local function generic_entrypoint(server, req, ...) local env = make_env(server, req, ...) local ok, resp = pcall(server.router_obj, env) @@ -150,7 +150,7 @@ local function generic_entrypoint(server, req, ...) -- luacheck: ignore end local function ngxserver_set_router(self, router) - checks('table', 'function|table') -- luacheck: ignore + checks('table', 'function|table') self.router_obj = router end @@ -160,7 +160,7 @@ local function ngxserver_router(self) end local function ngxserver_start(self) - checks('table') -- luacheck: ignore + checks('table') rawset(_G, self.tnt_method, function(...) return generic_entrypoint(self, ...) @@ -168,13 +168,13 @@ local function ngxserver_start(self) end local function ngxserver_stop(self) - checks('table') -- luacheck: ignore + checks('table') rawset(_G, self.tnt_method, nil) end local function new(opts) - checks({ -- luacheck: ignore + checks({ host = 'string', port = 'number', tnt_method = 'string', diff --git a/http/router/init.lua b/http/router/init.lua index bcf8282..db6aa63 100644 --- a/http/router/init.lua +++ b/http/router/init.lua @@ -7,7 +7,7 @@ local bless_request = require('http.router.request').bless local utils = require('http.utils') local tsgi = require('http.tsgi') -require('checks') +local checks = require('checks') local function uri_file_extension(s, default) -- cut from last dot till the end @@ -130,7 +130,7 @@ local function url_for_route(r, args, query) args = {} end local name = r.path - for i, sn in pairs(r.stash) do + for _, sn in pairs(r.stash) do local sv = args[sn] if sv == nil then sv = '' @@ -167,7 +167,7 @@ local possible_methods = { } local function use_middleware(self, handler, opts) - checks('table', 'function', { -- luacheck: ignore + checks('table', 'function', { path = '?string', method = '?string|table', name = '?string', @@ -175,7 +175,7 @@ local function use_middleware(self, handler, opts) before = '?string|table', after = '?string|table', }) - local opts = table.deepcopy(opts) -- luacheck: ignore + local opts = table.deepcopy(opts) opts.handler = handler local uuid = require('uuid') diff --git a/http/router/matching.lua b/http/router/matching.lua index 2733779..4a51ef5 100644 --- a/http/router/matching.lua +++ b/http/router/matching.lua @@ -1,7 +1,7 @@ local utils = require('http.utils') local function transform_filter(filter) - local path = filter.path -- luacheck: ignore + local path = filter.path -- route must have '/' at the begin and end if string.match(path, '.$') ~= '/' then path = path .. '/' diff --git a/http/server/init.lua b/http/server/init.lua index 039433a..3b3385b 100644 --- a/http/server/init.lua +++ b/http/server/init.lua @@ -28,8 +28,8 @@ local function normalize_headers(hdrs) end local function headers_ended(hdrs) - return string.endswith(hdrs, "\n\n") -- luacheck: ignore - or string.endswith(hdrs, "\r\n\r\n") -- luacheck: ignore + return string.endswith(hdrs, "\n\n") + or string.endswith(hdrs, "\r\n\r\n") end ---------- @@ -181,7 +181,7 @@ local function process_client(self, s, peer) end if hdrs.server == nil then - hdrs.server = utils.sprintf('Tarantool http (tarantool v%s)', _TARANTOOL) -- luacheck: ignore + hdrs.server = utils.sprintf('Tarantool http (tarantool v%s)', _TARANTOOL) end -- handle even more response headers @@ -239,7 +239,7 @@ local function process_client(self, s, peer) if not s:write(response) then break end - response = nil -- luacheck: ignore + response = nil -- luacheck: ignore 311 -- Transfer-Encoding: chunked for _, part in gen, param, state do part = tostring(part) diff --git a/http/server/tsgi_adapter.lua b/http/server/tsgi_adapter.lua index 45775b1..a7f352f 100644 --- a/http/server/tsgi_adapter.lua +++ b/http/server/tsgi_adapter.lua @@ -1,6 +1,6 @@ local tsgi = require('http.tsgi') -require('checks') +local checks = require('checks') local function tsgi_hijack(env) env[tsgi.KEY_IS_HIJACKED] = true @@ -15,7 +15,7 @@ end -- 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 + checks('table', '?number|string|table', '?number') local env = self._env local remaining = env[tsgi.KEY_REMAINING] diff --git a/http/tsgi.lua b/http/tsgi.lua index 838ea0c..7ab5c50 100644 --- a/http/tsgi.lua +++ b/http/tsgi.lua @@ -18,7 +18,7 @@ local KEY_MIDDLEWARE_CALLCHAIN_TABLE = 'tarantool.middleware.callchain_table' local function headers(env) local map = {} for name, value in pairs(env) do - if string.startswith(name, 'HEADER_') then -- luacheck: ignore + if string.startswith(name, 'HEADER_') then map[name] = value end end diff --git a/test/http_test.lua b/test/http_test.lua index 19d6832..c60ee68 100755 --- a/test/http_test.lua +++ b/test/http_test.lua @@ -29,8 +29,8 @@ package.loaded['io'].write = function(...) end g.before_all = function() - box.cfg{listen = '127.0.0.1:3301'} -- luacheck: ignore - box.schema.user.grant( -- luacheck: ignore + box.cfg{listen = '127.0.0.1:3301'} + box.schema.user.grant( 'guest', 'read,write,execute', 'universe', nil, {if_not_exists = true} ) end diff --git a/test/middleware.test.lua b/test/middleware.test.lua index 5825339..ad9c5c1 100755 --- a/test/middleware.test.lua +++ b/test/middleware.test.lua @@ -22,7 +22,7 @@ end local test = tap.test("http") test:plan(1) -test:test("ordering", function(test) -- luacheck: ignore +test:test("ordering", function(test) test:plan(7) local middleware = middleware_module.new() From b49f76025094045412f075dbf48fe76467a4085e Mon Sep 17 00:00:00 2001 From: Yaroslav Dynnikov Date: Thu, 3 Oct 2019 21:12:29 +0300 Subject: [PATCH 24/25] Remove runtime dependency on luatest --- http-scm-1.rockspec | 1 - 1 file changed, 1 deletion(-) diff --git a/http-scm-1.rockspec b/http-scm-1.rockspec index 7d37e79..c86fb19 100644 --- a/http-scm-1.rockspec +++ b/http-scm-1.rockspec @@ -12,7 +12,6 @@ description = { dependencies = { 'lua >= 5.1', 'checks >= 3.0.1', - 'luatest >= 0.2.2' } external_dependencies = { TARANTOOL = { From df6ba7dcb80d2bfda814732e9a3e4f0e953d79ce Mon Sep 17 00:00:00 2001 From: Konstantin Nazarov Date: Fri, 4 Oct 2019 17:30:57 +0300 Subject: [PATCH 25/25] Remove params to log_requests and log_errors from readme The parameterized log_requests and log_errors is not present in http v2. --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f601e21..ebbf6b7 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,8 @@ server = require('http.server').new(host, port[, { options } ]) needed). * `display_errors` - return application errors and backtraces to the client (like PHP). -* `log_requests` - log incoming requests. This parameter can receive: - - function value, supporting C-style formatting: log_requests(fmt, ...), where fmt is a format string and ... is Lua Varargs, holding arguments to be replaced in fmt. - - boolean value, where `true` choose default `log.info` and `false` disable request logs at all. - - By default uses `log.info` function for requests logging. -* `log_errors` - same as the `log_requests` option but is used for error messages logging. By default uses `log.error()` function. +* `log_errors` - log application errors using `log.error()`. +* `log_requests` - log incoming requests. ## Creating a router