diff --git a/src/http/README.mbt.md b/src/http/README.mbt.md index 64061947..281fb1a5 100644 --- a/src/http/README.mbt.md +++ b/src/http/README.mbt.md @@ -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("")) + inspect(response.reason, content="OK") + let text = body.text() + inspect(text.has_prefix(""), 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(""), 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("")) + 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. diff --git a/src/process/README.mbt.md b/src/process/README.mbt.md new file mode 100644 index 00000000..cbfe06c8 --- /dev/null +++ b/src/process/README.mbt.md @@ -0,0 +1,428 @@ +# Process Management (`@moonbitlang/async/process`) + +Asynchronous process spawning and management for MoonBit with support for pipes, environment variables, and I/O redirection. + +## Quick Start + +### Running Simple Commands + +Execute commands and collect their output: + +```moonbit +async test "simple command execution" { + let (exit_code, output) = @process.collect_stdout("echo", ["Hello, World!"]) + inspect(exit_code, content="0") + let text = output.text() + inspect(text.has_prefix("Hello"), content="true") +} + +async test "command with exit code" { + let exit_code = @process.run("sh", ["-c", "exit 42"]) + inspect(exit_code, content="42") +} +``` + +## Collecting Process Output + +### Collect Standard Output + +Capture stdout from a process: + +```moonbit +async test "collect stdout" { + let (code, output) = @process.collect_stdout("printf", ["test output"]) + inspect(code, content="0") + inspect(output.text(), content="test output") +} + +async test "collect stdout with args" { + let (code, output) = @process.collect_stdout("sh", ["-c", "echo 'line 1'; echo 'line 2'"]) + inspect(code, content="0") + let text = output.text() + inspect(text.contains("line 1"), content="true") + inspect(text.contains("line 2"), content="true") +} +``` + +### Collect Standard Error + +Capture stderr from a process: + +```moonbit +async test "collect stderr" { + let (code, output) = @process.collect_stderr("sh", ["-c", "printf 'error message' >&2"]) + inspect(code, content="0") + inspect(output.text(), content="error message") +} +``` + +### Collect Both Stdout and Stderr + +Capture both output streams separately: + +```moonbit +async test "collect both streams" { + let (code, stdout, stderr) = @process.collect_output("sh", [ + "-c", "printf 'out msg'; printf 'err msg' >&2", + ]) + inspect(code, content="0") + inspect(stdout.text(), content="out msg") + inspect(stderr.text(), content="err msg") +} +``` + +### Collect Merged Output + +Merge stdout and stderr into a single stream: + +```moonbit +async test "collect merged output" { + let (code, output) = @process.collect_output_merged("sh", [ + "-c", "printf 'ab'; printf 'cd' >&2; printf 'ef'", + ]) + inspect(code, content="0") + inspect(output.text(), content="abcdef") +} +``` + +## Process I/O with Pipes + +### Reading from Process + +Create a pipe to read from a process: + +```moonbit +async test "read from process with pipe" { + @async.with_task_group(fn(root) { + let (reader, writer) = @process.read_from_process() + defer reader.close() + + root.spawn_bg(fn() { + let _ = @process.run("echo", ["Hello from process"], stdout=writer) + }) + + let output = reader.read_all().text() + inspect(output.has_prefix("Hello"), content="true") + }) +} +``` + +### Writing to Process + +Create a pipe to write to a process: + +```moonbit +async test "write to process with pipe" { + @async.with_task_group(fn(root) { + let (cat_read, we_write) = @process.write_to_process() + let (we_read, cat_write) = @process.read_from_process() + + root.spawn_bg(fn() { + let _ = @process.run("cat", ["-"], stdin=cat_read, stdout=cat_write) + }) + + root.spawn_bg(fn() { + defer we_write.close() + we_write.write(b"test input\n") + }) + + root.spawn_bg(fn() { + defer we_read.close() + let output = we_read.read_all().text() + inspect(output.contains("test input"), content="true") + }) + }) +} +``` + +## File Redirection + +### Redirect from File + +Use a file as process input: + +```moonbit +async test "redirect input from file" { + @async.with_task_group(fn(root) { + let input_file = "target/process_test_input.txt" + @fs.write_file(input_file, "file content", create=0o644) + root.add_defer(fn() { @fs.remove(input_file) }) + + let (code, output) = @process.collect_stdout( + "cat", + [], + stdin=@process.redirect_from_file(input_file), + ) + inspect(code, content="0") + inspect(output.text(), content="file content") + }) +} +``` + +### Redirect to File + +Write process output to a file: + +```moonbit +async test "redirect output to file" { + @async.with_task_group(fn(root) { + let output_file = "target/process_test_output.txt" + root.add_defer(fn() { @fs.remove(output_file) }) + + let code = @process.run( + "echo", + ["test output"], + stdout=@process.redirect_to_file(output_file, create=0o644), + ) + inspect(code, content="0") + + let content = @fs.read_file(output_file).text() + inspect(content.has_prefix("test output"), content="true") + }) +} +``` + +### File to File Redirection + +Copy file content using process redirection: + +```moonbit +async test "file to file redirection" { + @async.with_task_group(fn(root) { + let input_file = "target/process_redirect_in.txt" + let output_file = "target/process_redirect_out.txt" + + @fs.write_file(input_file, "redirect test", create=0o644) + root.add_defer(fn() { @fs.remove(input_file) }) + root.add_defer(fn() { @fs.remove(output_file) }) + + let _ = @process.run( + "cat", + [], + stdin=@process.redirect_from_file(input_file), + stdout=@process.redirect_to_file(output_file, create=0o644), + ) + + inspect(@fs.read_file(output_file).text(), content="redirect test") + }) +} +``` + +## Environment Variables + +### Setting Environment Variables + +Pass custom environment variables to processes: + +```moonbit +async test "set environment variable" { + let (code, output) = @process.collect_stdout( + "sh", + ["-c", "echo $MY_VAR"], + extra_env={ "MY_VAR": "my_value" }, + ) + inspect(code, content="0") + inspect(output.text().trim_space(), content="my_value") +} + +async test "multiple environment variables" { + let (code, output) = @process.collect_stdout( + "sh", + ["-c", "echo $VAR1-$VAR2"], + extra_env={ + "VAR1": "first", + "VAR2": "second", + }, + ) + inspect(code, content="0") + inspect(output.text().trim_space(), content="first-second") +} +``` + +### Isolated Environment + +Run process without inheriting parent environment: + +```moonbit +async test "isolated environment" { + let (code, output) = @process.collect_stdout( + "env", + [], + extra_env={ + "ONLY_VAR": "only_value", + }, + inherit_env=false, + ) + inspect(code, content="0") + let text = output.text() + inspect(text.contains("ONLY_VAR=only_value"), content="true") + // Parent environment variables won't be present + inspect(text.contains("PATH="), content="false") +} +``` + +## Working Directory + +### Change Working Directory + +Execute processes in a specific directory: + +```moonbit +async test "set working directory" { + let (code, output) = @process.collect_stdout("pwd", [], cwd="/tmp") + inspect(code, content="0") + inspect(output.text().trim_space(), content="/private/tmp") +} + +async test "relative path in cwd" { + let (code, output) = @process.collect_stdout("ls", [], cwd="src") + inspect(code, content="0") + let text = output.text() + // Should list contents of src directory + let has_content = text.length() > 0 + inspect(has_content, content="true") +} +``` + +## Asynchronous Process Management + +### Spawn and Wait + +Spawn processes asynchronously and wait for completion: + +```moonbit +async test "spawn and wait" { + let exit_code = @process.run("sleep", ["0.1"]) + inspect(exit_code, content="0") +} + +async test "wait for specific exit code" { + let exit_code = @process.run("sh", ["-c", "exit 5"]) + inspect(exit_code, content="5") +} +``` + +### Spawn Orphan Process + +Start a process without blocking: + +```moonbit +async test "spawn orphan and wait later" { + let pid = @process.spawn_orphan("sh", ["-c", "sleep 0.1; exit 7"]) + + // Do other work... + @async.sleep(50) + + // Wait for the process to complete + let exit_code = @process.wait_pid(pid) + inspect(exit_code, content="7") +} +``` + +## Advanced Usage + +### Merge Output Streams + +Combine stdout and stderr into one stream: + +```moonbit +async test "merge stdout and stderr" { + @async.with_task_group(fn(root) { + let (reader, writer) = @process.read_from_process() + defer reader.close() + + root.spawn_bg(fn() { + let _ = @process.run( + "sh", + ["-c", "echo 'to stdout'; echo 'to stderr' >&2"], + stdout=writer, + stderr=writer, + ) + }) + + let output = reader.read_all().text() + inspect(output.contains("to stdout"), content="true") + inspect(output.contains("to stderr"), content="true") + }) +} +``` + +### Multiple Processes Sharing Output + +Run multiple processes writing to the same pipe: + +```moonbit +async test "multiple processes to one pipe" { + @async.with_task_group(fn(root) { + let (reader, writer) = @pipe.pipe() + + root.spawn_bg(no_wait=true, fn() { + defer reader.close() + let output = reader.read_all().text() + inspect(output.contains("first"), content="true") + inspect(output.contains("second"), content="true") + }) + + defer writer.close() + @async.with_task_group(fn(group) { + group.spawn_bg(fn() { + let _ = @process.run("echo", ["first"], stdout=writer) + }) + group.spawn_bg(fn() { + let _ = @process.run("echo", ["second"], stdout=writer) + }) + }) + }) +} +``` + +## Types Reference + +### ProcessInput + +Trait for types that can be used as process input: + +- Created with `write_to_process()` +- Created with `redirect_from_file(path)` +- Implemented by `@pipe.PipeRead` + +### ProcessOutput + +Trait for types that can be used as process output: + +- Created with `read_from_process()` +- Created with `redirect_to_file(path)` +- Implemented by `@pipe.PipeWrite` + +## Best Practices + +1. **Always close pipes** with `defer reader.close()` or `defer writer.close()` +2. **Use collect functions** for simple output capture +3. **Use task groups** when managing multiple processes +4. **Handle exit codes** appropriately for error detection +5. **Set working directory** explicitly when path-dependent +6. **Use environment variables** for configuration +7. **Prefer `spawn_orphan`** when you don't need to wait immediately + +## Error Handling + +Process operations handle errors through exit codes: + +```moonbit +async test "handle process errors" { + // Non-existent command fails + let result = try? @process.run("nonexistent_command", []) + match result { + Err(_) => inspect(true, content="true") + Ok(_) => inspect(false, content="true") + } +} + +async test "exit code indicates failure" { + let exit_code = @process.run("sh", ["-c", "exit 1"]) + let is_failure = exit_code != 0 + inspect(is_failure, content="true") +} +``` + +For complete examples, see the test files in `src/process/`. diff --git a/src/process/README.md b/src/process/README.md new file mode 120000 index 00000000..7ae1db3e --- /dev/null +++ b/src/process/README.md @@ -0,0 +1 @@ +README.mbt.md \ No newline at end of file diff --git a/src/process/basic_test.mbt b/src/process/basic_test.mbt index 711a282b..9a70aa69 100644 --- a/src/process/basic_test.mbt +++ b/src/process/basic_test.mbt @@ -35,10 +35,13 @@ async test "basic_ls" { #|moon.pkg.json #|pkg.generated.mbti #|process.mbt + #|README.mbt.md + #|README.md #|redirect_test.mbt #|stub.c #|wait_test.mbt #| + ), ) }) diff --git a/src/process/cwd_test.mbt b/src/process/cwd_test.mbt index 83653c06..7e7aa16f 100644 --- a/src/process/cwd_test.mbt +++ b/src/process/cwd_test.mbt @@ -37,10 +37,13 @@ async test "set cwd" { #|moon.pkg.json #|pkg.generated.mbti #|process.mbt + #|README.mbt.md + #|README.md #|redirect_test.mbt #|stub.c #|wait_test.mbt #| + ), ) })