feat(vim.net): vim.net.download

This commit is contained in:
TheLeoP 2024-06-13 16:59:22 -05:00
parent d82efeccc7
commit 7b05f4dbc4
6 changed files with 645 additions and 0 deletions

View File

@ -4433,4 +4433,160 @@ tohtml.tohtml({winid}, {opt}) *tohtml.tohtml.tohtml()*
(`string[]`)
==============================================================================
Lua module: vim.net *vim.net*
*vim.net.curl.Metadata*
See the `--write-out` section in `man curl`
Fields: ~
• {certs}? (`string`) (Added in 7.88.0)
• {conn_id}? (`number`) (Added in 8.2.0)
• {content_type}? (`string`)
• {errormsg}? (`string`) (Added in 7.75.0)
• {exitcode}? (`number`) (Added in 7.75.0)
• {filename_effective}? (`string`) (Added in 7.26.0)
• {ftp_entry_path}? (`string`)
• {headers}? (`table<string, string[]>`) (Added in
7.83.0) parsed result of ${header_json}
• {http_code}? (`number`)
• {http_connect}? (`number`)
• {http_version}? (`string`) (Added in 7.50.0)
• {local_ip}? (`string`)
• {local_port}? (`number`)
• {method}? (`string`) (Added in 7.72.0)
• {num_certs}? (`number`) (Added in 7.88.0)
• {num_connects}? (`number`)
• {num_headers}? (`number`) (Added in 7.73.0)
• {num_redirects}? (`number`)
• {num_retries}? (`number`) (Added in 8.9.0)
• {proxy_ssl_verify_result}? (`number`) (Added in 7.52.0)
• {proxy_used}? (`number`) (Added in 8.7.0)
• {redirect_url}? (`string`)
• {referer}? (`string`) (Added in 7.76.0)
• {remote_ip}? (`string`)
• {remote_port}? (`number`)
• {response_code}? (`number`)
• {scheme}? (`string`) (Added in 7.52.0)
• {size_download}? (`number`)
• {size_header}? (`number`)
• {size_request}? (`number`)
• {size_upload}? (`number`)
• {speed_download}? (`number`)
• {speed_upload}? (`number`)
• {ssl_verify_result}? (`number`)
• {time_appconnect}? (`number`)
• {time_connect}? (`number`)
• {time_namelookup}? (`number`)
• {time_pretransfer}? (`number`)
• {time_redirect}? (`number`)
• {time_starttransfer}? (`number`)
• {time_total}? (`number`)
• {url}? (`string`) (Added in 7.75.0)
• {url.scheme}? (`string`) (Added in 8.1.0)
• {url.user}? (`string`) (Added in 8.1.0)
• {url.password}? (`string`) (Added in 8.1.0)
• {url.options}? (`string`) (Added in 8.1.0)
• {url.host}? (`string`) (Added in 8.1.0)
• {url.port}? (`string`) (Added in 8.1.0)
• {url.path}? (`string`) (Added in 8.1.0)
• {url.query}? (`string`) (Added in 8.1.0)
• {url.fragment}? (`string`) (Added in 8.1.0)
• {url.zoneid}? (`string`) (Added in 8.1.0)
• {urle.scheme}? (`string`) (Added in 8.1.0)
• {urle.user}? (`string`) (Added in 8.1.0)
• {urle.password}? (`string`) (Added in 8.1.0)
• {urle.options}? (`string`) (Added in 8.1.0)
• {urle.host}? (`string`) (Added in 8.1.0)
• {urle.port}? (`string`) (Added in 8.1.0)
• {urle.path}? (`string`) (Added in 8.1.0)
• {urle.query}? (`string`) (Added in 8.1.0)
• {urle.fragment}? (`string`) (Added in 8.1.0)
• {urle.zoneid}? (`string`) (Added in 8.1.0)
• {urlnum}? (`number`) (Added in 7.75.0)
• {url_effective}? (`string`)
• {xfer_id}? (`number`) (Added in 8.2.0)
vim.net.download({url}, {opts}) *vim.net.download()*
Asynchronously download a file
Parameters: ~
• {url} (`string`) Request URL
• {opts} (`table?`) Additional options
Example: >lua
-- Download a file
-- The file will be saved in the `cwd` with the name `anything`
vim.net.download("https://httpbingo.org/anything")
-- Download a file to a path
-- The file will be saved in `/tmp/somefile`
vim.net.download("https://httpbingo.org/anything", {
download_location = "tmp/somefile",
})
-- Download a file while following redirects
vim.net.download("https://httpbingo.org/anything", {
follow_redirects = true,
})
-- Download a file while sending headers
vim.net.download("https://httpbingo.org/anything", {
headers = {
Authorization = { "Bearer foo" },
},
})
-- Download a file while handling basic auth
vim.net.download("https://httpbingo.org/basic-auth/user/password", {
credentials = "user:password",
})
-- Download a file without overriding a previous file with the same name
vim.net.download("https://httpbingo.org/anything", {
override = false,
})
<
• {download_location}? (`string`) Path to write the downloaded
file to. If not provided, the one inferred form the URL will
be used. Defaults to `nil`
• {try_suggested_remote_name}? (`boolean`) Whether the
`Content-Disposition` response header should be taken into
account to decide the name of the downloaded file. Fallbacks
to `download_location`. Defaults to `false`
• {credentials}? (`string`) Credentials with the format
`username:password`. Defaults to `nil`
• {override}? (`boolean`) Whether the file should be
overridden if it already exists. Defaults to `true`
• {remove_leftover_on_error}? (`boolean`) Whether the file
should be removed if an error happens while downloading it.
Defaults to `false`
• {compressed}? (`boolean`) Whether the file should be
requested in a compressed format (and decompressed
automatically). Defaults to `false`
• {max_filesize}? (`integer`) Maximum size in bytes of the
file to downlaod. The download will fail if the file
requested is larger. Defaults to `nil`
• {raw}? (`boolean`) Disables all internal HTTP decoding of
content or transfer encodings. Unaltered, raw, data is
passed. Defaults to `false`
• {headers}? (`table`) string[]> Request headers. Defaults to
`nil`
• {proxy}? (`string`) Proxy URL in the format
`scheme ":" ["//" authority] path ["?" query]` where
authority follows the format
`[userinfo "@"] host [":" port]`. Defaults to `nil`
• {proxy_credentials}? (`string`) Proxy credentials with the
format `username:password`. Defaults to `nil`
• {follow_redirects}? (`boolean`) Whether redirects should be
followed. Defaults to `false`
• {redirect_credentials}? (`boolean`) Whether `credentials`
should be send to host after a redirect. Defaults to `false`
• {on_exit}?
(`fun(err: string?, metadata: vim.net.curl.Metadata?)`)
Optional callback. Defaults to showing a notification when
the file has been downloaded.
vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:

