Skip to content

HTTP Server and Client

Spice ships a blocking HTTP/1.1 server and client in std/net/http-server and std/net/http-client, built directly on top of the plain TCP sockets in std/net/socket. Both understand request/response framing via Content-Length and chunked transfer encoding, so you get a working HTTP stack without pulling in any third-party dependency.

This tutorial builds a small server with a few routes, and a client that talks to it, as two separate programs - which is how you would run them in practice.

TLS

HttpClient and HttpServer both speak TLS via std/net/tls, built on the OpenSSL bindings in std/bindings/openssl - see TLS / HTTPS below. This adds a build-time dependency on OpenSSL (libssl-dev on Debian/Ubuntu), the same way the libcurl bindings in std/bindings/libcurl depend on libcurl.

A minimal server

Every server is built around the HttpServer struct. Construct it with a port, register routes on it, then start it and let it run:

Spice
// server.spice
import "std/net/http";
import "std/net/http-server";

f<int> main() {
    HttpServer server = HttpServer(8080s);

    server.serve("/", "<h1>Hello from Spice!</h1>");

    Result<bool> started = server.start();
    if started.isErr() {
        printf("Failed to start server: %s\n", started.getErr().message);
        return 1;
    }

    printf("Listening on http://127.0.0.1:8080\n");
    Result<bool> ran = server.run();
    if ran.isErr() {
        printf("Server stopped with an error: %s\n", ran.getErr().message);
        return 1;
    }
    return 0;
}

serve registers a GET route that always answers with the same, fixed piece of content - handy for static pages. start binds the port, and run then serves connections one at a time, forever, until something calls stop or a connection can no longer be accepted. Run it with:

Bash
$ spice run server.spice
Listening on http://127.0.0.1:8080

The process keeps running in the foreground; leave it there and press ++ctrl+c++ once you are done with the tutorial.

A minimal client

HttpClient is the counterpart on the other side. Every call opens its own connection, sends one request, and closes the connection again once the response has arrived:

Spice
// client.spice
import "std/net/http";
import "std/net/http-client";

f<int> main() {
    HttpClient client = HttpClient();

    Result<HttpResponse> getResult = client.get("http://127.0.0.1:8080/");
    if getResult.isErr() {
        printf("Request failed: %s\n", getResult.getErr().message);
        return 1;
    }

    HttpResponse& response = getResult.unwrap();
    printf("%d %s\n%s\n", response.statusCode, response.reason, response.body);
    return 0;
}

With the server from the previous section still running, open a second terminal and run the client:

Bash
1
2
3
$ spice run client.spice
200 OK
<h1>Hello from Spice!</h1>

Adding routes

Besides serve, HttpServer has one method per HTTP method - get, post, put, patch and delete - that takes a path and a handler lambda of type p(const HttpRequest&, HttpResponse&). The handler fills in the response that is passed to it:

Spice
1
2
3
4
5
6
7
8
9
server.get("/greet", p(const HttpRequest& request, HttpResponse& response) {
    String name = request.getQueryParam("name");
    if name.isEmpty() { name = String("stranger"); }

    String greeting = String("Hello, ");
    greeting.append(name);
    greeting.append('!');
    response.setBody(greeting, CONTENT_TYPE_TEXT);
});

getQueryParam reads and percent-decodes a value straight out of the query string, so a request to /greet?name=Spice answers with Hello, Spice!, while a plain /greet falls back to Hello, stranger!.

Routing is exact-match

A route only answers the exact path it was registered for - /a and /a/ are different routes, path parameters such as /users/{id} are not supported, and the query string never takes part in the match. Read dynamic parts of the path yourself out of request.getPath(), and query parameters via getQueryParam.

A path that exists for another method answers 405 Method Not Allowed automatically, and a HEAD request is answered from the matching GET route with the body dropped - you don't need to register those cases yourself.

Request and response bodies

A handler reads the request body straight off the request.body field, and hands a response body to response.setBody together with its media type. setJsonBody and setHtmlBody are shorthands for the two most common cases:

Spice
1
2
3
4
5
6
7
server.post("/users", p(const HttpRequest& request, HttpResponse& response) {
    response.setStatus(STATUS_CREATED);
    String body = String("{\"received\":");
    body.append(request.body);
    body.append('}');
    response.setJsonBody(body);
});

std/net/http defines constants for the common status codes (STATUS_OK, STATUS_CREATED, STATUS_NOT_FOUND, STATUS_INTERNAL_SERVER_ERROR, ...) and media types (CONTENT_TYPE_TEXT, CONTENT_TYPE_HTML, CONTENT_TYPE_JSON, ...), so you rarely have to spell out a raw number or MIME string yourself.

Handling unmatched requests

Without any configuration, a request that matches no route gets a plain 404. Register a custom handler with setNotFoundHandler to answer it differently:

Spice
1
2
3
4
5
6
server.setNotFoundHandler(p(const HttpRequest& request, HttpResponse& response) {
    const String path = request.getPath();
    String message = String("No route for ");
    message.append(path);
    response.setBody(message, CONTENT_TYPE_TEXT);
});

The status code is already set to 404 by the time your handler runs - you only need to fill in the body.

Sending more than GET

HttpClient mirrors the server's set of methods - get, head, post, put, patch and delete - plus a request method that takes an arbitrary HttpMethod for anything that does not fit the named ones. post, put and patch take a body and a content type, just like setBody does on the server side:

