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 = [[ + +
+<%= i %> | +<%= v %> | +