Introduction
Have you ever written a server and wanted to easely test it? I've always written a small request using curl. It might look something like this:
curl -X POST https://api.example.com/endpoint \
-H "Content-Type: application/json" \
-d '{"key1":"value1", "key2":"value2"}'
It works, but when you have multiple endpoints to test it will get out of hand pretty quickly. It is also quite annoying to edit commands like this in the command line.
Alternative
Before making this tool, I turned to Paw (Oh, yeah, its "RapidAPI" now). But it is a quite heavy GUI app. For me, a nvim keyboard only user, it is so frustrating to have to use my mouse, usually it on the other end of the table! The UI is also, of course its just me, confusing.
About
At some point, after having to deal with yet one more curl syntax error after changing a request in a Justfile, it was enough, I had to do something.
The idea I had was a simple CLI tool that runs Lua scripts and send requests defined in them. My favorite programming language is Rust, so thats what I chose. Fortunately, there are amazing lua bindings for rust.
Here is a preview of the script:
define({
name = "get",
func = function()
result = send({
url = "https://httpbin.org/get",
method = "GET",
headers = {
["User-Agent"] = "Neocurl",
["Accept"] = "application/json"
},
})
print_response(result)
end,
})
$ ncurl list
1: get
$ ncurl run get
Elapsed: 1843 ms
Status: 200 OK
Headers:
content-type: application/json
date: Sun, 01 Jun 2025 12:41:29 GMT
access-control-allow-origin: *
content-length: 263
access-control-allow-credentials: true
server: gunicorn/19.9.0
connection: keep-alive
Body:
{
"args": {},
"headers": {
"Accept": "application/json",
"Host": "httpbin.org",
"User-Agent": "Neocurl",
"X-Amzn-Trace-Id": "Root=1-683c4a79-753e34797d9b9d4a7c020b49"
},
"origin": "184.22.77.52",
"url": "https://httpbin.org/get"
}
It worked amazing! I iterated on the idea, eventually supporting a bunch of cool features (look here). Everything was wonderful, but the problem came with msgpack. Lua was unable to properly handle msgpack.
After some thought, Python was my choice. I used PyO₃ to run Python scripts from Rust.
Here is the same script, but in Python:
@nc.define
def get(client):
result = client.get(
"https://httpbin.org/get",
headers = {
"User-Agent": "Neocurl",
"Accept": "application/json"
}
)
result.print()
$ cargo run run get
Response:
Status: 200 200 OK
Duration: 1699
Headers:
(date: Sun, 01 Jun 2025 12:51:23 GMT),
(content-type: application/json),
(content-length: 263),
(connection: keep-alive),
(server: gunicorn/19.9.0),
(access-control-allow-origin: *),
(access-control-allow-credentials: true)
Body:
{
"args": {},
"headers": {
"Accept": "application/json",
"Host": "httpbin.org",
"User-Agent": "Neocurl",
"X-Amzn-Trace-Id": "Root=1-683c4ccb-10c14f0f5672dbd159a3b1fd"
},
"origin": "184.22.77.52",
"url": "https://httpbin.org/get"
}
Now, msgpack works!
Quick Start
This is a guide to install NeoCurl and run a simple request.
Installation
NeoCurl uses Python version 3.11!
Install python3.11
brew install python3.11
Create a venv for python3.11
python3.11 -m venv venv
Specify PYTHON_SYS_EXECUTABLE
export PYTHON_SYS_EXECUTABLE=$(which python3.11)
Install NeoCurl
cargo install [email protected]
Check
ncurl --version
neocurl 2.0.0-alpha.2
Create a project
Create
Create a new project with:
ncurl init
Initialized successfully at ncurl.py.
This will create a new file called ncurl.py with a default script inside.
Verify
Verify that the script has been created successfully and can be executed:
ncurl list
13:18:47.240 INFO: Neocurl initialized
Available definitions:
0: get
Running
Run the get definition:
ncurl run get
13:20:38.496 INFO: Neocurl initialized
13:20:40.559 INFO get: Response status: 200 OK, finished in 2047.00ms
Response:
Status: 200 200 OK
Duration: 2047
Headers:
(date: Sun, 01 Jun 2025 13:20:40 GMT),
(content-type: application/json),
(content-length: 220),
(connection: keep-alive),
(server: gunicorn/19.9.0),
(access-control-allow-origin: *),
(access-control-allow-credentials: true)
Body:
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"X-Amzn-Trace-Id": "Root=1-683c53a7-0f57c1491bcba048080b1b12"
},
"origin": "184.22.77.52",
"url": "https://httpbin.org/get"
}
Test results: 0/0
Call results: 1/0
13:20:40.559 INFO: Neocurl cleanup complete
Explanation
Lets take a look inside ncurl.py:
import neocurl as nc
@nc.on_init
def main():
if not nc.check_version("2.0.0-alpha.2"):
nc.fatal(f"This version of neocurl is not compatible with this script: {nc.version()}")
logger_config = nc.get_logger_config()
if nc.env("LOG") == "DEBUG":
logger_config.level = nc.LogLevel.Debug
else:
logger_config.level = nc.LogLevel.Info
logger_config.datetime_format = "%H:%M:%S%.3f"
logger_config.use_colors = True
nc.set_logger_config(logger_config)
nc.info("Neocurl initialized")
@nc.on_cleanup
def cleanup():
nc.info("Neocurl cleanup complete")
@nc.define
def get(client):
nc.debug("Sending GET request")
response = client.get("https://httpbin.org/get")
nc.info(f"Response status: {response.status}, finished in {response.duration:.2f}ms")
assert response.status_code == 200, f"Expected status code 200, but got {response.status_code} ({response.status})"
response.print()
This is not that scary as it look.
Line by line
import neocurl as nc
Module neocurl is provided by NeoCurl at runtime.
@nc.on_init
def main():
This defines a function with nc.on_init decorator. NeoCurl will run this function before running any definitions.
if not nc.check_version("2.0.0-alpha.2"):
nc.fatal(f"This version of neocurl is not compatible with this script: {nc.version()}")
This checks if the NeoCurl version is 2.0.0-alpha.2 (This might have beed updated since the book bas been written).
If the NeoCurl version does not match the version requested by the script, it fails with an error.
The function nc.fatal(msg) prints the message and exits the executable.
logger_config = nc.get_logger_config()
Gets the logger_config struct.
if nc.env("LOG") == "DEBUG":
logger_config.level = nc.LogLevel.Debug
else:
logger_config.level = nc.LogLevel.Info
If there is a LOG env var and it is set to DEBUG, set the logger level to Debug, otherwise set Info.
logger_config.datetime_format = "%H:%M:%S%.3f"
logger_config.use_colors = True
Sets the datatime format for logs and enabled colors.
nc.set_logger_config(logger_config)
Sets the logger config.
nc.info("Neocurl initialized")
Logs a message, saying NeoCurl has been initialized.
@nc.on_cleanup
def cleanup():
nc.info("Neocurl cleanup complete")
Defines a function with a nc.on_cleanup decorator. NeoCurl will run this after a definition is ran. The function logs a message about cleanup being successful.
@nc.define
def get(client):
Defines a function get (The definition name) with a nc.define decorator. This will be ran when ncurl run get is executed.
response = client.get("https://httpbin.org/get")
nc.info(f"Response status: {response.status}, finished in {response.duration:.2f}ms")
Send a GET request to https://httpbin.org/get and gets the response.
Logs the status and the duration.
assert response.status_code == 200, f"Expected status code 200, but got {response.status_code} ({response.status})"
Checks if the status code is 200, raises an error if it is not.
response.print()
Prints the response.
Advanced
In this section you can find information about advanced features like third party libs, async requests, and tests.
Third Party Libs
Virtual Enviroment
NeoCurl tries to find and use a Python Interpreter from a VIRTUAL_ENV and load modules installed there.
I recommend using the venv directory next to the script.
To create a virtual enviroment run:
python3.11 -m venv venv
Make sure to use python3.11, as that is the version NeoCurl is compiled with.
Activate it:
source ./venv/bin/activate
Install libraries
With a venv active, install libs:
pip3 install libs
Run
Make sure venv is active and run the script:
ncurl run definition
Libraries should be loaded.
Debug
Set RUST_LOG=debug to see debug messages. This might help debug venv.
Complex requests
NeoCurl supports sending complex requests via python's keyword arguments.
Send
Breakdown
Here is a breakdown of keyword args supported by nc.Client.send:
-
method: nc.MethodHTTP request method. Type is an enum defined in
neocurlmodule.nc.Method.Getornc.GETnc.Method.Headornc.HEADnc.Method.Postornc.POSTnc.Method.Putornc.POSTnc.Method.Deleteornc.DELETEnc.Method.Patchornc.PATCH
-
body: None | str | bytesBody of the request. NeoCurl tries to parse it as
str, on fails attempts to parse abytes. Can beNoneto disable body. -
timeout: None | intRequest timeout in milliseconds. Default is
100s. -
headers: None | DictRequest headers. Parsed as a dictionary, can be
None. Example:headers = { "Content-Type": "binary/octet-stream", "User-Agent": "Neocurl/2.0.0-alpha.2", } -
params: None | DictQuery params. Parsed the same as
headers, can beNone.
Example
response = client.send(
"https://httpbin.org/post",
method = nc.POST,
timeout = 10_000,
headers = {
"Content-Type": "binary/octet-stream",
"User-Agent": "Neocurl/2.0.0-alpha.2",
},
params = {
"id": "20438",
},
)
Get and Post
nc.Client has two more methods: .get() and .post().
These dont need method specified.
Return
Functions return nc.Response.
Async requests
To send async requests use nc.send_async.
It has two more keyword args:
-
amount: None | intAmount of requests to send. Default is
1. -
threads: None | intHow many threads to use. Default is
1.
Function returns nc.AsyncResponses.
Rusty inner workings
NeoCurl uses Tokio Runtime to schedule amount of tasks across threads concurent threads.
Total amount of requests ran is not amount * threads. Tasks are split equally across all threads.
Response
Sync
The return type of nc.Client.send() and similar methods is nc.Response, a struct defined in neocurl module. It has no Python constructor and can only be retrieved from the client.
Fields
-
status: strRequest status in a human readable form.
-
status_code: intRequest status as a num. E.g.
200,404,500. -
duration: intTime elapsed to send request and recive a response in milliseconds. Does not include the time to form the request and time to parse the response.
-
body: strResponse body.
-
body_raw: bytesResponse body as bytes.
-
headers: DictResponse headers. A dictionary.
Methods
-
print()Prints information about the response in a human readable form.
Async
The return type of async send functions is nc.AsyncResponses.
Fields
-
responses: nc.Response[]An array of responses.
Methods
-
amount() -> intReturns amount of responses.
-
print_nth(id)Calls
print()on nth element fromresponses.-
id: intId of response to print.
-
-
print_stats(chunk, cut off)Prints responses statistics.
-
chunk: intDuration grouping chunk to use.
-
cut_off: intCut off in percents. If amount is less than cut off, does not print the row.
-
New client
Create a new nc.Client using nc.client().