HTTP client for Neovim. Requests in pure Lua. Programmable, composable, extensible.
Screen.Recording.2025-12-04.at.02.03.36.mov
- Features
- Requirements
- Installation
- Quick Start
- Commands
- Request Format
- Environments
- Tests
- Hooks and Callbacks
- Type Reference
- API
- Configuration
- Winbar
- Highlight Groups
- Recipes
- Lua-based requests - Define HTTP requests as Lua tables with full language support
- Environments - Manage variables per environment (dev, staging, prod)
- Request history - SQLite-backed history with full request/response data
- Response viewer - Split window with body, headers, info, and raw curl output tabs
- Hooks - Pre/post hooks per request, or per environment (applies to all requests when env is active)
- Picker integration - Browse requests and history with snacks.nvim or telescope.nvim
- Neovim >= 0.10.0
curlin PATH- snacks.nvim or telescope.nvim (for pickers)
- Optional:
jqfor JSON formatting,styluafor environments file formatting
Using 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 |
When using % or <filepath>, if the file contains only one request, the action runs immediately without opening a picker.
Override request fields directly from the command-line for quick one-off changes.
:Nurl . data.id=42
:Nurl . data.name="John Doe" data.active=true
:Nurl % headers["X-Debug"]=true
:Nurl requests/login.lua data.user=adminThe syntax mirrors Lua table access:
data.user.name=valueheaders["Content-Type"]="application/json"url[2]=users
Types are inferred: 42 (number), true/false (boolean), "quoted" (string with spaces).
Overrides are useful for quickly replacing IDs in REST URLs:
return {
{ url = { "https://api.example.com/users", "1" } },
}:Nurl . url[2]=42Or adding a curl flag on the fly:
:Nurl . curl_args[1]="--insecure"A request file returns a list of request tables:
return {
{
-- URL (required): string, table of parts, or function
"https://api.example.com/users?active=true", -- shorthand (supports query params)
url = "https://api.example.com/users", -- explicit
url = { "https://api.example.com", "v1" }, -- parts joined with /
url = function()
return "https://..."
end, -- dynamic
-- Query parameters (optional): table or function
-- Values are URI-encoded automatically. Use functions for dynamic values.
query = {
page = 1,
limit = 10,
search = "hello world", -- becomes search=hello%20world
token = Nurl.env.var("api_token"),
},
-- 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,
-- Test function (optional)
test = function(ctx, response)
ctx.are.equal(200, response.status_code)
end,
},
}The shorthand [1] field and url field handle query parameters differently:
- Shorthand
[1]: Supports inline query parameters (e.g.,"https://api.example.com?foo=bar"). Query params are extracted and merged with thequeryfield. urlas table: Parts are joined with/, so query params should go in thequeryfield instead.
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.
Define assertions to validate responses. Results are displayed in the Test buffer tab.
return {
{
url = "https://api.example.com/users/1",
test = function(ctx, response)
ctx.are.equal(200, response.status_code)
local body = vim.json.decode(response.body)
ctx.test("user data", function()
ctx.is_not_nil(body.id)
ctx.are.equal("John", body.name, "name should be John")
ctx.are.same({ city = "New York" }, body.address)
end)
end,
},
}| Assertion | Description |
|---|---|
ctx.are.equal(expected, actual, msg?) |
Strict equality (==) |
ctx.are.same(expected, actual, msg?) |
Deep equality for tables |
ctx.are_not.equal(expected, actual, msg?) |
Not equal |
ctx.are_not.same(expected, actual, msg?) |
Not deeply equal |
ctx.is_true(value, msg?) |
Value is true |
ctx.is_false(value, msg?) |
Value is false |
ctx.truthy(value, msg?) |
Value is truthy (not nil and not false) |
ctx.falsy(value, msg?) |
Value is falsy (nil or false) |
ctx.is_nil(value, msg?) |
Value is nil |
ctx.is_not_nil(value, msg?) |
Value is not nil |
All assertions accept an optional message as the last argument.
The test buffer displays results in busted-style format:
3 successes / 1 failure / 0 errors
Failure
user data address
city should match
Passed in: "Los Angeles"
Expected: "New York"
- Successes: Passing assertions (not shown individually)
- Failures: Assertion mismatches with expected vs actual values
- Errors: Exceptions thrown during test execution
The test tab indicator in the winbar turns red when any test fails.
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()
endCalled 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
endApply 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 query? table<string,any> Query parameters (URI-encoded)
---@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)
---@field test? fun(ctx: nurl.TestContext, response: nurl.Response)Passed to pre_hook:
---@class nurl.RequestInput
---@field request nurl.Request The request about to be sentPassed 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 idParsed 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 timeCurl 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 IDlocal 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",
["<C-m>"] = { "toggle_secondary", opts = { buffer = "info" } },
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",
},
},
{
"test",
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 |
NurlWinbarWarning |
Warning messages |
NurlWinbarError |
Error messages |
NurlInfoIcon |
Section icons in info buffer |
NurlInfoLabel |
Field labels in info buffer |
NurlInfoValue |
Field values in info buffer |
NurlInfoHighlight |
Highlighted values (e.g., total time) |
NurlInfoUrl |
URL values |
NurlInfoQueryKey |
Query parameter keys |
NurlInfoQueryValue |
Query parameter values |
NurlInfoSeparator |
Separators (?, &, =) |
NurlInfoMethod |
HTTP method |
NurlInfoStatus |
Status code (fallback) |
NurlInfoStatusSuccess |
2xx status codes |
NurlInfoStatusRedirect |
3xx status codes |
NurlInfoStatusClientError |
4xx status codes |
NurlInfoStatusServerError |
5xx status codes |
NurlTestPass |
Passing test count |
NurlTestFail |
Failing test count and "Failure" header |
NurlTestError |
Error count and "Error" header |
NurlTestLabel |
"Passed in:" and "Expected:" labels |
NurlTestValueActual |
Actual values (diff delete style) |
NurlTestValueExpected |
Expected values (diff add style) |
NurlTestSuiteName |
Test suite breadcrumb |
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
-- Make <cfile> include query params
vim.opt.isfname:append("?")
vim.opt.isfname:append("&")
vim.keymap.set("n", "gx", super_gx, { desc = "Super gx" })