Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 272 additions & 61 deletions src/http/README.mbt.md
Original file line number Diff line number Diff line change
@@ -1,92 +1,303 @@
HTTP support for `moonbitlang/async`.
# HTTP Client & Server (`@moonbitlang/async/http`)

## Making simple HTTP request
Asynchronous HTTP/1.1 client and server implementation for MoonBit with support for both HTTP and HTTPS protocols.

Simple HTTP request can be made in just one line:
## Quick Start

### Simple HTTP Requests

Make HTTP requests with a single function call:

```moonbit
///|
async test {
async test "simple GET request" {
let (response, body) = @http.get("https://www.example.org")
inspect(response.code, content="200")
assert_true(body.text().has_prefix("<!doctype html>"))
inspect(response.reason, content="OK")
let text = body.text()
inspect(text.has_prefix("<!doctype html>"), content="true")
}

///|
async test "GET with custom headers" {
let headers = { "User-Agent": "MoonBit HTTP Client" }
let (response, body) = @http.get("http://www.example.org", headers~)
inspect(response.code, content="200")
let content = body.text()
inspect(content.length() > 0, content="true")
}
```

You can use use `body.text()` to get a `String` (decoded via UTF8)
or `body.json()` for a `Json` from the response body.
## HTTP Client

### One-Shot Requests

For simple requests where you don't need to reuse connections:

```moonbit
///|
async test "one-shot GET" {
let (response, body) = @http.get("http://www.example.org")
inspect(response.code, content="200")
inspect(body.text().length() > 0, content="true")
}

///|
async test "one-shot POST with body" {
let post_data = b"key=value&foo=bar"
let headers = { "Content-Type": "application/x-www-form-urlencoded" }

## Generic HTTP client
Sometimes, the simple one-time `@http.get` etc. is insufficient,
for example you need to reuse the same connection for multiple requests,
or the request/response body is very large and need to be processed lazily.
In this case, you can use the `@http.Client` type.
`@http.Client` can be created via `@http.Client::connect(hostname)`,
by default `https` is used, this can be overriden using the `protocol` parameter.
// Note: This will fail against example.org which doesn't accept POST
let result = try? @http.post(
"http://www.example.org/api",
post_data,
headers~,
)
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="false")
}
}

The workflow of performing a request with `@http.Client` is:
///|
async test "one-shot PUT request" {
let data = b"Updated content"

1. initiate the request via `client.request(..)`
1. send the request body by using `@http.Client` as a `@io.Writer`
1. complete the request and obtain response header from the server
via `client.end_request()`
1. read the response body by using `@http.Client` as a `@io.Reader`,
or use `client.read_all()` to obtain the whole response body.
Yon can also ignore the body via `client.skip_response_body()`
// Note: This will fail against example.org
let result = try? @http.put("http://www.example.org/resource", data)
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="false")
}
}
```

The helpers `client.get(..)`, `client.put(..)` etc.
can be used to perform step (1)-(3) above.
### Persistent Connections

A complete example:
Use Client for connection reuse and more control:

```moonbit
///|
async test {
let client = @http.Client::connect("www.example.org")
async test "client with persistent connection" {
let client = @http.Client::connect("www.example.org", protocol=Http)

// Make first request
let response1 = client.get("/")
inspect(response1.code, content="200")
let body1 = client.read_all()
inspect(body1.text().has_prefix("<!doctype html>"), content="true")
client.close()
}

///|
async test "HTTPS connection" {
let client = @http.Client::connect("www.example.org", protocol=Https)
defer client.close()
let response = client..request(Get, "/").end_request()
let response = client.get("/")
inspect(response.code, content="200")
let body = client.read_all()
assert_true(body.text().has_prefix("<!doctype html>"))
inspect(body.text().length() > 0, content="true")
}

///|
async test "custom port" {
// Connect to HTTP server on custom port
let result = try? @http.Client::connect("localhost", protocol=Http, port=8080)
// This will fail if no server is running on port 8080
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="true")
}
}
```

### Request Methods

Make different types of HTTP requests:

```moonbit
///|
async test "GET request" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
let response = client.get("/")
inspect(response.code, content="200")
client.skip_response_body()
}

///|
async test "POST request with body" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
let data = b"test data"

// Will fail as example.org doesn't support POST
let result = try? client.post("/api", data)
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="false")
}
}

///|
async test "PUT request with body" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
let data = b"updated content"

// Will fail as example.org doesn't support PUT
let result = try? client.put("/resource", data)
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="false")
}
}
```