View File

@ -40,6 +40,7 @@ for k, v in pairs({
secure = true,
snippet = true,
_watch = true,
net = true,
}) do
vim._submodules[k] = v
end

View File

@ -397,6 +397,23 @@ local function check_external_tools()
end
end
local function check_net()
health.start('External Tools (vim.net)')
if vim.fn.executable('curl') == 1 then
local curl = vim.fn.exepath('curl')
local cmd = { 'curl', ' --version' }
local result = vim.system(cmd, { text = true }):wait()
health.ok(('%s\n(%s)'):format(vim.trim(result.stdout), curl))
else
health.error(
'curl could not be found. Cannot use |vim.net|.',
'Follow this guide to install curl https://everything.curl.dev/install/index.html'
)
end
end
function M.check()
check_config()
check_runtime()
@ -405,6 +422,7 @@ function M.check()
check_terminal()
check_tmux()
check_external_tools()
check_net()
end
return M

275
runtime/lua/vim/net.lua Normal file
View File

@ -0,0 +1,275 @@
local M = {}
--HTTP methods in curl
-- default GET (there is also --get for transforming --data into URL query params)
-- --data (and its variants) or --form POST
-- --head HEAD
-- --upload-file PUT (there is also --method PUT while using --data)
local separator = '__SEPARATOR__'
---@class vim.net.download.Opts
---@inlinedoc
---Path to write the downloaded file to. If not provided, the one inferred form the URL will be used. Defaults to `nil`
---@field download_location? string
---Whether the `Content-Disposition` response header should be taken into account to decide the name of the downloaded file. Fallbacks to `download_location`. Defaults to `false`
---@field try_suggested_remote_name? boolean
---Credentials with the format `username:password`. Defaults to `nil`
---@field credentials? string
---Whether the file should be overridden if it already exists. Defaults to `true`
---@field override? boolean
---Whether the file should be removed if an error happens while downloading it. Defaults to `false`
---@field remove_leftover_on_error? boolean
---Whether the file should be requested in a compressed format (and decompressed automatically). Defaults to `false`
---@field compressed? boolean
---Maximum size in bytes of the file to downlaod. The download will fail if the file requested is larger. Defaults to `nil`
---@field max_filesize? integer
---Disables all internal HTTP decoding of content or transfer encodings. Unaltered, raw, data is passed. Defaults to `false`
---@field raw? boolean
---string[]> Request headers. Defaults to `nil`
---@field headers? table<string,
---Proxy URL in the format `scheme ":" ["//" authority] path ["?" query]` where authority follows the format `[userinfo "@"] host [":" port]`. Defaults to `nil`
---@field proxy? string
---Proxy credentials with the format `username:password`. Defaults to `nil`
---@field proxy_credentials? string
---Whether redirects should be followed. Defaults to `false`
---@field follow_redirects? boolean
---Whether `credentials` should be send to host after a redirect. Defaults to `false`
---@field redirect_credentials? boolean
---Optional callback. Defaults to showing a notification when the file has been downloaded.
---@field on_exit? fun(err: string?, metadata: vim.net.curl.Metadata?)
---See the `--write-out` section in `man curl`
---@class vim.net.curl.Metadata
---@field certs? string (Added in 7.88.0)
---@field conn_id? number (Added in 8.2.0)
---@field content_type? string
---@field errormsg? string (Added in 7.75.0)
---@field exitcode? number (Added in 7.75.0)
---@field filename_effective? string (Added in 7.26.0)
---@field ftp_entry_path? string
---@field headers? table<string, string[]> (Added in 7.83.0) parsed result of ${header_json}
---@field http_code? number
---@field http_connect? number
---@field http_version? string (Added in 7.50.0)
---@field local_ip? string
---@field local_port? number
---@field method? string (Added in 7.72.0)
---@field num_certs? number (Added in 7.88.0)
---@field num_connects? number
---@field num_headers? number (Added in 7.73.0)
---@field num_redirects? number
---@field num_retries? number (Added in 8.9.0)
---@field proxy_ssl_verify_result? number (Added in 7.52.0)
---@field proxy_used? number (Added in 8.7.0)
---@field redirect_url? string
---@field referer? string (Added in 7.76.0)
---@field remote_ip? string
---@field remote_port? number
---@field response_code? number
---@field scheme? string (Added in 7.52.0)
---@field size_download? number
---@field size_header? number
---@field size_request? number
---@field size_upload? number
---@field speed_download? number
---@field speed_upload? number
---@field ssl_verify_result? number
---@field time_appconnect? number
---@field time_connect? number
---@field time_namelookup? number
---@field time_pretransfer? number
---@field time_redirect? number
---@field time_starttransfer? number
---@field time_total? number
---@field url? string (Added in 7.75.0)
---@field url.scheme? string (Added in 8.1.0)
---@field url.user? string (Added in 8.1.0)
---@field url.password? string (Added in 8.1.0)
---@field url.options? string (Added in 8.1.0)
---@field url.host? string (Added in 8.1.0)
---@field url.port? string (Added in 8.1.0)
---@field url.path? string (Added in 8.1.0)
---@field url.query? string (Added in 8.1.0)
---@field url.fragment? string (Added in 8.1.0)
---@field url.zoneid? string (Added in 8.1.0)
---@field urle.scheme? string (Added in 8.1.0)
---@field urle.user? string (Added in 8.1.0)
---@field urle.password? string (Added in 8.1.0)
---@field urle.options? string (Added in 8.1.0)
---@field urle.host? string (Added in 8.1.0)
---@field urle.port? string (Added in 8.1.0)
---@field urle.path? string (Added in 8.1.0)
---@field urle.query? string (Added in 8.1.0)
---@field urle.fragment? string (Added in 8.1.0)
---@field urle.zoneid? string (Added in 8.1.0)
---@field urlnum? number (Added in 7.75.0)
---@field url_effective? string
---@field xfer_id? number (Added in 8.2.0)
---@type vim.net.download.Opts
local download_defaults = {
download_location = nil,
try_suggested_remote_name = false,
credentials = nil,
override = true,
remove_leftover_on_error = false,
compressed = false,
max_filesize = nil,
raw = false,
headers = nil,
proxy = nil,
proxy_credentials = nil,
follow_redirects = false,
redirect_credentials = false,
on_exit = function(err, metadata)
if err then
return vim.notify(err, vim.log.levels.ERROR)
end
if not metadata or not metadata.filename_effective then
return vim.notify('The file has been downloaded', vim.log.levels.INFO)
end
vim.notify(
('The file `%s` has been downloaded'):format(metadata.filename_effective),
vim.log.levels.INFO
)
end,
}
---Asynchronously download a file
---@param url string Request URL
---@param opts? vim.net.download.Opts Additional options
---
---Example:
--- ```lua
--- -- Download a file
--- -- The file will be saved in the `cwd` with the name `anything`
--- vim.net.download("https://httpbingo.org/anything")
---
--- -- Download a file to a path
--- -- The file will be saved in `/tmp/somefile`
--- vim.net.download("https://httpbingo.org/anything", {
--- download_location = "tmp/somefile",
--- })
---
--- -- Download a file while following redirects
--- vim.net.download("https://httpbingo.org/anything", {
--- follow_redirects = true,
--- })
---
--- -- Download a file while sending headers
--- vim.net.download("https://httpbingo.org/anything", {
--- headers = {
--- Authorization = { "Bearer foo" },
--- },
--- })
---
--- -- Download a file while handling basic auth
--- vim.net.download("https://httpbingo.org/basic-auth/user/password", {
--- credentials = "user:password",
--- })
---
--- -- Download a file without overriding a previous file with the same name
--- vim.net.download("https://httpbingo.org/anything", {
--- override = false,
--- })
--- ```
function M.download(url, opts)
vim.validate {
url = { url, 'string' },
opts = { opts, 'table', true },
}
opts = vim.tbl_extend('force', download_defaults, opts or {}) --[[@as vim.net.download.Opts]]
local cmd = { 'curl' } ---@type string[]
-- (Added in 7.67.0)
table.insert(cmd, '--no-progress-meter')
if opts.download_location then
vim.list_extend(cmd, { '--output', opts.download_location, url })
else
vim.list_extend(cmd, { '--remote-name', url })
end
if opts.try_suggested_remote_name then
table.insert(cmd, '--remote-header-name')
end
if opts.credentials then
vim.list_extend(cmd, { '--user', opts.credentials })
end
-- (Added in 7.83.0)
if not opts.override then
table.insert(cmd, '--no-clober')
end
-- (Added in 7.83.0)
if opts.remove_leftover_on_error then
table.insert(cmd, '--remove-on-error')
end
if opts.compressed then
table.insert(cmd, '--compressed')
end
if opts.max_filesize then
vim.list_extend(cmd, { '--max-filesize', tostring(opts.max_filesize) })
end
if opts.raw then
table.insert(cmd, '--raw')
end
if opts.headers then
for header, values in pairs(opts.headers) do
for _, value in ipairs(values) do
table.insert(cmd, '--header')
table.insert(cmd, ('%s:%s'):format(header, value))
end
end
end
if opts.proxy then
vim.list_extend(cmd, { '--proxy', opts.proxy })
end
if opts.proxy_credentials then
vim.list_extend(cmd, { '--proxy-user', opts.proxy_credentials })
end
if opts.follow_redirects then
table.insert(cmd, '--location')
end
if opts.redirect_credentials then
table.insert(cmd, '--location-trusted')
end
-- stdout will contain the following separated by `separator` (local variable):
-- (json) A JSON object with all available keys. (Added in 7.70.0)
-- (header_json) A JSON object with all HTTP response headers from the recent transfer. (Added in 7.83.0)
-- (`%` is duplicated in order to escape it from `format`)
vim.list_extend(cmd, { '--write-out', ('%%{json}%s%%{header_json}'):format(separator) })
vim.system(cmd, { text = true }, function(out)
local err = out.stderr ~= '' and out.stderr or nil
local lines = vim.split(out.stdout, separator)
local json_string = lines[1]
local header_json_string = lines[2]
local ok, metadata = pcall(vim.json.decode, json_string)
if ok then
---@cast metadata vim.net.curl.Metadata
local ok2, headers = pcall(vim.json.decode, header_json_string)
if ok2 then
metadata.headers = headers
end
opts.on_exit(err, metadata)
else
opts.on_exit(err)
end
end)
end
return M

