diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a984bb2e..defb0a79 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -78,7 +78,7 @@ jobs: export LIBCLANG_PATH="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib" fi cargo build --manifest-path example/rust-hello/Cargo.toml - bash scripts/ci/rust_hello_smoke.sh example/rust-hello/target/debug/rust-hello + bash scripts/ci/hello_smoke.sh example/rust-hello/target/debug/rust-hello "CWIST Rust" - name: FFI overhead micro-benchmark shell: bash diff --git a/.github/workflows/zig.yml b/.github/workflows/zig.yml new file mode 100644 index 00000000..3ef8d790 --- /dev/null +++ b/.github/workflows/zig.yml @@ -0,0 +1,98 @@ +name: "Zig bindings" + +# bindings/zig: build and install CWIST, then translate its installed headers +# and link libcwist statically through cwist.pc, run the binding tests, and +# serve real requests with example/zig-hello. Linux and macOS, on one pinned +# Zig release (issue #36: Zig is pre-1.0, so a new release must never change +# what CI builds with until a PR moves the pin). + +on: + push: + branches: [main, master, dev] + pull_request: + branches: [main, master, dev] + workflow_dispatch: + +permissions: + contents: read + +env: + ZIG_VERSION: 0.17.0 + +jobs: + zig: + name: zig (${{ matrix.os }}) + if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + zig_platform: x86_64-linux + zig_sha256: 1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 + - os: macos-15 + zig_platform: aarch64-macos + zig_sha256: b607e9b9234790a008116ae5bdb71c6243b84b9fb42a53a9e70fde41c06c536a + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install build dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y cmake pkg-config \ + libcurl4-openssl-dev libnghttp2-dev libbrotli-dev libzstd-dev + + - name: Install build dependencies (macOS) + if: runner.os == 'macOS' + run: brew install cmake pkg-config curl nghttp2 brotli zstd + + # The official release archive, checked against its published SHA-256, + # so the pinned compiler cannot change underneath CI. + - name: Install Zig ${{ env.ZIG_VERSION }} + shell: bash + run: | + name="zig-${{ matrix.zig_platform }}-$ZIG_VERSION" + curl -sSfL -o "$RUNNER_TEMP/zig.tar.xz" "https://ziglang.org/download/$ZIG_VERSION/$name.tar.xz" + echo "${{ matrix.zig_sha256 }} $RUNNER_TEMP/zig.tar.xz" | shasum -a 256 -c - + tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/$name" >> "$GITHUB_PATH" + + - name: Build and install CWIST + shell: bash + env: + # libttak's default stack adds -flto; AppleClang cannot emit fat LTO + # objects, and plain objects are what the Zig linker consumes. + PERF_STACK_FLAGS: ${{ runner.os == 'macOS' && '-O2 -g -pipe' || '' }} + PKG_CONFIG_PATH: ${{ runner.os == 'macOS' && '/opt/homebrew/opt/curl/lib/pkgconfig' || '' }} + run: | + if [ -z "$PERF_STACK_FLAGS" ]; then unset PERF_STACK_FLAGS; fi + jobs=$(getconf _NPROCESSORS_ONLN) + make -j"$jobs" + make install PREFIX="$RUNNER_TEMP/cwist" + + - name: zig build test + shell: bash + run: | + export PKG_CONFIG_PATH="$RUNNER_TEMP/cwist/lib/pkgconfig" + if [ "$RUNNER_OS" = "macOS" ]; then + export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:/opt/homebrew/opt/curl/lib/pkgconfig" + fi + zig version + pkg-config --cflags --libs --static cwist + cd bindings/zig + zig build test --summary all + + - name: example/zig-hello serves requests + shell: bash + run: | + export PKG_CONFIG_PATH="$RUNNER_TEMP/cwist/lib/pkgconfig" + if [ "$RUNNER_OS" = "macOS" ]; then + export PKG_CONFIG_PATH="$PKG_CONFIG_PATH:/opt/homebrew/opt/curl/lib/pkgconfig" + fi + (cd example/zig-hello && zig build) + bash scripts/ci/hello_smoke.sh example/zig-hello/zig-out/bin/zig-hello "CWIST Zig" diff --git a/bindings/zig/.gitignore b/bindings/zig/.gitignore new file mode 100644 index 00000000..3389c86c --- /dev/null +++ b/bindings/zig/.gitignore @@ -0,0 +1,2 @@ +.zig-cache/ +zig-out/ diff --git a/bindings/zig/README.md b/bindings/zig/README.md new file mode 100644 index 00000000..a88e414d --- /dev/null +++ b/bindings/zig/README.md @@ -0,0 +1,63 @@ +# CWIST Zig bindings + +Zig API for CWIST (issue #36), next to the Rust bindings in `bindings/rust/`. + +* The C API is translated from the installed CWIST headers at build time + (`b.addTranslateC`, see `src/cwist.h`) and available as `cwist.c`. +* `src/cwist.zig` adds a small Zig layer on top: `App` with `get`/`post`/ + `put`/`delete`/`patch` routes, `Request`, `Response`, in-memory `dispatch`, + `listen` and `shutdown`. + +## Zig version + +Pinned to **Zig 0.17.0** (`minimum_zig_version` in `build.zig.zon`, and the +exact release in CI). Zig is pre-1.0 and changes its build and language APIs +between releases, so the pin moves only in a dedicated PR. + +## Build + +CWIST must be built and installed first; libcwist is found through +`pkg-config --static cwist`: + +```bash +make && make install PREFIX=$HOME/.local +export PKG_CONFIG_PATH=$HOME/.local/lib/pkgconfig +cd bindings/zig && zig build test +``` + +`build.zig` links libcwist and its bundled dependencies statically from the +`cwist.pc` link directories; `-lstdc++` maps to Zig's own libc++. On macOS, +add Homebrew's curl to `PKG_CONFIG_PATH` +(`/opt/homebrew/opt/curl/lib/pkgconfig`), as for the Rust bindings. + +## Use from another package + +```zig +// build.zig.zon +.dependencies = .{ .cwist = .{ .path = "path/to/CWIST/bindings/zig" } }, + +// build.zig +const cwist = b.dependency("cwist", .{ .target = target, .optimize = optimize }); +exe.root_module.addImport("cwist", cwist.module("cwist")); +``` + +See `example/zig-hello/` for a complete server. + +## Handlers, lifetimes and threads + +* A handler is `fn (Context, cwist.Request, cwist.Response) void`. `Context` + is a pointer, or `void` with `{}` at registration; CWIST hands it back on + every request. It is borrowed: it must outlive the app. +* `Request` and `Response` are views of CWIST's objects for one handler call. + Slices from `Request` point into CWIST's memory and must not be kept after + the handler returns. +* CWIST runs handlers on several worker threads at once, so a context must + be safe to use from any thread. +* A handler returns nothing, so no error crosses into C, and a panic aborts + the process (Zig does not unwind). +* `listen` serves in the calling process on the reactor server, with no + forked workers, and returns after a graceful shutdown (`cwist.shutdown()`, + SIGTERM or SIGINT) with every handler thread joined. + +Not wrapped yet: middleware, deferred (async) responses, TLS. The raw API in +`cwist.c` covers them in the meantime. diff --git a/bindings/zig/build.zig b/bindings/zig/build.zig new file mode 100644 index 00000000..654f94ed --- /dev/null +++ b/bindings/zig/build.zig @@ -0,0 +1,141 @@ +//! Zig bindings for CWIST: `zig build test` against an installed libcwist. +//! +//! libcwist is found through `pkg-config --static cwist` (the `cwist.pc` that +//! `make install` writes); set PKG_CONFIG_PATH when it is not in a default +//! location. Other packages use the `cwist` module this file exports. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const flags = pkgConfigFlags(b); + + // The C API, translated from the installed headers (src/cwist.h). + const translate = b.addTranslateC(.{ + .root_source_file = b.path("src/cwist.h"), + .target = target, + .optimize = optimize, + }); + // src/cwist.h includes the system itself; this keeps + // libttak's portable fallback, which translate-c cannot parse, out. + translate.defineCMacro("__TTAK_STDATOMIC_SYSTEM_INCLUDED", "1"); + for (flags.include_dirs.items) |dir| translate.addIncludePath(b.graph.cwdRelativePath(dir)); + + const mod = b.addModule("cwist", .{ + .root_source_file = b.path("src/cwist.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{.{ .name = "c", .module = translate.createModule() }}, + }); + for (flags.lib_dirs.items) |dir| mod.addLibraryPath(b.graph.cwdRelativePath(dir)); + // -lc, -lm, -lpthread and -ldl map to Zig's own libc handling; everything + // else is searched in the -L directories first, so libcwist and its + // bundled dependencies link statically. + for (flags.libs.items) |lib| { + if (std.mem.eql(u8, lib, "stdc++")) { + linkCxxRuntime(b, mod, target); + } else { + mod.linkSystemLibrary(lib, .{ .use_pkg_config = .no }); + } + } + + const tests = b.addTest(.{ .root_module = mod }); + const test_step = b.step("test", "Run the binding tests against libcwist"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} + +/// Links the C++ runtime libcwist's bundled C++ code (BoringSSL) was built +/// against. On a native Linux build that is the system libstdc++ plus the +/// libgcc_s unwinder, as the g++ driver links them: the objects reference +/// libstdc++ internals (std::__throw_out_of_range_fmt) and _Unwind_Resume, +/// which Zig's own libc++ does not provide. Elsewhere, including macOS where +/// the system runtime is libc++, Zig's libc++ is used. +fn linkCxxRuntime(b: *std.Build, mod: *std.Build.Module, target: std.Build.ResolvedTarget) void { + if (target.query.isNative() and target.result.os.tag == .linux) { + const cxx = b.graph.environ_map.get("CXX") orelse "c++"; + // libgcc_s.so is usually a linker script; the .so.1 is the library. + const stdcxx = compilerFile(b, cxx, "libstdc++.so"); + const gcc_s = compilerFile(b, cxx, "libgcc_s.so.1"); + if (stdcxx != null and gcc_s != null) { + mod.addObjectFile(b.graph.cwdRelativePath(stdcxx.?)); + mod.addObjectFile(b.graph.cwdRelativePath(gcc_s.?)); + mod.link_libc = true; + return; + } + } + mod.linkSystemLibrary("stdc++", .{ .use_pkg_config = .no }); +} + +/// The absolute path the C++ compiler resolves `name` to, or null. +fn compilerFile(b: *std.Build, cxx: []const u8, name: []const u8) ?[]const u8 { + const arg = b.fmt("-print-file-name={s}", .{name}); + switch (b.runFallible(&.{ cxx, arg }, .{ .stderr_behavior = .ignore })) { + .success => |stdout| { + const path = std.mem.trim(u8, stdout, " \t\r\n"); + // A bare name back means the compiler did not find it. + return if (std.fs.path.isAbsolute(path)) path else null; + }, + else => return null, + } +} + +const Flags = struct { + include_dirs: std.ArrayList([]const u8) = .empty, + lib_dirs: std.ArrayList([]const u8) = .empty, + libs: std.ArrayList([]const u8) = .empty, +}; + +/// System libraries `cwist.pc` names as plain `-l` flags. Their own +/// pkg-config files supply the directory when it is not a default one +/// (Homebrew on macOS), the same way the CWIST Makefile finds them. +const system_deps = [_][]const u8{ + "libzstd", "libbrotlienc", "libbrotlicommon", "libbrotlidec", "libcurl", "libnghttp2", +}; + +fn pkgConfigFlags(b: *std.Build) Flags { + const arena = b.graph.arena; + const pkg_config = b.graph.environ_map.get("PKG_CONFIG") orelse "pkg-config"; + var flags: Flags = .{}; + + // --static: Libs.private (curl, nghttp2) is needed for a static libcwist. + const out = switch (b.runFallible(&.{ pkg_config, "--cflags", "--libs", "--static", "cwist" }, .{ + .stderr_behavior = .inherit, + })) { + .success => |stdout| stdout, + else => std.debug.panic( + "libcwist was not found through {s}. Build and install CWIST first, for example\n" ++ + " make && make install PREFIX=$HOME/.local\n" ++ + "then set PKG_CONFIG_PATH=$HOME/.local/lib/pkgconfig", + .{pkg_config}, + ), + }; + addFlags(arena, &flags, out); + + for (system_deps) |dep| { + switch (b.runFallible(&.{ pkg_config, "--libs-only-L", dep }, .{ .stderr_behavior = .ignore })) { + .success => |stdout| addFlags(arena, &flags, stdout), + // Optional: without a .pc file the library must be on a default path. + else => {}, + } + } + return flags; +} + +fn addFlags(arena: std.mem.Allocator, flags: *Flags, output: []const u8) void { + var it = std.mem.tokenizeAny(u8, output, " \t\r\n"); + while (it.next()) |arg| { + const list, const value = if (std.mem.startsWith(u8, arg, "-I")) + .{ &flags.include_dirs, arg[2..] } + else if (std.mem.startsWith(u8, arg, "-L")) + .{ &flags.lib_dirs, arg[2..] } + else if (std.mem.startsWith(u8, arg, "-l")) + .{ &flags.libs, arg[2..] } + else + continue; + for (list.items) |seen| { + if (std.mem.eql(u8, seen, value)) break; + } else list.append(arena, value) catch @panic("OOM"); + } +} diff --git a/bindings/zig/build.zig.zon b/bindings/zig/build.zig.zon new file mode 100644 index 00000000..965e8cd5 --- /dev/null +++ b/bindings/zig/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .cwist, + .version = "0.1.0", + .fingerprint = 0xfa240d1a22ae273b, // Changing this has security and trust implications. + // Pinned on purpose (issue #36): Zig is pre-1.0 and changes its build + // and language APIs between releases. Move to a newer one in a + // dedicated PR. + .minimum_zig_version = "0.17.0", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "README.md", + }, +} diff --git a/bindings/zig/src/cwist.h b/bindings/zig/src/cwist.h new file mode 100644 index 00000000..6e9249c8 --- /dev/null +++ b/bindings/zig/src/cwist.h @@ -0,0 +1,17 @@ +/* The C API the Zig bindings use, translated by build.zig (addTranslateC) + * from the installed CWIST headers. Only the canonical headers: the legacy + * umbrella headers carry stale copies of some structs (tests/abi_layout.h). + * + * The system and come first so that libttak's + * headers use them instead of their portable fallback (build.zig defines + * __TTAK_STDATOMIC_SYSTEM_INCLUDED to match). */ +#include +#include + +#include +#include +#include +#include +#include +#include +#include diff --git a/bindings/zig/src/cwist.zig b/bindings/zig/src/cwist.zig new file mode 100644 index 00000000..27a10830 --- /dev/null +++ b/bindings/zig/src/cwist.zig @@ -0,0 +1,403 @@ +//! Zig API for CWIST, on top of the C API translated from its headers +//! (`c`, see src/cwist.h). +//! +//! ```zig +//! var app = try cwist.App.init(); +//! defer app.deinit(); +//! try app.get("/users/:id", {}, struct { +//! fn handle(_: void, req: cwist.Request, res: cwist.Response) void { +//! res.setBody(req.param("id") orelse "?") catch {}; +//! } +//! }.handle); +//! try app.listen(8080); +//! ``` +//! +//! Lifetimes: a `Request` and a `Response` are views of CWIST's objects for +//! one handler call. Slices they return point into CWIST's memory and must +//! not be kept after the handler returns; copy what has to outlive it. +//! +//! Threads: CWIST runs handlers on its worker threads, several at once, so a +//! handler's context must be safe to use from any thread. +//! +//! Errors: a handler returns nothing; CWIST answers with whatever it set on +//! the response. A panic in a handler aborts the process (Zig does not +//! unwind), so nothing can escape into C. + +const std = @import("std"); + +/// The raw C API, for anything this module does not wrap yet. +pub const c = @import("c"); + +pub const Error = error{ + /// `cwist_app_create()` failed (out of memory). + AppCreate, + /// CWIST rejected a route registration. + Route, + /// CWIST rejected a response header, for example one containing CR or LF. + Header, + /// CWIST could not store a response body (out of memory). + Body, + /// In-memory dispatch failed: the request was malformed or the response + /// could not be serialized. + Dispatch, + /// The server could not start, for example because the port is in use. + Listen, +}; + +/// HTTP request method. +pub const Method = enum { get, post, put, delete, patch, head, options, connect, unknown }; + +/// Checks a `cwist_error_t` and releases it, as C callers must. +fn consume(err_in: c.cwist_error_t) bool { + var err = err_in; + const ok = c.cwist_error_is_ok_extern(&err); + c.cwist_error_dispose(&err); + return ok; +} + +/// Bytes of a `cwist_sstring`, or "" when it is NULL or empty. +fn sstringBytes(s: [*c]c.cwist_sstring) []const u8 { + if (s == null) return ""; + const str = s.*; + if (str.data == null or str.size == 0) return ""; + return str.data[0..str.size]; +} + +/// A NUL-terminated C string returned by CWIST, or null. +fn cString(p: [*c]const u8) ?[]const u8 { + if (p == null) return null; + return std.mem.span(@as([*:0]const u8, @ptrCast(p))); +} + +/// The request a handler receives; a view for one handler call. +pub const Request = struct { + raw: *c.cwist_http_request, + + /// The request method. + pub fn method(self: Request) Method { + return switch (self.raw.method) { + c.CWIST_HTTP_GET => .get, + c.CWIST_HTTP_POST => .post, + c.CWIST_HTTP_PUT => .put, + c.CWIST_HTTP_DELETE => .delete, + c.CWIST_HTTP_PATCH => .patch, + c.CWIST_HTTP_HEAD => .head, + c.CWIST_HTTP_OPTIONS => .options, + c.CWIST_HTTP_CONNECT => .connect, + else => .unknown, + }; + } + + /// The request path without the query string, e.g. "/users/7". + pub fn path(self: Request) []const u8 { + return sstringBytes(self.raw.path); + } + + /// The request body. + pub fn body(self: Request) []const u8 { + return sstringBytes(self.raw.body); + } + + /// The value of a request header (case-insensitive name), if present. + pub fn header(self: Request, name: [:0]const u8) ?[]const u8 { + return cString(c.cwist_http_header_get(self.raw.headers, name.ptr)); + } + + /// A path parameter captured by a `:name` segment of the route. + pub fn param(self: Request, name: [:0]const u8) ?[]const u8 { + if (self.raw.path_params == null) return null; + return cString(c.cwist_query_map_get(self.raw.path_params, name.ptr)); + } + + /// A query-string parameter (`?name=value`). + pub fn query(self: Request, name: [:0]const u8) ?[]const u8 { + if (self.raw.query_params == null) return null; + return cString(c.cwist_query_map_get(self.raw.query_params, name.ptr)); + } +}; + +/// The response a handler fills in; a view for one handler call. +pub const Response = struct { + raw: *c.cwist_http_response, + + /// Sets the status code, e.g. 201. + pub fn setStatus(self: Response, code: u16) void { + self.raw.status_code = code; + } + + /// Replaces the response body. CWIST copies the bytes. + pub fn setBody(self: Response, bytes: []const u8) Error!void { + if (self.raw.body == null) return error.Body; + if (!consume(c.cwist_sstring_assign_len(self.raw.body, bytes.ptr, bytes.len))) { + return error.Body; + } + } + + /// Adds a response header. CWIST copies both strings and rejects names + /// or values containing CR or LF. + pub fn addHeader(self: Response, name: [:0]const u8, value: [:0]const u8) Error!void { + if (!consume(c.cwist_http_header_add(&self.raw.headers, name.ptr, value.ptr))) { + return error.Header; + } + } +}; + +const RegisterFn = *const fn ( + [*c]c.cwist_app, + [*c]const u8, + c.cwist_handler_ex_func, + ?*anyopaque, + c.cwist_handler_ctx_destroy_func, +) callconv(.c) c.cwist_error_t; + +/// The C entry point for a route: turns CWIST's user context back into the +/// handler's context and calls it. +fn Trampoline(comptime Context: type, comptime handler: fn (Context, Request, Response) void) type { + switch (@typeInfo(Context)) { + .void, .pointer => {}, + else => @compileError("a route context must be a pointer or void, not " ++ @typeName(Context)), + } + return struct { + fn call( + user_ctx: ?*anyopaque, + req: [*c]c.cwist_http_request, + res: [*c]c.cwist_http_response, + ) callconv(.c) void { + if (req == null or res == null) return; + const context: Context = if (Context == void) {} else @ptrCast(@alignCast(user_ctx.?)); + handler(context, .{ .raw = req }, .{ .raw = res }); + } + }; +} + +/// A CWIST application: routes plus the C app object that serves them. +/// +/// Route contexts are borrowed, not owned: each must stay valid until +/// `deinit`. +pub const App = struct { + raw: *c.cwist_app, + + /// Creates an empty application. + pub fn init() Error!App { + const raw = c.cwist_app_create(); + if (raw == null) return error.AppCreate; + return .{ .raw = raw }; + } + + /// Destroys the C app. + pub fn deinit(self: *App) void { + c.cwist_app_destroy(self.raw); + self.* = undefined; + } + + /// Registers a `GET` route. `path` may contain `:name` segments, read in + /// the handler with `Request.param`. `context` is a pointer (or `{}`) that + /// CWIST passes back to `handler` on every request; it is borrowed and + /// must outlive the app. Registering the same method and path again + /// replaces the previous handler. + pub fn get( + self: *App, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + return self.route(c.cwist_app_get_ex, path, context, handler); + } + + /// Registers a `POST` route; see `get`. + pub fn post( + self: *App, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + return self.route(c.cwist_app_post_ex, path, context, handler); + } + + /// Registers a `PUT` route; see `get`. + pub fn put( + self: *App, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + return self.route(c.cwist_app_put_ex, path, context, handler); + } + + /// Registers a `DELETE` route; see `get`. + pub fn delete( + self: *App, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + return self.route(c.cwist_app_delete_ex, path, context, handler); + } + + /// Registers a `PATCH` route; see `get`. + pub fn patch( + self: *App, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + return self.route(c.cwist_app_patch_ex, path, context, handler); + } + + fn route( + self: *App, + register: RegisterFn, + path: [:0]const u8, + context: anytype, + comptime handler: fn (@TypeOf(context), Request, Response) void, + ) Error!void { + const Context = @TypeOf(context); + const user_ctx: ?*anyopaque = if (Context == void) null else @ptrCast(@constCast(context)); + // No destructor: the context is borrowed and stays the caller's. + const err = register(self.raw, path.ptr, &Trampoline(Context, handler).call, user_ctx, null); + if (!consume(err)) return error.Route; + } + + /// Runs one raw HTTP/1.x request through the router and handlers in + /// memory, with no socket, and returns the serialized response (status + /// line, headers and body), allocated with `allocator`. + pub fn dispatch(self: *App, allocator: std.mem.Allocator, request: []const u8) (Error || std.mem.Allocator.Error)![]u8 { + var out: [*c]u8 = null; + var out_len: usize = 0; + const rc = c.cwist_app_dispatch_memory(self.raw, request.ptr, request.len, &out, &out_len); + defer c.cwist_free(out); + if (rc != 0 or out == null) return error.Dispatch; + return allocator.dupe(u8, out[0..out_len]); + } + + /// Serves this app on `port` (all IPv4 interfaces) and blocks until a + /// graceful shutdown is requested with `shutdown()` or SIGTERM/SIGINT; + /// the server then stops accepting, drains and returns. + /// + /// It serves in the calling process on the reactor server, ignoring + /// `CWIST_WORKERS` and `CWIST_C1M_MODE`: nothing forks, and every handler + /// thread has been joined when `listen` returns, so the app and its + /// route contexts can be released right after. CWIST runs one server per + /// process. CWIST handles SIGTERM and SIGINT only while `listen` runs. + pub fn listen(self: *App, port: u16) Error!void { + const rc = c.cwist_app_listen_ex(self.raw, port, 1, 1); + // A shutdown leaves the process-wide running flag cleared; reset it + // so a later listen can serve. + c.cwist_shutdown_reset(); + if (rc != 0) return error.Listen; + } +}; + +/// Requests a graceful shutdown of the running server, as SIGTERM/SIGINT +/// do. Safe from any thread, including a handler; repeated calls are +/// harmless. A request made while no server runs applies to the next +/// `listen`, which then returns at once. +pub fn shutdown() void { + c.cwist_shutdown_request(); +} + +// --- Tests: the API against the real libcwist, through in-memory dispatch. + +const testing = std.testing; + +fn testRequest(comptime method_line: []const u8, comptime extra: []const u8, comptime body_text: []const u8) []const u8 { + return std.fmt.comptimePrint( + "{s} HTTP/1.1\r\nHost: localhost\r\n{s}Content-Length: {d}\r\n\r\n{s}", + .{ method_line, extra, body_text.len, body_text }, + ); +} + +fn bodyOf(response: []const u8) []const u8 { + const split = std.mem.indexOf(u8, response, "\r\n\r\n") orelse return ""; + return response[split + 4 ..]; +} + +fn echoUser(_: void, req: Request, res: Response) void { + var buf: [128]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "{s} {s} as {s} from {s}", .{ + @tagName(req.method()), + req.param("id") orelse "?", + req.query("fmt") orelse "plain", + req.header("x-client") orelse "none", + }) catch "too long"; + res.setStatus(200); + res.addHeader("X-Route", "users") catch {}; + res.setBody(text) catch {}; +} + +fn echoBody(_: void, req: Request, res: Response) void { + res.setStatus(201); + res.setBody(req.body()) catch {}; +} + +test "routes read the request and write the response" { + var app = try App.init(); + defer app.deinit(); + try app.get("/users/:id", {}, echoUser); + try app.post("/echo", {}, echoBody); + + const users = try app.dispatch(testing.allocator, testRequest("GET /users/42?fmt=json", "X-Client: zig\r\n", "")); + defer testing.allocator.free(users); + try testing.expect(std.mem.startsWith(u8, users, "HTTP/1.1 200")); + try testing.expect(std.mem.indexOf(u8, users, "X-Route: users\r\n") != null); + try testing.expectEqualStrings("get 42 as json from zig", bodyOf(users)); + + const echo = try app.dispatch(testing.allocator, testRequest("POST /echo", "", "payload")); + defer testing.allocator.free(echo); + try testing.expect(std.mem.startsWith(u8, echo, "HTTP/1.1 201")); + try testing.expectEqualStrings("payload", bodyOf(echo)); + + const missing = try app.dispatch(testing.allocator, testRequest("GET /nope", "", "")); + defer testing.allocator.free(missing); + try testing.expect(std.mem.startsWith(u8, missing, "HTTP/1.1 404")); +} + +const Counter = struct { + hits: std.atomic.Value(u32) = .init(0), +}; + +fn countHit(counter: *Counter, _: Request, res: Response) void { + const n = counter.hits.fetchAdd(1, .monotonic) + 1; + var buf: [16]u8 = undefined; + res.setBody(std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?") catch {}; +} + +test "a route context is passed back to its handler" { + var counter: Counter = .{}; + var app = try App.init(); + defer app.deinit(); + try app.get("/count", &counter, countHit); + + for (1..4) |i| { + const out = try app.dispatch(testing.allocator, testRequest("GET /count", "", "")); + defer testing.allocator.free(out); + var buf: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{i}), bodyOf(out)); + } + try testing.expectEqual(@as(u32, 3), counter.hits.load(.monotonic)); +} + +fn badHeader(_: void, _: Request, res: Response) void { + // CR/LF would split the response; CWIST refuses it and nothing is added. + if (res.addHeader("X-Bad", "a\r\nInjected: yes")) |_| { + res.setBody("added") catch {}; + } else |err| { + res.setBody(@errorName(err)) catch {}; + } +} + +test "a header with CR or LF is rejected" { + var app = try App.init(); + defer app.deinit(); + try app.get("/bad", {}, badHeader); + const out = try app.dispatch(testing.allocator, testRequest("GET /bad", "", "")); + defer testing.allocator.free(out); + try testing.expectEqualStrings("Header", bodyOf(out)); + try testing.expect(std.mem.indexOf(u8, out, "Injected") == null); +} + +test "a malformed request is a dispatch error" { + var app = try App.init(); + defer app.deinit(); + try testing.expectError(error.Dispatch, app.dispatch(testing.allocator, "this is not http")); +} diff --git a/example/zig-hello/.gitignore b/example/zig-hello/.gitignore new file mode 100644 index 00000000..3389c86c --- /dev/null +++ b/example/zig-hello/.gitignore @@ -0,0 +1,2 @@ +.zig-cache/ +zig-out/ diff --git a/example/zig-hello/README.md b/example/zig-hello/README.md new file mode 100644 index 00000000..973a32d0 --- /dev/null +++ b/example/zig-hello/README.md @@ -0,0 +1,28 @@ +# zig-hello + +A minimal CWIST application written in Zig, using the bindings in +`bindings/zig` (Zig 0.17.0). + +## Build + +CWIST must be built and installed first so the bindings can find it through +`pkg-config`: + +```bash +make && make install PREFIX=$HOME/.local +export PKG_CONFIG_PATH=$HOME/.local/lib/pkgconfig +``` + +Then build and run the example: + +```bash +cd example/zig-hello +zig build run +``` + +The server listens on port 8080 (all IPv4 interfaces) and serves: + +- `GET /` -> `Hello, World!` +- `GET /users/:id` -> `user ` + +Stop it with Ctrl-C (SIGINT) or SIGTERM; it shuts down gracefully. diff --git a/example/zig-hello/build.zig b/example/zig-hello/build.zig new file mode 100644 index 00000000..c40c0411 --- /dev/null +++ b/example/zig-hello/build.zig @@ -0,0 +1,23 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const cwist = b.dependency("cwist", .{ .target = target, .optimize = optimize }); + + const exe = b.addExecutable(.{ + .name = "zig-hello", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "cwist", .module = cwist.module("cwist") }}, + }), + }); + b.installArtifact(exe); + + const run = b.addRunArtifact(exe); + run.step.dependOn(b.getInstallStep()); + b.step("run", "Serve on http://127.0.0.1:8080").dependOn(&run.step); +} diff --git a/example/zig-hello/build.zig.zon b/example/zig-hello/build.zig.zon new file mode 100644 index 00000000..82166d9f --- /dev/null +++ b/example/zig-hello/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .zig_hello, + .version = "0.1.0", + .fingerprint = 0xfdb7fa7cc34b243, // Changing this has security and trust implications. + .minimum_zig_version = "0.17.0", + .dependencies = .{ + .cwist = .{ .path = "../../bindings/zig" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/zig-hello/src/main.zig b/example/zig-hello/src/main.zig new file mode 100644 index 00000000..d3337283 --- /dev/null +++ b/example/zig-hello/src/main.zig @@ -0,0 +1,27 @@ +const std = @import("std"); +const cwist = @import("cwist"); + +fn hello(_: void, _: cwist.Request, res: cwist.Response) void { + res.setStatus(200); + res.addHeader("X-Powered-By", "CWIST Zig") catch {}; + res.setBody("Hello, World!") catch {}; +} + +fn user(_: void, req: cwist.Request, res: cwist.Response) void { + var buf: [128]u8 = undefined; + const body = std.fmt.bufPrint(&buf, "user {s}", .{req.param("id") orelse "?"}) catch "user ?"; + res.setStatus(200); + res.addHeader("X-Powered-By", "CWIST Zig") catch {}; + res.setBody(body) catch {}; +} + +pub fn main() !void { + var app = try cwist.App.init(); + defer app.deinit(); + + try app.get("/", {}, hello); + try app.get("/users/:id", {}, user); + + std.debug.print("Listening on http://127.0.0.1:8080 (press Ctrl-C to stop)\n", .{}); + try app.listen(8080); +} diff --git a/scripts/ci/rust_hello_smoke.sh b/scripts/ci/hello_smoke.sh similarity index 75% rename from scripts/ci/rust_hello_smoke.sh rename to scripts/ci/hello_smoke.sh index d8f93558..b6e807ca 100755 --- a/scripts/ci/rust_hello_smoke.sh +++ b/scripts/ci/hello_smoke.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Serve example/rust-hello and check real HTTP responses from it -# (ROADMAP.md, v3.8 Phase 2: "the Rust example serves requests"). +# Serve one of the language-binding hello examples (example/rust-hello, +# example/zig-hello) and check real HTTP responses from it (ROADMAP.md, v3.8 +# Phase 2: "the Rust example serves requests"; issue #36). # -# Usage: scripts/ci/rust_hello_smoke.sh +# Usage: scripts/ci/hello_smoke.sh # # Starts the server, waits until it answers, checks the routes and the Rust # middleware header, then stops it with SIGTERM and requires a clean exit. @@ -10,8 +11,9 @@ # running. set -euo pipefail -bin=${1:?usage: rust_hello_smoke.sh } -port=8080 # fixed in example/rust-hello/src/main.rs +bin=${1:?usage: hello_smoke.sh } +powered_by=${2:?usage: hello_smoke.sh } +port=8080 # fixed in the hello examples base="http://127.0.0.1:$port" work=$(mktemp -d) @@ -29,7 +31,7 @@ cleanup() { trap cleanup EXIT fail() { - echo "rust-hello smoke: $*" >&2 + echo "hello smoke: $*" >&2 echo "--- server output ---" >&2 cat "$log" >&2 || true exit 1 @@ -71,12 +73,12 @@ check() { } check / 200 "Hello, World!" -grep -qi '^X-Powered-By: CWIST Rust' "$hdr" || - fail "GET /: no X-Powered-By header from the Rust middleware" +grep -qi "^X-Powered-By: $powered_by" "$hdr" || + fail "GET /: no X-Powered-By: $powered_by header" check /users/42 200 "user 42" check /no/such/route 404 "" -# Graceful shutdown: SIGTERM makes App::listen return and main exit 0. +# Graceful shutdown: SIGTERM makes the app's listen return and main exit 0. # A server that does not stop within 20 s is a failure, not a stuck job. kill -TERM "$pid" for _ in $(seq 1 200); do @@ -90,4 +92,4 @@ wait "$pid" || rc=$? echo "--- server output ---" cat "$log" -echo "rust-hello smoke: OK" +echo "hello smoke: OK"