HTTP client for Neovim. Requests in pure Lua. Programmable, composable, extensible.
https://github.com/user-attachments/assets/cfbcde44-0608-4f79-9327-15b4bf072a70
curl in PATHjq for JSON formatting, stylua for environments file formattingUsing lazy.nvim:
{
"rodrigoscc/nurl.nvim",
version = "*",
dependencies = { "folke/snacks.nvim" }, -- or telescope.nvim
opts = {},
}
Create .nurl/requests.lua in your project:
return {
{
"https://jsonplaceholder.typicode.com/posts/1",
},
{
"https://jsonplaceholder.typicode.com/posts",
method = "POST",
headers = {
["Content-Type"] = "application/json",
},
data = {
title = "Hello",
body = "World",
userId = 1,
},
},
}
Position cursor on a request and run :Nurl ., or use the picker with :Nurl.
| Command | Description |
|---|---|
:Nurl |
Project picker -> send request |
:Nurl . |
Send request at cursor |
:Nurl % |
Current buffer picker -> send request |
:Nurl <filepath> |
File picker -> send request |
:Nurl jump |
Project picker -> jump to definition |
:Nurl jump % |
Current buffer picker -> jump |
:Nurl jump <filepath> |
File picker -> jump |
:Nurl history |
History picker -> view response |
:Nurl resend |
Recent requests picker -> resend |
:Nurl resend <-n> |
Resend nth last request (-1 = last) |
:Nurl env |
Environment picker -> activate |
:Nurl env <name> |
Activate environment directly |
:Nurl env_file |
Open environments file |
:Nurl yank |
Project picker -> yank curl command |
:Nurl yank . |
Yank curl at cursor |
:Nurl yank % |
Current buffer picker -> yank |
:Nurl yank <filepath> |
File picker -> yank |
A request file returns a list of request tables:
return {
{
-- URL (required): string, table of parts, or function
"https://api.example.com/users", -- shorthand
url = "https://api.example.com/users", -- explicit
url = { "https://api.example.com", "v1" }, -- parts joined with /
url = function()
return "https://..."
end, -- dynamic
-- Method (optional, defaults to GET)
method = "POST",
-- Title (optional): display name in pickers
title = "Create user",
-- Headers (optional): table or function
headers = {
["Authorization"] = "Bearer token",
["Content-Type"] = "application/json",
},
-- Body (optional, use only one)
data = { key = "value" }, -- table: JSON encoded
data = '{"raw": "json"}', -- string: sent as-is
form = { field = "value" }, -- multipart/form-data
data_urlencode = { q = "search" }, -- URL encoded
-- Additional curl flags (optional)
curl_args = { "--insecure", "--compressed" },
-- Hooks (optional)
pre_hook = function(next, input)
next()
end,
post_hook = function(out) end,
},
}
Use functions for values computed at request time:
return {
{
url = "https://api.example.com/users/",
headers = function()
return { ["X-Timestamp"] = tostring(os.time()) }
end,
},
}
Use Nurl.lazy() to defer evaluation until send time (skipped during picker preview):
return {
{
url = "https://api.example.com/login",
method = "POST",
data = {
username = "user",
password = Nurl.lazy(function()
return vim.fn.inputsecret("Password: ")
end),
},
},
}
Create .nurl/environments.lua:
return {
staging = {
base_url = "https://staging.example.com",
token = "staging-token",
},
production = {
base_url = "https://prod.example.com",
token = "prod-token",
},
}
Access variables in requests:
return {
{
url = { Nurl.env.var("base_url"), "users" },
headers = {
["Authorization"] = function()
return "Bearer " .. Nurl.env.get("token")
end,
},
},
}
| Function | Description |
|---|---|
Nurl.env.var("name", env?) |
Returns a function that resolves the variable (for use in tables) |
Nurl.env.get("name", env?) |
Returns the value immediately (for use inside functions) |
Nurl.env.set("name", value, env?) |
Updates the variable |
Nurl.env.unset("name", env?) |
Removes the variable |
By default, all functions operate on the active environment. Pass an optional env argument to target a specific environment instead.
Switch the active environment with :Nurl env.
Hooks let you run code before/after requests. They can be defined per-request or per-environment.
+-----------------------+
| User calls :Nurl |
+-----------------------+
|
v
+-----------------------+
| Request is expanded |
| (functions evaluated) |
+-----------------------+
|
v
+-----------------------+
| Environment pre_hook |
+-----------------------+
|
| calls next()
v
+-----------------------+
| Request pre_hook |
+-----------------------+
|
| calls next()
v
+-----------------------+
| curl executes |
+-----------------------+
|
v
+-----------------------+
| Request post_hook |
+-----------------------+
|
v
+-----------------------+
| Environment post_hook |
+-----------------------+
|
v
+-----------------------+
| Response is displayed |
| or on_complete func |
+-----------------------+
Called before sending. Must call next() to proceed.
---@param next fun() Call to continue the request
---@param input nurl.RequestInput
pre_hook = function(next, input)
-- input.request contains the expanded request
-- Modify input.request fields if needed
input.request.headers["X-Custom"] = "value"
next()
end
Called after curl completes.
---@param out nurl.RequestOut
post_hook = function(out)
-- out.request: the request that was sent
-- out.response: the parsed response (nil if curl failed)
-- out.curl: curl execution details
-- out.win: the response window id
if out.response then
print("Status: " .. out.response.status_code)
end
end
Apply to all requests when an environment is active:
-- .nurl/environments.lua
return {
production = {
base_url = "https://prod.example.com",
pre_hook = function(next, input)
vim.ui.select({ "Yes", "No" }, {
prompt = "Send to production?",
}, function(choice)
if choice == "Yes" then
next()
end
end)
end,
post_hook = function(out)
print("Prod request completed")
end,
},
}
Used with Nurl.send() for programmatic requests:
Nurl.send(request, {
---@param out nurl.RequestOut
on_complete = function(out)
if out.response then
local body = vim.json.decode(out.response.body)
Nurl.env.set("token", body.access_token)
end
end,
})
The response window won't be opened if on_complete is defined.
Reuse an existing response window instead of opening a new one:
Nurl.send(request, {
win = existing_win_id,
})
Useful for updating a response in place, like when resending a request.
The expanded request object (all functions resolved):
---@class nurl.Request
---@field method string HTTP method (GET, POST, etc.)
---@field url string Full URL
---@field title? string Display name
---@field headers table<string,string> Headers
---@field data? string|table Request body
---@field form? table<string,string> Form data
---@field data_urlencode? table URL-encoded data
---@field curl_args? string[] Extra curl flags
---@field pre_hook? fun(next: fun(), input: nurl.RequestInput)
---@field post_hook? fun(out: nurl.RequestOut)
Passed to pre_hook:
---@class nurl.RequestInput
---@field request nurl.Request The request about to be sent
Passed to post_hook and on_complete:
---@class nurl.RequestOut
---@field request nurl.Request The request that was sent
---@field response? nurl.Response Parsed response (nil if curl failed)
---@field curl nurl.Curl Curl execution details
---@field win? integer Response window id
Parsed HTTP response:
---@class nurl.Response
---@field status_code integer HTTP status code
---@field reason_phrase string Status text (e.g., "OK")
---@field protocol string Protocol (e.g., "HTTP/2")
---@field headers table<string,string> Response headers
---@field body string Response body
---@field body_file? string Path if body saved to file
---@field time nurl.ResponseTime Timing breakdown
---@field size nurl.ResponseSize Size breakdown
---@field speed nurl.ResponseSpeed Speed metrics
---@class nurl.ResponseTime
---@field time_total number Total time in seconds
---@field time_namelookup number DNS lookup time
---@field time_connect number TCP connect time
---@field time_appconnect number TLS handshake time
---@field time_pretransfer number Pre-transfer time
---@field time_starttransfer number Time to first byte
---@field time_redirect number Redirect time
Curl execution details:
---@class nurl.Curl
---@field args string[] Curl arguments
---@field result? vim.SystemCompleted Execution result
---@field exec_datetime string Execution timestamp
---@field pid? integer Process ID
local Nurl = require("nurl")
-- Send a request programmatically
Nurl.send(request, {
on_complete = function(out) end, -- optional callback
})
-- Resend from history
Nurl.resend_last_request() -- resend last
Nurl.resend_last_request(-2) -- resend second to last
-- Environment
Nurl.get_active_env() -- returns active env name or nil
Nurl.activate_env("production")
Nurl.env.get("variable") -- get variable value
Nurl.env.set("variable", val) -- set variable value
Nurl.env.var("variable") -- get resolver function
-- Winbar components
Nurl.winbar.status_code()
Nurl.winbar.time()
Nurl.winbar.tabs()
Nurl.winbar.request_title()
require("nurl").setup({
-- Project directory for request files
dir = ".nurl",
-- Environments file name (in dir)
environments_file = "environments.lua",
-- Active environments per working directory file name (in dir)
active_environments_file = vim.fn.stdpath("data") .. "/nurl/envs.json",
-- History settings
history = {
enabled = true,
db_file = vim.fn.stdpath("data") .. "/nurl/history.sqlite3",
max_history_items = 5000,
},
-- Directory for non-displayable response bodies (images, etc.)
responses_files_dir = vim.fn.stdpath("data") .. "/nurl/responses",
-- Response window config (see :help nvim_open_win)
win_config = { split = "right" },
-- Response formatters by filetype
formatters = {
json = {
cmd = { "jq", "--sort-keys", "--indent", "2" },
available = function()
return vim.fn.executable("jq") == 1
end,
},
lua = {
cmd = { "stylua", "-" },
available = function()
return vim.fn.executable("stylua") == 1
end,
},
},
-- Buffer keymaps
buffers = {
{
"body",
keys = {
["<Tab>"] = "next_buffer",
["<S-Tab>"] = "previous_buffer",
["<C-r>"] = "rerun",
["<C-x>"] = "cancel",
q = "close",
},
},
{
"headers",
keys = {
["<Tab>"] = "next_buffer",
["<S-Tab>"] = "previous_buffer",
["<C-r>"] = "rerun",
["<C-x>"] = "cancel",
q = "close",
},
},
{
"info",
keys = {
["<Tab>"] = "next_buffer",
["<S-Tab>"] = "previous_buffer",
["<C-r>"] = "rerun",
["<C-x>"] = "cancel",
q = "close",
},
},
{
"raw",
keys = {
["<Tab>"] = "next_buffer",
["<S-Tab>"] = "previous_buffer",
["<C-r>"] = "rerun",
["<C-x>"] = "cancel",
q = "close",
},
},
},
highlight = {
groups = {
spinner = "NurlSpinner",
elapsed_time = "NurlElapsedTime",
winbar_title = "NurlWinbarTitle",
winbar_tab_active = "NurlWinbarTabActive",
winbar_tab_inactive = "NurlWinbarTabInactive",
winbar_success_status_code = "NurlWinbarSuccessStatusCode",
winbar_error_status_code = "NurlWinbarErrorStatusCode",
winbar_loading = "NurlWinbarLoading",
winbar_time = "NurlWinbarTime",
winbar_warning = "NurlWinbarWarning",
winbar_error = "NurlWinbarError",
},
},
})
The response window includes a winbar. Use it in your own winbar:
vim.o.winbar = "%{%v:lua.Nurl.winbar.status_code()%}"
.. "%<%{%v:lua.Nurl.winbar.request_title()%}"
.. "%{%v:lua.Nurl.winbar.time()%}"
.. " %=%{%v:lua.Nurl.winbar.tabs()%}"
| Group | Description |
|---|---|
NurlSpinner |
Loading spinner |
NurlElapsedTime |
Elapsed time display |
NurlWinbarTitle |
Request title in winbar |
NurlWinbarTabActive |
Active tab |
NurlWinbarTabInactive |
Inactive tab |
NurlWinbarSuccessStatusCode |
2xx status codes |
NurlWinbarErrorStatusCode |
4xx/5xx status codes |
NurlWinbarLoading |
Loading state |
NurlWinbarTime |
Response time |
NurlWinbarError |
Error messages |
local function op_get(item_id, field)
return Nurl.lazy(function()
local result = vim.system({
"op",
"item",
"get",
item_id,
"--fields",
field,
"--format",
"json",
}, { text = true }):wait()
if result.code ~= 0 then
error("Failed getting op item")
end
local data = vim.json.decode(result.stdout)
return data.value
end)
end
return {
{
url = "https://api.example.com/login",
method = "POST",
data = {
username = op_get("item-id", "username"),
password = op_get("item-id", "password"),
},
},
}
-- .nurl/environments.lua
local env = require("nurl.environments")
return {
default = {
access_token = nil,
refresh_token = "initial-refresh-token",
expires_at = nil,
pre_hook = function(next, input)
local expires = env.get("expires_at")
if expires and tonumber(expires) > os.time() then
next()
return
end
Nurl.send({
url = "https://auth.example.com/token",
method = "POST",
data = {
grant_type = "refresh_token",
refresh_token = env.get("refresh_token"),
},
}, {
on_complete = function(out)
if out.response and out.response.status_code == 200 then
local body = vim.json.decode(out.response.body)
env.set("access_token", body.access_token)
env.set("expires_at", os.time() + body.expires_in)
-- This request has already been expanded before the pre_hook,
-- so we need to update the header here so that it reflects the
-- above changes.
input.request.headers["Authorization"] = "Bearer "
.. body.access_token
next()
end
end,
})
end,
},
}
local function hmac_sha256(key, message)
local result = vim.fn.system({
"openssl",
"dgst",
"-sha256",
"-hmac",
key,
}, message)
return result:match("=%s*(%x+)") or ""
end
local body = '{"action":"test"}'
return {
{
url = "https://api.example.com/secure",
method = "POST",
headers = function()
local timestamp = tostring(os.time())
return {
["X-Timestamp"] = timestamp,
["X-Signature"] = hmac_sha256("secret", timestamp .. body),
}
end,
data = body,
},
}
return {
{
url = "https://api.example.com/upload",
method = "POST",
pre_hook = function(next, input)
vim.ui.input({
prompt = "File: ",
completion = "file",
}, function(path)
if path then
input.request.form = { file = "@" .. vim.fn.expand(path) }
next()
end
end)
end,
},
}
local function graphql(query, variables)
return {
url = { Nurl.env.var("base_url"), "graphql" },
method = "POST",
headers = { ["Content-Type"] = "application/json" },
data = { query = query, variables = variables },
}
end
return {
graphql(
[[
query GetUser($id: ID!) {
user(id: $id) { id name }
}
]],
{ id = "123" }
),
}
Follow URLs directly from response bodies, especially useful for paginated APIs where the response includes next links. Map gx to send a request to the URL under the cursor:
local function super_gx()
local cursor_url = vim.fn.expand("<cfile>")
if not vim.b.nurl_data then
-- Default gx implementation if cursor isn't in a Nurl response buffer.
vim.ui.open(cursor_url)
return
end
local nurl_data = vim.b.nurl_data
-- Will send the same headers, since they may include authentication.
local orig_headers = nurl_data.request.headers
if vim.v.count == 0 then
-- Display response in the current window
Nurl.send(
{ cursor_url, headers = orig_headers },
{ win = vim.api.nvim_get_current_win() }
)
else
-- Create new window if a count is given before pressing `gx`.
Nurl.send({ cursor_url, headers = orig_headers })
end
end
vim.keymap.set("n", "gx", super_gx, { desc = "Super gx" })