HTTP Client Lands: Luma Talks to the Web
Every modern program talks to an API. Weather data, payment gateways, authentication services, third-party integrations — HTTP is the lingua franca of the internet. Luma could already serve HTTP with server.*, but it couldn’t make requests. That gap closed today.
Luma now has a built-in HTTP client with a builder pattern: create a request, configure it step by step, execute.
The simplest case
res = http("https://api.example.com/users").get()
print(res.status) // 200
print(res.body) // JSON stringOne line to make a GET request. http() creates the client, .get() fires it. The response gives you status, body, and headers — everything you need.
Builder pattern
For more control, build the request step by step:
h: http = http("https://api.example.com/users")
h.header("Authorization", "Bearer token123")
h.header("Accept", "application/json")
h.timeout(10)
res = h.post()This follows the same pattern as buffer() and tcp.connect() — create an object, configure it, use it. No surprise constructors, no magic options objects.
POST with JSON body
Pass a map to .body() and Luma serializes it to JSON automatically. The Content-Type header is set for you.
h: http = http("https://api.example.com/users")
h.body({"name": "Alice", "age": 30, "active": true})
res = h.post()
print(res.body)No json.encode() call. No manual Content-Type. Just pass the data.
POST with form fields
For classic form submissions, use .field():
h: http = http("https://example.com/login")
h.field("username", "alice")
h.field("password", "secret")
res = h.post()Fields are URL-encoded automatically. Content-Type: application/x-www-form-urlencoded is set for you. .field() and .body() are mutually exclusive — use one or the other.
Raw string body
When you need full control over the body, pass a string to .body():
h: http = http("https://example.com/api")
h.header("Content-Type", "application/xml")
h.body("<user><name>Alice</name></user>")
res = h.post()String bodies don’t auto-set Content-Type — you control the header yourself.
All HTTP methods
Five methods cover the standard REST operations:
res = h.get()
res = h.post()
res = h.put()
res = h.delete()
res = h.patch()Reading the response
The response object has three fields:
res = http("https://api.example.com/data").get()
print(res.status) // 200
print(res.body) // response body as string
ct = res.headers["Content-Type"] // "application/json"res.status— HTTP status code as integerres.body— response body as stringres.headers— response headers as{str: str}map
HTTP 4xx and 5xx responses are valid responses, not errors. You get the status code and body — your code decides what to do with them.
Timeouts
Default timeout is 30 seconds. Override it with .timeout():
h: http = http("https://slow-api.example.com/data")
h.timeout(5) // 5 seconds
res = h.get()Error handling
Network errors and timeouts panic — consistent with how TCP, file I/O, and every other I/O operation works in Luma. Catch them with .or():
h: http = http("https://unreachable.example.com")
h.timeout(2)
res = h.get().or(err -> {
print("Request failed: ${err}")
})This is the same .or() pattern you already use for file operations, database queries, and TCP connections. No new error handling concept to learn.
A real example: calling a REST API
Here’s a complete program that creates a user via a REST API and prints the result:
// Create user
h: http = http("https://jsonplaceholder.typicode.com/users")
h.header("Accept", "application/json")
h.body({
"name": "Alice Johnson",
"username": "alice",
"email": "alice@example.com"
})
res = h.post()
if res.status == 201 {
print("User created!")
print(res.body)
} else {
print("Failed with status: ${res.status}")
}No imports. No dependency installation. No HTTP client initialization boilerplate. Just build the request and send it.
Design decisions
Why a builder pattern? Because HTTP requests have many optional parts — headers, body, timeout, authentication. A builder lets you configure only what you need without overloading a single function with dozens of parameters.
Why http() and not http.new()? Consistency. Luma uses buffer(), file(), cli(), datetime() — constructors are bare function calls. The HTTP client follows the same convention.
Why auto-serialize maps? Because the most common HTTP body is JSON, and making developers call json.encode() every time adds friction without adding clarity. If you pass a map, it becomes JSON. If you pass a string, it stays a string. The intent is obvious from the argument type.
Why panic on network errors? Because that’s what Luma does for all I/O. File not found? Panic. TCP connection refused? Panic. DNS resolution failed? Panic. The developer catches it with .or() when they want to handle the failure. This keeps the happy path clean and the error path explicit.
What this unlocks
With the HTTP client in place, Luma programs can now:
- Consume REST APIs and microservices
- Integrate with third-party services (Stripe, Twilio, SendGrid, etc.)
- Build API-to-API bridges and webhooks
- Fetch remote data for processing pipelines
- Health-check external services
- Interact with any HTTP-based protocol
Combined with the web server (server.*), JSON (json.encode / json.decode), and database support (db.*), Luma now covers the full request cycle: receive a request, query a database, call an external API, return a response. That’s a complete backend.
Full API reference
| Method | Description |
|---|---|
http(url) |
Create an HTTP client for the given URL |
.header(key, value) |
Set a request header |
.field(key, value) |
Add a form field (URL-encoded) |
.body(map) |
Set JSON body (auto-serialized) |
.body(string) |
Set raw string body |
.timeout(seconds) |
Set timeout (default: 30s) |
.get() |
Execute GET request |
.post() |
Execute POST request |
.put() |
Execute PUT request |
.delete() |
Execute DELETE request |
.patch() |
Execute PATCH request |
Response fields:
| Field | Type | Description |
|---|---|---|
res.status |
int |
HTTP status code |
res.body |
str |
Response body |
res.headers |
{str: str} |
Response headers |
The HTTP client is available now. No installation, no imports — it’s built into the language.