From c7573aaf1c2617a32a1d4f2906e7e19afd852b99 Mon Sep 17 00:00:00 2001 From: DerekBum Date: Thu, 5 Sep 2024 19:24:32 +0300 Subject: [PATCH] roles: introduce role for http servers This patch adds `roles.http`. This role allows to configurate one or more HTTP servers. Those servers could be reused by several other roles. Each server is assigned with unique ID. Servers could be accessed by this ID or by their names (from the config). `get_default_server` method returns default server (or `nil`). The server is default, if it has `default_server_name` as a name. Closes #196 --- http-scm-1.rockspec | 1 + roles/http.lua | 153 ++++++++++++++++++++++++++++++ test/unit/http_role_test.lua | 178 +++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 roles/http.lua create mode 100644 test/unit/http_role_test.lua diff --git a/http-scm-1.rockspec b/http-scm-1.rockspec index efd5408..cb85e35 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', } } diff --git a/roles/http.lua b/roles/http.lua new file mode 100644 index 0000000..c3861a3 --- /dev/null +++ b/roles/http.lua @@ -0,0 +1,153 @@ +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 enabled = false + local host, port, err = parse_listen(node.listen) + if err ~= nil then + error("failed to parse URI: " .. err, 2) + end + + enabled = true + + if servers[name] == nil then + local httpd = http_server.new(host, port) + httpd:start() + servers[name] = { + httpd = httpd, + routes = {}, + } + end + + if not enabled then + servers[name].httpd:stop() + servers[name] = nil + else + 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 = {} +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..0bad638 --- /dev/null +++ b/test/unit/http_role_test.lua @@ -0,0 +1,178 @@ +local t = require('luatest') +local g = t.group() + +local http_role = require('roles.http') + +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