Skip to content

Commit 611450a

Browse files
feat: cookies
- parse multiple headers with same key - support cookies - save received cookies - automatically send request with matching cookies
1 parent 7fbc292 commit 611450a

File tree

14 files changed

+439
-70
lines changed

14 files changed

+439
-70
lines changed

ftplugin/http.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
vim.bo.commentstring = "# %s"
22

33
local commands = require("rest-nvim.commands")
4+
---@diagnostic disable-next-line: invisible
45
commands.init(0)

lua/rest-nvim/client/curl/cli.lua

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function parser.parse_header_pair(str)
6666
if not key then
6767
return
6868
end
69-
return key, vim.trim(value)
69+
return key:lower(), vim.trim(value)
7070
end
7171

7272
---@package
@@ -126,7 +126,10 @@ function parser.parse_verbose(lines)
126126
-- response header
127127
local key, value = parser.parse_header_pair(ln.str)
128128
if key then
129-
response.headers[key:lower()] = value
129+
if not response.headers[key] then
130+
response.headers[key] = {}
131+
end
132+
table.insert(response.headers[key], value)
130133
end
131134
end
132135
elseif ln.prefix == VERBOSE_PREFIX_STAT then
@@ -166,23 +169,27 @@ function builder.method(method)
166169
end
167170

168171
---@package
169-
---@param header table<string,string>
172+
---@param header table<string,string[]>
170173
---@return string[] args
171174
function builder.headers(header)
172-
return kv_to_list(
173-
(function()
174-
local upper = function(str)
175-
return string.gsub(" " .. str, "%W%l", string.upper):sub(2)
176-
end
177-
local normilzed = {}
178-
for k, v in pairs(header) do
179-
normilzed[upper(k:gsub("_", "%-"))] = v
180-
end
181-
return normilzed
182-
end)(),
183-
"-H",
184-
": "
185-
)
175+
local args = {}
176+
local upper = function(str)
177+
return string.gsub(" " .. str, "%W%l", string.upper):sub(2)
178+
end
179+
for key, values in pairs(header) do
180+
for _, value in ipairs(values) do
181+
vim.list_extend(args, { "-H", upper(key) .. ": " .. value })
182+
end
183+
end
184+
return args
185+
end
186+
187+
---@param cookies rest.Cookie[]
188+
---@return string[] args
189+
function builder.cookies(cookies)
190+
return vim.iter(cookies):map(function (cookie)
191+
return { "-b", cookie.name .. "=" .. cookie.value }
192+
end):totable()
186193
end
187194

188195
---@param body string?
@@ -260,9 +267,9 @@ end
260267
builder.STAT_ARGS = builder.statistics()
261268

262269
---build curl request arguments based on Request object
263-
---@param request rest.Request
270+
---@param req rest.Request
264271
---@return string[] args
265-
function builder.build(request)
272+
function builder.build(req)
266273
local args = {}
267274
---@param list table
268275
---@param value any
@@ -271,22 +278,23 @@ function builder.build(request)
271278
table.insert(list, value)
272279
end
273280
end
274-
insert(args, request.url)
275-
insert(args, builder.method(request.method))
276-
insert(args, builder.headers(request.headers))
277-
if request.body then
278-
if request.body.__TYPE == "form" then
279-
insert(args, builder.form(request.body.data))
280-
elseif request.body.__TYPE == "external" then
281-
insert(args, builder.file(request.body.data.path))
281+
insert(args, req.url)
282+
insert(args, builder.method(req.method))
283+
insert(args, builder.headers(req.headers))
284+
insert(args, builder.cookies(req.cookies))
285+
if req.body then
286+
if req.body.__TYPE == "form" then
287+
insert(args, builder.form(req.body.data))
288+
elseif req.body.__TYPE == "external" then
289+
insert(args, builder.file(req.body.data.path))
282290
else
283-
insert(args, builder.raw_body(request.body.data))
291+
insert(args, builder.raw_body(req.body.data))
284292
end
285293
end
286294
-- TODO: auth?
287-
insert(args, builder.http_version(request.http_version) or {})
295+
insert(args, builder.http_version(req.http_version) or {})
288296
insert(args, builder.STAT_ARGS)
289-
return vim.iter(args):flatten():totable()
297+
return vim.iter(args):flatten(math.huge):totable()
290298
end
291299

292300
---returns future containing Result

lua/rest-nvim/config/init.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,16 @@ local config
4949
--- to hide title If true, rest.nvim will use lowered `title` field
5050
---@field winbar? string|boolean
5151

