From 9734f33bc73250ead81ab143a52739de69652f79 Mon Sep 17 00:00:00 2001 From: Ellison Date: Fri, 1 May 2026 07:54:44 -0300 Subject: [PATCH] feat(vim.net): request() accepts more http methods #39406 --- runtime/doc/lua.txt | 27 +++++++++---- runtime/doc/news.txt | 1 + runtime/lua/vim/net.lua | 65 +++++++++++++++++++++++++++----- test/functional/lua/net_spec.lua | 64 ++++++++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 3e368a02cb..249e9f4f91 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -4204,9 +4204,10 @@ Lua module: vim.net *vim.net* • {body} (`string`) The HTTP body of the request -vim.net.request({url}, {opts}, {on_response}) *vim.net.request()* - Makes an HTTP GET request to the given URL, asynchronously passing the - result to the specified `on_response`, `outpath` or `outbuf`. + *vim.net.request()* +vim.net.request({method}, {url}, {opts}, {on_response}) + Makes an HTTP request to the given URL, asynchronously passing the result + to the specified `on_response`, `outpath` or `outbuf`. Examples: >lua -- Write response body to file. @@ -4236,11 +4237,21 @@ vim.net.request({url}, {opts}, {on_response}) *vim.net.request()* vim.net.request('https://neovim.io/charter/', { headers = { Authorization = 'Bearer XYZ' }, }) + + -- POST request with body. + vim.net.request('POST', 'https://example.com/api', { + body = '{"key": "value"}', + headers = {['Content-Type'] = 'application/json' } + }) < Parameters: ~ + • {method} (`vim.net.HttpMethod`) (default: GET) The HTTP method + (GET, POST, PUT, PATCH, HEAD, DELETE). • {url} (`string`) The URL for the request. • {opts} (`table?`) A table with the following fields: + • {body}? (`string`) Request body for POST/PUT/PATCH + requests. • {headers}? (`table`) Custom headers to send with the request. Supports basic key/value headers and empty headers as supported by curl. Does @@ -4253,10 +4264,12 @@ vim.net.request({url}, {opts}, {on_response}) *vim.net.request()* • {retry}? (`integer`) Number of retries on transient failures (default: 3). • {verbose}? (`boolean`) Enables verbose output. - • {on_response} (`fun(err: string?, response: vim.net.request.Response?)?`) - Callback invoked on request completion. The `body` - field in the response parameter contains the raw - response data (text or binary). + • {on_response} (`vim.net.request.ResponseFunc?`) Callback invoked on + request completion. + + Overloads: ~ + • `fun(url: string, opts: vim.net.request.Opts, response: vim.net.request.ResponseFunc)` + • `fun(method: vim.net.HttpMethod, url: string, opts: vim.net.request.Opts, response: vim.net.request.ResponseFunc)` Return: ~ (`{ close: fun() }`) Object with `close()` method which cancels the diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 47b6fa6d55..4f114ddfab 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -161,6 +161,7 @@ LSP LUA • |vim.net.request()| can specify custom headers by passing `opts.headers`. +• |vim.net.request()| can now accept `method` param overload for multiple HTTP methods. • |writefile()| treats Lua strings as "blob", so it can be used to write binary data. • |vim.filetype.inspect()| returns a copy of the internal tables used for diff --git a/runtime/lua/vim/net.lua b/runtime/lua/vim/net.lua index 6d63fdc77f..6e09619615 100644 --- a/runtime/lua/vim/net.lua +++ b/runtime/lua/vim/net.lua @@ -1,5 +1,17 @@ local M = {} +local http_methods = { + GET = true, + POST = true, + PUT = true, + PATCH = true, + HEAD = true, + DELETE = true, +} + +---@alias vim.net.request.ResponseFunc fun(err: string?, response: vim.net.request.Response?) +---@alias vim.net.HttpMethod string "GET" | "POST" | "PUT" | "PACH" | "HEAD"| "DELETE + ---@class vim.net.request.Opts ---@inlinedoc --- @@ -9,6 +21,9 @@ local M = {} ---Number of retries on transient failures (default: 3). ---@field retry? integer --- +---Request body for POST/PUT/PATCH requests. +---@field body? string +--- ---File path to save the response body to. ---@field outpath? string --- @@ -24,7 +39,7 @@ local M = {} ---The HTTP body of the request ---@field body string ---- Makes an HTTP GET request to the given URL, asynchronously passing the result to the specified +--- Makes an HTTP request to the given URL, asynchronously passing the result to the specified --- `on_response`, `outpath` or `outbuf`. --- --- Examples: @@ -56,20 +71,43 @@ local M = {} --- vim.net.request('https://neovim.io/charter/', { --- headers = { Authorization = 'Bearer XYZ' }, --- }) +--- +--- -- POST request with body. +--- vim.net.request('POST', 'https://example.com/api', { +--- body = '{"key": "value"}', +--- headers = {['Content-Type'] = 'application/json' } +--- }) --- ``` --- +--- @param method vim.net.HttpMethod (default: GET) The HTTP method (GET, POST, PUT, PATCH, HEAD, DELETE). --- @param url string The URL for the request. --- @param opts? vim.net.request.Opts ---- @param on_response? fun(err: string?, response: vim.net.request.Response?) ---- Callback invoked on request completion. The `body` field in the response ---- parameter contains the raw response data (text or binary). +--- @param on_response? vim.net.request.ResponseFunc Callback invoked on request completion. +--- @overload fun(url: string, opts: vim.net.request.Opts, response: vim.net.request.ResponseFunc) +--- @overload fun(method: vim.net.HttpMethod, url: string, opts: vim.net.request.Opts, response: vim.net.request.ResponseFunc) --- @return { close: fun() } # Object with `close()` method which cancels the request. -function M.request(url, opts, on_response) +function M.request(method, url, opts, on_response) + if type(url) ~= 'string' then + ---@diagnostic disable-next-line: cast-local-type + on_response = opts + ---@diagnostic disable-next-line: no-unknown + opts = url + url = method + method = 'GET' + end + opts = opts or {} + + vim.validate('method', method, function(m) + return http_methods[m] == true, ('invalid HTTP method: %s'):format(m) + end) vim.validate('url', url, 'string') vim.validate('opts', opts, 'table', true) + vim.validate('opts.headers', opts.headers, 'table', true) + vim.validate('opts.body', opts.body, function(b) + return (b == nil and true) or (type(b) == 'string' and not b:match('^@')) + end, true, 'body should be string and not start with @') vim.validate('on_response', on_response, 'function', true) - opts = opts or {} local retry = opts.retry or 3 -- Build curl command @@ -85,8 +123,14 @@ function M.request(url, opts, on_response) vim.list_extend(args, { '--output', opts.outpath }) end + -- curl -X HEAD does not work and advises to use --head instead + vim.list_extend(args, method == 'HEAD' and { '--head' } or { '--request', method }) + + if vim.list_contains({ 'POST', 'PUT', 'PATCH' }, method) and opts.body then + vim.list_extend(args, { '--data-binary', '@-' }) + end + if opts.headers then - vim.validate('opts.headers', opts.headers, 'table', true) for key, value in pairs(opts.headers) do if type(key) ~= 'string' or type(value) ~= 'string' then error('headers keys and values must be strings') @@ -97,16 +141,17 @@ function M.request(url, opts, on_response) end if value == '' then - vim.list_extend(args, { '-H', key .. ';' }) + vim.list_extend(args, { '--header', key .. ';' }) else - vim.list_extend(args, { '-H', key .. ': ' .. value }) + vim.list_extend(args, { '--header', key .. ': ' .. value }) end end end table.insert(args, url) - local job = vim.system(args, {}, function(res) + local system_opts = opts.body and { stdin = opts.body } or {} + local job = vim.system(args, system_opts, function(res) ---@type string?, vim.net.request.Response? local err, response = nil, nil if res.signal ~= 0 then diff --git a/test/functional/lua/net_spec.lua b/test/functional/lua/net_spec.lua index 12090171b7..9270be1fca 100644 --- a/test/functional/lua/net_spec.lua +++ b/test/functional/lua/net_spec.lua @@ -1,6 +1,5 @@ local n = require('test.functional.testnvim')() local t = require('test.testutil') -local pcall_err = t.pcall_err local skip_integ = os.getenv('NVIM_TEST_INTEG') ~= '1' local exec_lua = n.exec_lua @@ -21,6 +20,41 @@ local function assert_wrong_headers(expected_err, header) t.matches(expected_err, err) end +local function assert_wrong_body(expected_err, body) + local err = t.pcall_err(exec_lua, [[ + return vim.net.request('POST', 'https://example.com', { + body = ]] .. body .. [[, + }, function() end) + ]]) + t.matches(expected_err, err) +end + +local function assert_invalid_method(method) + local err = t.pcall_err(exec_lua, [[ + return vim.net.request(']] .. method .. [[', 'https://example.com', {}, function() end) + ]]) + t.matches('invalid HTTP method', err) +end + +local function request(method, opts) + return exec_lua([[ + local done = false + local result + + vim.net.request("]] .. method .. [[", "https://httpbingo.org/anything", ]] .. opts .. [[, function(err, res) + if err then + result = { error = err } + else + result = vim.json.decode(res.body) + end + done = true + end) + + vim.wait(2000, function() return done end) + return result + ]]) +end + describe('vim.net.request', function() before_each(function() n:clear() @@ -28,6 +62,7 @@ describe('vim.net.request', function() it('fetches a URL into memory (async success)', function() t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + local content = exec_lua([[ local done = false local result @@ -207,6 +242,22 @@ describe('vim.net.request', function() t.eq(headers['Empty'][1], '', 'Expected Empty header') end) + it('accepts multiple HTTP methods', function() + t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + + t.eq(request('GET', '{}').method, 'GET') + t.eq(request('PUT', '{}').method, 'PUT') + t.eq(request('PATCH', '{}').method, 'PATCH') + t.eq(request('DELETE', '{}').method, 'DELETE') + + ---@diagnostic disable-next-line: no-unknown + local post = + request('POST', "{body='{\"a\": 1}', headers={['Content-Type'] = 'application/json'}}") + t.eq(post.method, 'POST') + t.eq(post.headers['Content-Type'][1], 'application/json') + t.eq(post.json.a, 1) + end) + it('validation', function() assert_wrong_headers('opts.headers: expected table, got number', '123') assert_wrong_headers('headers keys and values must be strings', "{ [123] = 'value' }") @@ -223,5 +274,16 @@ describe('vim.net.request', function() 'header keys must not start with @ or end with : and ;', "{ ['@filename'] = '' }" ) + + assert_wrong_body('opts.body: expected body should be string and not start with @', '123') + assert_wrong_body('opts.body: expected body should be string and not start with @', '{}') + assert_wrong_body('opts.body: expected body should be string and not start with @', "'@test'") + + -- OPTIONS is not accepted + assert_invalid_method('OPTIONS') + + -- lowercase methods are not accepted as well + assert_invalid_method('options') + assert_invalid_method('get') end) end)