View File

@ -162,6 +162,7 @@ local config = {
'snippet.lua',
'text.lua',
'tohtml.lua',
'net.lua',
},
files = {
'runtime/lua/vim/iter.lua',
@ -171,6 +172,7 @@ local config = {
'runtime/lua/vim/loader.lua',
'runtime/lua/vim/uri.lua',
'runtime/lua/vim/ui.lua',
'runtime/lua/vim/net.lua',
'runtime/lua/vim/filetype.lua',
'runtime/lua/vim/keymap.lua',
'runtime/lua/vim/fs.lua',

View File

@ -0,0 +1,193 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local eq = t.eq
local exec_lua = n.exec_lua
local read_file = t.read_file
local write_file = t.write_file
local path = './downloaded_file'
describe('vim.net', function()
before_each(function()
os.remove(path)
end)
describe('download()', function()
it('can download a file without a path', function()
local destination_path = './anything'
eq(nil, read_file(destination_path))
exec_lua([[
local done
vim.net.download("https://httpbingo.org/anything", {
on_exit = function()
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
]])
local data = read_file(destination_path)
eq('https://httpbingo.org/anything', vim.json.decode(data).url)
end)
it('can download a file to a path', function()
eq(nil, read_file(path))
exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/anything", {
download_location = path,
on_exit = function()
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
]],
path
)
local data = read_file(path)
eq('https://httpbingo.org/anything', vim.json.decode(data).url)
end)
it('does not follow redirects', function()
eq(nil, read_file(path))
local response_code = exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/redirect/1", {
download_location = path,
on_exit = function(err, metadata)
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
assert(not err, err)
assert(metadata, 'no metadata was received')
return metadata.response_code
]],
path
)
eq(response_code, 302)
eq('', read_file(path))
end)
it('can follow redirects', function()
eq(nil, read_file(path))
local response_code = exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/redirect/1", {
download_location = path,
follow_redirects = true,
on_exit = function(err, metadata)
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
assert(not err, err)
assert(metadata, 'no metadata was received')
return metadata.response_code
]],
path
)
eq(response_code, 202)
local data = read_file(path)
eq('https://httpbingo.org/get', vim.json.decode(data).url)
end)
it('can send headers', function()
eq(nil, read_file(path))
exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/basic-auth/user/password", {
download_location = path,
headers = {
Authorization = { "Bearer foo" },
},
on_exit = function(err, metadata)
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
]],
path
)
local data = read_file(path)
eq(true, vim.json.decode(data).authenticated)
end)
it('can handle basic auth', function()
eq(nil, read_file(path))
exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/basic-auth/user/password", {
download_location = path,
credentials = "user:password",
on_exit = function(err, metadata)
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
]],
path
)
local data = read_file(path)
eq(true, vim.json.decode(data).authorized)
end)
it('does not override file', function()
write_file(path, '')
eq('', read_file(path))
exec_lua(
[[
local path = ...
local done
vim.net.download("https://httpbingo.org/anything", {
download_location = path,
override = false,
on_exit = function(err, metadata)
done = true
end
})
local _, interrupted = vim.wait(10000, function()
return done
end)
assert(done, 'file was not downloaded')
]],
path
)
eq('', read_file(path))
end)
end)
end)