diff --git a/.luarc.json b/.luarc.json index da8138e..8a0949e 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,4 +1,4 @@ { - "diagnostics.globals": ["describe", "it"], - "diagnostics.disable": ["undefined-field"] + "diagnostics.globals": ["describe", "it", "vim"], + "diagnostics.disable": [] } diff --git a/README.md b/README.md index 1256b89..9dbc78f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ > [!IMPORTANT] > Version 2 is in development! We're working on making hurl.nvim even better. Try out the [canary branch](https://github.com/jellydn/hurl.nvim/pull/207) and share your feedback: +> > ```bash > { > "jellydn/hurl.nvim", @@ -13,7 +14,6 @@ > } > ``` - [![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors-) @@ -46,8 +46,16 @@ Add the following configuration to your Neovim setup with [lazy.nvim](https://gi dependencies = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", - "nvim-treesitter/nvim-treesitter" - }, + "nvim-treesitter/nvim-treesitter", + -- Optional, for markdown rendering with render-markdown.nvim + { + 'MeanderingProgrammer/render-markdown.nvim', + opts = { + file_types = { "markdown" }, + }, + ft = { "markdown" }, + }, + } ft = "hurl", opts = { -- Show debugging info @@ -86,6 +94,7 @@ Add the following configuration to your Neovim setup with [lazy.nvim](https://gi { "tE", "HurlRunnerToEnd", desc = "Run Api request from current entry to end" }, { "tm", "HurlToggleMode", desc = "Hurl Toggle Mode" }, { "tv", "HurlVerbose", desc = "Run Api in verbose mode" }, + { "tV", "HurlVeryVerbose", desc = "Run Api in very verbose mode" }, -- Run Hurl request in visual mode { "h", ":HurlRunner", desc = "Hurl Runner", mode = "v" }, }, @@ -211,9 +220,9 @@ Place your cursor on a HURL entry and press `a` or run `HurlRunnerAt` co #### Verbose mode -Run `HurlVerbose` command to execute the request in verbose mode. The response will be displayed in QuickFix window. This is useful for debugging purposes or getting the curl command from hurl file. +Run `HurlVerbose` command to execute the request in verbose mode. -[![Run at current line in verbose mode](https://i.gyazo.com/7d0f709e2db53f8c9e05655347f11bc9.gif)](https://gyazo.com/7d0f709e2db53f8c9e05655347f11bc9) +[![Run in verbose mode](https://i.gyazo.com/6136ea63c0a3d0e1293e1fd2c724973a.gif)](https://gyazo.com/6136ea63c0a3d0e1293e1fd2c724973a) ### Run to entry @@ -373,17 +382,6 @@ Adjust the settings as per your needs to enhance your development experience wit - Logs are saved at `~/.local/state/nvim/hurl.nvim.log` on macOS. -> [!TIP] -> Split mode with Edgy - -- `hurl.nvim` can be used with [edgy.nvim](https://github.com/folke/edgy.nvim) to manage layout when using the split mode. - -```lua -right = { - { title = "Hurl Nvim", size = { width = 0.5 }, ft = "hurl-nvim" }, -} -``` - > [!TIP] > Syntax Highlighting in Stable Neovim diff --git a/example/dogs.hurl b/example/dogs.hurl index 544ce14..a4b2c1c 100644 --- a/example/dogs.hurl +++ b/example/dogs.hurl @@ -5,6 +5,7 @@ HTTP 200 [Captures] id: jsonpath "$.data[0].id" +name: jsonpath "$.data[0].attributes.name" GET https://dogapi.dog/api/v2/breeds/{{id}} diff --git a/example/example.hurl b/example/example.hurl index 684aa40..2318056 100644 --- a/example/example.hurl +++ b/example/example.hurl @@ -8,9 +8,9 @@ header "server" contains "MangaDex" GET https://google.com -HTTP 301 +HTTP 302 [Asserts] -xpath "string(//title)" == "301 Moved" +xpath "string(//title)" == "302 Moved" GET https://www.google.com diff --git a/example/todo.hurl b/example/todo.hurl new file mode 100644 index 0000000..d48700e --- /dev/null +++ b/example/todo.hurl @@ -0,0 +1,25 @@ +# Get all todos +GET https://wattpm-demo.fly.dev/express/api/todo + +HTTP 200 +[Asserts] +jsonpath "$.status" == "success" +header "server" contains "Fly" + + +# Throw error if text is not provided +POST https://wattpm-demo.fly.dev/express/api/todo +Content-Type: application/json +{} +HTTP 400 +[Asserts] +jsonpath "$.status" == "error" +jsonpath "$.message" == "Text is required" + +# Create a new todo +POST https://wattpm-demo.fly.dev/express/api/todo +Content-Type: application/json +{ + "text": "Call Express API from hurl.nvim at {{now}}" +} +HTTP 201 diff --git a/lua/hurl/codelens.lua b/lua/hurl/codelens.lua new file mode 100644 index 0000000..82b1576 --- /dev/null +++ b/lua/hurl/codelens.lua @@ -0,0 +1,56 @@ +local M = {} + +-- Add virtual text for Hurl entries by finding HTTP verbs +function M.add_virtual_text_for_hurl_entries() + local bufnr = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Create a dedicated namespace for Hurl entry markers + local ns_id = vim.api.nvim_create_namespace('hurl_entries') + + -- Clear existing virtual text before adding new ones + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + + -- Define all supported HTTP methods + local http_methods = { + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'HEAD', + 'OPTIONS', + } + + local entry_number = 1 + for i, line in ipairs(lines) do + -- Match any HTTP method, ignoring preceding whitespace and comments + local method = line:match('^%s*#?%s*([A-Z]+)') + if method and vim.tbl_contains(http_methods, method) then + vim.api.nvim_buf_set_virtual_text(bufnr, ns_id, i - 1, { + { 'Entry #' .. entry_number, 'Comment' }, + }, {}) + entry_number = entry_number + 1 + end + end +end + +-- Setup function to attach buffer autocmd +function M.setup() + local group = vim.api.nvim_create_augroup('HurlEntryMarkers', { clear = true }) + + -- Handle buffer events + vim.api.nvim_create_autocmd({ + 'BufEnter', + 'TextChanged', + 'InsertLeave', + }, { + group = group, + pattern = '*.hurl', + callback = function() + M.add_virtual_text_for_hurl_entries() + end, + }) +end + +return M diff --git a/lua/hurl/health.lua b/lua/hurl/health.lua index 2c4abfa..75a1400 100644 --- a/lua/hurl/health.lua +++ b/lua/hurl/health.lua @@ -31,7 +31,22 @@ M.check = function() ) end - -- TODO: Add check for hurl version, e.g: > 4.3.0 to use new features + -- Check for hurl version > 4.3.0 + local hurl_version_output = vim.fn.system('hurl --version') + local hurl_version = hurl_version_output:match('%d+%.%d+%.%d+') + + if hurl_version then + local major, minor, patch = hurl_version:match('(%d+)%.(%d+)%.(%d+)') + major, minor, patch = tonumber(major), tonumber(minor), tonumber(patch) + + if major > 4 or (major == 4 and (minor > 3 or (minor == 3 and patch > 0))) then + ok('hurl version > 4.3.0 found') + else + error('hurl version <= 4.3.0 found') + end + else + error('Unable to determine hurl version') + end ok('hurl.nvim: All good!') end diff --git a/lua/hurl/history.lua b/lua/hurl/history.lua index 4da3205..fc14246 100644 --- a/lua/hurl/history.lua +++ b/lua/hurl/history.lua @@ -1,38 +1,60 @@ local utils = require('hurl.utils') local M = {} ---- Show the last request in the history ----@param response table -M.show = function(response) - local container = require('hurl.' .. _HURL_GLOBAL_CONFIG.mode) - if not response.headers then - -- Do not show anything if there is no response - return +-- Store the last 10 responses +local response_history = {} +local max_history_size = 10 + +-- Add a response to the history +local function add_to_history(response) + table.insert(response_history, 1, response) + if #response_history > max_history_size then + table.remove(response_history) end +end - local content_type = response.headers['content-type'] - or response.headers['Content-Type'] - or response.headers['Content-type'] - or 'unknown' +-- Show the last response +function M.show_last_response() + if #response_history == 0 then + utils.notify('No response history available', vim.log.levels.INFO) + return + end - utils.log_info('Detected content type: ' .. content_type) - if response.headers['content-length'] == '0' then - utils.log_info('hurl: empty response') - utils.notify('hurl: empty response', vim.log.levels.INFO) + local last_response = response_history[1] + local ok, display = pcall(require, 'hurl.' .. (_HURL_GLOBAL_CONFIG.mode or 'split')) + if not ok then + utils.notify('Failed to load display module: ' .. display, vim.log.levels.ERROR) + return end - if utils.is_json_response(content_type) then - container.show(response, 'json') + + display.show(last_response, last_response.display_type or 'text') +end + +-- Function to be called after each successful request +function M.update_history(response) + -- Ensure response_time is a number + response.response_time = tonumber(response.response_time) or '-' + + -- Determine the content type and set display_type + local content_type = response.headers['Content-Type'] + or response.headers['content-type'] + or 'text/plain' + + if content_type:find('json') then + response.display_type = 'json' + elseif content_type:find('html') then + response.display_type = 'html' + elseif content_type:find('xml') then + response.display_type = 'xml' else - if utils.is_html_response(content_type) then - container.show(response, 'html') - else - if utils.is_xml_response(content_type) then - container.show(response, 'xml') - else - container.show(response, 'text') - end - end + response.display_type = 'text' end + + add_to_history(response) +end + +function M.get_last_response() + return response_history[1] end return M diff --git a/lua/hurl/init.lua b/lua/hurl/init.lua index 826abb6..c322632 100644 --- a/lua/hurl/init.lua +++ b/lua/hurl/init.lua @@ -53,6 +53,8 @@ local default_config = { }, -- File root directory for uploading files -- file_root = vim.fn.getcwd(), + -- Save capture as global variable + save_captures_as_globals = true, } --- Global configuration for entire plugin, easy to access from anywhere _HURL_GLOBAL_CONFIG = default_config diff --git a/lua/hurl/lib/hurl_parser.lua b/lua/hurl/lib/hurl_parser.lua new file mode 100644 index 0000000..716eb74 --- /dev/null +++ b/lua/hurl/lib/hurl_parser.lua @@ -0,0 +1,132 @@ +local M = {} + +local function trim(s) + return s:match('^%s*(.-)%s*$') +end + +function M.parse_hurl_output(stderr, stdout) + local lines = {} + for line in stderr:gmatch('[^\r\n]+') do + table.insert(lines, line) + end + + local entries = {} + local currentEntry = nil + local isResponseHeader = false + local isTimings = false + local isCaptures = false + local isError = false + + for i, line in ipairs(lines) do + if line:find('^%* Executing entry') then + if currentEntry then + table.insert(entries, currentEntry) + end + currentEntry = { + requestMethod = '', + requestUrl = '', + requestHeaders = {}, + response = { + status = '', + headers = {}, + body = '', + }, + timings = {}, + captures = {}, + error = nil, + } + isResponseHeader = false + isTimings = false + isCaptures = false + isError = false + elseif line:find('^%* Request:') then + -- Check the next line for the actual request details + if i < #lines then + local nextLine = lines[i + 1] + local method, url = nextLine:match('^%* (%w+)%s+([^%s].*)$') + if method and url and currentEntry then + currentEntry.requestMethod = method + currentEntry.requestUrl = url:gsub('%%20', ' '):gsub('%%(%x%x)', function(h) + return string.char(tonumber(h, 16)) + end) + end + end + elseif line:find('^error:') then + isError = true + currentEntry.error = line:sub(8) -- Remove "error: " prefix + elseif isError then + -- Append additional error information + currentEntry.error = currentEntry.error .. '\n' .. line + elseif line:find('^%* curl') then + if currentEntry then + currentEntry.curlCommand = trim(line:sub(3)) + end + elseif line:find('^> ') then + local key, value = line:sub(3):match('([^:]+):%s*(.+)') + if key and value and currentEntry then + currentEntry.requestHeaders[trim(key)] = trim(value) + end + elseif line:find('^< ') then + isResponseHeader = true + if line:find('^< HTTP/') then + if currentEntry then + currentEntry.response.status = line:sub(3) + end + else + local key, value = line:sub(3):match('([^:]+):%s*(.+)') + if key and value and currentEntry then + currentEntry.response.headers[trim(key)] = trim(value) + end + end + elseif line:find('^%* Response body:') then + -- Skip the response body section + elseif line:find('^%* Timings:') then + isTimings = true + isCaptures = false + elseif line:find('^%* Captures:') then + isTimings = false + isCaptures = true + elseif isTimings and line:find('^%* ') then + local key, value = line:sub(3):match('([^:]+):%s*(.+)') + if currentEntry and key and value then + currentEntry.timings[key] = value + end + elseif isCaptures and line:find('^%* ') then + local key, value = line:sub(3):match('([^:]+):%s*(.+)') + if currentEntry and key and value then + currentEntry.captures[key] = value + end + end + end + + if currentEntry then + table.insert(entries, currentEntry) + end + + for _, entry in ipairs(entries) do + if not entry.error then + entry.response.body = entry.response.body .. trim(stdout) + end + end + + local successful = 0 + local failed = 0 + for _, entry in ipairs(entries) do + if entry.error then + failed = failed + 1 + else + successful = successful + 1 + end + end + + return { + entries = entries, + metadata = { + total = #entries, + successful = successful, + failed = failed, + }, + } +end + +return M diff --git a/lua/hurl/lib/hurl_runner.lua b/lua/hurl/lib/hurl_runner.lua new file mode 100644 index 0000000..5b1b689 --- /dev/null +++ b/lua/hurl/lib/hurl_runner.lua @@ -0,0 +1,421 @@ +local hurl_parser = require('hurl.lib.hurl_parser') +local utils = require('hurl.utils') +local spinner = require('hurl.spinner') +local history = require('hurl.history') + +local M = {} +M.is_running = false +M.start_time = nil +M.response = {} + +--- Log the Hurl command +---@param cmd table +local function save_last_hurl_command(cmd) + local command_str = table.concat(cmd, ' ') + _HURL_GLOBAL_CONFIG.last_hurl_command = command_str + utils.log_info('hurl: running command: ' .. command_str) +end + +--- Save captures as global variables +---@param captures table +local function save_captures_as_globals(captures) + if _HURL_GLOBAL_CONFIG.save_captures_as_globals and captures then + for key, value in pairs(captures) do + _HURL_GLOBAL_CONFIG.global_vars = _HURL_GLOBAL_CONFIG.global_vars or {} + -- Wrap the value in quotes if it contains spaces + local formatted_value = value:find('%s') and ('"' .. value .. '"') or value + _HURL_GLOBAL_CONFIG.global_vars[key] = formatted_value + utils.log_info( + string.format('hurl: saved capture %s = %s as global variable', key, formatted_value) + ) + end + end +end + +--- Run the hurl command in verbose or very verbose mode +---@param filePath string +---@param fromEntry integer +---@param toEntry integer +---@param isVeryVerbose boolean +---@param additionalArgs table +function M.run_hurl_verbose(filePath, fromEntry, toEntry, isVeryVerbose, additionalArgs) + local args = { filePath } + table.insert(args, isVeryVerbose and '--very-verbose' or '--verbose') + if fromEntry then + table.insert(args, '--from-entry') + table.insert(args, tostring(fromEntry)) + end + if toEntry then + table.insert(args, '--to-entry') + table.insert(args, tostring(toEntry)) + end + + -- Add additional arguments (like --json) + if additionalArgs then + vim.list_extend(args, additionalArgs) + end + + -- Inject environment variables from .env files + local env_files = _HURL_GLOBAL_CONFIG.find_env_files_in_folders() + for _, env in ipairs(env_files) do + utils.log_info( + 'hurl: looking for ' .. vim.inspect(_HURL_GLOBAL_CONFIG.env_file) .. ' in ' .. env.path + ) + if vim.fn.filereadable(env.path) == 1 then + utils.log_info('hurl: found env file in ' .. env.path) + table.insert(args, '--variables-file') + table.insert(args, env.path) + end + end + + -- Inject global variables into the command + if _HURL_GLOBAL_CONFIG.global_vars then + for var_name, var_value in pairs(_HURL_GLOBAL_CONFIG.global_vars) do + table.insert(args, '--variable') + table.insert(args, var_name .. '=' .. var_value) + end + end + + -- Inject fixture variables into the command + if _HURL_GLOBAL_CONFIG.fixture_vars then + for _, fixture in pairs(_HURL_GLOBAL_CONFIG.fixture_vars) do + table.insert(args, '--variable') + table.insert(args, fixture.name .. '=' .. fixture.callback()) + end + end + + -- Add file root for uploads + local file_root = _HURL_GLOBAL_CONFIG.file_root or vim.fn.getcwd() + table.insert(args, '--file-root') + table.insert(args, file_root) + + local stdout_data = '' + local stderr_data = '' + + -- Log the Hurl command + local hurl_command = 'hurl ' .. table.concat(args, ' ') + save_last_hurl_command({ 'hurl', unpack(args) }) + + -- Always use split mode for verbose commands + local display = require('hurl.split') + + -- Clear the display and show processing message + display.clear() + spinner.show() + + local start_time = vim.loop.hrtime() + + -- Start the Hurl command asynchronously + vim.fn.jobstart({ 'hurl', unpack(args) }, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + stdout_data = stdout_data .. table.concat(data, '\n') + end + end, + on_stderr = function(_, data) + if data then + stderr_data = stderr_data .. table.concat(data, '\n') + end + end, + on_exit = function(_, code) + -- Hide the spinner + spinner.hide() + + local end_time = vim.loop.hrtime() + local response_time = (end_time - start_time) / 1e6 -- Convert to milliseconds + + if code ~= 0 then + utils.log_info('Hurl command failed with code ' .. code) + display.show({ body = '# Hurl Error\n\n```sh\n' .. stderr_data .. '\n```' }, 'markdown') + return + end + + utils.log_info('Hurl command executed successfully') + + -- Parse the output using the hurl_parser + local result = hurl_parser.parse_hurl_output(stderr_data, stdout_data) + + -- Format the parsed result + local output_lines = {} + table.insert(output_lines, '# Hurl Command') + table.insert(output_lines, '') + table.insert(output_lines, '```sh') + table.insert(output_lines, hurl_command) + table.insert(output_lines, '```') + table.insert(output_lines, '') + + for index, entry in ipairs(result.entries) do + -- Request + table.insert(output_lines, '# Request #' .. index) + table.insert(output_lines, '') + table.insert(output_lines, '## ' .. entry.requestMethod .. ' ' .. entry.requestUrl) + table.insert(output_lines, '') + table.insert(output_lines, '## Response: ' .. entry.response.status) + + -- Curl Command + table.insert(output_lines, '## Curl Command:') + table.insert(output_lines, '```bash') + table.insert(output_lines, entry.curlCommand or 'N/A') + table.insert(output_lines, '```') + table.insert(output_lines, '') + + -- Headers + table.insert(output_lines, '### Headers:') + for key, value in pairs(entry.response.headers) do + table.insert(output_lines, '- **' .. key .. '**: ' .. value) + end + table.insert(output_lines, '') + + -- Body + table.insert(output_lines, '### Body:') + table.insert(output_lines, '```json') + local formatted_body = utils.format(entry.response.body, 'json') + for _, line in ipairs(formatted_body or {}) do + table.insert(output_lines, line) + end + table.insert(output_lines, '```') + + -- Captures + if entry.captures and next(entry.captures) then + table.insert(output_lines, '') + table.insert(output_lines, '### Captures:') + for key, value in pairs(entry.captures) do + table.insert(output_lines, '- **' .. key .. '**: ' .. value) + end + end + + -- Timings + table.insert(output_lines, '') + table.insert(output_lines, '### Timing:') + table.insert( + output_lines, + string.format('- **Total Response Time**: %.2f ms', response_time) + ) + if entry.timings then + for key, value in pairs(entry.timings) do + table.insert(output_lines, string.format('- **%s**: %s', key, value)) + end + end + + table.insert(output_lines, '---') + + -- Update history for each entry + local display_data = { + headers = entry.response.headers, + body = entry.response.body, + response_time = response_time, + status = entry.response.status, + url = entry.requestUrl, + method = entry.requestMethod, + curl_command = entry.curlCommand, + hurl_command = hurl_command, + captures = entry.captures, + timings = entry.timings, + } + history.update_history(display_data) + + -- Save captures as global variables + save_captures_as_globals(entry.captures) + end + + -- Show the result using the display module + display.show({ body = table.concat(output_lines, '\n') }, 'markdown') + end, + }) +end + +--- Execute Hurl command +---@param opts table The options +---@param callback? function The callback function +function M.execute_hurl_cmd(opts, callback) + -- Check if a request is currently running + if M.is_running then + utils.log_info('hurl: request is already running') + utils.notify('hurl: request is running. Please try again later.', vim.log.levels.INFO) + return + end + + M.is_running = true + M.start_time = vim.loop.hrtime() -- Capture the start time + spinner.show() + utils.log_info('hurl: running request') + utils.notify('hurl: running request', vim.log.levels.INFO) + + local is_json_mode = vim.tbl_contains(opts, '--json') + local is_file_mode = utils.has_file_in_opts(opts) + + -- Add verbose mode by default if not in JSON mode + if not is_json_mode and not vim.tbl_contains(opts, '--verbose') then + table.insert(opts, '--verbose') + end + + -- Check vars.env exist on the current file buffer + -- Then inject the command with --variables-file vars.env + local env_files = _HURL_GLOBAL_CONFIG.find_env_files_in_folders() + for _, env in ipairs(env_files) do + utils.log_info( + 'hurl: looking for ' .. vim.inspect(_HURL_GLOBAL_CONFIG.env_file) .. ' in ' .. env.path + ) + if vim.fn.filereadable(env.path) == 1 then + utils.log_info('hurl: found env file in ' .. env.path) + table.insert(opts, '--variables-file') + table.insert(opts, env.path) + end + end + + -- Inject global variables into the command + if _HURL_GLOBAL_CONFIG.global_vars then + for var_name, var_value in pairs(_HURL_GLOBAL_CONFIG.global_vars) do + table.insert(opts, '--variable') + -- If the value is wrapped in quotes, we need to escape it properly + if var_value:sub(1, 1) == '"' and var_value:sub(-1) == '"' then + table.insert(opts, var_name .. '=' .. var_value:gsub('"', '\\"')) + else + table.insert(opts, var_name .. '=' .. var_value) + end + end + end + + -- Inject fixture variables into the command + if _HURL_GLOBAL_CONFIG.fixture_vars then + for _, fixture in pairs(_HURL_GLOBAL_CONFIG.fixture_vars) do + table.insert(opts, '--variable') + table.insert(opts, fixture.name .. '=' .. fixture.callback()) + end + end + + local cmd = vim.list_extend({ 'hurl' }, opts) + if is_file_mode then + local file_root = _HURL_GLOBAL_CONFIG.file_root or vim.fn.getcwd() + vim.list_extend(cmd, { '--file-root', file_root }) + end + M.response = {} + + save_last_hurl_command(cmd) + + -- Clear the display and show processing message with Hurl command + local ok, display = pcall(require, 'hurl.' .. (_HURL_GLOBAL_CONFIG.mode or 'split')) + if not ok then + utils.notify('Failed to load display module: ' .. display, vim.log.levels.ERROR) + return + end + display.clear() + + local stdout_data = '' + local stderr_data = '' + + vim.fn.jobstart(cmd, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + stdout_data = stdout_data .. table.concat(data, '\n') + end + end, + on_stderr = function(_, data) + if data then + stderr_data = stderr_data .. table.concat(data, '\n') + end + end, + on_exit = function(_, code) + M.is_running = false + spinner.hide() + + if code ~= 0 then + utils.log_error('Hurl command failed with code ' .. code) + utils.notify('Hurl command failed. Check the split view for details.', vim.log.levels.ERROR) + + -- Show error in split view + local split = require('hurl.split') + local error_data = { + body = '# Hurl Error\n\n```sh\n' .. stderr_data .. '\n```', + headers = {}, + method = 'ERROR', + url = 'N/A', + status = code, + response_time = 0, + curl_command = 'N/A', + } + split.show(error_data, 'markdown') + return + end + + utils.log_info('hurl: request finished') + utils.notify('hurl: request finished', vim.log.levels.INFO) + + -- Calculate the response time + local end_time = vim.loop.hrtime() + M.response.response_time = (end_time - M.start_time) / 1e6 -- Convert to milliseconds + + if is_json_mode then + M.response.body = stdout_data + M.response.display_type = 'json' + if callback then + return callback(M.response) + end + else + -- Parse the output using the hurl_parser + local result = hurl_parser.parse_hurl_output(stderr_data, stdout_data) + + -- Display the result using popup or split based on the configuration + local container = require('hurl.' .. _HURL_GLOBAL_CONFIG.mode) + + -- Prepare the data for display + local last_entry = result.entries[#result.entries] + local display_data = { + headers = last_entry.response.headers, + body = last_entry.response.body, + response_time = M.response.response_time, + status = last_entry.response.status, + url = last_entry.requestUrl, + method = last_entry.requestMethod, + curl_command = last_entry.curlCommand, + } + + -- Separate headers from body + local body_start = display_data.body:find('\n\n') + if body_start then + local headers_str = display_data.body:sub(1, body_start - 1) + display_data.body = display_data.body:sub(body_start + 2) + + -- Parse additional headers from the body + for header in headers_str:gmatch('([^\n]+)') do + local key, value = header:match('([^:]+):%s*(.*)') + if key and value then + display_data.headers[key] = value + end + end + end + + -- Determine the content type + local content_type = display_data.headers['Content-Type'] + or display_data.headers['content-type'] + or 'text/plain' + + local display_type = 'text' + if content_type:find('json') then + display_type = 'json' + elseif content_type:find('html') then + display_type = 'html' + elseif content_type:find('xml') then + display_type = 'xml' + end + + display_data.display_type = display_type + + container.show(display_data, display_type) + + history.update_history(display_data) + + -- Save captures as global variables + if result.entries and #result.entries > 0 then + save_captures_as_globals(result.entries[#result.entries].captures) + end + end + end, + }) +end + +return M diff --git a/lua/hurl/main.lua b/lua/hurl/main.lua index 9945e7b..6557d30 100644 --- a/lua/hurl/main.lua +++ b/lua/hurl/main.lua @@ -1,287 +1,28 @@ local utils = require('hurl.utils') local http = require('hurl.http_utils') -local spinner = require('hurl.spinner') +local hurl_runner = require('hurl.lib.hurl_runner') +local codelens = require('hurl.codelens') local M = {} -local response = {} -local head_state = '' -local is_running = false -local start_time = nil - ---- Convert from --json flag to same format with other command -local function convert_headers(headers) - local converted_headers = {} - - for _, header in ipairs(headers) do - converted_headers[header.name] = header.value - end - - return converted_headers -end - --- NOTE: Check the output with below command --- hurl example/dogs.hurl --json | jq -local on_json_output = function(code, data, event) - utils.log_info('hurl: on_output ' .. vim.inspect(code) .. vim.inspect(data) .. vim.inspect(event)) - - -- Remove the first element if it is an empty string - if data[1] == '' then - table.remove(data, 1) - end - -- If there is no data, return early - if not data[1] then - return - end - - local result = vim.json.decode(data[1]) - utils.log_info('hurl: json result ' .. vim.inspect(result)) - response.response_time = result.time - - -- TODO: It might have more than 1 entry, so we need to handle it - if - result - and result.entries - and result.entries[1] - and result.entries[1].calls - and result.entries[1].calls[1] - and result.entries[1].calls[1].response - then - local converted_headers = convert_headers(result.entries[1].calls[1].response.headers) - -- Add the status code and time to the headers - converted_headers.status = result.entries[1].calls[1].response.status - - response.headers = converted_headers - response.status = result.entries[1].calls[1].response.status - end - - response.body = { - vim.json.encode({ - msg = 'The flag --json does not contain the body yet. Refer to https://github.com/Orange-OpenSource/hurl/issues/1907', - }), - } -end - ---- Output handler ----@class Output -local on_output = function(code, data, event) - utils.log_info('hurl: on_output ' .. vim.inspect(code) .. vim.inspect(data)) - - if data[1] == '' then - table.remove(data, 1) - end - if not data[1] then - return - end - - if event == 'stderr' and #data > 1 then - response.body = data - utils.log_error(vim.inspect(data)) - response.raw = data - response.headers = {} - return - end - - if head_state == 'body' then - -- Append the data to the body if we are in the body state - utils.log_info('hurl: append data to body' .. vim.inspect(data)) - response.body = response.body or '' - response.body = response.body .. table.concat(data, '\n') - return - end - - -- TODO: The header parser sometime not working properly, e.g: https://google.com - local status = tonumber(string.match(data[1], '([%w+]%d+)')) - head_state = 'start' - if status then - response.status = status - response.headers = { status = data[1] } - response.headers_str = data[1] .. '\r\n' - end - - for i = 2, #data do - local line = data[i] - if line == '' or line == nil then - head_state = 'body' - elseif head_state == 'start' then - local key, value = string.match(line, '([%w-]+):%s*(.+)') - if key and value then - response.headers[key] = value - response.headers_str = response.headers_str .. line .. '\r\n' - end - elseif head_state == 'body' then - response.body = response.body or '' - response.body = response.body .. line - end - end - response.raw = data - - utils.log_info('hurl: response status ' .. response.status) - utils.log_info('hurl: response headers ' .. vim.inspect(response.headers)) - if response.body then - utils.log_info('hurl: response body ' .. response.body) - else - -- Fall back to empty string for non-body responses - response.body = '' - end -end - ---- Call hurl command ----@param opts table The options ----@param callback? function The callback function -local function execute_hurl_cmd(opts, callback) - -- Check if a request is currently running - if is_running then - utils.log_info('hurl: request is already running') - utils.notify('hurl: request is running. Please try again later.', vim.log.levels.INFO) - return - end - - is_running = true - start_time = vim.loop.hrtime() -- Capture the start time - spinner.show() - head_state = '' - utils.log_info('hurl: running request') - utils.notify('hurl: running request', vim.log.levels.INFO) - - local is_verbose_mode = vim.tbl_contains(opts, '--verbose') - local is_json_mode = vim.tbl_contains(opts, '--json') - local is_file_mode = utils.has_file_in_opts(opts) - - if - not _HURL_GLOBAL_CONFIG.auto_close - and not is_verbose_mode - and not is_json_mode - and response.body - then - local container = require('hurl.' .. _HURL_GLOBAL_CONFIG.mode) - utils.log_info('hurl: clear previous response if this is not auto close') - container.clear() - end - - -- Check vars.env exist on the current file buffer - -- Then inject the command with --variables-file vars.env - local env_files = _HURL_GLOBAL_CONFIG.find_env_files_in_folders() - for _, env in ipairs(env_files) do - utils.log_info( - 'hurl: looking for ' .. vim.inspect(_HURL_GLOBAL_CONFIG.env_file) .. ' in ' .. env.path - ) - if vim.fn.filereadable(env.path) == 1 then - utils.log_info('hurl: found env file in ' .. env.path) - table.insert(opts, '--variables-file') - table.insert(opts, env.path) - end - end - - -- Inject global variables into the command - if _HURL_GLOBAL_CONFIG.global_vars then - for var_name, var_value in pairs(_HURL_GLOBAL_CONFIG.global_vars) do - table.insert(opts, '--variable') - table.insert(opts, var_name .. '=' .. var_value) - end - end - - -- Inject fixture variables into the command - -- This is a workaround to inject dynamic variables into the hurl command, refer https://github.com/Orange-OpenSource/hurl/issues?q=sort:updated-desc+is:open+label:%22topic:+generators%22 - if _HURL_GLOBAL_CONFIG.fixture_vars then - for _, fixture in pairs(_HURL_GLOBAL_CONFIG.fixture_vars) do - table.insert(opts, '--variable') - table.insert(opts, fixture.name .. '=' .. fixture.callback()) - end - end - - -- Include the HTTP headers in the output and do not colorize output. - local cmd = vim.list_extend({ 'hurl', '-i', '--no-color' }, opts) - if is_file_mode then - local file_root = _HURL_GLOBAL_CONFIG.file_root or vim.fn.getcwd() - vim.list_extend(cmd, { '--file-root', file_root }) - end - response = {} - - utils.log_info('hurl: running command' .. vim.inspect(cmd)) - - vim.fn.jobstart(cmd, { - on_stdout = callback or (is_json_mode and on_json_output or on_output), - on_stderr = callback or (is_json_mode and on_json_output or on_output), - on_exit = function(i, code) - utils.log_info('exit at ' .. i .. ' , code ' .. code) - is_running = false - spinner.hide() - if code ~= 0 then - -- Send error code and response to quickfix and open it - -- It should display the error message - vim.fn.setqflist({}, 'r', { - title = 'hurl', - lines = response.raw or response.body, - }) - vim.fn.setqflist({}, 'a', { - title = 'hurl', - lines = { response.headers_str }, - }) - vim.cmd('copen') - return - end - - utils.log_info('hurl: request finished') - utils.notify('hurl: request finished', vim.log.levels.INFO) - - -- Calculate the response time - local end_time = vim.loop.hrtime() - response.response_time = (end_time - start_time) / 1e6 -- Convert to milliseconds - - if callback then - return callback(response) - else - -- show messages - local lines = response.raw or response.body - if #lines == 0 then - return - end - - local content_type = response.headers['content-type'] - or response.headers['Content-Type'] - or response.headers['Content-type'] - or 'unknown' - - utils.log_info('Detected content type: ' .. content_type) - if response.headers['content-length'] == '0' then - utils.log_info('hurl: empty response') - utils.notify('hurl: empty response', vim.log.levels.INFO) - end - - local container = require('hurl.' .. _HURL_GLOBAL_CONFIG.mode) - if utils.is_json_response(content_type) then - container.show(response, 'json') - else - if utils.is_html_response(content_type) then - container.show(response, 'html') - else - if utils.is_xml_response(content_type) then - container.show(response, 'xml') - else - container.show(response, 'text') - end - end - end - end - end, - }) -end - --- Run current file --- It will throw an error if that is not valid hurl file ---@param opts table The options local function run_current_file(opts) opts = opts or {} table.insert(opts, vim.fn.expand('%:p')) - execute_hurl_cmd(opts) + hurl_runner.execute_hurl_cmd(opts) end ---- Create a temporary file with the lines to run ----@param lines string[] +-- Run selection ---@param opts table The options ----@param callback? function The callback function -local function run_lines(lines, opts, callback) +local function run_selection(opts) + opts = opts or {} + local lines = utils.get_visual_selection() + if not lines then + return + end + -- Create a temporary file with the lines to run local fname = utils.create_tmp_file(lines) if not fname then @@ -292,7 +33,7 @@ local function run_lines(lines, opts, callback) -- Add the temporary file to the arguments table.insert(opts, fname) - execute_hurl_cmd(opts, callback) + hurl_runner.execute_hurl_cmd(opts) -- Clean up the temporary file after a delay local timeout = 1000 @@ -307,37 +48,46 @@ local function run_lines(lines, opts, callback) end, timeout) end ---- Run selection ----@param opts table The options -local function run_selection(opts) - opts = opts or {} - local lines = utils.get_visual_selection() - if not lines then - return - end +-- Store the last used from_entry and to_entry +local last_from_entry +local last_to_entry - run_lines(lines, opts) -end - ---- Run at current line +-- Run at current line ---@param start_line number ----@param end_line number +---@param end_line number|nil ---@param opts table ---@param callback? function local function run_at_lines(start_line, end_line, opts, callback) opts = opts or {} - -- Get the lines from the buffer - local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) + local file_path = vim.fn.expand('%:p') - if not lines or vim.tbl_isempty(lines) then - utils.notify('hurl: no lines to run', vim.log.levels.WARN) - return + -- Insert the file path first + table.insert(opts, file_path) + + -- Then add the --from-entry and --to-entry options + table.insert(opts, '--from-entry') + table.insert(opts, tostring(start_line)) + if end_line then + table.insert(opts, '--to-entry') + table.insert(opts, tostring(end_line)) end - run_lines(lines, opts, callback) + -- Store the last used from_entry and to_entry + last_from_entry = start_line + last_to_entry = end_line + + hurl_runner.execute_hurl_cmd(opts, callback) +end + +-- Helper function to run verbose commands in split mode +local function run_verbose_command(filePath, fromEntry, toEntry, isVeryVerbose, additionalArgs) + hurl_runner.run_hurl_verbose(filePath, fromEntry, toEntry, isVeryVerbose, additionalArgs) end function M.setup() + -- Show virtual text for Hurl entries + codelens.setup() + -- Run request for a range of lines or the entire file utils.create_cmd('HurlRunner', function(opts) if opts.range ~= 0 then @@ -369,7 +119,7 @@ function M.setup() utils.log_info( 'hurl: running request at line ' .. result.start_line .. ' to ' .. result.end_line ) - run_at_lines(result.start_line, result.end_line, opts.fargs) + run_at_lines(result.current, result.current, opts.fargs) else utils.log_info('hurl: not HTTP method found in the current line' .. result.start_line) utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) @@ -383,14 +133,14 @@ function M.setup() or http.find_http_verb_positions_in_buffer() utils.log_info('hurl: running request to entry #' .. vim.inspect(result)) if result.current > 0 then - run_at_lines(1, result.end_line, opts.fargs) + run_at_lines(1, result.current, opts.fargs) else utils.log_info('hurl: not HTTP method found in the current line' .. result.end_line) utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) end end, { nargs = '*', range = true }) - -- Add new command to change env file with input + -- Set the env file utils.create_cmd('HurlSetEnvFile', function(opts) local env_file = opts.fargs[1] if not env_file then @@ -404,81 +154,76 @@ function M.setup() utils.notify('hurl: env file changed to ' .. updated_env, vim.log.levels.INFO) end, { nargs = '*', range = true }) - -- Run Hurl in verbose mode and send output to quickfix + -- Run Hurl in verbose mode utils.create_cmd('HurlVerbose', function(opts) - -- It should be the same logic with run at current line but with verbose flag - -- The response will be sent to quickfix - local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available - local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() - or http.find_http_verb_positions_in_buffer() - if result.current > 0 and result.start_line and result.end_line then - utils.log_info( - 'hurl: running request at line ' .. result.start_line .. ' to ' .. result.end_line - ) - opts.fargs = opts.fargs or {} - opts.fargs = vim.list_extend(opts.fargs, { '--verbose' }) - - -- Clear quickfix list - vim.fn.setqflist({}, 'r', { - title = 'hurl', - lines = {}, - }) - run_at_lines(1, result.end_line, opts.fargs, function(code, data, event) - utils.log_info('hurl: verbose callback ' .. vim.inspect(code) .. vim.inspect(data)) - vim.fn.setqflist({}, 'a', { - title = 'hurl - data', - lines = data, - }) - vim.fn.setqflist({}, 'a', { - title = 'hurl - event', - lines = event, - }) - vim.cmd('copen') - end) - else - if result then - utils.log_info('hurl: not HTTP method found in the current line' .. result.start_line) + local filePath = vim.fn.expand('%:p') + local fromEntry = opts.fargs[1] and tonumber(opts.fargs[1]) or nil + local toEntry = opts.fargs[2] and tonumber(opts.fargs[2]) or nil + + -- Detect the current entry if fromEntry and toEntry are not provided + if not fromEntry or not toEntry then + local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available + local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() + or http.find_http_verb_positions_in_buffer() + if result.current > 0 then + fromEntry = result.current + toEntry = result.current + else + utils.log_info('hurl: no HTTP method found in the current line') utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) + return end end + + run_verbose_command(filePath, fromEntry, toEntry, false) end, { nargs = '*', range = true }) - -- NOTE: Get output from --json output - -- Run Hurl in JSON mode and send output to quickfix + -- Run Hurl in very verbose mode + utils.create_cmd('HurlVeryVerbose', function(opts) + local filePath = vim.fn.expand('%:p') + local fromEntry = opts.fargs[1] and tonumber(opts.fargs[1]) or nil + local toEntry = opts.fargs[2] and tonumber(opts.fargs[2]) or nil + + -- Detect the current entry if fromEntry and toEntry are not provided + if not fromEntry or not toEntry then + local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available + local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() + or http.find_http_verb_positions_in_buffer() + if result.current > 0 then + fromEntry = result.current + toEntry = result.current + else + utils.log_info('hurl: no HTTP method found in the current line') + utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) + return + end + end + + run_verbose_command(filePath, fromEntry, toEntry, true) + end, { nargs = '*', range = true }) + + -- Run Hurl in JSON mode utils.create_cmd('HurlJson', function(opts) - local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available - local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() - or http.find_http_verb_positions_in_buffer() - if result.current > 0 and result.start_line and result.end_line then - utils.log_info( - 'hurl: running request at line ' .. result.start_line .. ' to ' .. result.end_line - ) - opts.fargs = opts.fargs or {} - opts.fargs = vim.list_extend(opts.fargs, { '--json' }) - - -- Clear quickfix list - vim.fn.setqflist({}, 'r', { - title = 'hurl', - lines = {}, - }) - run_at_lines(1, result.end_line, opts.fargs, function(code, data, event) - utils.log_info('hurl: verbose callback ' .. vim.inspect(code) .. vim.inspect(data)) - -- Only send to output if the data is json format - if #data > 1 and data ~= nil then - vim.fn.setqflist({}, 'a', { - title = 'hurl - data', - lines = data, - }) - end - - vim.cmd('copen') - end) - else - if result then - utils.log_info('hurl: not HTTP method found in the current line' .. result.start_line) + local filePath = vim.fn.expand('%:p') + local fromEntry = opts.fargs[1] and tonumber(opts.fargs[1]) or nil + local toEntry = opts.fargs[2] and tonumber(opts.fargs[2]) or nil + + -- Detect the current entry if fromEntry and toEntry are not provided + if not fromEntry or not toEntry then + local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available + local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() + or http.find_http_verb_positions_in_buffer() + if result.current > 0 then + fromEntry = result.current + toEntry = result.current + else + utils.log_info('hurl: no HTTP method found in the current line') utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) + return end end + + run_verbose_command(filePath, fromEntry, toEntry, false, { '--json' }) end, { nargs = '*', range = true }) utils.create_cmd('HurlSetVariable', function(opts) @@ -576,7 +321,19 @@ function M.setup() -- Show last request response utils.create_cmd('HurlShowLastResponse', function() local history = require('hurl.history') - history.show(response) + local last_response = history.get_last_response() + if last_response then + -- Ensure response_time is a number + last_response.response_time = tonumber(last_response.response_time) or '-' + local ok, display = pcall(require, 'hurl.' .. (_HURL_GLOBAL_CONFIG.mode or 'split')) + if not ok then + utils.notify('Failed to load display module: ' .. display, vim.log.levels.ERROR) + return + end + display.show(last_response, 'json') + else + utils.notify('No response history available', vim.log.levels.INFO) + end end, { nargs = '*', range = true, @@ -587,18 +344,47 @@ function M.setup() local is_support_hurl = utils.is_nightly() or utils.is_hurl_parser_available local result = is_support_hurl and http.find_hurl_entry_positions_in_buffer() or http.find_http_verb_positions_in_buffer() - if result.current > 0 and result.start_line and result.end_line then - utils.log_info( - 'hurl: running request at line ' .. result.start_line .. ' to ' .. result.end_line - ) + if result.current > 0 then + utils.log_info('hurl: running request from entry ' .. result.current .. ' to end') opts.fargs = opts.fargs or {} - local end_line = vim.api.nvim_buf_line_count(0) - run_at_lines(result.start_line, end_line, opts.fargs) + run_at_lines(result.current, nil, opts.fargs) else utils.log_info('hurl: no HTTP method found in the current line') utils.notify('hurl: no HTTP method found in the current line', vim.log.levels.INFO) end end, { nargs = '*', range = true }) + + -- Re-run last Hurl command + utils.create_cmd('HurlRerun', function() + if last_from_entry then + utils.log_info( + string.format( + 'hurl: re-running last command from entry %d to %s', + last_from_entry, + last_to_entry or 'end' + ) + ) + utils.notify('hurl: re-running last command', vim.log.levels.INFO) + + local opts = {} + local file_path = vim.fn.expand('%:p') + + -- Reconstruct the command with the stored from_entry and to_entry + table.insert(opts, file_path) + table.insert(opts, '--from-entry') + table.insert(opts, tostring(last_from_entry)) + if last_to_entry then + table.insert(opts, '--to-entry') + table.insert(opts, tostring(last_to_entry)) + end + + -- Execute the command + hurl_runner.execute_hurl_cmd(opts) + else + utils.log_info('hurl: no previous command to re-run') + utils.notify('hurl: no previous command to re-run', vim.log.levels.WARN) + end + end, { nargs = 0 }) end return M diff --git a/lua/hurl/popup.lua b/lua/hurl/popup.lua index 3f9031a..07e6f93 100644 --- a/lua/hurl/popup.lua +++ b/lua/hurl/popup.lua @@ -5,15 +5,17 @@ local Layout = require('nui.layout') local utils = require('hurl.utils') local M = {} + local popups = { - bottom = Popup({ + body = Popup({ border = 'single', enter = true, - buf_options = { filetype = 'json' }, + buf_options = { filetype = 'markdown' }, }), - top = Popup({ - border = { style = 'rounded' }, - buf_options = { filetype = 'bash' }, + info = Popup({ + border = 'single', + enter = true, + buf_options = { filetype = 'markdown' }, }), } @@ -24,10 +26,8 @@ local layout = Layout( size = _HURL_GLOBAL_CONFIG.popup_size, }, Layout.Box({ - Layout.Box(popups.top, { size = { - height = '20%', - } }), - Layout.Box(popups.bottom, { grow = 1 }), + Layout.Box(popups.info, { size = '30%' }), + Layout.Box(popups.body, { grow = 1 }), }, { dir = 'col' }) ) @@ -57,82 +57,103 @@ M.show = function(data, type) end local function quit() - vim.cmd('q') + vim.cmd(_HURL_GLOBAL_CONFIG.mappings.close) layout:unmount() end - -- Map q to quit - popups.top:map('n', _HURL_GLOBAL_CONFIG.mappings.close, function() - quit() - end) - popups.bottom:map('n', _HURL_GLOBAL_CONFIG.mappings.close, function() - quit() - end) + -- Map q to quit for both popups + for _, popup in pairs(popups) do + popup:map('n', _HURL_GLOBAL_CONFIG.mappings.close, function() + quit() + end) + end -- Map to next popup - popups.top:map('n', _HURL_GLOBAL_CONFIG.mappings.next_panel, function() - vim.api.nvim_set_current_win(popups.bottom.winid) + popups.body:map('n', _HURL_GLOBAL_CONFIG.mappings.next_panel, function() + vim.api.nvim_set_current_win(popups.info.winid) end) - popups.bottom:map('n', _HURL_GLOBAL_CONFIG.mappings.next_panel, function() - vim.api.nvim_set_current_win(popups.top.winid) + popups.info:map('n', _HURL_GLOBAL_CONFIG.mappings.next_panel, function() + vim.api.nvim_set_current_win(popups.body.winid) end) -- Map to previous popup - popups.top:map('n', _HURL_GLOBAL_CONFIG.mappings.prev_panel, function() - vim.api.nvim_set_current_win(popups.bottom.winid) + popups.body:map('n', _HURL_GLOBAL_CONFIG.mappings.prev_panel, function() + vim.api.nvim_set_current_win(popups.info.winid) end) - popups.bottom:map('n', _HURL_GLOBAL_CONFIG.mappings.prev_panel, function() - vim.api.nvim_set_current_win(popups.top.winid) + popups.info:map('n', _HURL_GLOBAL_CONFIG.mappings.prev_panel, function() + vim.api.nvim_set_current_win(popups.body.winid) end) - - -- Add headers to the top - local headers_table = utils.render_header_table(data.headers) - -- Hide header block if empty headers - if headers_table.line == 0 then - vim.api.nvim_win_close(popups.top.winid, true) - else - if headers_table.line > 0 then - vim.api.nvim_buf_set_lines(popups.top.bufnr, 0, 1, false, headers_table.headers) - end + -- Info popup content + local info_lines = {} + + -- Add request information + table.insert(info_lines, '# Request') + table.insert(info_lines, '') + table.insert(info_lines, string.format('**Method**: %s', data.method)) + table.insert(info_lines, string.format('**URL**: %s', data.url)) + table.insert(info_lines, string.format('**Status**: %s', data.status)) + table.insert(info_lines, '') + + -- Add curl command + table.insert(info_lines, '# Curl Command') + table.insert(info_lines, '') + table.insert(info_lines, '```bash') + table.insert(info_lines, data.curl_command or 'N/A') + table.insert(info_lines, '```') + table.insert(info_lines, '') + + -- Add headers + table.insert(info_lines, '# Headers') + table.insert(info_lines, '') + for key, value in pairs(data.headers) do + table.insert(info_lines, string.format('- **%s**: %s', key, value)) end - -- Add response time as virtual text - vim.api.nvim_buf_set_extmark( - popups.top.bufnr, - vim.api.nvim_create_namespace('response_time_ns'), - 0, - 0, - { - end_line = 1, - id = 1, - virt_text = { { 'Response: ' .. data.response_time .. ' ms', 'Comment' } }, - virt_text_pos = 'eol', - } - ) + -- Add response time + table.insert(info_lines, '') + table.insert(info_lines, string.format('**Response Time**: %.2f ms', data.response_time)) + + -- Set info content + vim.api.nvim_buf_set_lines(popups.info.bufnr, 0, -1, false, info_lines) + -- Body popup content + local body_lines = {} + + -- Add body + table.insert(body_lines, '# Body') + table.insert(body_lines, '') + table.insert(body_lines, '```' .. type) local content = utils.format(data.body, type) - if not content then - utils.log_info('No content') - return + if content then + for _, line in ipairs(content) do + table.insert(body_lines, line) + end + else + table.insert(body_lines, 'No content') end + table.insert(body_lines, '```') - -- Add content to the bottom - vim.api.nvim_buf_set_lines(popups.bottom.bufnr, 0, -1, false, content) - - -- Set content to highlight, refer https://github.com/MunifTanjim/nui.nvim/issues/76#issuecomment-1001358770 - vim.api.nvim_buf_set_option(popups.bottom.bufnr, 'filetype', type) + -- Set body content + vim.api.nvim_buf_set_lines(popups.body.bufnr, 0, -1, false, body_lines) -- Show the popup after populating the content for alignment layout:show() + + -- Set cursor to the body popup + vim.api.nvim_set_current_win(popups.body.winid) end M.clear = function() -- Check if popup is open - if not popups.bottom.winid then + if not layout.winid then return end - -- Clear the buffer and adding `Processing...` message - vim.api.nvim_buf_set_lines(popups.top.bufnr, 0, -1, false, { 'Processing...' }) - vim.api.nvim_buf_set_lines(popups.bottom.bufnr, 0, -1, false, { 'Processing...' }) + -- Clear the buffer and add `Processing...` message with spinner and Hurl command + for _, popup in pairs(popups) do + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, { + 'Processing... ', + _HURL_GLOBAL_CONFIG.last_hurl_command or 'N/A', + }) + end end --- Show text in a popup @@ -156,8 +177,8 @@ M.show_text = function(title, lines, bottom) }, position = '50%', size = { - width = '50%', - height = '50%', + width = '90%', + height = '90%', }, }) diff --git a/lua/hurl/spinner.lua b/lua/hurl/spinner.lua index cb5a78f..034bc58 100644 --- a/lua/hurl/spinner.lua +++ b/lua/hurl/spinner.lua @@ -7,6 +7,9 @@ local M = {} -- User configuration section local config = { + -- Show notification when done. + -- Set to false to disable. + show_notification = true, -- Name of the plugin. plugin = 'hurl.nvim', -- Spinner frames. @@ -55,13 +58,15 @@ function M.show(position) 0, 100, vim.schedule_wrap(function() - vim.api.nvim_buf_set_lines( - spinner_buf, - 0, - -1, - false, - { config.spinner_frames[spinner_index] } - ) + if vim.api.nvim_buf_is_valid(spinner_buf) then + vim.api.nvim_buf_set_lines( + spinner_buf, + 0, + -1, + false, + { config.spinner_frames[spinner_index] } + ) + end spinner_index = spinner_index % #config.spinner_frames + 1 end) ) @@ -81,7 +86,7 @@ function M.hide(show_msg) vim.api.nvim_buf_delete(spinner_buf, { force = true }) end - if show_msg or _HURL_GLOBAL_CONFIG.show_notification then + if config.show_notification or show_msg then vim.notify('Done!', vim.log.levels.INFO, { title = config.plugin }) end end diff --git a/lua/hurl/split.lua b/lua/hurl/split.lua index abc0e2c..97343aa 100644 --- a/lua/hurl/split.lua +++ b/lua/hurl/split.lua @@ -5,29 +5,26 @@ local split = Split({ relative = 'editor', position = _HURL_GLOBAL_CONFIG.split_position, size = _HURL_GLOBAL_CONFIG.split_size, + buf_options = { filetype = 'markdown' }, }) local utils = require('hurl.utils') local M = {} --- Show content in a popup +-- Show content in a split ---@param data table --- - body string --- - headers table ----@param type 'json' | 'html' | 'xml' | 'text' +---@param type 'json' | 'html' | 'xml' | 'text' | 'markdown' M.show = function(data, type) local function quit() - vim.cmd('q') + vim.cmd(_HURL_GLOBAL_CONFIG.mappings.close) split:unmount() end -- mount/open the component split:mount() - -- Create a custom filetype so that we can use https://github.com/folke/edgy.nvim to manage the window - -- E.g: { title = "Hurl Nvim", ft = "hurl-nvim" }, - vim.bo[split.bufnr].filetype = 'hurl-nvim' - if _HURL_GLOBAL_CONFIG.auto_close then -- unmount component when buffer is closed split:on(event.BufLeave, function() @@ -35,47 +32,62 @@ M.show = function(data, type) end) end - -- Add headers to the top - local headers_table = utils.render_header_table(data.headers) - -- Hide header block if empty headers - if headers_table.line == 0 then - utils.log_info('no headers') + local output_lines = {} + + if type == 'markdown' then + -- For markdown, we just use the body as-is + output_lines = vim.split(data.body, '\n') else - if headers_table.line > 0 then - vim.api.nvim_buf_set_lines(split.bufnr, 0, 1, false, headers_table.headers) + -- Add request information + table.insert(output_lines, '# Request') + table.insert(output_lines, '') + table.insert(output_lines, string.format('**Method**: %s', data.method or 'N/A')) + table.insert(output_lines, string.format('**URL**: %s', data.url or 'N/A')) + table.insert(output_lines, string.format('**Status**: %s', data.status or 'N/A')) + table.insert(output_lines, '') + + -- Add curl command + table.insert(output_lines, '# Curl Command') + table.insert(output_lines, '') + table.insert(output_lines, '```bash') + table.insert(output_lines, data.curl_command or 'N/A') + table.insert(output_lines, '```') + table.insert(output_lines, '') + + -- Add headers + table.insert(output_lines, '# Headers') + table.insert(output_lines, '') + if data.headers then + for key, value in pairs(data.headers) do + table.insert(output_lines, string.format('- **%s**: %s', key, value)) + end + else + table.insert(output_lines, 'No headers available') end - end - -- Add response time as virtual text - vim.api.nvim_buf_set_extmark( - split.bufnr, - vim.api.nvim_create_namespace('response_time_ns'), - 0, - 0, - { - end_line = 1, - id = 1, - virt_text = { { 'Response: ' .. data.response_time .. ' ms', 'Comment' } }, - virt_text_pos = 'eol', - } - ) - - local content = utils.format(data.body, type) - if not content then - utils.log_info('No content') - return - end + -- Add response time + table.insert(output_lines, '') + local response_time = tonumber(data.response_time) or 0 + table.insert(output_lines, string.format('**Response Time**: %.2f ms', response_time)) + table.insert(output_lines, '') - -- Add content to the bottom - vim.api.nvim_buf_set_lines(split.bufnr, headers_table.line, -1, false, content) + -- Add body + table.insert(output_lines, '# Body') + table.insert(output_lines, '') + table.insert(output_lines, '```' .. type) + local content = utils.format(data.body, type) + if content then + for _, line in ipairs(content) do + table.insert(output_lines, line) + end + else + table.insert(output_lines, 'No content') + end + table.insert(output_lines, '```') + end - -- Set content to highlight, refer https://github.com/MunifTanjim/nui.nvim/issues/76#issuecomment-1001358770 - -- After 200ms, the highlight will be applied - vim.defer_fn(function() - vim.bo[split.bufnr].filetype = type - -- recomputing foldlevel, this is needed if we setup foldexpr - vim.api.nvim_feedkeys('zx', 'n', true) - end, 200) + -- Set content + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, output_lines) split:map('n', _HURL_GLOBAL_CONFIG.mappings.close, function() quit() @@ -88,8 +100,16 @@ M.clear = function() return end - -- Clear the buffer and adding `Processing...` message - vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { 'Processing...' }) + -- Clear the buffer and add `Processing...` message with the current Hurl command + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { + 'Processing...', + '', + '# Hurl Command', + '', + '```sh', + _HURL_GLOBAL_CONFIG.last_hurl_command or 'N/A', + '```', + }) end return M diff --git a/lua/hurl/utils.lua b/lua/hurl/utils.lua index c342ceb..7351a8c 100644 --- a/lua/hurl/utils.lua +++ b/lua/hurl/utils.lua @@ -1,17 +1,17 @@ local log = require('hurl.vlog') local git = require('hurl.git_utils') -local util = {} +local M = {} --- Get the log file path ---@return string -util.get_log_file_path = function() +M.get_log_file_path = function() return log.get_log_file() end --- Log info ---@vararg any -util.log_info = function(...) +M.log_info = function(...) -- Only save log when debug is on if not _HURL_GLOBAL_CONFIG.debug then return @@ -22,7 +22,7 @@ end --- Log error ---@vararg any -util.log_error = function(...) +M.log_error = function(...) -- Only save log when debug is on if not _HURL_GLOBAL_CONFIG.debug then return @@ -33,7 +33,7 @@ end --- Show info notification ---@vararg any -util.notify = function(...) +M.notify = function(...) -- Ignore if the flag is off if not _HURL_GLOBAL_CONFIG.show_notification then return @@ -44,7 +44,7 @@ end --- Get visual selection ---@return string[] -util.get_visual_selection = function() +M.get_visual_selection = function() local s_start = vim.fn.getpos("'<") local s_end = vim.fn.getpos("'>") local n_lines = math.abs(s_end[2] - s_start[2]) + 1 @@ -61,7 +61,7 @@ end --- Create tmp file ---@param content any ---@return string|nil -util.create_tmp_file = function(content) +M.create_tmp_file = function(content) -- create temp file base on pid and datetime local tmp_file = string.format( '%s/%s.hurl', @@ -70,8 +70,8 @@ util.create_tmp_file = function(content) ) if not tmp_file then - util.lor_error('hurl: failed to create tmp file') - util.notify('hurl: failed to create tmp file', vim.log.levels.ERROR) + M.log_error('hurl: failed to create tmp file') + M.notify('hurl: failed to create tmp file', vim.log.levels.ERROR) return end @@ -94,7 +94,7 @@ end ---@param cmd string The command name ---@param func function The function to execute ---@param opt table The options -util.create_cmd = function(cmd, func, opt) +M.create_cmd = function(cmd, func, opt) opt = vim.tbl_extend('force', { desc = 'hurl.nvim ' .. cmd }, opt or {}) vim.api.nvim_create_user_command(cmd, func, opt) end @@ -103,7 +103,7 @@ end ---@param body string ---@param type 'json' | 'html' | 'xml' | 'text' ---@return string[] | nil -util.format = function(body, type) +M.format = function(body, type) local formatters = _HURL_GLOBAL_CONFIG.formatters or { json = { 'jq' }, @@ -116,72 +116,25 @@ util.format = function(body, type) return vim.split(body, '\n') end - util.log_info('formatting body with ' .. type) + M.log_info('formatting body with ' .. type) local stdout = vim.fn.systemlist(formatters[type], body) if vim.v.shell_error ~= 0 then - util.log_error('formatter failed' .. vim.v.shell_error) - util.notify('formatter failed' .. vim.v.shell_error, vim.log.levels.ERROR) + M.log_error('formatter failed' .. vim.v.shell_error) return vim.split(body, '\n') end if stdout == nil or #stdout == 0 then - util.log_info('formatter returned empty body') + M.log_info('formatter returned empty body') return vim.split(body, '\n') end - util.log_info('formatted body: ' .. table.concat(stdout, '\n')) + M.log_info('formatted body: ' .. table.concat(stdout, '\n')) return stdout end ---- Render header table ----@param headers table -util.render_header_table = function(headers) - local result = {} - local maxKeyLength = 0 - for k, _ in pairs(headers) do - maxKeyLength = math.max(maxKeyLength, #k) - end - - local line = 0 - for k, v in pairs(headers) do - line = line + 1 - if line == 1 then - -- Add header for the table view - table.insert( - result, - string.format('%-' .. maxKeyLength .. 's | %s', 'Header Key', 'Header Value') - ) - - line = line + 1 - end - table.insert(result, string.format('%-' .. maxKeyLength .. 's | %s', k, v)) - end - - return { - line = line, - headers = result, - } -end - ---- Check if the response is json ----@param content_type string ----@return boolean -util.is_json_response = function(content_type) - return string.find(content_type, 'json') ~= nil -end - -util.is_html_response = function(content_type) - return string.find(content_type, 'text/html') ~= nil -end - -util.is_xml_response = function(content_type) - return string.find(content_type, 'text/xml') ~= nil - or string.find(content_type, 'application/xml') ~= nil -end - --- Check if nvim is running in nightly or stable version ---@return boolean -util.is_nightly = function() +M.is_nightly = function() local is_stable_version = false if vim.fn.has('nvim-0.11.0') == 1 then is_stable_version = true @@ -198,7 +151,7 @@ local function treesitter_parser_available(ft) return res and parser ~= nil end -util.is_hurl_parser_available = treesitter_parser_available('hurl') +M.is_hurl_parser_available = treesitter_parser_available('hurl') -- Looking for vars.env file base on the current file buffer ---@return table @@ -252,7 +205,7 @@ end -- Looking for vars.env file base on the current file buffer ---@return table -util.find_env_files_in_folders = function() +M.find_env_files_in_folders = function() local root_dir = vim.fn.expand('%:p:h') local cache_dir = vim.fn.stdpath('cache') local current_file_dir = vim.fn.expand('%:p:h:h') @@ -286,9 +239,10 @@ util.find_env_files_in_folders = function() return env_files end -util.has_file_in_opts = function(opts) + +M.has_file_in_opts = function(opts) if #opts == 0 then - util.log_error('No file path provided in opts.') + M.log_error('No file path provided in opts.') return false end @@ -296,7 +250,7 @@ util.has_file_in_opts = function(opts) local file = io.open(file_path, 'r') if not file then - util.log_error('Error: Failed to open file: ' .. file_path) + M.log_error('Error: Failed to open file: ' .. file_path) vim.notify('Error: Failed to open file: ' .. file_path, vim.log.levels.ERROR) return false end @@ -313,4 +267,4 @@ util.has_file_in_opts = function(opts) return false end -return util +return M diff --git a/test/hurl_parser_spec.lua b/test/hurl_parser_spec.lua new file mode 100644 index 0000000..e355089 --- /dev/null +++ b/test/hurl_parser_spec.lua @@ -0,0 +1,494 @@ +local hurl_parser = require('hurl.lib.hurl_parser') + +describe('Hurl Parser', function() + it('should parse hurl output correctly', function() + local stderr = [[ +* Executing 1/2 entries +* ------------------------------------------------------------------------------ +* Executing entry 1 +* +* Cookie store: +* +* Request: +* GET https://dogapi.dog/api/v2/breeds +* +* Request can be run with the following curl command: +* curl 'https://dogapi.dog/api/v2/breeds' +* +> GET /api/v2/breeds HTTP/2 +> Host: dogapi.dog +> Accept: */* +> User-Agent: hurl/5.0.1 +> +* Request body: +* +* Response: (received 8035 bytes in 1352 ms) +* +< HTTP/2 200 +< cache-control: max-age=0, private, must-revalidate +< content-type: application/vnd.api+json; charset=utf-8 +< etag: W/"6e98619b9e70f8f3f0fe1739d6e7e48f" +< referrer-policy: strict-origin-when-cross-origin +< vary: Accept, Origin +< x-content-type-options: nosniff +< x-download-options: noopen +< x-frame-options: SAMEORIGIN +< x-permitted-cross-domain-policies: none +< x-request-id: 3a8f5671-3330-434d-94cf-bf286620de69 +< x-runtime: 0.030100 +< x-xss-protection: 0 +< date: Thu, 24 Oct 2024 14:13:35 GMT +< +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* +* Response body: +* Bytes <7b2264617461223a5b7b226964223a2236386634376335612d353131352d343763642d393834392d653435643363333738663132222c2274797065223a226272...> +* +* Timings: +* begin: 2024-10-24 14:13:34.437445 UTC +* end: 2024-10-24 14:13:35.790169750 UTC +* namelookup: 1985 µs +* connect: 49931 µs +* app_connect: 887533 µs +* pre_transfer: 887930 µs +* start_transfer: 1195453 µs +* total: 1352357 µs +* +]] + local stdout = + [[{"data":[{"id":"68f47c5a-5115-47cd-9849-e45d3c378f12","type":"breed","attributes":{"name":"Caucasian Shepherd Dog","description":"The Caucasian Shepherd Dog is a large and powerful breed of dog from the Caucasus Mountains region. These dogs are large in size, with a thick double coat to protect them from the cold. They have a regal bearing, with a proud and confident demeanor. They are highly intelligent and loyal, making them excellent guard dogs. They are courageous and alert, with an instinct to protect their family and property. They are highly trainable, but require firm and consistent training.","life":{"max":20,"min":15},"male_weight":{"max":90,"min":50},"female_weight":{"max":70,"min":45},"hypoallergenic":false},"relationships":{"group":{"data":{"id":"8000793f-a1ae-4ec4-8d55-ef83f1f644e5","type":"group"}}}}]}]] + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('GET', entry.requestMethod) + assert.are.equal('https://dogapi.dog/api/v2/breeds', entry.requestUrl) + assert.are.equal('HTTP/2 200', entry.response.status) + assert.are.equal( + 'application/vnd.api+json; charset=utf-8', + entry.response.headers['content-type'] + ) + assert.are.equal('max-age=0, private, must-revalidate', entry.response.headers['cache-control']) + assert.are.equal('Thu, 24 Oct 2024 14:13:35 GMT', entry.response.headers['date']) + assert.are.equal('1352357 µs', entry.timings['total']) + assert.are.equal('49931 µs', entry.timings['connect']) + assert.are.equal('1985 µs', entry.timings['namelookup']) + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + end) + + it('should parse hurl output with error correctly', function() + local stderr = [[ +* ------------------------------------------------------------------------------ +* Executing entry 2 +error: Undefined variable + --> /Users/huynhdung/Projects/research/vscode-hurl-runner/example/dogs.hurl:9:40 + | + 9 | GET https://dogapi.dog/api/v2/breeds/{{id}} + | ^^ you must set the variable id + | +]] + local stdout = '' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('Undefined variable', entry.error:match('^[^\n]+')) + assert.is_true(entry.error:find('you must set the variable id') ~= nil) + end) + + it('should parse verbose output with multiple entries correctly', function() + local stderr = [[ +* Executing 2/2 entries +* ------------------------------------------------------------------------------ +* Executing entry 1 +* +* Cookie store: +* +* Request: +* GET https://dogapi.dog/api/v2/breeds +* +* Request can be run with the following curl command: +* curl 'https://dogapi.dog/api/v2/breeds' +* +> GET /api/v2/breeds HTTP/2 +> Host: dogapi.dog +> Accept: */* +> User-Agent: hurl/5.0.1 +> +* Response: (received 8035 bytes in 1272 ms) +* +< HTTP/2 200 +< cache-control: max-age=0, private, must-revalidate +< content-type: application/vnd.api+json; charset=utf-8 +< etag: W/"6e98619b9e70f8f3f0fe1739d6e7e48f" +< referrer-policy: strict-origin-when-cross-origin +< vary: Accept, Origin +< x-content-type-options: nosniff +< x-download-options: noopen +< x-frame-options: SAMEORIGIN +< x-permitted-cross-domain-policies: none +< x-request-id: 8080162a-0b2c-4972-b6ef-6fe9ecdb44fc +< x-runtime: 0.021512 +< x-xss-protection: 0 +< date: Thu, 24 Oct 2024 14:17:28 GMT +< +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* +* ------------------------------------------------------------------------------ +* Executing entry 2 +* +* Cookie store: +* +* Request: +* GET https://dogapi.dog/api/v2/breeds/68f47c5a-5115-47cd-9849-e45d3c378f12 +* +* Request can be run with the following curl command: +* curl 'https://dogapi.dog/api/v2/breeds/68f47c5a-5115-47cd-9849-e45d3c378f12' +* +> GET /api/v2/breeds/68f47c5a-5115-47cd-9849-e45d3c378f12 HTTP/2 +> Host: dogapi.dog +> Accept: */* +> User-Agent: hurl/5.0.1 +> +* Response: (received 915 bytes in 241 ms) +* +< HTTP/2 200 +< cache-control: max-age=0, private, must-revalidate +< content-type: application/vnd.api+json; charset=utf-8 +< etag: W/"50468c5bd4e22a8856dda44b0c5ab6d9" +< referrer-policy: strict-origin-when-cross-origin +< vary: Accept, Origin +< x-content-type-options: nosniff +< x-download-options: noopen +< x-frame-options: SAMEORIGIN +< x-permitted-cross-domain-policies: none +< x-request-id: eea473d8-8502-40ab-ac91-e5598e43e85e +< x-runtime: 0.011166 +< x-xss-protection: 0 +< date: Thu, 24 Oct 2024 14:17:28 GMT +< +]] + local stdout = [[ +{"data":{"id":"68f47c5a-5115-47cd-9849-e45d3c378f12","type":"breed","attributes":{"name":"Caucasian Shepherd Dog","description":"The Caucasian Shepherd Dog is a large and powerful breed of dog from the Caucasus Mountains region. These dogs are large in size, with a thick double coat to protect them from the cold. They have a regal bearing, with a proud and confident demeanor. They are highly intelligent and loyal, making them excellent guard dogs. They are courageous and alert, with an instinct to protect their family and property. They are highly trainable, but require firm and consistent training.","life":{"max":20,"min":15},"male_weight":{"max":90,"min":50},"female_weight":{"max":70,"min":45},"hypoallergenic":false},"relationships":{"group":{"data":{"id":"8000793f-a1ae-4ec4-8d55-ef83f1f644e5","type":"group"}}}},"links":{"self":"https://dogapi.dog/api/v2/breeds/68f47c5a-5115-47cd-9849-e45d3c378f12"}} +]] + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(2, #result.entries) + + -- Check first entry + local entry1 = result.entries[1] + assert.are.equal('GET', entry1.requestMethod) + assert.are.equal('https://dogapi.dog/api/v2/breeds', entry1.requestUrl) + assert.are.equal('HTTP/2 200', entry1.response.status) + assert.are.equal( + 'application/vnd.api+json; charset=utf-8', + entry1.response.headers['content-type'] + ) + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry1.captures['id']) + + -- Check second entry + local entry2 = result.entries[2] + assert.are.equal('GET', entry2.requestMethod) + assert.are.equal( + 'https://dogapi.dog/api/v2/breeds/68f47c5a-5115-47cd-9849-e45d3c378f12', + entry2.requestUrl + ) + assert.are.equal('HTTP/2 200', entry2.response.status) + assert.are.equal( + 'application/vnd.api+json; charset=utf-8', + entry2.response.headers['content-type'] + ) + assert.is_true(entry2.response.body:find('"name":"Caucasian Shepherd Dog"') ~= nil) + end) + + it( + 'should parse captures correctly when there is no space between timings and captures', + function() + local stderr = [[ +* Executing entry 1 +* Timings: +* begin: 2024-10-26 07:39:55.048471 UTC +* end: 2024-10-26 07:39:56.990312125 UTC +* namelookup: 165918 µs +* connect: 467360 µs +* app_connect: 1288249 µs +* pre_transfer: 1288938 µs +* start_transfer: 1904466 µs +* total: 1941484 µs +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +]] + local stdout = '' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + assert.are.equal('1941484 µs', entry.timings['total']) + assert.are.equal('2024-10-26 07:39:55.048471 UTC', entry.timings['begin']) + assert.are.equal('2024-10-26 07:39:56.990312125 UTC', entry.timings['end']) + end + ) + + it('should parse captures correctly when there is a response body section', function() + local stderr = [[ +* Executing entry 1 +* Response body: +* Bytes <7b2264617461223a5b7b226964223a2236386634376335612d353131352d343763642d393834392d653435643363333738663132222c2274797065223a226272...> +* +* Timings: +* begin: 2024-10-26 07:47:00.610282 UTC +* end: 2024-10-26 07:47:02.170303375 UTC +* namelookup: 1304 µs +* connect: 280119 µs +* app_connect: 989082 µs +* pre_transfer: 989736 µs +* start_transfer: 1523378 µs +* total: 1559741 µs +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* name: Caucasian Shepherd Dog +]] + local stdout = '' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + assert.are.equal('Caucasian Shepherd Dog', entry.captures['name']) + assert.are.equal('1559741 µs', entry.timings['total']) + assert.are.equal('2024-10-26 07:47:00.610282 UTC', entry.timings['begin']) + assert.are.equal('2024-10-26 07:47:02.170303375 UTC', entry.timings['end']) + end) + + it('should parse verbose output correctly', function() + local stderr = [[ +* Variables: +* manga_id: 8b34f37a-0181-4f0b-8ce3-01217e9a602c +* Executing 1/2 entries +* ------------------------------------------------------------------------------ +* Executing entry 1 +* +* Cookie store: +* +* Request: +* GET https://dogapi.dog/api/v2/breeds +* +* Request can be run with the following curl command: +* curl 'https://dogapi.dog/api/v2/breeds' +* +> GET /api/v2/breeds HTTP/2 +> Host: dogapi.dog +> Accept: */* +> User-Agent: hurl/5.0.1 +> +* Request body: +* +* Response: (received 8035 bytes in 1643 ms) +* +< HTTP/2 200 +< cache-control: max-age=0, private, must-revalidate +< content-type: application/vnd.api+json; charset=utf-8 +< etag: W/"6e98619b9e70f8f3f0fe1739d6e7e48f" +< referrer-policy: strict-origin-when-cross-origin +< vary: Accept, Origin +< x-content-type-options: nosniff +< x-download-options: noopen +< x-frame-options: SAMEORIGIN +< x-permitted-cross-domain-policies: none +< x-request-id: 3defaaba-6aa9-4e21-a92a-9b8ffa68722c +< x-runtime: 0.019435 +< x-xss-protection: 0 +< date: Sat, 26 Oct 2024 07:53:12 GMT +< +* Response body: +* Bytes <7b2264617461223a5b7b226964223a2236386634376335612d353131352d343763642d393834392d653435643363333738663132222c2274797065223a226272...> +* +* Timings: +* begin: 2024-10-26 07:53:10.697106 UTC +* end: 2024-10-26 07:53:12.340969625 UTC +* namelookup: 462855 µs +* connect: 685650 µs +* app_connect: 1178810 µs +* pre_transfer: 1179386 µs +* start_transfer: 1605548 µs +* total: 1643543 µs +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* name: Caucasian Shepherd Dog +* +]] + local stdout = + '{"data":[{"id":"68f47c5a-5115-47cd-9849-e45d3c378f12","type":"breed","attributes":{"name":"Caucasian Shepherd Dog"}}]}' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('GET', entry.requestMethod) + assert.are.equal('https://dogapi.dog/api/v2/breeds', entry.requestUrl) + assert.are.equal('HTTP/2 200', entry.response.status) + assert.are.equal( + 'application/vnd.api+json; charset=utf-8', + entry.response.headers['content-type'] + ) + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + assert.are.equal('Caucasian Shepherd Dog', entry.captures['name']) + assert.are.equal('1643543 µs', entry.timings['total']) + assert.are.equal('2024-10-26 07:53:10.697106 UTC', entry.timings['begin']) + assert.are.equal('2024-10-26 07:53:12.340969625 UTC', entry.timings['end']) + assert.is_true(entry.response.body:find('"name":"Caucasian Shepherd Dog"') ~= nil) + end) + + it('should parse verbose output with SSL information correctly', function() + local stderr = [[ +* Variables: +* manga_id: 8b34f37a-0181-4f0b-8ce3-01217e9a602c +* Executing 1/2 entries +* ------------------------------------------------------------------------------ +* Executing entry 1 +* +* Cookie store: +* +* Request: +* GET https://dogapi.dog/api/v2/breeds +* +* Request can be run with the following curl command: +* curl 'https://dogapi.dog/api/v2/breeds' +* +** Host dogapi.dog:443 was resolved. +** IPv6: (none) +** IPv4: 167.71.54.211 +** Trying 167.71.54.211:443... +** Connected to dogapi.dog (167.71.54.211) port 443 +** ALPN: curl offers h2,http/1.1 +** CAfile: /etc/ssl/cert.pem +** CApath: none +** (304) (OUT), TLS handshake, Client hello (1): +** (304) (IN), TLS handshake, Server hello (2): +** TLSv1.2 (IN), TLS handshake, Certificate (11): +** TLSv1.2 (IN), TLS handshake, Server key exchange (12): +** TLSv1.2 (IN), TLS handshake, Server finished (14): +** TLSv1.2 (OUT), TLS handshake, Client key exchange (16): +** TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1): +** TLSv1.2 (OUT), TLS handshake, Finished (20): +** TLSv1.2 (IN), TLS change cipher, Change cipher spec (1): +** TLSv1.2 (IN), TLS handshake, Finished (20): +** SSL connection using TLSv1.2 / ECDHE-RSA-CHACHA20-POLY1305 / [blank] / UNDEF +** ALPN: server accepted h2 +** Server certificate: +** subject: CN=dogapi.dog +** start date: Oct 12 00:28:09 2024 GMT +** expire date: Jan 10 00:28:08 2025 GMT +** subjectAltName: host "dogapi.dog" matched cert's "dogapi.dog" +** issuer: C=US; O=Let's Encrypt; CN=R11 +** SSL certificate verify ok. +** using HTTP/2 +** [HTTP/2] [1] OPENED stream for https://dogapi.dog/api/v2/breeds +** [HTTP/2] [1] [:method: GET] +** [HTTP/2] [1] [:scheme: https] +** [HTTP/2] [1] [:authority: dogapi.dog] +** [HTTP/2] [1] [:path: /api/v2/breeds] +** [HTTP/2] [1] [accept: */*] +** [HTTP/2] [1] [user-agent: hurl/5.0.1] +> GET /api/v2/breeds HTTP/2 +> Host: dogapi.dog +> Accept: */* +> User-Agent: hurl/5.0.1 +> +* Request body: +* +** Request completely sent off +** Connection #0 to host dogapi.dog left intact +* Response: (received 8035 bytes in 1135 ms) +* +< HTTP/2 200 +< cache-control: max-age=0, private, must-revalidate +< content-type: application/vnd.api+json; charset=utf-8 +< etag: W/"6e98619b9e70f8f3f0fe1739d6e7e48f" +< referrer-policy: strict-origin-when-cross-origin +< vary: Accept, Origin +< x-content-type-options: nosniff +< x-download-options: noopen +< x-frame-options: SAMEORIGIN +< x-permitted-cross-domain-policies: none +< x-request-id: 068e99e9-bea8-4280-8c7e-d28f00a6ec35 +< x-runtime: 0.035859 +< x-xss-protection: 0 +< date: Sat, 26 Oct 2024 07:55:07 GMT +< +* Response body: +* Bytes <7b2264617461223a5b7b226964223a2236386634376335612d353131352d343763642d393834392d653435643363333738663132222c2274797065223a226272...> +* +* Timings: +* begin: 2024-10-26 07:55:05.994961 UTC +* end: 2024-10-26 07:55:07.130251375 UTC +* namelookup: 1212 µs +* connect: 208799 µs +* app_connect: 690830 µs +* pre_transfer: 691349 µs +* start_transfer: 1097045 µs +* total: 1135005 µs +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* name: Caucasian Shepherd Dog +* +]] + local stdout = + '{"data":[{"id":"68f47c5a-5115-47cd-9849-e45d3c378f12","type":"breed","attributes":{"name":"Caucasian Shepherd Dog"}}]}' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('GET', entry.requestMethod) + assert.are.equal('https://dogapi.dog/api/v2/breeds', entry.requestUrl) + assert.are.equal('HTTP/2 200', entry.response.status) + assert.are.equal( + 'application/vnd.api+json; charset=utf-8', + entry.response.headers['content-type'] + ) + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + assert.are.equal('Caucasian Shepherd Dog', entry.captures['name']) + assert.are.equal('1135005 µs', entry.timings['total']) + assert.are.equal('2024-10-26 07:55:05.994961 UTC', entry.timings['begin']) + assert.are.equal('2024-10-26 07:55:07.130251375 UTC', entry.timings['end']) + assert.is_true(entry.response.body:find('"name":"Caucasian Shepherd Dog"') ~= nil) + end) + + it('should parse timings and captures correctly with Captures: line', function() + local stderr = [[ +* Executing entry 1 +* Timings: +* begin: 2024-10-26 07:58:32.650882 UTC +* end: 2024-10-26 07:58:33.774139 UTC +* namelookup: 1803 µs +* connect: 230174 µs +* app_connect: 712316 µs +* pre_transfer: 712848 µs +* start_transfer: 957261 µs +* total: 1122952 µs +* Captures: +* id: 68f47c5a-5115-47cd-9849-e45d3c378f12 +* name: Caucasian Shepherd Dog +]] + local stdout = '' + + local result = hurl_parser.parse_hurl_output(stderr, stdout) + + assert.are.equal(1, #result.entries) + local entry = result.entries[1] + assert.are.equal('1122952 µs', entry.timings['total']) + assert.are.equal('2024-10-26 07:58:32.650882 UTC', entry.timings['begin']) + assert.are.equal('2024-10-26 07:58:33.774139 UTC', entry.timings['end']) + assert.are.equal('68f47c5a-5115-47cd-9849-e45d3c378f12', entry.captures['id']) + assert.are.equal('Caucasian Shepherd Dog', entry.captures['name']) + end) +end)