From 057fea47713a049172371d8126269eedfb89c587 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 4 Sep 2024 09:52:02 +0000 Subject: [PATCH] ref: parse multiple requests when redirected (fix #235) --- lua/rest-nvim/client/curl/cli.lua | 140 ++++++++++++++++++++------ lua/rest-nvim/client/curl/libcurl.lua | 26 ++++- lua/rest-nvim/request.lua | 11 +- lua/rest-nvim/ui/result.lua | 25 ++--- spec/client/curl/cli_spec.lua | 130 ++++++++++++++++++++++-- 5 files changed, 265 insertions(+), 67 deletions(-) diff --git a/lua/rest-nvim/client/curl/cli.lua b/lua/rest-nvim/client/curl/cli.lua index c606d35b..0a01798c 100644 --- a/lua/rest-nvim/client/curl/cli.lua +++ b/lua/rest-nvim/client/curl/cli.lua @@ -48,11 +48,65 @@ end ---@private local parser = {} +---@class rest.Result +---@field requests rest.Response_[] +---@field statistics table Response statistics + +---@class rest.Response_ +---@field request rest.RequestCore +---@field response rest.Response + +---@class rest.RequestCore +---@field method string +---@field url string +---@field http_version string +---@field headers table + ---@package ---@param str string ----@return rest.Response.status -function parser.parse_verbose_status(str) - local version, code, text = str:match("^(%S+) (%d+) ?(.*)") +---@return rest.RequestCore? +function parser.parse_req_info(str) + local method, url, version = str:match("^([A-Z]+) (.+) (HTTP/[%d%.]+)") + if not method then + return + end + return { + method = method, + url = url, + http_version = version, + headers = {}, + } +end + +function parser.parse_req_header(str, requests) + local info = parser.parse_req_info(str) + if info then + table.insert(requests, { + request = info, + response = {}, + }) + return + end + local req = requests[#requests].request + local key, value = parser.parse_header_pair(str) + if key then + if not req.headers[key] then + req.headers[key] = {} + end + table.insert(req.headers[key], value) + else + log.error("Error while parsing verbose curl output header:", str) + end +end + +---@package +---@param str string +---@return rest.Response.status? +function parser.parse_res_status(str) + local version, code, text = str:match("^(HTTP/[%d%.]+) (%d+) ?(.*)") + if not version then + return + end return { version = version, code = tonumber(code), @@ -73,22 +127,49 @@ function parser.parse_header_pair(str) end ---@package +---@param str string +function parser.parse_res_header(str, requests) + local status = parser.parse_res_status(str) + if status then + -- reset response object + requests[#requests].response = { + status = status, + headers = {}, + } + return + end + local res = requests[#requests].response + local key, value = parser.parse_header_pair(str) + if key then + if not res.headers[key] then + res.headers[key] = {} + end + table.insert(res.headers[key], value) + else + log.error("Error while parsing verbose curl output header:", str) + end +end + +---@package +---@param idx number ---@param line string ----@return {prefix:string,str:string?}|nil -function parser.parse_verbose_line(line) +---@return {idx:number,prefix:string,str:string?}|nil +function parser.lex_verbose_line(idx, line) local prefix, str = line:match("(.) ?(.*)") + log.debug("line", idx, line) if not prefix then - log.error("Error while parsing verbose curl output:\n" .. line) + log.error(("Error while parsing verbose curl output at line %d:"):format(idx), line) return end return { + idx = idx, prefix = prefix, str = str, } end local _VERBOSE_PREFIX_META = "*" -local _VERBOSE_PREFIX_REQ_HEADER = ">" +local VERBOSE_PREFIX_REQ_HEADER = ">" local _VERBOSE_PREFIX_REQ_BODY = "}" local VERBOSE_PREFIX_RES_HEADER = "<" -- NOTE: we don't parse response body with trace output. response body will @@ -114,35 +195,32 @@ function parser.parse_stat_pair(str) end ---@param lines string[] ----@return rest.Response +---@return rest.Result function parser.parse_verbose(lines) - local response = { - headers = {}, + ---@type rest.Result + local result = { + ---@type rest.Response_[] + requests = {}, + ---@type table Response statistics statistics = {}, } - vim.iter(lines):map(parser.parse_verbose_line):each(function(ln) - if ln.prefix == VERBOSE_PREFIX_RES_HEADER then - if not response.status then - -- response status - response.status = parser.parse_verbose_status(ln.str) - else - -- response header - local key, value = parser.parse_header_pair(ln.str) - if key then - if not response.headers[key] then - response.headers[key] = {} - end - table.insert(response.headers[key], value) - end - end + -- ignore last newline + if lines[#lines] == "" then + lines[#lines] = nil + end + vim.iter(lines):enumerate():map(parser.lex_verbose_line):each(function(ln) + if ln.prefix == VERBOSE_PREFIX_REQ_HEADER then + parser.parse_req_header(ln.str, result.requests) + elseif ln.prefix == VERBOSE_PREFIX_RES_HEADER then + parser.parse_res_header(ln.str, result.requests) elseif ln.prefix == VERBOSE_PREFIX_STAT then local key, value = parser.parse_stat_pair(ln.str) if key then - response.statistics[key] = value + result.statistics[key] = value end end end) - return response + return result end --- Builder --- @@ -332,7 +410,7 @@ end ---Send request via `curl` cli ---@param request rest.Request Request data to be passed to cURL ----@return nio.control.Future future Future containing rest.Response +---@return nio.control.Future future Future containing rest.Result function curl.request(request) local progress_handle = progress.handle.create({ title = "Executing", @@ -354,9 +432,9 @@ function curl.request(request) progress_handle:report({ message = "Parsing response...", }) - local response = parser.parse_verbose(vim.split(sc.stderr, "\n")) - response.body = sc.stdout - future.set(response) + local result = parser.parse_verbose(vim.split(sc.stderr, "\n")) + result.requests[#result.requests].response.body = sc.stdout + future.set(result) progress_handle:report({ message = "Success", }) diff --git a/lua/rest-nvim/client/curl/libcurl.lua b/lua/rest-nvim/client/curl/libcurl.lua index 767da14c..a41b9605 100644 --- a/lua/rest-nvim/client/curl/libcurl.lua +++ b/lua/rest-nvim/client/curl/libcurl.lua @@ -56,7 +56,7 @@ end ---Execute an HTTP request using cURL ---return return nil if execution failed ---@param req rest.Request Request data to be passed to cURL ----@return rest.Response? info The request information (url, method, headers, body, etc) +---@return rest.Result? function client.request(req) logger.info("sending request to: " .. req.url) if not found_curl then @@ -162,8 +162,13 @@ function client.request(req) logger.error("Something went wrong when making the request with cURL:\n" .. curl_utils.curl_error(err:no())) return end + local status_str = table.remove(res_raw_headers, 1) ---@diagnostic disable-next-line: invisible - local status = curl_cli.parser.parse_verbose_status(table.remove(res_raw_headers, 1)) + local status = curl_cli.parser.parse_res_status(status_str) + if not status then + logger.error("can't parse response status:", status_str) + return + end local res_headers = {} for _, header in ipairs(res_raw_headers) do ---@diagnostic disable-next-line: invisible @@ -180,12 +185,25 @@ function client.request(req) status = status, headers = res_headers, body = table.concat(res_result), - statistics = get_stats(req_, {}), } logger.debug(vim.inspect(res.headers)) res.status.text = vim.trim(res.status.text) req_:close() - return res + ---@type rest.Result + return { + requests = { + { + request = { + method = req.method, + url = req.url, + http_version = req.http_version or "HTTP/1.1", + headers = req.headers, + }, + response = res, + }, + }, + statistics = get_stats(req_, {}), + } end return client diff --git a/lua/rest-nvim/request.lua b/lua/rest-nvim/request.lua index dd9fc1e1..06550184 100644 --- a/lua/rest-nvim/request.lua +++ b/lua/rest-nvim/request.lua @@ -31,7 +31,6 @@ local Context = require("rest-nvim.context").Context ---@field status rest.Response.status Status information from response ---@field body string? Raw response body ---@field headers table Response headers ----@field statistics table Response statistics ---@class rest.Response.status ---@field code number @@ -69,9 +68,11 @@ local function run_request(req) vim.notify("request failed. See `:Rest logs` for more info", vim.log.levels.ERROR, { title = "rest.nvim" }) return end - ---@cast res rest.Response + ---@cast res rest.Result logger.info("request success") + local last_response = res.requests[#res.requests].response + -- run request handler scripts vim.iter(req.handlers):each(function(f) f(res) @@ -79,7 +80,7 @@ local function run_request(req) logger.info("handler done") _G.rest_request = req - _G.rest_response = res + _G.rest_response = last_response vim.api.nvim_exec_autocmds("User", { pattern = { "RestResponse", "RestResponsePre" }, }) @@ -87,10 +88,10 @@ local function run_request(req) _G.rest_response = nil -- update cookie jar - jar.update_jar(req.url, res) + jar.update_jar(req.url, last_response) -- update result UI - ui.update({ response = res }) + ui.update({ response = last_response, statistics = res.statistics }) end) -- FIXME(boltless): return future to pass the command state end diff --git a/lua/rest-nvim/ui/result.lua b/lua/rest-nvim/ui/result.lua index c9147034..4e57d40d 100644 --- a/lua/rest-nvim/ui/result.lua +++ b/lua/rest-nvim/ui/result.lua @@ -34,6 +34,8 @@ local data = { request = nil, ---@type rest.Response? response = nil, + ---@type table? + statistics = nil, } ---@param req rest.Request @@ -76,8 +78,9 @@ local panes = { ) ) local content_type = data.response.headers["content-type"] + table.insert(lines, "") + table.insert(lines, "# @_RES") local body = vim.split(data.response.body, "\n") - local body_meta = {} if content_type then local base_type, res_type = content_type[1]:match("(.*)/([^;]+)") if base_type == "image" then @@ -86,19 +89,9 @@ local panes = { body = { "Binary answer" } elseif config.response.hooks.format then -- NOTE: format hook runs here because it should be done last. - local ok - body, ok = utils.gq_lines(body, res_type) - if ok then - table.insert(body_meta, "formatted") - end + body = utils.gq_lines(body, res_type) end end - local meta_str = "" - if #body_meta > 0 then - meta_str = " (" .. table.concat(body_meta, ",") .. ")" - end - table.insert(lines, "") - table.insert(lines, "# @_RES" .. meta_str) vim.list_extend(lines, body) table.insert(lines, "# @_END") else @@ -166,14 +159,14 @@ local panes = { return end local lines = {} - if not data.response.statistics then + if not data.statistics then set_lines(self.bufnr, { "No Statistics" }) return end syntax_highlight(self.bufnr, "http_stat") for _, style in ipairs(config.clients.curl.statistics) do local title = style.title or style.id - local value = data.response.statistics[style.id] or "" + local value = data.statistics[style.id] or "" table.insert(lines, ("%s: %s"):format(title, value)) end set_lines(self.bufnr, lines) @@ -191,7 +184,7 @@ winbar = winbar .. "%#RestText#Press %#Keyword#?%#RestText# for help%#Normal# " ---@return string function ui.stat_winbar() local content = "" - if not data.response then + if not data.statistics then return "Loading...%#Normal#" end for _, style in ipairs(config.clients.curl.statistics) do @@ -200,7 +193,7 @@ function ui.stat_winbar() if title ~= "" then title = title .. ": " end - local value = data.response.statistics[style.id] or "" + local value = data.statistics[style.id] or "" content = content .. " %#RestText#" .. title .. "%#Normal#" .. value end end diff --git a/spec/client/curl/cli_spec.lua b/spec/client/curl/cli_spec.lua index 2d28574f..02bdb97a 100644 --- a/spec/client/curl/cli_spec.lua +++ b/spec/client/curl/cli_spec.lua @@ -146,20 +146,128 @@ describe("curl cli response parser", function() "{ [15 bytes data]", "* Connection #0 to host localhost left intact", } - local response = parser.parse_verbose(stdin) + local result = parser.parse_verbose(stdin) assert.same({ - status = { - version = "HTTP/1.1", - code = 200, - text = "OK", + request = { + method = "GET", + url = "/", + http_version = "HTTP/1.1", + headers = { + host = { "localhost:8000" }, + ["user-agent"] = { "curl/7.81.0" }, + accept = { "*/*" }, + }, }, - statistics = {}, - headers = { - ["content-type"] = { "text/plain" }, - date = { "Tue, 06 Aug 2024 12:22:44 GMT" }, - ["content-length"] = { "15" }, + response = { + status = { + version = "HTTP/1.1", + code = 200, + text = "OK", + }, + headers = { + ["content-type"] = { "text/plain" }, + date = { "Tue, 06 Aug 2024 12:22:44 GMT" }, + ["content-length"] = { "15" }, + }, + }, + }, result.requests[1]) + end) + it("from redirected request", function() + local stdin = { + "* Trying 127.0.0.1:8000...", + "* Connected to localhost (127.0.0.1) port 8000 (#0)", + -- first request + "> GET /api/v1 HTTP/1.1", + "> Host: localhost:8000", + "> User-Agent: curl/7.81.0", + "> Accept: */*", + ">", + -- + "* Mark bundle as not supporting multiuse", + -- first response + "< HTTP/1.1 301 Moved Permanently", + "< Content-Type: text/html; charset=utf-8", + "< Location: /api/v1/", + "< Date: Tue, 03 Sep 2024 17:28:35 GMT", + "< Content-Length: 43", + "<", + -- + "* Ignoring the response-body", + "{ [43 bytes data]", + "* Connection #0 to host localhost left intact", + "* Issue another request to this URL: 'http://localhost:8000/api/v1/'", + "* Found bundle for host localhost: 0xaaaac8b6bca0 [serially]", + "* Can not multiplex, even if we wanted to!", + "* Re-using existing connection! (#0) with host localhost", + "* Connected to localhost (127.0.0.1) port 8000 (#0)", + -- second request + "> GET /api/v1/ HTTP/1.1", + "> Host: localhost:8000", + "> User-Agent: curl/7.81.0", + "> Accept: */*", + ">", + -- + "* Mark bundle as not supporting multiuse", + -- second response + "< HTTP/1.1 200 OK", + "< Date: Tue, 03 Sep 2024 17:28:35 GMT", + "< Content-Length: 24", + "< Content-Type: text/plain; charset=utf-8", + "<", + "{ [24 bytes data]", + -- + "* Connection #0 to host localhost left intact", + } + local result = parser.parse_verbose(stdin) + assert.same({ + request = { + method = "GET", + url = "/api/v1", + http_version = "HTTP/1.1", + headers = { + host = { "localhost:8000" }, + ["user-agent"] = { "curl/7.81.0" }, + accept = { "*/*" }, + }, + }, + response = { + status = { + version = "HTTP/1.1", + code = 301, + text = "Moved Permanently", + }, + headers = { + ["content-type"] = { "text/html; charset=utf-8" }, + date = { "Tue, 03 Sep 2024 17:28:35 GMT" }, + ["content-length"] = { "43" }, + location = { "/api/v1/" }, + }, + }, + }, result.requests[1]) + assert.same({ + request = { + method = "GET", + url = "/api/v1/", + http_version = "HTTP/1.1", + headers = { + host = { "localhost:8000" }, + ["user-agent"] = { "curl/7.81.0" }, + accept = { "*/*" }, + }, + }, + response = { + status = { + version = "HTTP/1.1", + code = 200, + text = "OK", + }, + headers = { + ["content-type"] = { "text/plain; charset=utf-8" }, + date = { "Tue, 03 Sep 2024 17:28:35 GMT" }, + ["content-length"] = { "24" }, + }, }, - }, response) + }, result.requests[2]) end) end)