diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1473401 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.roc text eol=lf +*.sh text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1e5998 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: package examples (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-15 + - windows-2025 + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Zig + uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # ratchet:mlugg/setup-zig@v2.0.5 + with: + version: 0.15.2 + use-cache: false + + - name: Clone Roc source + run: | + ROC_COMMIT=$(python3 ci/get_roc_commit.py) + + git init roc-src + cd roc-src + git remote add origin https://github.com/roc-lang/roc + git fetch --depth 1 origin "$ROC_COMMIT" + git checkout --detach "$ROC_COMMIT" + + - name: Build Roc + uses: roc-lang/roc/.github/actions/flaky-retry@3d0fdde9ae6ffb5ef411f8c8c2fc2cdce22a74fe + with: + command: cd roc-src && zig build roc + error_string_contains: "build.zig.zon" + + - name: Add Roc to PATH + run: echo "$(pwd)/roc-src/zig-out/bin" >> "$GITHUB_PATH" + + - name: Run checks + run: ci/all_tests.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..80fe682 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,171 @@ +name: Release + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Release tag, e.g. v0.1.0" + required: true + type: string + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-bundle: + name: Build package bundle + runs-on: ubuntu-24.04 + outputs: + bundle_filename: ${{ steps.bundle.outputs.bundle_filename }} + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Zig + uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # ratchet:mlugg/setup-zig@v2.0.5 + with: + version: 0.15.2 + use-cache: false + + - name: Clone Roc source + run: | + ROC_COMMIT=$(python3 ci/get_roc_commit.py) + + git init roc-src + cd roc-src + git remote add origin https://github.com/roc-lang/roc + git fetch --depth 1 origin "$ROC_COMMIT" + git checkout --detach "$ROC_COMMIT" + + - name: Build Roc + uses: roc-lang/roc/.github/actions/flaky-retry@3d0fdde9ae6ffb5ef411f8c8c2fc2cdce22a74fe + with: + command: cd roc-src && zig build roc + error_string_contains: "build.zig.zon" + + - name: Add Roc to PATH + run: echo "$(pwd)/roc-src/zig-out/bin" >> "$GITHUB_PATH" + + - name: Check format and package + run: | + roc fmt --check package examples + roc check package/main.roc + roc test package/main.roc + + - name: Bundle package + id: bundle + run: | + BUNDLE_OUTPUT=$(scripts/bundle.sh --output-dir dist 2>&1) + echo "$BUNDLE_OUTPUT" + + BUNDLE_PATH=$(echo "$BUNDLE_OUTPUT" | grep "^Created:" | awk '{print $2}') + BUNDLE_FILENAME=$(basename "$BUNDLE_PATH") + + if [ -z "$BUNDLE_FILENAME" ]; then + echo "Error: could not extract bundle filename from roc bundle output" + exit 1 + fi + + echo "bundle_filename=$BUNDLE_FILENAME" >> "$GITHUB_OUTPUT" + + - name: Upload package bundle artifact + uses: actions/upload-artifact@v4 + with: + name: package-bundle + path: dist/${{ steps.bundle.outputs.bundle_filename }} + retention-days: 30 + + test-bundle: + name: Test package bundle (${{ matrix.os }}) + needs: build-bundle + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-15 + - windows-2025 + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Zig + uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # ratchet:mlugg/setup-zig@v2.0.5 + with: + version: 0.15.2 + use-cache: false + + - name: Clone Roc source + run: | + ROC_COMMIT=$(python3 ci/get_roc_commit.py) + + git init roc-src + cd roc-src + git remote add origin https://github.com/roc-lang/roc + git fetch --depth 1 origin "$ROC_COMMIT" + git checkout --detach "$ROC_COMMIT" + + - name: Build Roc + uses: roc-lang/roc/.github/actions/flaky-retry@3d0fdde9ae6ffb5ef411f8c8c2fc2cdce22a74fe + with: + command: cd roc-src && zig build roc + error_string_contains: "build.zig.zon" + + - name: Add Roc to PATH + run: echo "$(pwd)/roc-src/zig-out/bin" >> "$GITHUB_PATH" + + - name: Download package bundle artifact + uses: actions/download-artifact@v4 + with: + name: package-bundle + path: dist + + - name: Test examples against downloaded bundle + run: python3 ci/test_bundle_examples.py --bundle-path "dist/${{ needs.build-bundle.outputs.bundle_filename }}" + + create-release: + name: Create GitHub release + needs: + - build-bundle + - test-bundle + runs-on: ubuntu-24.04 + if: github.event_name == 'workflow_dispatch' + permissions: + contents: write + defaults: + run: + shell: bash + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download package bundle artifact + uses: actions/download-artifact@v4 + with: + name: package-bundle + path: dist + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + BUNDLE_FILE="dist/${{ needs.build-bundle.outputs.bundle_filename }}" + ROC_COMMIT=$(python3 ci/get_roc_commit.py | cut -c1-8) + + gh release create "${{ github.event.inputs.release_tag }}" \ + "$BUNDLE_FILE" \ + --title "${{ github.event.inputs.release_tag }}" \ + --generate-notes \ + --notes "HTTP package bundle built with Roc $ROC_COMMIT." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d94ae6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build-output/ +dist/ +generated-docs/ +roc-src/ +__pycache__/ diff --git a/README.md b/README.md index 4235380..17d0cfc 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,42 @@ metadata in those records based on new HTTP spec releases. `Method` should in the future become a non-exhaustive custom tag union, also for backwards-compatibility. This means if new HTTP spec releases introduce new relevant methods, we can expand the tag union to include them as a nonbreaking change. + +## Examples + +The `examples/` directory contains small apps that demonstrate requests, responses, headers, +body bytes, timeouts, custom methods, and pure mock HTTP workflows. + +Run an example with: + +```bash +roc examples/hello_request.roc +``` + +Run the example test module with: + +```bash +roc test examples/tests.roc +``` + +## Bundling + +Create a package bundle with: + +```bash +scripts/bundle.sh +``` + +This writes a `.tar.zst` package archive to `dist/`. + +## CI checks + +Run the same checks used by CI with: + +```bash +ci/all_tests.sh +``` + +The checks format the package and examples, check/test the package, generate docs, bundle the +package, serve that bundle from localhost, and then check, test, run, build, and execute every +example against the bundled package URL. diff --git a/ci/ROC_COMMIT b/ci/ROC_COMMIT new file mode 100644 index 0000000..974673a --- /dev/null +++ b/ci/ROC_COMMIT @@ -0,0 +1 @@ +cf21a1e29315caf430bea4e67269bb94e51b5b3d diff --git a/ci/all_tests.sh b/ci/all_tests.sh new file mode 100755 index 0000000..d644579 --- /dev/null +++ b/ci/all_tests.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +if [ -n "${ROC_HTTP_TMPDIR:-}" ]; then + tmp_base="$ROC_HTTP_TMPDIR" +elif [ -d /private/tmp ]; then + tmp_base=/private/tmp +else + tmp_base="${TMPDIR:-/tmp}" +fi + +tmp_dir="$tmp_base/roc-http-ci" +docs_dir="$tmp_dir/docs" +bundle_dir="$tmp_dir/bundle" + +rm -rf "$tmp_dir" +mkdir -p "$docs_dir" "$bundle_dir" + +echo "roc $(roc version)" + +echo "" +echo "Checking format..." +roc fmt --check package examples + +echo "" +echo "Checking package..." +roc check package/main.roc + +echo "" +echo "Running package tests..." +roc test package/main.roc + +echo "" +echo "Generating package docs..." +roc docs package/main.roc --output="$docs_dir" + +echo "" +echo "Bundling package..." +scripts/bundle.sh --output-dir "$bundle_dir" + +echo "" +echo "Testing examples against localhost bundle..." +python3 ci/test_bundle_examples.py diff --git a/ci/get_roc_commit.py b/ci/get_roc_commit.py new file mode 100644 index 0000000..a42ce1a --- /dev/null +++ b/ci/get_roc_commit.py @@ -0,0 +1,19 @@ +import re +from pathlib import Path + + +def main() -> None: + commit_path = Path(__file__).resolve().parent / "ROC_COMMIT" + try: + commit = commit_path.read_text().strip() + except FileNotFoundError: + raise SystemExit(f"Missing ROC_COMMIT at {commit_path}") + + if not re.fullmatch(r"[0-9a-fA-F]{40}", commit): + raise SystemExit(f"Invalid commit hash in ROC_COMMIT: {commit!r}") + + print(commit) + + +if __name__ == "__main__": + main() diff --git a/ci/test_bundle_examples.py b/ci/test_bundle_examples.py new file mode 100755 index 0000000..c950ed7 --- /dev/null +++ b/ci/test_bundle_examples.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import functools +import http.server +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LOCAL_PACKAGE = 'http: "../package/main.roc"' + + +def run(cmd: list[str], *, cwd: Path = ROOT, stdin: str | None = None) -> subprocess.CompletedProcess[str]: + print("+", " ".join(cmd)) + completed = subprocess.run( + cmd, + cwd=cwd, + input=stdin, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if completed.returncode != 0: + if completed.stdout: + print(completed.stdout) + if completed.stderr: + print(completed.stderr, file=sys.stderr) + raise SystemExit(f"command failed with exit code {completed.returncode}: {' '.join(cmd)}") + + return completed + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def bundle_package(bundle_dir: Path) -> Path: + completed = run(["scripts/bundle.sh", "--output-dir", str(bundle_dir)]) + match = re.search(r"^Created:\s+(.+\.tar\.zst)\s*$", completed.stdout, re.MULTILINE) + + if match is None: + raise SystemExit("Could not find bundle path in roc bundle output") + + bundle_path = Path(match.group(1)) + if not bundle_path.exists(): + raise SystemExit(f"Bundle was not created: {bundle_path}") + + return bundle_path + + +def start_server(directory: Path) -> tuple[http.server.ThreadingHTTPServer, str]: + port = find_free_port() + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, f"http://127.0.0.1:{port}" + + +def copy_examples_with_bundle_url(examples_dir: Path, bundle_url: str) -> list[Path]: + target_dir = examples_dir / "examples" + shutil.copytree(ROOT / "examples", target_dir) + + examples = sorted(target_dir.glob("*.roc")) + for example in examples: + source = example.read_text() + if LOCAL_PACKAGE not in source: + raise SystemExit(f"{example.name} does not use the expected local package dependency") + + example.write_text(source.replace(LOCAL_PACKAGE, f'http: "{bundle_url}"')) + + return examples + + +def run_example_checks(examples: list[Path]) -> None: + run(["roc", "fmt", "--check", str(examples[0].parent)]) + + for example in examples: + run(["roc", "check", str(example), "--no-cache"]) + + +def run_example_apps(examples: list[Path]) -> None: + for example in examples: + if example.name == "tests.roc": + continue + run(["roc", str(example), "--no-cache"]) + + +def run_example_tests(examples: list[Path]) -> None: + tests = [example for example in examples if example.name == "tests.roc"] + if len(tests) != 1: + raise SystemExit("Expected exactly one examples/tests.roc file") + + run(["roc", "test", str(tests[0]), "--no-cache"]) + + +def build_and_run_examples(examples: list[Path], build_dir: Path) -> None: + build_dir.mkdir(parents=True, exist_ok=True) + exe_suffix = ".exe" if os.name == "nt" else "" + + for example in examples: + if example.name == "tests.roc": + continue + + output = build_dir / f"{example.stem}{exe_suffix}" + run(["roc", "build", str(example), f"--output={output}", "--no-cache"]) + run([str(output)]) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bundle-path", type=Path, help="Use an existing bundle instead of creating one") + parser.add_argument("--skip-build-run", action="store_true", help="Skip compiled example execution") + args = parser.parse_args() + + default_tmp = Path("/private/tmp") if Path("/private/tmp").exists() else Path(tempfile.gettempdir()) + tmp_parent = Path(os.environ.get("ROC_HTTP_TMPDIR", default_tmp)) + + with tempfile.TemporaryDirectory(prefix="roc-http-bundle-", dir=tmp_parent) as tmp: + tmp_dir = Path(tmp) + bundle_dir = tmp_dir / "bundle" + examples_dir = tmp_dir / "rewritten" + build_dir = tmp_dir / "build" + + bundle_dir.mkdir() + examples_dir.mkdir() + + if args.bundle_path is None: + bundle_path = bundle_package(bundle_dir) + else: + source_bundle = args.bundle_path.resolve() + if not source_bundle.exists(): + raise SystemExit(f"Bundle does not exist: {source_bundle}") + + bundle_path = bundle_dir / source_bundle.name + shutil.copy2(source_bundle, bundle_path) + + server, base_url = start_server(bundle_dir) + try: + bundle_url = f"{base_url}/{bundle_path.name}" + examples = copy_examples_with_bundle_url(examples_dir, bundle_url) + + print(f"Testing examples with bundled package: {bundle_url}") + run_example_checks(examples) + run_example_tests(examples) + run_example_apps(examples) + + if not args.skip_build_run: + build_and_run_examples(examples, build_dir) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/examples/body_bytes.roc b/examples/body_bytes.roc new file mode 100644 index 0000000..897685a --- /dev/null +++ b/examples/body_bytes.roc @@ -0,0 +1,28 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request +import http.Response + +bytes_to_str = |bytes| + match Str.from_utf8(bytes) { + Ok(str) => str + Err(_) => "" + } + +main! = |_args| { + request0 = Request.from_method(POST) + request1 = Request.with_uri(request0, "https://example.com/messages") + request = Request.with_body(request1, Str.to_utf8("hello")) + + response0 = Response.from_status(201) + response = Response.with_body(response0, Str.to_utf8("created")) + + Stdout.line!("request body: ${bytes_to_str(Request.body(request))}") + Stdout.line!("response body: ${bytes_to_str(Response.body(response))}") + Ok({}) +} diff --git a/examples/custom_method.roc b/examples/custom_method.roc new file mode 100644 index 0000000..1070b3e --- /dev/null +++ b/examples/custom_method.roc @@ -0,0 +1,16 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request + +main! = |_args| { + request0 = Request.from_method(Unknown("PROPFIND")) + request = Request.with_uri(request0, "https://dav.example.com/docs") + + Stdout.line!("custom method: ${Request.method_str(request)}") + Ok({}) +} diff --git a/examples/headers.roc b/examples/headers.roc new file mode 100644 index 0000000..2e4f4aa --- /dev/null +++ b/examples/headers.roc @@ -0,0 +1,25 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request + +header_to_str = |(name, value)| + "${name}: ${value}" + +headers_to_str = |headers| + headers.map(header_to_str).join_with("\n") + +main! = |_args| { + request0 = Request.from_method(GET) + request1 = Request.with_uri(request0, "https://api.example.com/items") + request2 = Request.add_header(request1, "Accept", "application/json") + request3 = Request.add_header(request2, "X-Trace-Id", "demo-123") + request = Request.add_header(request3, "X-Trace-Id", "demo-456") + + Stdout.line!(headers_to_str(Request.headers(request))) + Ok({}) +} diff --git a/examples/hello_request.roc b/examples/hello_request.roc new file mode 100644 index 0000000..7524219 --- /dev/null +++ b/examples/hello_request.roc @@ -0,0 +1,16 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request + +main! = |_args| { + request0 = Request.from_method(GET) + request = Request.with_uri(request0, "https://example.com/widgets") + + Stdout.line!("${Request.method_str(request)} ${Request.uri(request)}") + Ok({}) +} diff --git a/examples/mock_send.roc b/examples/mock_send.roc new file mode 100644 index 0000000..61f7cef --- /dev/null +++ b/examples/mock_send.roc @@ -0,0 +1,27 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request +import http.Response + +mock_send = |request| + if Request.method(request) == GET { + response0 = Response.from_status(200) + response1 = Response.add_header(response0, "Content-Type", "text/plain") + Response.with_body(response1, Str.to_utf8("mock response for ${Request.uri(request)}")) + } else { + Response.from_status(405) + } + +main! = |_args| { + request0 = Request.from_method(GET) + request = Request.with_uri(request0, "https://example.com/offline") + response = mock_send(request) + + Stdout.line!("mock status: ${Response.status(response).to_str()}") + Ok({}) +} diff --git a/examples/response.roc b/examples/response.roc new file mode 100644 index 0000000..ebe4071 --- /dev/null +++ b/examples/response.roc @@ -0,0 +1,18 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Response + +main! = |_args| { + response0 = Response.from_status(404) + response1 = Response.add_header(response0, "Content-Type", "text/plain") + response = Response.with_body(response1, Str.to_utf8("not found")) + + Stdout.line!("status: ${Response.status(response).to_str()}") + Stdout.line!("headers: ${Response.headers(response).len().to_str()}") + Stdout.line!("body bytes: ${Response.body(response).len().to_str()}") + Ok({}) +} diff --git a/examples/router.roc b/examples/router.roc new file mode 100644 index 0000000..da93d06 --- /dev/null +++ b/examples/router.roc @@ -0,0 +1,32 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request +import http.Response + +text_response = |status, body| { + response0 = Response.from_status(status) + response1 = Response.add_header(response0, "Content-Type", "text/plain") + Response.with_body(response1, Str.to_utf8(body)) +} + +route = |request| + match (Request.method(request), Request.uri(request)) { + (GET, "/health") => text_response(200, "ok") + (GET, "/widgets") => text_response(200, "[]") + (POST, "/widgets") => text_response(201, "created") + _ => text_response(404, "not found") + } + +main! = |_args| { + request0 = Request.from_method(POST) + request = Request.with_uri(request0, "/widgets") + response = route(request) + + Stdout.line!("route status: ${Response.status(response).to_str()}") + Ok({}) +} diff --git a/examples/tests.roc b/examples/tests.roc new file mode 100644 index 0000000..1639a10 --- /dev/null +++ b/examples/tests.roc @@ -0,0 +1,44 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request +import http.Response + +main! = |_args| { + Stdout.line!("Run `roc test examples/tests.roc` to exercise the http package examples.") + Ok({}) +} + +expect { + request0 = Request.from_method(POST) + request1 = Request.with_uri(request0, "https://example.com/messages") + request2 = Request.add_header(request1, "Content-Type", "text/plain") + request3 = Request.with_body(request2, Str.to_utf8("hello")) + request = Request.with_timeout(request3, TimeoutMilliseconds(250)) + + Request.method(request) == POST + and Request.method_str(request) == "POST" + and Request.uri(request) == "https://example.com/messages" + and Request.headers(request) == [("Content-Type", "text/plain")] + and Request.body(request) == Str.to_utf8("hello") + and Request.timeout(request) == TimeoutMilliseconds(250) +} + +expect { + request = Request.from_method(Unknown("PROPFIND")) + Request.method_str(request) == "PROPFIND" +} + +expect { + response0 = Response.from_status(204) + response1 = Response.add_header(response0, "X-Test", "yes") + response = Response.with_body(response1, []) + + Response.status(response) == 204 + and Response.headers(response) == [("X-Test", "yes")] + and Response.body(response) == [] +} diff --git a/examples/timeouts.roc b/examples/timeouts.roc new file mode 100644 index 0000000..86c739a --- /dev/null +++ b/examples/timeouts.roc @@ -0,0 +1,24 @@ +app [main!] { + pf: platform "https://github.com/lukewilliamboswell/roc-platform-template-zig/releases/download/0.7/DuRUyJh31Gt41YArMcVcvybLa2bCWboccWQ7Zq1KZPZ6.tar.zst", + http: "../package/main.roc", +} + +import pf.Stdout +import http.Method +import http.Request + +timeout_to_str = |request| + match Request.timeout(request) { + NoTimeout => "no timeout" + TimeoutMilliseconds(ms) => "${ms.to_str()}ms" + } + +main! = |_args| { + base = Request.from_method(GET) + no_timeout = Request.with_uri(base, "https://example.com/stream") + bounded = Request.with_timeout(no_timeout, TimeoutMilliseconds(1500)) + + Stdout.line!("default: ${timeout_to_str(no_timeout)}") + Stdout.line!("bounded: ${timeout_to_str(bounded)}") + Ok({}) +} diff --git a/package/Method.roc b/package/Method.roc index adfa49e..310a912 100644 --- a/package/Method.roc +++ b/package/Method.roc @@ -1,2 +1,2 @@ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods -Method := [OPTIONS, GET, POST, PUT, DELETE, HEAD, TRACE, CONNECT, PATCH, Unknown(Str)] \ No newline at end of file +Method := [OPTIONS, GET, POST, PUT, DELETE, HEAD, TRACE, CONNECT, PATCH, Unknown(Str)] diff --git a/package/Request.roc b/package/Request.roc index 14d69cb..61d8491 100644 --- a/package/Request.roc +++ b/package/Request.roc @@ -1,88 +1,89 @@ import Method Request :: { - method : Method, - headers : List((Str, Str)), - uri : Str, - body : List(U8), - timeout_ms : [TimeoutMilliseconds(U64), NoTimeout], + method : Method, + headers : List((Str, Str)), + uri : Str, + body : List(U8), + timeout_ms : [TimeoutMilliseconds(U64), NoTimeout], }.{ - ## Create a `Request` with the given HTTP method and empty/default values for all other fields. - from_method : Method -> Request - from_method = |initial_method| - { - method: initial_method, - headers: [], - uri: "", - body: [], - timeout_ms: NoTimeout, - } - ## Get the HTTP method of the request. - method : Request -> Method - method = |req| req.method + ## Create a `Request` with the given HTTP method and empty/default values for all other fields. + from_method : Method -> Request + from_method = |initial_method| + { + method: initial_method, + headers: [], + uri: "", + body: [], + timeout_ms: NoTimeout, + } - ## Get the HTTP method of the request as a string. - method_str : Request -> Str - method_str = |req| - match req.method { - OPTIONS => "OPTIONS" - GET => "GET" - POST => "POST" - PUT => "PUT" - DELETE => "DELETE" - HEAD => "HEAD" - TRACE => "TRACE" - CONNECT => "CONNECT" - PATCH => "PATCH" - Unknown(str) => str - } + ## Get the HTTP method of the request. + method : Request -> Method + method = |req| req.method - ## Get the list of HTTP headers in the request. - headers : Request -> List((Str, Str)) - headers = |req| req.headers + ## Get the HTTP method of the request as a string. + method_str : Request -> Str + method_str = |req| + match req.method { + OPTIONS => "OPTIONS" + GET => "GET" + POST => "POST" + PUT => "PUT" + DELETE => "DELETE" + HEAD => "HEAD" + TRACE => "TRACE" + CONNECT => "CONNECT" + PATCH => "PATCH" + Unknown(str) => str + } - ## Get the body of the request. - body : Request -> List(U8) - body = |req| req.body + ## Get the list of HTTP headers in the request. + headers : Request -> List((Str, Str)) + headers = |req| req.headers - ## Get the URI of the request. - uri : Request -> Str - uri = |req| req.uri + ## Get the body of the request. + body : Request -> List(U8) + body = |req| req.body - ## Get the timeout of the request. - timeout : Request -> [TimeoutMilliseconds(U64), NoTimeout] - timeout = |req| req.timeout_ms + ## Get the URI of the request. + uri : Request -> Str + uri = |req| req.uri - ## Set the HTTP method of the request. - with_method : Request, Method -> Request - with_method = |req, new_method| { ..req, method: new_method } + ## Get the timeout of the request. + timeout : Request -> [TimeoutMilliseconds(U64), NoTimeout] + timeout = |req| req.timeout_ms - ## Set the request's exact list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). - ## - ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to - ## have the same name. However, some recipients may not interpret this the way you would hope - ## they would, so it's generally best to make all the header names unique. - with_headers : Request, List((Str, Str)) -> Request - with_headers = |req, new_headers| { ..req, headers: new_headers } + ## Set the HTTP method of the request. + with_method : Request, Method -> Request + with_method = |req, new_method| { ..req, method: new_method } - ## Add a header to the request's list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). - ## - ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to - ## have the same name. However, some recipients may not interpret this the way you would hope - ## they would, so it's generally best to make all the header names unique. - add_header : Request, Str, Str -> Request - add_header = |req, name, value| { ..req, headers: List.append(req.headers, (name, value)) } + ## Set the request's exact list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). + ## + ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to + ## have the same name. However, some recipients may not interpret this the way you would hope + ## they would, so it's generally best to make all the header names unique. + with_headers : Request, List((Str, Str)) -> Request + with_headers = |req, new_headers| { ..req, headers: new_headers } - ## Set the URI of the request. - with_uri : Request, Str -> Request - with_uri = |req, new_uri| { ..req, uri: new_uri } + ## Add a header to the request's list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). + ## + ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to + ## have the same name. However, some recipients may not interpret this the way you would hope + ## they would, so it's generally best to make all the header names unique. + add_header : Request, Str, Str -> Request + add_header = |req, name, value| { ..req, headers: List.append(req.headers, (name, value)) } - ## Set the body of the request. - with_body : Request, List(U8) -> Request - with_body = |req, new_body| { ..req, body: new_body } + ## Set the URI of the request. + with_uri : Request, Str -> Request + with_uri = |req, new_uri| { ..req, uri: new_uri } - ## Set the timeout of the request. - with_timeout : Request, [TimeoutMilliseconds(U64), NoTimeout] -> Request - with_timeout = |req, timeout_ms| { ..req, timeout_ms: timeout_ms } + ## Set the body of the request. + with_body : Request, List(U8) -> Request + with_body = |req, new_body| { ..req, body: new_body } + + ## Set the timeout of the request. + with_timeout : Request, [TimeoutMilliseconds(U64), NoTimeout] -> Request + with_timeout = |req, timeout_ms| { ..req, timeout_ms: timeout_ms } } diff --git a/package/Response.roc b/package/Response.roc index d23a1e9..7bb00e3 100644 --- a/package/Response.roc +++ b/package/Response.roc @@ -1,50 +1,51 @@ Response :: { - status : U16, - headers : List((Str, Str)), - body : List(U8), + status : U16, + headers : List((Str, Str)), + body : List(U8), }.{ - ## Create a `Response` with the given HTTP status code and empty headers and body. - from_status : U16 -> Response - from_status = |initial_status| - { - status: initial_status, - headers: [], - body: [], - } - - ## Get the HTTP status code of the response. - status : Response -> U16 - status = |resp| resp.status - - ## Get the list of HTTP headers in the response. - headers : Response -> List((Str, Str)) - headers = |resp| resp.headers - - ## Get the body of the response. - body : Response -> List(U8) - body = |resp| resp.body - - ## Set the HTTP status code of the response. - with_status : Response, U16 -> Response - with_status = |resp, new_status| { ..resp, status: new_status } - - ## Set the response's exact list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). - ## - ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to - ## have the same name. However, some recipients may not interpret this the way you would hope - ## they would, so it's generally best to make all the header names unique. - with_headers : Response, List((Str, Str)) -> Response - with_headers = |resp, new_headers| { ..resp, headers: new_headers } - - ## Add a header to the response's list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). - ## - ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to - ## have the same name. However, some recipients may not interpret this the way you would hope - ## they would, so it's generally best to make all the header names unique. - add_header : Response, Str, Str -> Response - add_header = |resp, name, value| { ..resp, headers: List.append(resp.headers, (name, value)) } - - ## Set the body of the response. - with_body : Response, List(U8) -> Response - with_body = |resp, new_body| { ..resp, body: new_body } -} \ No newline at end of file + + ## Create a `Response` with the given HTTP status code and empty headers and body. + from_status : U16 -> Response + from_status = |initial_status| + { + status: initial_status, + headers: [], + body: [], + } + + ## Get the HTTP status code of the response. + status : Response -> U16 + status = |resp| resp.status + + ## Get the list of HTTP headers in the response. + headers : Response -> List((Str, Str)) + headers = |resp| resp.headers + + ## Get the body of the response. + body : Response -> List(U8) + body = |resp| resp.body + + ## Set the HTTP status code of the response. + with_status : Response, U16 -> Response + with_status = |resp, new_status| { ..resp, status: new_status } + + ## Set the response's exact list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). + ## + ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to + ## have the same name. However, some recipients may not interpret this the way you would hope + ## they would, so it's generally best to make all the header names unique. + with_headers : Response, List((Str, Str)) -> Response + with_headers = |resp, new_headers| { ..resp, headers: new_headers } + + ## Add a header to the response's list of [HTTP headers](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields). + ## + ## The HTTP spec [allows](https://www.rfc-editor.org/rfc/rfc7230#section-3.2.2) multiple headers to + ## have the same name. However, some recipients may not interpret this the way you would hope + ## they would, so it's generally best to make all the header names unique. + add_header : Response, Str, Str -> Response + add_header = |resp, name, value| { ..resp, headers: List.append(resp.headers, (name, value)) } + + ## Set the body of the response. + with_body : Response, List(U8) -> Response + with_body = |resp, new_body| { ..resp, body: new_body } +} diff --git a/package/main.roc b/package/main.roc index 246b793..e9f240e 100644 --- a/package/main.roc +++ b/package/main.roc @@ -1,5 +1,7 @@ -package [ - Method, - Request, - Response, -] {} +package + [ + Method, + Request, + Response, + ] + {} diff --git a/scripts/bundle.sh b/scripts/bundle.sh new file mode 100755 index 0000000..508ef8e --- /dev/null +++ b/scripts/bundle.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_dir="$root_dir/dist" +args=() + +while (($# > 0)); do + case "$1" in + --output-dir) + output_dir="$2" + shift 2 + ;; + --output-dir=*) + output_dir="${1#--output-dir=}" + shift + ;; + *) + args+=("$1") + shift + ;; + esac +done + +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" + +cd "$root_dir/package" +roc_files=(main.roc) +for file in *.roc; do + if [ "$file" != "main.roc" ]; then + roc_files+=("$file") + fi +done + +if ((${#args[@]} > 0)); then + roc bundle "${roc_files[@]}" --output-dir "$output_dir" "${args[@]}" +else + roc bundle "${roc_files[@]}" --output-dir "$output_dir" +fi