Skip to content

Commit 44b5d38

Browse files
committed
feat: improved response formatting, file extension detection, and buffer commands
- Automatic file extension detection when saving responses (.json, .csv, .xml, .html, .txt) - Prettified buffer display for JSON, XML - Added :HttpResponseTab command to open the latest response buffer in a new tab - Improved Content-Type detection for CSV (text/csv, application/csv) - Always sanitize and re-encode JSON for valid output (jq/jmespath compatibility) - Fixed Neovim fast event context errors with pure Lua list detection - Save dialog now suggests correct file extension based on response type - Improved robustness for malformed or double-encoded JSON from APIs
1 parent 9df88ea commit 44b5d38

File tree

9 files changed

+202
-38
lines changed

9 files changed

+202
-38
lines changed

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Release
2+
on:
3+
push:
4+
branches:
5+
- main
6+
7+
jobs:
8+
release:
9+
name: Release
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
with:
15+
fetch-depth: 0
16+
17+
- name: Setup Node.js
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version: '20'
21+
22+
- name: Install dependencies
23+
run: npm install -g semantic-release @semantic-release/git @semantic-release/github @semantic-release/changelog
24+
25+
- name: Release
26+
env:
27+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
run: npx semantic-release

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.idea
2+

.releaserc.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"branches": ["main"],
3+
"plugins": [
4+
"@semantic-release/commit-analyzer",
5+
"@semantic-release/release-notes-generator",
6+
"@semantic-release/changelog",
7+
"@semantic-release/github",
8+
"@semantic-release/git"
9+
]
10+
}

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
9+
## [1.4.3] 2025-04-26
10+
### Added
11+
* Automatic file extension detection when saving responses:
12+
* .json for JSON, .csv for CSV, .xml for XML, .html for HTML, .txt as fallback
13+
* Prettified buffer display for JSON, XML
14+
* New command :HttpResponseTab to open the latest response buffer in a new tab
15+
* Content-Type detection improved: text/csv and application/csv are now recognized as CSV
16+
17+
### Fixed
18+
* Always sanitize and re-encode JSON responses to guarantee valid JSON output (for jq/jmespath/json path compatibility)
19+
* Fixed plugin to avoid Neovim fast event context errors by using pure Lua list detection
20+
21+
### Improved
22+
* Save dialog now suggests the correct file extension based on response type
23+
* General robustness for malformed or double-encoded JSON from APIs
24+
825
## [1.4.2] 2025-04-08
926
### Fixed
1027
* Fixed bug where `:HttpEnvFile` would give error saying config is nil.

