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.Method
HTTP request method. Type is an enum defined in
neocurl
module.nc.Method.Get
ornc.GET
nc.Method.Head
ornc.HEAD
nc.Method.Post
ornc.POST
nc.Method.Put
ornc.POST
nc.Method.Delete
ornc.DELETE
nc.Method.Patch
ornc.PATCH
-
body: None | str | bytes
Body of the request. NeoCurl tries to parse it as
str
, on fails attempts to parse abytes
. Can beNone
to disable body. -
timeout: None | int
Request timeout in milliseconds. Default is
100s
. -
headers: None | Dict
Request 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 | Dict
Query 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 | int
Amount of requests to send. Default is
1
. -
threads: None | int
How 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: str
Request status in a human readable form.
-
status_code: int
Request status as a num. E.g.
200
,404
,500
. -
duration: int
Time 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: str
Response body.
-
body_raw: bytes
Response body as bytes.
-
headers: Dict
Response 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() -> int
Returns amount of responses.
-
print_nth(id)
Calls
print()
on nth element fromresponses
.-
id: int
Id of response to print.
-
-
print_stats(chunk, cut off)
Prints responses statistics.
-
chunk: int
Duration grouping chunk to use.
-
cut_off: int
Cut 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()
.