### Custom Headers

Add custom headers to requests:

```moonbit
///|
async test "persistent headers on client" {
let headers = { "User-Agent": "MoonBit/1.0", "Accept": "text/html" }
let client = @http.Client::connect("www.example.org", headers~, protocol=Http)
defer client.close()

// All requests from this client will include the headers above
let response = client.get("/")
inspect(response.code, content="200")
client.skip_response_body()
}

///|
async test "extra headers per request" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
let extra_headers = { "X-Custom-Header": "custom-value" }
let response = client.get("/", extra_headers~)
inspect(response.code, content="200")
client.skip_response_body()
}
```

## Writing HTTP servers
The `@http.ServerConnection` type provides abstraction for a connection in a HTTP server.
It can be created via `@http.ServerConnection::new(tcp_connection)`.
The workflow of processing a request via `@http.ServerConnection` is:
### Request and Response Bodies

Work with request and response bodies:

```moonbit
///|
async test "send request body manually" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
client.request(Post, "/api")

// Write body data
client.write(b"First part ")
client.write(b"Second part")
client.flush()

1. use `server.read_request()` to wait for incoming request
and obtain the header of the request
1. read the request body by usign `@http.ServerConnection` as a `@io.Reader`.
or use `server.read_all()` to obtain the whole request body.
Yon can also ignore the body via `server.skip_request_body()`
1. use `server.send_response` to initiate a response and send the response header
1. send response body by using `@http.ServerConnection` as a `@io.Writer`
1. call `server.end_response()` to complete the response
// Will fail as example.org doesn't accept POST
let result = try? client.end_request()
match result {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="false")
}
}

Here's an example server that returns 404 to every request:
///|
async test "read response headers" {
let (response, _body) = @http.get("http://www.example.org")
inspect(response.code, content="200")
inspect(response.reason, content="OK")

// Check if specific header exists
let has_content_type = response.headers.contains("Content-Type")
inspect(has_content_type, content="false")
}

///|
async test "skip response body" {
let client = @http.Client::connect("www.example.org", protocol=Http)
defer client.close()
let response = client.get("/")
inspect(response.code, content="200")

// Skip reading the body if not needed
client.skip_response_body()
}
```

## Types Reference

### Protocol

Enum representing HTTP protocol:

```moonbit
///|
pub async fn server(listen_addr : @socket.Addr) -> Unit {
@async.with_task_group(fn(group) {
let server = @socket.TcpServer::new(listen_addr)
for {
let (conn, _) = server.accept()
group.spawn_bg(allow_failure=true, fn() {
let conn = @http.ServerConnection::new(conn)
defer conn.close()
for {
let request = conn.read_request()
conn.skip_request_body()
conn
..send_response(404, "NotFound")
..write("`\{request.path}` not found")
..end_response()
}
})
}
})
async test "Protocol enum" {
let http = @http.Http
let https = @http.Https
inspect(http.default_port(), content="80")
inspect(https.default_port(), content="443")
}
```

### Response

Structure representing an HTTP response:

```moonbit
///|
async test "Response structure" {
let (response, _body) = @http.get("http://www.example.org")

// Access response fields
inspect(response.code, content="200")
inspect(response.reason, content="OK")
let headers = response.headers
let has_headers = headers.length() > 0
inspect(has_headers, content="true")
}
```

Fields:
- `code: Int` - HTTP status code
- `reason: String` - Status reason phrase
- `headers: Map[String, String]` - Response headers

## Best Practices

1. Always close connections with `defer client.close()`
2. Handle errors using `try?` or `catch`
3. Skip unused bodies with `skip_response_body()`
4. Reuse `Client` for multiple requests to the same host
5. Call `flush()` when sending data in chunks
6. Prefer `protocol=Https` for secure connections

## Error Handling

HTTP operations can raise errors:

```moonbit
///|
async test "error handling" {
// Invalid URL
let result1 = try? @http.get("invalid://example.com")
match result1 {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="true")
}

// Connection refused
let result2 = try? @http.get("http://localhost:9999")
match result2 {
Err(_) => inspect(true, content="true")
Ok(_) => inspect(false, content="true")
}
}
```

For complete server examples, see `examples/http_file_server` and `examples/http_server_benchmark` directories.
Loading
Loading