diff --git a/http-scm-1.rockspec b/http-scm-1.rockspec index efd5408..1b01c44 100644 --- a/http-scm-1.rockspec +++ b/http-scm-1.rockspec @@ -31,6 +31,7 @@ build = { ['http.version'] = 'http/version.lua', ['http.mime_types'] = 'http/mime_types.lua', ['http.codes'] = 'http/codes.lua', + ['roles.http'] = 'roles/http.lua', } } diff --git a/roles/http.lua b/roles/http.lua new file mode 100644 index 0000000..ca2e905 --- /dev/null +++ b/roles/http.lua @@ -0,0 +1,146 @@ +local urilib = require("uri") +local http_server = require('http.server') + +local M = { + default_server_name = 'default', +} +local servers = {} +local name_by_id = {} +local current_id = 1 -- To match Lua 1-indexing. + +local function parse_listen(listen) + if listen == nil then + return nil, nil, "must exist" + end + if type(listen) ~= "string" and type(listen) ~= "number" then + return nil, nil, "must be a string or a number, got " .. type(listen) + end + + local host + local port + if type(listen) == "string" then + local uri, err = urilib.parse(listen) + if err ~= nil then + return nil, nil, "failed to parse URI: " .. err + end + + if uri.scheme ~= nil then + if uri.scheme == "unix" then + uri.unix = uri.path + else + return nil, nil, "URI scheme is not supported" + end + end + + if uri.login ~= nil or uri.password then + return nil, nil, "URI login and password are not supported" + end + + if uri.query ~= nil then + return nil, nil, "URI query component is not supported" + end + + if uri.unix ~= nil then + host = "unix/" + port = uri.unix + else + if uri.service == nil then + return nil, nil, "URI must contain a port" + end + + port = tonumber(uri.service) + if port == nil then + return nil, nil, "URI port must be a number" + end + if uri.host ~= nil then + host = uri.host + elseif uri.ipv4 ~= nil then + host = uri.ipv4 + elseif uri.ipv6 ~= nil then + host = uri.ipv6 + else + host = "0.0.0.0" + end + end + elseif type(listen) == "number" then + host = "0.0.0.0" + port = listen + end + + if type(port) == "number" and (port < 1 or port > 65535) then + return nil, nil, "port must be in the range [1, 65535]" + end + return host, port, nil +end + +local function apply_http(name, node) + local host, port, err = parse_listen(node.listen) + if err ~= nil then + error("failed to parse URI: " .. err) + end + + if servers[name] == nil then + local httpd = http_server.new(host, port) + httpd:start() + servers[name] = { + httpd = httpd, + routes = {}, + } + + name_by_id[current_id] = name + current_id = current_id + 1 + end +end + +M.validate = function(conf) + if conf ~= nil and type(conf) ~= "table" then + error("configuration must be a table, got " .. type(conf)) + end + conf = conf or {} + + for name, node in pairs(conf) do + if type(name) ~= 'string' then + error("name of the server must be a string") + end + + local _, _, err = parse_listen(node.listen) + if err ~= nil then + error("failed to parse http 'listen' param: " .. err) + end + end +end + +M.apply = function(conf) + -- This should be called on the role's lifecycle, but it's better to give + -- a meaningful error if something goes wrong. + M.validate(conf) + + for name, node in pairs(conf or {}) do + apply_http(name, node) + end +end + +M.stop = function() + for _, server in pairs(servers) do + server.httpd:stop() + end + servers = {} + name_by_id = {} + current_id = 1 +end + +M.get_default_server = function() + return servers[M.default_server_name] +end + +M.get_server = function(id) + if type(id) == 'string' then + return servers[id] + elseif type(id) == 'number' then + return servers[name_by_id[id]] + end + + error('expected string or number, got ' .. type(id)) +end + +return M diff --git a/test/unit/http_role_test.lua b/test/unit/http_role_test.lua new file mode 100644 index 0000000..ea7b969 --- /dev/null +++ b/test/unit/http_role_test.lua @@ -0,0 +1,182 @@ +local t = require('luatest') +local g = t.group() + +local http_role = require('roles.http') + +g.after_each(function() + http_role.stop() +end) + +local validation_cases = { + ["not_table"] = { + cfg = 42, + err = "configuration must be a table, got number", + }, + ["name_not_string"] = { + cfg = { + [42] = { + listen = 8081, + }, + }, + err = "name of the server must be a string", + }, + ["listen_not_exist"] = { + cfg = { + server = { + listen = nil, + }, + }, + err = "failed to parse http 'listen' param: must exist", + }, + ["listen_not_string_and_not_number"] = { + cfg = { + server = { + listen = {}, + }, + }, + err = "failed to parse http 'listen' param: must be a string or a number, got table", + }, + ["listen_port_too_small"] = { + cfg = { + server = { + listen = 0, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["listen_port_in_range"] = { + cfg = { + server = { + listen = 8081, + }, + }, + }, + ["listen_port_too_big"] = { + cfg = { + server = { + listen = 65536, + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["listen_uri_no_port"] = { + cfg = { + server = { + listen = "localhost", + }, + }, + err = "failed to parse http 'listen' param: URI must contain a port", + }, + ["listen_uri_port_too_small"] = { + cfg = { + server = { + listen = "localhost:0", + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["listen_uri_with_port_in_range"] = { + cfg = { + server = { + listen = "localhost:8081", + }, + }, + }, + ["listen_uri_port_too_big"] = { + cfg = { + server = { + listen = "localhost:65536", + }, + }, + err = "failed to parse http 'listen' param: port must be in the range [1, 65535]", + }, + ["listen_uri_port_not_number"] = { + cfg = { + server = { + listen = "localhost:foo", + }, + }, + err = "failed to parse http 'listen' param: URI port must be a number", + }, + ["listen_uri_non_unix_scheme"] = { + cfg = { + server = { + listen = "http://localhost:123", + }, + }, + err = "failed to parse http 'listen' param: URI scheme is not supported", + }, + ["listen_uri_login_password"] = { + cfg = { + server = { + listen = "login:password@localhost:123", + }, + }, + err = "failed to parse http 'listen' param: URI login and password are not supported", + }, + ["listen_uri_query"] = { + cfg = { + server = { + listen = "localhost:123/?foo=bar", + }, + }, + err = "failed to parse http 'listen' param: URI query component is not supported", + }, +} + +for name, case in pairs(validation_cases) do + local test_name = ('test_validate_http_%s%s'):format( + (case.err ~= nil) and 'fails_on_' or 'success_for_', + name + ) + + g[test_name] = function() + local ok, res = pcall(http_role.validate, case.cfg) + + if case.err ~= nil then + t.assert_not(ok) + t.assert_str_contains(res, case.err) + else + t.assert(ok) + t.assert_is(res, nil) + end + end +end + +g['test_get_default_without_apply'] = function() + local result = http_role.get_default_server() + t.assert_is(result, nil) +end + +g['test_get_default_no_default'] = function() + local cfg = { + not_a_default = { + listen = 13000, + }, + } + + http_role.apply(cfg) + + local result = http_role.get_default_server() + t.assert_is(result, nil) +end + +g['test_get_default'] = function() + local cfg = { + [http_role.default_server_name] = { + listen = 13001, + }, + } + + http_role.apply(cfg) + + local result = http_role.get_default_server() + t.assert(result) +end + +g['test_get_server_bad_type'] = function() + local ok, res = pcall(http_role.get_server, {}) + + t.assert_not(ok) + t.assert_str_contains(res, 'expected string or number') +end