Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 or nc.GET
    • nc.Method.Head or nc.HEAD
    • nc.Method.Post or nc.POST
    • nc.Method.Put or nc.POST
    • nc.Method.Delete or nc.DELETE
    • nc.Method.Patch or nc.PATCH
  • body: None | str | bytes

    Body of the request. NeoCurl tries to parse it as str, on fails attempts to parse a bytes. Can be None 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 be None.

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 from responses.

    • 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().