diff --git a/.luacheckrc b/.luacheckrc index 0be09dd..261f754 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -5,4 +5,5 @@ exclude_files = { ".rocks", "build", "http/mime_types.lua", + "test/integration/test_tap_v1.lua" } diff --git a/.travis.yml b/.travis.yml index 0686ca9..928dd94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,7 @@ _test: &test - tarantoolctl rocks install luatest 0.5.0 script: - .rocks/bin/luacheck . - - .rocks/bin/luatest -v --shuffle all + - .rocks/bin/luatest -v --shuffle all && tarantool test/integration/test_tap_v1.lua _deploy: &deploy provider: packagecloud diff --git a/CHANGELOG.md b/CHANGELOG.md index 298d787..26bb56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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] +- Add compatibility with v1. Allowed to use http-v2 as http-v1 (methods, fields, options) +- Added ability to set and get cookie without escaping +- Get rid of io module, use fio instead +- Check pointer to string before use it at escaping in templates ## [2.1.0] - 2020-01-30 ### Added diff --git a/README.md b/README.md index 59b2934..1450838 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ align="right"> * [Prerequisites](#prerequisites) * [Installation](#installation) +* [Compatibility with v1](#compatibility-with-v1) * [Usage](#usage) * [Creating a server](#creating-a-server) * [Using routes](#using-routes) @@ -65,6 +66,19 @@ You can: luarocks install https://raw.githubusercontent.com/tarantool/http/master/rockspecs/http-scm-1.rockspec --local ``` +##Compatibility with v1 +Added ability to use [http-v1 API](https://github.com/tarantool/http/tree/1.1.0). You just need to use it as was in +the first version and it will work. + +```lua +local http_server = require('http.server') +local httpd = http_server.new('localhost', 8080, {max_header_size = 9000}) -- v1 http_server option +httpd:route({ path = '/hello', method = 'GET' }, function(req) + return { status = 200, body = 'Path = ' .. req.path } -- req.path as in v1 +end) +httpd:start() +``` + ## Usage There are 4 main logical objects you can operate with: diff --git a/http/api_versions.lua b/http/api_versions.lua new file mode 100644 index 0000000..c18a99e --- /dev/null +++ b/http/api_versions.lua @@ -0,0 +1,5 @@ +return { + V1 = 1, + V2 = 2, + UNKNOWN = 3, +} diff --git a/http/server.lua b/http/server.lua new file mode 100644 index 0000000..5b15ca8 --- /dev/null +++ b/http/server.lua @@ -0,0 +1,297 @@ +local v2_server = require('http.server.init') +local v2_router = require('http.router') +local v1_server_adapter = require('http.v1_server_adapter') +local log = require('log') +local API_VERSIONS = require('http.api_versions') + +local function httpd_stop(self) + return self.__v2_server:stop() +end + +local function httpd_set_router(self, router) + return self.__v2_server:set_router(router) +end + +local function httpd_router(self) + return self.__v2_server:router() +end + +local function __set_v1_handler(self, handler) + return self.__v1_server_adapter:set_handler(handler) +end + +local function server_route(self, opts, handler) + self.__v1_server_adapter:route(opts, handler) + return self +end + +local function server_match(self, method, route) + return self.__v1_server_adapter:match(method, route) +end + +local function server_helper(self, name, handler) + self.__v1_server_adapter:helper(name, handler) + return self +end + +local function server_hook(self, name, handler) + self.__v1_server_adapter:hook(name, handler) + return self +end + +local function server_url_for(self, name, args, query) + return self.__v1_server_adapter:url_for(name, args, query) +end + +local server_fields_set = { + host = true, + port = true, + tcp_server = true, + is_run = true, +} + +local router_fields_set = { + routes = true, + iroutes = true, + helpers = true, + hooks = true, + cache = true, +} + +local v1_server_options_set = { + log_requests = true, + log_errors = true, + display_errors = true, +} + +local v1_router_options_set = { + max_header_size = true, + header_timeout = true, + app_dir = true, + charset = true, + cache_templates = true, + cache_controllers = true, + cache_static = true, +} + +local function is_v1_only_option(option_name) + return v1_router_options_set[option_name] ~= nil or option_name == 'handler' +end + +local function is_v2_only_option(option_name) + return option_name == 'router' +end + +local function get_router_options_for_v1(options) + local result = {} + for option_name, _ in pairs(v1_router_options_set) do + result[option_name] = options[option_name] + end + return result +end + +local function get_v2_server_options(options) + return { + router = options.router, + log_requests = options.log_requests, + log_errors = options.log_errors, + display_errors = options.display_errors, + } +end + +local function httpd_start(self) + if self.__api_version == API_VERSIONS.UNKNOWN then + local router = v2_router.new(get_router_options_for_v1(self.options)) + self.__api_version = API_VERSIONS.V1 + self.__v2_server:set_router(router) + end + return self.__v2_server:start() +end + +local function v1_method_decorator(method, method_name) + return function(self, ...) + if self.__api_version == API_VERSIONS.V1 then + return method(self, ...) + elseif self.__api_version == API_VERSIONS.V2 then + error( + ('":%s" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master.'): + format(method_name) + ) + elseif self.__api_version == API_VERSIONS.UNKNOWN then + log.warn("You are using v1 API") + local router = v2_router.new(get_router_options_for_v1(self.options)) + -- self.__api_version = API_VERSIONS.V1 should be below because it would try get option from router, + -- but it not created yet + self.__api_version = API_VERSIONS.V1 + self.__v2_server:set_router(router) + return method(self, ...) + end + end +end + +local function v2_method_decorator(method, method_name) + return function(self, ...) + if self.__api_version == API_VERSIONS.V1 then + error( + ('":%s" method does not supported. Use http-v1 api https://github.com/tarantool/http/tree/1.1.0.'): + format(method_name) + ) + elseif self.__api_version == API_VERSIONS.V2 then + return method(self, ...) + elseif self.__api_version == API_VERSIONS.UNKNOWN then + self.__api_version = API_VERSIONS.V2 + return method(self, ...) + end + end +end + +local function __create_server_options(self) + local options = {} + + local mt = { + __newindex = function(_, key, value) + if self.__api_version == API_VERSIONS.V1 then + if v1_server_options_set[key] ~= nil then + self.__v2_server.options[key] = value + elseif v1_router_options_set[key] ~= nil then + self.__v2_server:router().options[key] = value + elseif key == 'handler' then + self:__set_v1_handler(value) + else + options[key] = value + end + elseif self.__api_version == API_VERSIONS.V2 then + self.__v2_server.options[key] = value + elseif self.__api_version == API_VERSIONS.UNKNOWN then + if is_v1_only_option(key) then + log.warn("You are using v1 API") + self.__api_version = API_VERSIONS.V1 + options[key] = value + local router = v2_router.new(get_router_options_for_v1(options)) + self.__v2_server:set_router(router) + if key == 'handler' then + self:__set_v1_handler(value) + end + elseif is_v2_only_option(key) then + self.__api_version = API_VERSIONS.V2 + else + options[key] = value + end + end + end, + __index = function(_, key) + if self.__api_version == API_VERSIONS.V1 then + if v1_server_options_set[key] ~= nil then + return self.__v2_server.options[key] + elseif v1_router_options_set[key] ~= nil then + return self.__v2_server:router().options[key] + else + return options[key] + end + elseif self.__api_version == API_VERSIONS.V2 then + return self.__v2_server.options[key] + else + return options[key] + end + end + } + return setmetatable({}, mt) +end + +local function __set_options(self, options_from_user) + local server_options = self:__create_server_options() + for option_name, option_value in pairs(options_from_user) do + server_options[option_name] = option_value + end + self.options = server_options +end + +local function __set_server_metatable(self) + local server = {} + local server_mt = { + __newindex = function(_, key, value) + if self.__api_version == API_VERSIONS.V1 then + if server_fields_set[key] then + self.__v2_server[key] = value + elseif router_fields_set[key] then + self.__v2_server:router()[key] = value + else + server[key] = value + end + elseif self.__api_version == API_VERSIONS.V2 then + self.__v2_server[key] = value + elseif self.__api_version == API_VERSIONS.UNKNOWN then + if server_fields_set[key] then + self.__v2_server[key] = value + else + error('API version is unknown, set version via method call or option set') + end + end + end, + __index = function(_, key) + if self.__api_version == API_VERSIONS.V1 then + if server_fields_set[key] then + return self.__v2_server[key] + elseif router_fields_set[key] then + return self.__v2_server:router()[key] + else + return server[key] + end + elseif self.__api_version == API_VERSIONS.V2 then + return self.__v2_server[key] + elseif self.__api_version == API_VERSIONS.UNKNOWN then + if server_fields_set[key] then + return self.__v2_server[key] + end + error('API version is unknown, set version via method call or option set') + end + end + } + setmetatable(self, server_mt) +end + +local function new(host, port, options) + if options == nil then + options = {} + end + + local api_version = API_VERSIONS.UNKNOWN + + local server = v2_server.new(host, port, get_v2_server_options(options)) + + local obj = { + -- private + __api_version = api_version, + __v2_server = server, + __set_v1_handler = __set_v1_handler, + __v1_server_adapter = v1_server_adapter.new(server), + __create_server_options = __create_server_options, + __set_options = __set_options, + __set_server_metatable = __set_server_metatable, + + -- common + stop = httpd_stop, + start = httpd_start, + options = nil, + + -- http 2 only + set_router = v2_method_decorator(httpd_set_router, 'set_router'), + router = v2_method_decorator(httpd_router, 'router'), + + -- http1 only + route = v1_method_decorator(server_route, 'route'), + match = v1_method_decorator(server_match, 'match'), + helper = v1_method_decorator(server_helper, 'helper'), + hook = v1_method_decorator(server_hook, 'hook'), + url_for = v1_method_decorator(server_url_for, 'url_for'), + } + obj:__set_options(options) + obj:__set_server_metatable() + return obj +end + +return { + VERSION = v2_server.VERSION, + DETACHED = v2_server.DETACHED, + new = new, +} diff --git a/http/v1_server_adapter.lua b/http/v1_server_adapter.lua new file mode 100644 index 0000000..2117eff --- /dev/null +++ b/http/v1_server_adapter.lua @@ -0,0 +1,138 @@ +local lib = require('http.lib') +local utils = require('http.utils') + +local function cached_query_param(self, name) + if name == nil then + return self.query_params + end + return self.query_params[ name ] +end + +local function request_line(self) + local rstr = self.path + + 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['REQUEST_METHOD'], + rstr, + self['SERVER_PROTOCOL'] or 'HTTP/?') +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_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 cookie(self, cookiename) + if self.headers.cookie == nil then + return nil + end + for k, v in string.gmatch( + self.headers.cookie, "([^=,; \t]+)=([^,; \t]+)") do + if k == cookiename then + return utils.uri_unescape(v) + end + end + return nil +end + +local function create_http_v1_request(request) + local new_request = table.copy(request) + + local new_request_mt = table.deepcopy(getmetatable(request)) + local mt_index_table = new_request_mt.__index + + new_request.headers = request:headers() + new_request.path = request:path() + new_request.peer = request:peer() + new_request.method = request:method() + new_request.proto = request:proto() + new_request.query = request:query() + + -- redefine methods, which have conflicts with http-v1 + mt_index_table.request_line = request_line + mt_index_table.query_param = query_param + mt_index_table.cookie = cookie + + setmetatable(new_request, new_request_mt) + return new_request +end + +local function server_route(self, opts, handler) + -- todo handle handler option + local decorated_handler = handler + if type(handler) == 'function'then + decorated_handler = function(req) + local hooks = self.server:router().hooks + if hooks.before_dispatch ~= nil then + hooks.before_dispatch(self, req) + end + + local resp = handler(create_http_v1_request(req)) + + if hooks.after_dispatch ~= nil then + hooks.after_dispatch(req, resp) + end + + return resp + end + end + self.server:router():route(opts, decorated_handler) + return self.server +end + +local function server_match(self, method, route) + return self.server:router():match(method, route) +end + +local function server_helper(self, name, handler) + self.server:router():helper(name, handler) + return self.server +end + +local function server_hook(self, name, handler) + self.server:router():hook(name, handler) + return self.server +end + +local function server_url_for(self, name, args, query) + return self.server:router():url_for(name, args, query) +end + +local function set_handler(self, handler) + setmetatable(self.server:router(), {__call = handler}) +end + +local function new(server) + local server_adapter = { + server = server, + -- v1 methods + route = server_route, + match = server_match, + helper = server_helper, + hook = server_hook, + url_for = server_url_for, + set_handler = set_handler, + } + + return server_adapter +end + +return { + new = new, +} diff --git a/test/integration/test_tap_v1.lua b/test/integration/test_tap_v1.lua new file mode 100644 index 0000000..b7dfc67 --- /dev/null +++ b/test/integration/test_tap_v1.lua @@ -0,0 +1,525 @@ +#!/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 json = require('json') +local urilib = require('uri') + +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 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 }) + :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) + return httpd +end + +test:test("server url match", function(test) + test:plan(18) + local httpd = 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", + "/abc/123/122") + test:is(httpd:match('GET', '/abc/123/122').stash.def, "122", + "/abc/123/122") + test:is(httpd:match('GET', '/abc/123/122').stash.cde, "123", + "/abc/123/122") + test:is(httpd: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", + "/abc_123-122") + test:is(httpd:match('GET', '/abc-123-def').endpoint.path, "/abc-:cde-def", + "/abc-123-def") + test:is(httpd:match('GET', '/abc-123-def').stash.cde, "123", + "/abc-123-def") + test:is(httpd: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, + "-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, + "/abb*def/cde", '/abb-123-dea/1/2/3/cde') + test:is(httpd: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, + '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' }), + '/abc/cde_v/def_v', '/abc/cde_v/def_v') + test:is(httpd: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' }), + '/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() + 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') + + httpd: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 }) + 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'}, + 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'}, + 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'}, + 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'}, + 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'}, + function(tx) + return tx:render({text = 'PATCH = ' .. tx:read()}) + end ) + test:istable(r, 'add PATCH method') + + 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) + + httpd: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) + httpd: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) + httpd: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('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(); + } + 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) + + 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) + local log = require('log') + test:test("Setting log option for server instance", function(test) + test:plan(2) + 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() + + 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() + + 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() + + httpd:stop() + end) + + 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") + + 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, 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") + + 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) + + 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() + + 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() + + httpd.routes = {} -- todo with it, setmetatable to body. should be 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() + + httpd:stop() + end) + + 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: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() + + httpd:stop() + 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() + + 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() + + httpd:stop() + end) +end) + +os.exit(test:check() == true and 0 or 1) diff --git a/test/integration/v1_compatibility_test.lua b/test/integration/v1_compatibility_test.lua new file mode 100644 index 0000000..4c5c822 --- /dev/null +++ b/test/integration/v1_compatibility_test.lua @@ -0,0 +1,281 @@ +local t = require('luatest') +local g = t.group() +local http_server = require('http.server') +local http_router = require('http.router') +local helper = require('test.helper') +local http_client = require('http.client') +local API_VERSIONS = require('http.api_versions') + +g.server = nil +local function set_server(options) + g.server = http_server.new(helper.base_host, helper.base_port, options) + g.server:start() +end + +g.after_each(function() + if g.server ~= nil and g.server.__v2_server.is_run then + g.server:stop() + end +end) + +g.test_handler_option_on_server_creation = function() + set_server({handler = function() return {status = 200, body = 'Universal handler'} end}) + g.server:route({ path = '/get', method = 'GET' }, function() return { status = 200, body = 'GET' } end) + g.server:route({ path = '/post', method = 'POST' }, function() return { status = 200, body = 'POST' } end) + g.server:route({ path = '/put', method = 'PUT' }, function() return { status = 200, body = 'PUT' } end) + g.server:route({ path = '/', mathod = 'ANY'}, function() return { status = 200, body = 'ANY' } end) + + t.assert_equals(http_client.get(helper.base_uri .. 'get').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri .. 'post').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri .. 'put').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri).body, 'Universal handler') +end + +g.test_handler_option_on_option_set = function() + set_server() + g.server:route({ path = '/get', method = 'GET' }, function() return { status = 200, body = 'GET' } end) + g.server:route({ path = '/post', method = 'POST' }, function() return { status = 200, body = 'POST' } end) + g.server:route({ path = '/put', method = 'PUT' }, function() return { status = 200, body = 'PUT' } end) + g.server:route({ path = '/', mathod = 'ANY'}, function() return { status = 200, body = 'ANY' } end) + + g.server.options.handler = function() return {status = 200, body = 'Universal handler'} end + + t.assert_equals(http_client.get(helper.base_uri .. 'get').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri .. 'post').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri .. 'put').body, 'Universal handler') + t.assert_equals(http_client.get(helper.base_uri).body, 'Universal handler') +end + +g.test_set_version_by_creation = function() + local function check_version_by_creation(options, desired_version) + local httpd = http_server.new(helper.base_host, helper.base_port, options) + t.assert_equals(httpd.__api_version, desired_version) + end + + check_version_by_creation({max_header_size = 42}, API_VERSIONS.V1) + check_version_by_creation({handler = function() end}, API_VERSIONS.V1) + check_version_by_creation({app_dir = 'test'}, API_VERSIONS.V1) + check_version_by_creation({charset = 'utf-8'}, API_VERSIONS.V1) + check_version_by_creation({cache_templates = true}, API_VERSIONS.V1) + check_version_by_creation({cache_controllers = false}, API_VERSIONS.V1) + check_version_by_creation({cache_static = true}, API_VERSIONS.V1) + check_version_by_creation({log_requests = true}, API_VERSIONS.UNKNOWN) + check_version_by_creation({log_errors = true}, API_VERSIONS.UNKNOWN) + check_version_by_creation({display_errors = false}, API_VERSIONS.UNKNOWN) + check_version_by_creation({ + display_errors = false, + log_errors = true, + max_header_size = 9000 + }, API_VERSIONS.V1) +end + +g.test_set_version_by_option_set = function() + local function check_version_by_option_set(old_options, new_options, desired_version) + local httpd = http_server.new(helper.base_host, helper.base_port, old_options) + for option_name, option in pairs(new_options) do + httpd.options[option_name] = option + end + t.assert_equals(httpd.__api_version, desired_version) + end + check_version_by_option_set({}, {max_header_size = 42}, API_VERSIONS.V1) + check_version_by_option_set({log_errors = true}, {handler = function() end}, API_VERSIONS.V1) + check_version_by_option_set( + {display_errors = false, log_errors = true}, {log_requests = true}, API_VERSIONS.UNKNOWN + ) + check_version_by_option_set({}, {router = function() end}, API_VERSIONS.V2) + check_version_by_option_set({max_header_size = 42}, {router = function() end}, API_VERSIONS.V1) + check_version_by_option_set( + {display_errors = true, log_errors = true, log_requests = true}, {max_header_size = 42}, API_VERSIONS.V1 + ) +end + +g.test_set_version_by_method = function() + local function check_set_version_by_method(options, method_name, method_args, desired_version) + local httpd = http_server.new(helper.base_host, helper.base_port, options) + t.assert_equals(httpd.__api_version, API_VERSIONS.UNKNOWN) + httpd[method_name](httpd, unpack(method_args)) + t.assert_equals(httpd.__api_version, desired_version) + end + + check_set_version_by_method({}, 'set_router', {http_router.new()}, API_VERSIONS.V2) + check_set_version_by_method({log_errors = true}, 'set_router', {http_router.new()}, API_VERSIONS.V2) + check_set_version_by_method( + {display_errors = true, log_errors = true, log_requests = true}, + 'set_router', {http_router.new()}, API_VERSIONS.V2 + ) + check_set_version_by_method({}, 'router', {}, API_VERSIONS.V2) + check_set_version_by_method( + {display_errors = true, log_errors = true, log_requests = true}, 'router', {}, API_VERSIONS.V2 + ) + + check_set_version_by_method({}, 'route', {{path = '/'}, function() end}, API_VERSIONS.V1) + check_set_version_by_method({}, 'match', {'GET', '/'}, API_VERSIONS.V1) + check_set_version_by_method({}, 'helper', {'helper', function() end}, API_VERSIONS.V1) + check_set_version_by_method({}, 'hook', {'hook', function() end}, API_VERSIONS.V1) + check_set_version_by_method({}, 'url_for', {'/'}, API_VERSIONS.V1) +end + +g.test_v2_method_on_v1_api = function() + local function check_error_on_v2_method_call(method_name, method_args, error_string) + local httpd = http_server.new(helper.base_host, helper.base_port, {max_header_size = 9000}) + local ok, err = pcall(httpd[method_name], httpd, unpack(method_args)) + t.assert_not(ok) + t.assert_str_contains(err, error_string) + end + check_error_on_v2_method_call( + 'set_router', {http_router.new()}, + '":set_router" method does not supported. Use http-v1 api https://github.com/tarantool/http/tree/1.1.0' + ) + check_error_on_v2_method_call( + 'router', {}, + '":router" method does not supported. Use http-v1 api https://github.com/tarantool/http/tree/1.1.0' + ) +end + +g.test_v1_method_on_v2_api = function() + local function check_error_on_v1_method_call(method_name, method_args, error_string) + local httpd = http_server.new(helper.base_host, helper.base_port) + httpd:set_router(http_router.new()) + local ok, err = pcall(httpd[method_name], httpd, unpack(method_args)) + t.assert_not(ok) + t.assert_str_contains(err, error_string) + end + check_error_on_v1_method_call('route', {{path = '/'}, function() end}, + ':route" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master' + ) + check_error_on_v1_method_call('match', {'GET', '/'}, + ':match" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master' + ) + check_error_on_v1_method_call( + 'helper', {'helper', function() end}, + ':helper" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master' + ) + check_error_on_v1_method_call( + 'hook', {'hook', function() end}, + ':hook" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master' + ) + check_error_on_v1_method_call( + 'url_for', {'/'}, + ':url_for" method does not supported. Use http-v2 api https://github.com/tarantool/http/tree/master' + ) +end + +g.test_v1_fields_access_by_version = function() + local function check_v1_field_get_from_v1(field_name) + local httpd_v1 = http_server.new(helper.base_host, helper.base_port, {max_header_size = 42}) + httpd_v1:start() + t.assert_is_not(httpd_v1[field_name], nil, ("tried to get %s"):format(field_name)) + httpd_v1:stop() + end + + local function check_v1_field_get_from_v2(field_name, is_nil) + local httpd_v2 = http_server.new(helper.base_host, helper.base_port) + httpd_v2:set_router(http_router.new()) + httpd_v2:start() + if is_nil then + t.assert_is(httpd_v2[field_name], nil, ("tried to get %s"):format(field_name)) + else + t.assert_is_not(httpd_v2[field_name], nil, ("tried to get %s"):format(field_name)) + end + + httpd_v2:stop() + end + + local function check_v1_field_get_from_unknown(field_name, is_raise) + local httpd_unknown = http_server.new(helper.base_host, helper.base_port) + local ok, err = pcall(function() return httpd_unknown[field_name] end) + if is_raise then + t.assert_not(ok, ("tried to get %s"):format(field_name)) + t.assert_str_contains(err, "API version is unknown, set version via method call or option set") + else + t.assert(ok, ("tried to get %s"):format(field_name)) + end + end + + local function check_v1_field_set_to_v1(field_name, field_value, is_server_field) + local httpd_v1 = http_server.new(helper.base_host, helper.base_port, {max_header_size = 42}) + httpd_v1[field_name] = field_value + if is_server_field then + t.assert_equals(httpd_v1.__v2_server[field_name], field_value, ("tried to set %s"):format(field_name)) + else + t.assert_equals( + httpd_v1.__v2_server:router()[field_name], field_value, ("tried to set %s"):format(field_name) + ) + end + end + + local function check_v1_field_set_to_v2(field_name, field_value) + local httpd_v2 = http_server.new(helper.base_host, helper.base_port) + httpd_v2:set_router(http_router.new()) + httpd_v2[field_name] = field_value + t.assert_equals(httpd_v2.__v2_server[field_name], field_value, ("tried to set %s"):format(field_name)) + end + + local function check_v1_field_set_to_unknown(field_name, field_value, is_raise) + local httpd_unknown = http_server.new(helper.base_host, helper.base_port) + local ok, err = pcall(function() httpd_unknown[field_name] = field_value end) + if is_raise then + t.assert_not(ok, ("tried to set %s"):format(field_name)) + t.assert_str_contains(err, "API version is unknown, set version via method call or option set") + else + t.assert(ok, ("tried to set %s"):format(field_name)) + end + end + + local v1_only_fields = {'routes', 'iroutes', 'helpers', 'hooks', 'cache'} + local v1_v2_fields = {'host', 'port', 'tcp_server', 'is_run'} + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_get_from_v1(field_name) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_get_from_v1(field_name) + end + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_get_from_v2(field_name, true) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_get_from_v2(field_name, false) + end + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_get_from_unknown(field_name, true) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_get_from_unknown(field_name, false) + end + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_set_to_v1(field_name, 42, false) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_set_to_v1(field_name, 42, true) + end + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_set_to_v2(field_name, 42) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_set_to_v2(field_name, 42) + end + + for _, field_name in ipairs(v1_only_fields) do + check_v1_field_set_to_unknown(field_name, 42, true) + end + for _, field_name in ipairs(v1_v2_fields) do + check_v1_field_set_to_unknown(field_name, 42, false) + end +end + +g.test_set_version_on_start = function() + local function check_version_on_start(options, desired_version) + set_server(options) + t.assert_equals(g.server.__api_version, desired_version) + g.server:stop() + end + check_version_on_start({}, API_VERSIONS.V1) + check_version_on_start({display_errors = true}, API_VERSIONS.V1) + check_version_on_start({log_errors = true}, API_VERSIONS.V1) + check_version_on_start({max_header_size = 42}, API_VERSIONS.V1) +end