doc/tags

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@
66
:HttpRunAll http_client.txt /*:HttpRunAll*
77
:HttpStop http_client.txt /*:HttpStop*
88
:HttpVerbose http_client.txt /*:HttpVerbose*
9+
http_client-autocompletion http_client.txt /*http_client-autocompletion*
910
http_client-commands http_client.txt /*http_client-commands*
1011
http_client-comments http_client.txt /*http_client-comments*
1112
http_client-config-keybindings http_client.txt /*http_client-config-keybindings*
1213
http_client-configuration http_client.txt /*http_client-configuration*
14+
http_client-content-type-completion http_client.txt /*http_client-content-type-completion*
1315
http_client-contents http_client.txt /*http_client-contents*
1416
http_client-copy-curl http_client.txt /*http_client-copy-curl*
1517
http_client-default_env_file http_client.txt /*http_client-default_env_file*
1618
http_client-dry-run http_client.txt /*http_client-dry-run*
19+
http_client-env-completion http_client.txt /*http_client-env-completion*
1720
http_client-environment-files http_client.txt /*http_client-environment-files*
1821
http_client-global-variables http_client.txt /*http_client-global-variables*
22+
http_client-header-completion http_client.txt /*http_client-header-completion*
1923
http_client-http-versions http_client.txt /*http_client-http-versions*
2024
http_client-keybindings http_client.txt /*http_client-keybindings*
25+
http_client-method-completion http_client.txt /*http_client-method-completion*
2126
http_client-no-environment http_client.txt /*http_client-no-environment*
2227
http_client-request_timeout http_client.txt /*http_client-request_timeout*
2328
http_client-response-handlers http_client.txt /*http_client-response-handlers*

lua/http_client/commands/response.lua

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,21 @@ M.save_response = function(opts)
99
return
1010
end
1111

12-
local default_ext = response.content_type == "json" and ".json"
13-
or response.content_type == "xml" and ".xml"
14-
or response.content_type == "html" and ".html"
15-
or ".txt"
12+
local function get_extension_for_content_type(content_type)
13+
if content_type == "json" then
14+
return ".json"
15+
elseif content_type == "xml" then
16+
return ".xml"
17+
elseif content_type == "html" then
18+
return ".html"
19+
elseif content_type == "csv" then
20+
return ".csv"
21+
else
22+
return ".txt"
23+
end
24+
end
1625

26+
local default_ext = get_extension_for_content_type(response.content_type)
1727
local content = opts and opts.raw and response.raw_body or response.formatted_body
1828

1929
vim.ui.input({

lua/http_client/core/http_client.lua

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,45 +31,44 @@ local function detect_content_type(headers)
3131
return "xml"
3232
elseif content_type:match("text/html") then
3333
return "html"
34+
elseif content_type:match("text/csv") or content_type:match("application/csv") then
35+
return "csv"
3436
end
3537
end
3638
return "text"
3739
end
3840

41+
local function clean_invalid_escapes(json_str)
42+
-- Remove octal/decimal escapes like \13
43+
json_str = json_str:gsub("\\%d%d?%d?", "")
44+
-- Remove any other invalid escapes (not one of the valid JSON escapes)
45+
json_str = json_str:gsub("\\([^\"\\/bfnrtu])", "")
46+
return json_str
47+
end
48+
49+
local function is_list(t)
50+
if type(t) ~= "table" then return false end
51+
local i = 0
52+
for _ in pairs(t) do
53+
i = i + 1
54+
if t[i] == nil then return false end
55+
end
56+
return true
57+
end
58+
3959
local function format_json(body)
40-
local ok, parsed = pcall(vim.json.decode, body)
60+
local cleaned_body = clean_invalid_escapes(body)
61+
local ok, parsed = pcall(vim.json.decode, cleaned_body)
4162
if not ok then
42-
return body -- Return original body if it's not valid JSON
63+
return "{}" -- Always return valid JSON
4364
end
44-
45-
local function encode_with_indent(value, indent)
46-
indent = indent or ""
47-
local newline = "\n" .. indent
48-
49-
if value == vim.NIL then
50-
return "null"
51-
elseif type(value) == "table" then
52-
if vim.tbl_islist(value) then
53-
local items = {}
54-
for _, v in ipairs(value) do
55-
table.insert(items, encode_with_indent(v, indent .. " "))
56-
end
57-
return "[" .. newline .. " " .. table.concat(items, "," .. newline .. " ") .. newline .. "]"
58-
else
59-
local items = {}
60-
for k, v in pairs(value) do
61-
table.insert(items, string.format('%q: %s', k, encode_with_indent(v, indent .. " ")))
62-
end
63-
return "{" .. newline .. " " .. table.concat(items, "," .. newline .. " ") .. newline .. "}"
64-
end
65-
elseif type(value) == "string" then
66-
return string.format('%q', value)
67-
else
68-
return tostring(value)
69-
end
65+
-- Always re-encode to ensure valid JSON
66+
local ok2, encoded = pcall(vim.json.encode, parsed)
67+
if ok2 then
68+
return encoded
69+
else
70+
return "{}"
7071
end
71-
72-
return encode_with_indent(parsed)
7372
end
7473

7574
local function format_xml(body)
@@ -99,6 +98,70 @@ local function format_headers(headers)
9998
return table.concat(formatted, "\n")
10099
end
101100

101+
local function prettify_json(json_str)
102+
local ok, parsed = pcall(vim.json.decode, json_str)
103+
if not ok then
104+
return json_str -- fallback to original if not valid JSON
105+
end
106+
local function encode_pretty(val, indent)
107+
indent = indent or ""
108+
local next_indent = indent .. " "
109+
if type(val) == "table" then
110+
local is_array = is_list(val)
111+
local items = {}
112+
if is_array then
113+
for _, v in ipairs(val) do
114+
table.insert(items, encode_pretty(v, next_indent))
115+
end
116+
return "[\n" .. next_indent .. table.concat(items, ",\n" .. next_indent) .. "\n" .. indent .. "]"
117+
else
118+
for k, v in pairs(val) do
119+
table.insert(items, string.format('%q: %s', k, encode_pretty(v, next_indent)))
120+
end
121+
return "{\n" .. next_indent .. table.concat(items, ",\n" .. next_indent) .. "\n" .. indent .. "}"
122+
end
123+
elseif type(val) == "string" then
124+
return string.format('%q', val)
125+
else
126+
return tostring(val)
127+
end
128+
end
129+
return encode_pretty(parsed)
130+
end
131+
132+
local function prettify_csv(csv_str)
133+
-- Split lines
134+
local lines = {}
135+
for line in csv_str:gmatch("[^\r\n]+") do
136+
table.insert(lines, line)
137+
end
138+
-- Split columns and find max width for each column
139+
local columns = {}
140+
for i, line in ipairs(lines) do
141+
local row = {}
142+
for cell in line:gmatch("([^,]+)") do
143+
table.insert(row, vim.trim(cell))
144+
end
145+
columns[i] = row
146+
end
147+
local col_widths = {}
148+
for _, row in ipairs(columns) do
149+
for j, cell in ipairs(row) do
150+
col_widths[j] = math.max(col_widths[j] or 0, #cell)
151+
end
152+
end
153+
-- Build formatted string
154+
local formatted = {}
155+
for _, row in ipairs(columns) do
156+
local cells = {}
157+
for j, cell in ipairs(row) do
158+
table.insert(cells, cell .. string.rep(' ', col_widths[j] - #cell))
159+
end
160+
table.insert(formatted, table.concat(cells, " | "))
161+
end
162+
return table.concat(formatted, "\n")
163+
end
164+
102165
local function prepare_response(request, response)
103166
local content_type = detect_content_type(response.headers or {})
104167
local formatted_body = response.body or "No body"
@@ -144,6 +207,15 @@ local function display_response(pr)
144207
end
145208
end
146209

210+
local formatted_body = pr.formatted_body
211+
if pr.content_type == "json" then
212+
formatted_body = prettify_json(pr.formatted_body)
213+
elseif pr.content_type == "xml" then
214+
formatted_body = format_xml(pr.formatted_body)
215+
elseif pr.content_type == "csv" then
216+
-- formatted_body = prettify_csv(pr.formatted_body)
217+
end
218+
147219
local content = string.format([[
148220
Response Information (%s):
149221
---------------------
@@ -164,7 +236,7 @@ Response Information (%s):
164236
format_headers(pr.headers),
165237
timing_str,
166238
pr.content_type,
167-
pr.formatted_body
239+
formatted_body
168240
)
169241

170242
ui.display_in_buffer(content, "HTTP Response")

lua/http_client/init.lua

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ M.setup = function(opts)
5454
M.v = require("http_client.utils.verbose")
5555
M.commands = require("http_client.commands")
5656
M.completion = require("http_client.completion")
57-
57+
5858
-- Initialize completion module
5959
M.completion.setup()
6060

6161
-- Set up filetype detection
62-
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
63-
pattern = {"*.http", "*.rest"},
62+
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
63+
pattern = { "*.http", "*.rest" },
6464
callback = function()
6565
vim.bo.filetype = "http"
6666
end
@@ -131,6 +131,12 @@ M.setup = function(opts)
131131
desc = "Save the current HTTP response body to a file (formatted)",
132132
})
133133

134+
vim.api.nvim_create_user_command("HttpResponseTab", function()
135+
require("http_client.ui.display").open_latest_response_in_tab()
136+
end, {
137+
desc = "Open the latest HTTP response buffer in a new tab",
138+
})
139+
134140
setup_docs()
135141
set_keybindings()
136142

lua/http_client/ui/display.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,19 @@ function M.display_in_buffer(content, title)
151151
end)
152152
end
153153

154+
function M.open_latest_response_in_tab()
155+
if #buffers == 0 then
156+
vim.notify("No response buffer available", vim.log.levels.WARN)
157+
return
158+
end
159+
local buf = buffers[1]
160+
if vim.api.nvim_buf_is_valid(buf) then
161+
vim.cmd('tabnew')
162+
vim.api.nvim_win_set_buf(0, buf)
163+
else
164+
vim.notify("Latest response buffer is not valid", vim.log.levels.WARN)
165+
end
166+
end
167+
154168
return M
155169

0 commit comments

Comments
 (0)