Spice
1
2
3
4
5
6
7
8
String payload = String("{\"name\":\"Spice\"}");
Result<HttpResponse> created = client.post("http://127.0.0.1:8080/users", payload, CONTENT_TYPE_JSON);
if created.isErr() {
    printf("Request failed: %s\n", created.getErr().message);
    return 1;
}
HttpResponse& createdResponse = created.unwrap();
printf("%d %s\n", createdResponse.statusCode, createdResponse.body);

A handful of public fields on HttpClient let you adjust its behavior:

Spice
1
2
3
client.setDefaultHeader("Authorization", "Bearer secret-token"); // sent with every request
client.timeoutMillis = 5000l;  // per read/write timeout, 0 to block indefinitely
client.maxRedirects = 0u;      // hand 3xx responses back as-is instead of following them

defaultHeaders fields are only added to a request if it does not already carry a field with that name, so a one-off request can always override them.

TLS / HTTPS

HttpClient.get (and the other request methods) transparently use TLS whenever the URL starts with https:// - no extra code needed on the client side beyond the URL itself:

Spice
Result<HttpResponse> secure = client.get("https://example.com/");

By default, the server's certificate is verified against the operating system's trust store, and its host name is checked against the URL. Point verification at a specific CA bundle instead - for example to talk to a server with a self-signed certificate - with trustedCaFile:

Spice
client.trustedCaFile = String("./ca-bundle.pem");

There is no way to switch verification off: a client that does not verify the server it talks to gets no real confidentiality guarantee, so std/net/tls does not offer that option.

To serve https instead of plain http, call useTls with a certificate and private key (PEM files) before start:

Spice
1
2
3
4
5
6
HttpServer server = HttpServer(8443s);
Result<bool> tlsConfigured = server.useTls("./cert.pem", "./key.pem");
if tlsConfigured.isErr() {
    printf("Could not configure TLS: %s\n", tlsConfigured.getErr().message);
    return 1;
}

Everything else - routes, handlers, start/run/stop - stays the same; only the listening socket's connections are now TLS-handshaked before their request is read.

Putting it all together

server.spice:

Spice
import "std/net/http";
import "std/net/http-server";

f<int> main() {
    HttpServer server = HttpServer(8080s);

    server.serve("/", "<h1>Hello from Spice!</h1>");

    server.get("/greet", p(const HttpRequest& request, HttpResponse& response) {
        String name = request.getQueryParam("name");
        if name.isEmpty() { name = String("stranger"); }

        String greeting = String("Hello, ");
        greeting.append(name);
        greeting.append('!');
        response.setBody(greeting, CONTENT_TYPE_TEXT);
    });

    server.post("/users", p(const HttpRequest& request, HttpResponse& response) {
        response.setStatus(STATUS_CREATED);
        String body = String("{\"received\":");
        body.append(request.body);
        body.append('}');
        response.setJsonBody(body);
    });

    server.setNotFoundHandler(p(const HttpRequest& request, HttpResponse& response) {
        const String path = request.getPath();
        String message = String("No route for ");
        message.append(path);
        response.setBody(message, CONTENT_TYPE_TEXT);
    });

    Result<bool> started = server.start();
    if started.isErr() {
        printf("Failed to start server: %s\n", started.getErr().message);
        return 1;
    }

    printf("Listening on http://127.0.0.1:8080\n");
    Result<bool> ran = server.run();
    if ran.isErr() {
        printf("Server stopped with an error: %s\n", ran.getErr().message);
        return 1;
    }
    return 0;
}

client.spice:

Spice
import "std/net/http";
import "std/net/http-client";

f<int> main() {
    HttpClient client = HttpClient();

    Result<HttpResponse> index = client.get("http://127.0.0.1:8080/");
    HttpResponse& indexResponse = index.unwrap();
    printf("index: %d %s\n", indexResponse.statusCode, indexResponse.body);

    Result<HttpResponse> greet = client.get("http://127.0.0.1:8080/greet?name=Spice");
    HttpResponse& greetResponse = greet.unwrap();
    printf("greet: %d %s\n", greetResponse.statusCode, greetResponse.body);

    String payload = String("{\"name\":\"Spice\"}");
    Result<HttpResponse> created = client.post("http://127.0.0.1:8080/users", payload, CONTENT_TYPE_JSON);
    HttpResponse& createdResponse = created.unwrap();
    printf("users: %d %s\n", createdResponse.statusCode, createdResponse.body);

    Result<HttpResponse> missing = client.get("http://127.0.0.1:8080/missing");
    HttpResponse& missingResponse = missing.unwrap();
    printf("missing: %d %s\n", missingResponse.statusCode, missingResponse.body);

    return 0;
}

Run the server in one terminal and the client in another:

Bash
$ spice run server.spice
Listening on http://127.0.0.1:8080
Bash
1
2
3
4
5
$ spice run client.spice
index: 200 <h1>Hello from Spice!</h1>
greet: 200 Hello, Spice!
users: 201 {"received":{"name":"Spice"}}
missing: 404 No route for /missing

Skipping error handling in a demo

The full example above calls unwrap() straight away for brevity. unwrap() aborts the program if the Result holds an error, so in real code check isErr() first, the way the earlier sections do.