52+
---@class rest.Opts.Cookies
53+
--- Whether to enable cookies support (Default: `true`)
54+
---@field enable? boolean
55+
--- File path to save cookies file
56+
--- (Default: `"stdpath("data")/rest-nvim.cookies"`)
57+
---@field path? string
58+
5259
---@class rest.Opts
60+
--- Cookies config
61+
---@field cookies? rest.Opts.Cookies
5362
--- Environment variables file pattern for telescope.nvim
5463
--- (Default: `".*env.*$"`)
5564
---@field env_pattern? string
@@ -70,6 +79,13 @@ vim.g.rest_nvim = vim.g.rest_nvim
7079
---rest.nvim default configuration
7180
---@class rest.Config
7281
local default_config = {
82+
---@class rest.Config.Cookies
83+
cookies = {
84+
---@type boolean Whether enable cookies support or not
85+
enable = true,
86+
---@type string Cookies file path
87+
path = vim.fs.joinpath(vim.fn.stdpath("data") --[[@as string]], "rest-nvim.cookies")
88+
},
7389
---@type string Environment variables file pattern for telescope.nvim
7490
env_pattern = ".*env.*$",
7591

lua/rest-nvim/context.lua

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ local M = {}
66

77
---@class rest.Context
88
---@field vars table<string,string>
9-
---@field files string[]
10-
---@field request? rest.Request
119
---@field response? rest.Response
1210
local Context = {}
1311
Context.__index = Context
@@ -46,7 +44,6 @@ function Context:new()
4644
local obj = {
4745
__index = self,
4846
vars = {},
49-
files = {},
5047
---@diagnostic disable-next-line: missing-fields
5148
response = {}, -- create response table here to pass the reference first
5249
}
@@ -56,7 +53,6 @@ end
5653

5754
---@param filepath string
5855
function Context:load_file(filepath)
59-
table.insert(self.files, filepath)
6056
dotenv.load_file(filepath, function (key, value)
6157
self:set(key, value)
6258
end)

lua/rest-nvim/cookie_jar.lua

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
---@mod rest-nvim.cookie_jar Cookie handler module
2+
3+
local M = {
4+
---@type rest.Cookie[]
5+
jar = {},
6+
}
7+
8+
local utils = require("rest-nvim.utils")
9+
local logger = require("rest-nvim.logger")
10+
local config = require("rest-nvim.config")
11+
12+
---@class rest.Cookie
13+
---@field name string
14+
---@field value string
15+
---@field domain string
16+
---@field path string
17+
---@field expires integer
18+
---@field max_date integer?
19+
---@field secure boolean?
20+
---@field httponly boolean?
21+
---@field samesite string?
22+
---@field priority string?
23+
24+
---Load Cookie jar from rest-nvim.cookies file
25+
function M.load_jar()
26+
if not utils.file_exists(config.cookies.path) then
27+
return
28+
end
29+
local file, openerr = io.open(config.cookies.path, "r")
30+
if not file then
31+
local err_msg = string.format("Failed to open rest.nvim cookies file: %s", openerr)
32+
vim.notify(err_msg, vim.log.levels.ERROR)
33+
logger.error(err_msg)
34+
return
35+
end
36+
for line in file:lines() do
37+
local seps = vim.split(line, "\t")
38+
if seps[1] ~= "" and not vim.startswith(seps[1], "#") then
39+
if #seps ~= 5 then
40+
local err_msg = "error while parsing cookies file at line:\n" .. line .. "\n"
41+
vim.notify(err_msg, vim.log.levels.ERROR)
42+
logger.error(err_msg)
43+
return
44+
end
45+
---@type rest.Cookie
46+
local cookie = {
47+
domain = seps[1],
48+
path = seps[2],
49+
name = seps[3],
50+
value = seps[4],
51+
expires = assert(tonumber(seps[5])),
52+
}
53+
table.insert(M.jar, cookie)
54+
end
55+
end
56+
end
57+
58+
---parse url to domain and path
59+
---path will be fallback to "/" if not found
60+
---@param url string
61+
---@return string domain
62+
---@return string path
63+
local function parse_url(url)
64+
local domain, path = url:match("^https?://([^/]+)(/[^?#]*)$")
65+
if not path then
66+
domain = url:match("^https?://([^/]+)")
67+
path = "/"
68+
end
69+
return domain, path
70+
end
71+
72+
---@private
73+
---parse Set-Cookie header to cookie
74+
---@param req_url string request URL to be used as fallback domain & path of cookie
75+
---@param header string
76+
---@return rest.Cookie?
77+
function M.parse_set_cookie(req_url, header)
78+
local name, value = header:match("^%s*([^=]+)=([^;]*)")
79+
if not name then
80+
logger.error("Invalid Set-Cookie header: " .. header)
81+
return
82+
end
83+
local cookie = {
84+
name = name,
85+
value = value or "",
86+
}
87+
for attr, val in header:gmatch(";%s*([^=]+)=?([^;]*)") do
88+
attr = attr:lower()
89+
if attr == "domain" then
90+
cookie.domain = val
91+
elseif attr == "path" then
92+
cookie.path = val
93+
elseif attr == "expires" then
94+
cookie.expires = utils.parse_http_time(val)
95+
elseif attr == "max-age" then
96+
cookie.max_age = tonumber(val)
97+
elseif attr == "secure" then
98+
cookie.secure = true
99+
elseif attr == "httponly" then
100+
cookie.httponly = true
101+
elseif attr == "samesite" then
102+
cookie.samesite = val
103+
elseif attr == "priority" then
104+
cookie.priority = val
105+
end
106+
end
107+
cookie.domain = cookie.domain or req_url:match("^https?://([^/]+)")
108+
cookie.domain = "." .. cookie.domain
109+
cookie.path = cookie.path or "/"
110+
cookie.expires = cookie.expires or -1
111+
return cookie
112+
end
113+
114+
---@param jar rest.Cookie[]
115+
---@param cookie rest.Cookie
116+
local function jar_insert(jar, cookie)
117+
for i, c in ipairs(jar) do
118+
if c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path then
119+
jar[i] = cookie
120+
return
121+
end
122+
end
123+
table.insert(jar, cookie)
124+
end
125+
126+
---@param fn function
127+
---@param arg any
128+
local function curry(fn, arg)
129+
return function(...)
130+
return fn(arg, ...)
131+
end
132+
end
133+
134+
---Save cookies from response
135+
---Request is provided as a context
136+
---@param req_url string
137+
---@param res rest.Response
138+
function M.update_jar(req_url, res)
139+
if not res.headers["set-cookie"] then
140+
return
141+
end
142+
vim.iter(res.headers["set-cookie"]):map(curry(M.parse_set_cookie, req_url)):each(curry(jar_insert, M.jar))
143+
M.clean()
144+
M.save_jar()
145+
end
146+
147+
---@private
148+
---Cleanup expired cookies
149+
function M.clean()
150+
M.jar = vim
151+
.iter(M.jar)
152+
:filter(function(cookie)
153+
return cookie.max_age == 0 or cookie.expires < os.time()
154+
end)
155+
:totable()
156+
end
157+
158+
---Save current cookie jar to cookies file
159+
function M.save_jar()
160+
-- TOOD: make this function asynchronous
161+
local file, openerr = io.open(config.cookies.path, "w")
162+
if not file then
163+
local err_msg = string.format("Failed to open rest.nvim cookies file: %s", openerr)
164+
vim.notify(err_msg, vim.log.levels.ERROR)
165+
logger.error(err_msg)
166+
return
167+
end
168+
file:write("# domain\tpath\tname\tvalue\texpires\n")
169+
for _, cookie in ipairs(M.jar) do
170+
file:write(table.concat({
171+
cookie.domain,
172+
cookie.path,
173+
cookie.name,
174+
cookie.value,
175+
cookie.expires,
176+
}, "\t") .. "\n")
177+
end
178+
file:close()
179+
end
180+
181+
local function match_cookie(url, cookie)
182+
local req_domain, req_path = parse_url(url)
183+
if not req_domain then
184+
return false
185+
end
186+
local domain_matches = ("." .. req_domain):match(vim.pesc(cookie.domain) .. "$")
187+
local path_matches = req_path:sub(1, #cookie.path) == cookie.path
188+
if domain_matches and path_matches then
189+
logger.debug(
190+
("cookie %s with domain %s and path %s matched to url: %s"):format(cookie.name, cookie.domain, cookie.path, url)
191+
)
192+
else
193+
logger.debug(
194+
("cookie %s with domain %s and path %s NOT matched to url: %s"):format(
195+
cookie.name,
196+
cookie.domain,
197+
cookie.path,
198+
url
199+
)
200+
)
201+
end
202+
return domain_matches and path_matches
203+
end
204+
205+
---Load cookies for request
206+
---@param req rest.Request
207+
function M.load_cookies(req)
208+
logger.debug("loading cookies for request:" .. req.url)
209+
vim.iter(M.jar):filter(curry(match_cookie, req.url)):each(curry(jar_insert, req.cookies))
210+
end
211+
212+
M.load_jar()
213+
214+
return M

0 commit comments

Comments
 (0)