From f9906f288abe5ddd8cce55ab5e98b79295c1c64b Mon Sep 17 00:00:00 2001 From: jiacai2050 Date: Sat, 16 Sep 2023 23:52:34 +0800 Subject: [PATCH] first init. --- .github/workflows/CI.yml | 59 ++++++++ .gitignore | 13 ++ LICENSE | 21 +++ README.org | 60 +++++++++ build.zig | 40 ++++++ examples/basic.zig | 47 +++++++ src/easy.zig | 284 +++++++++++++++++++++++++++++++++++++++ src/main.zig | 4 + 8 files changed, 528 insertions(+) create mode 100644 .github/workflows/CI.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.org create mode 100644 build.zig create mode 100644 examples/basic.zig create mode 100644 src/easy.zig create mode 100644 src/main.zig diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..b3d1857 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,59 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + paths: + - '**.zig' + - '**.yml' + push: + branches: + - main + paths: + - '**.zig' + - '**.yml' + +jobs: + test: + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + - uses: goto-bus-stop/setup-zig@v2 + with: + version: master + - name: Run example + run: zig build run-basic + - name: Install deps + run: | + sudo apt update && sudo apt install -y valgrind libcurl4-openssl-dev + - name: Memory leak detect + run: | + zig build -Dcpu=baseline --verbose + TEST_BINARY=./zig-out/bin/basic + valgrind --leak-check=full --tool=memcheck \ + --show-leak-kinds=all --error-exitcode=1 ${TEST_BINARY} + + cross-compile: + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + targets: [x86_64-windows, x86_64-linux, x86_64-macos, aarch64-macos] + steps: + - uses: actions/checkout@v2 + - uses: goto-bus-stop/setup-zig@v1 + with: + version: master + - name: Install + run: | + sudo apt update && sudo apt install -y libcurl4-openssl-dev + - name: Build demo + run: | + zig build -Dtarget=${{ matrix.targets }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dc0ebe --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Created by https://www.toptal.com/developers/gitignore/api/zig +# Edit at https://www.toptal.com/developers/gitignore?templates=zig + +### zig ### +# Zig programming language + +zig-cache/ +zig-out/ +build/ +build-*/ +docgen_tmp/ + +# End of https://www.toptal.com/developers/gitignore/api/zig diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97fb4db --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Jiacai Liu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.org b/README.org new file mode 100644 index 0000000..4f2f205 --- /dev/null +++ b/README.org @@ -0,0 +1,60 @@ +#+TITLE: Zig-curl +#+DATE: 2023-09-16T23:16:15+0800 +#+LASTMOD: 2023-09-16T23:52:27+0800 +#+OPTIONS: toc:nil num:nil +#+STARTUP: content + +[[https://github.com/jiacai2050/zig-curl/actions/workflows/CI.yml][https://github.com/jiacai2050/zig-curl/actions/workflows/CI.yml/badge.svg]] + +[[https://curl.haxx.se/libcurl/][libcurl]] bindings for Zig. + +* Installation +First add this package to =build.zig.zon= of your project like this: +#+begin_src zig +.{ + .dependencies = .{ + .curl = .{ + .url = "https://github.com/jiacai2050/zig-curl/archive/${COMMIT}.tar.gz", + .hash = "xxx", + }, + }, +} +#+end_src +Then in your =build.zig=, access the module like this: +#+begin_src zig +const curl = b.dependency("curl", .{ + .target = target, + .optimize = optimize, +}); + +exe.addModule("curl", sqlite.module("curl")); +// Note: since this package doesn't bundle static libcurl, +// so users need to link to system-wide libcurl. +exe.linkSystemLibrary("libcurl"); +#+end_src + +* Usage +#+begin_src zig +const Easy = @import("curl").Easy; + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const easy = try Easy.init(allocator); + defer easy.deinit(); + + const resp = try easy.get("http://httpbin.org/anything"); + defer resp.deinit(); + + std.debug.print("Status code: {d}\nBody: {s}\n", .{ + resp.status_code, + resp.body.items, + }); +} +#+end_src +More usage can refer to [[file:examples/basic.zig][basic.zig]]. +* Roadmap +- [ ] Currently only easy API is supported, support [[https://curl.se/libcurl/c/libcurl-multi.html][multi API]]. +- By default, this package will attempt to dynamically link to the system-wide libcurl and the system-wide SSL library. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..e26fd60 --- /dev/null +++ b/build.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const Build = std.Build; +const Module = Build.Module; +const LazyPath = Build.LazyPath; + +const MODULE_NAME = "curl"; + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const module = b.addModule(MODULE_NAME, .{ + .source_file = .{ .path = "src/main.zig" }, + }); + + try addExample(b, "basic", module); + + const main_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_main_tests = b.addRunArtifact(main_tests); + const test_step = b.step("test", "Run library tests"); + test_step.dependOn(&run_main_tests.step); +} + +fn addExample(b: *std.Build, comptime name: []const u8, curl_module: *Module) !void { + const exe = b.addExecutable(.{ + .name = name, + .root_source_file = LazyPath.relative("examples/" ++ name ++ ".zig"), + }); + + b.installArtifact(exe); + exe.addModule(MODULE_NAME, curl_module); + exe.linkSystemLibrary("libcurl"); + + const run_step = b.step("run-" ++ name, std.fmt.comptimePrint("Run {s} example", .{name})); + run_step.dependOn(&b.addRunArtifact(exe).step); +} diff --git a/examples/basic.zig b/examples/basic.zig new file mode 100644 index 0000000..9160a2f --- /dev/null +++ b/examples/basic.zig @@ -0,0 +1,47 @@ +const std = @import("std"); +const mem = std.mem; +const Allocator = mem.Allocator; +const Easy = @import("curl").Easy; + +fn get(easy: Easy) !void { + const resp = try easy.get("http://httpbin.org/anything"); + defer resp.deinit(); + + std.debug.print("Status code: {d}\nBody: {s}\n", .{ + resp.status_code, + resp.body.items, + }); + const date_header = try resp.get_header("date"); + if (date_header) |h| { + std.debug.print("date header: {s}\n", .{h.get()}); + } else { + std.debug.print("date header not found\n", .{}); + } +} + +fn post(easy: Easy) !void { + var payload = std.io.fixedBufferStream( + \\\ {"name": "John", "age": 15} + ); + const resp = try easy.post("http://httpbin.org/anything", "application/json", payload.reader()); + defer resp.deinit(); + + std.debug.print("Status code: {d}\nBody: {s}\n", .{ + resp.status_code, + resp.body.items, + }); +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const easy = try Easy.init(allocator); + defer easy.deinit(); + + std.debug.print("-----------GET demo\n", .{}); + try get(easy); + std.debug.print("-----------POST demo\n", .{}); + try post(easy); +} diff --git a/src/easy.zig b/src/easy.zig new file mode 100644 index 0000000..6e61c65 --- /dev/null +++ b/src/easy.zig @@ -0,0 +1,284 @@ +const c = @cImport({ + @cInclude("curl/curl.h"); +}); +const std = @import("std"); +const mem = std.mem; +const fmt = std.fmt; + +const testing = std.testing; + +const Self = @This(); + +allocator: mem.Allocator, +handle: *c.CURL, +/// The maximum time in milliseconds that the entire transfer operation to take. +timeout_ms: usize = 30_000, + +const HEADER_CONTENT_TYPE: []const u8 = "Content-Type"; + +pub const Method = enum { + GET, + POST, + PUT, + HEAD, + PATCH, + DELETE, + + fn asString(self: @This()) [:0]const u8 { + return @tagName(self); + } +}; + +pub const RequestHeader = struct { + entries: std.StringHashMap([]const u8), + allocator: mem.Allocator, + + pub fn init(allocator: mem.Allocator) @This() { + return .{ + .entries = std.StringHashMap([]const u8).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *@This()) void { + self.entries.deinit(); + } + + pub fn add(self: *@This(), k: []const u8, v: []const u8) !void { + try self.entries.put(k, v); + } + + /// Users should be free returned list (after usage) with `freeCHeader`. + fn asCHeader(self: @This()) !?*c.struct_curl_slist { + if (self.entries.count() == 0) { + return null; + } + + var lst: ?*c.struct_curl_slist = null; + var it = self.entries.iterator(); + while (it.next()) |entry| { + const kv = try fmt.allocPrintZ(self.allocator, "{s}: {s}", .{ entry.key_ptr.*, entry.value_ptr.* }); + defer self.allocator.free(kv); + + lst = c.curl_slist_append(lst, kv); + } + + return lst; + } + + fn freeCHeader(lst: *c.struct_curl_slist) void { + c.curl_slist_free_all(lst); + } +}; + +pub fn Request(comptime ReaderType: type) type { + return struct { + url: []const u8, + method: Method = .GET, + // body is io.Reader type + body: ?ReaderType = null, + header: ?RequestHeader = null, + verbose: bool = false, + /// Redirection limit, 0 refuse any redirect, -1 for an infinite number of redirects. + redirects: i32 = 10, + /// Max body size, default 128M. + max_body_size: usize = 128 * 1024 * 1024, + + pub fn deinit(self: *@This()) void { + if (self.header) |*h| { + h.deinit(); + } + } + + fn getVerbose(self: @This()) c_long { + return if (self.verbose) 1 else 0; + } + + fn getBody(self: @This(), allocator: mem.Allocator) !?[]u8 { + if (self.body == null) { + return null; + } + + if (@TypeOf(self.body.?) == void) { + return null; + } + + return try self.body.?.readAllAlloc(allocator, self.max_body_size); + } + }; +} + +pub fn request(body: anytype, url: []const u8) Request(@TypeOf(body)) { + return .{ + .url = url, + .body = body, + }; +} + +pub const Buffer = std.ArrayList(u8); +pub const Response = struct { + body: Buffer, + status_code: i32, + + handle: *c.CURL, + allocator: mem.Allocator, + + pub fn deinit(self: @This()) void { + self.body.deinit(); + } + + pub const Header = struct { + c_header: *c.struct_curl_header, + name: []const u8, + + /// Get gets the first value associated with the given key. + /// Applications need to copy the data if it wants to keep it around. + pub fn get(self: @This()) []const u8 { + return mem.sliceTo(self.c_header.value, 0); + } + }; + + pub fn get_header(self: @This(), name: []const u8) !?Header { + const c_name = try fmt.allocPrintZ(self.allocator, "{s}", .{name}); + defer self.allocator.free(c_name); + + var header: ?*c.struct_curl_header = null; + // https://curl.se/libcurl/c/curl_easy_header.html + const code = c.curl_easy_header(self.handle, name.ptr, 0, c.CURLH_HEADER, -1, &header); + + // https://curl.se/libcurl/c/libcurl-errors.html + return switch (code) { + c.CURLHE_OK => .{ + .c_header = header.?, + .name = name, + }, + c.CURLHE_MISSING => null, + c.CURLHE_BADINDEX => error.BadIndex, + else => error.HeaderOther, + }; + } +}; + +pub fn init(allocator: mem.Allocator) !Self { + const handle = c.curl_easy_init(); + if (handle == null) { + return error.Init; + } + + return Self{ + .allocator = allocator, + .handle = handle.?, + }; +} + +pub fn deinit(self: Self) void { + c.curl_easy_cleanup(self.handle); +} + +/// Do sends an HTTP request and returns an HTTP response. +pub fn do(self: Self, req: anytype) !Response { + try self.set_common_opts(); + + const url = try fmt.allocPrintZ(self.allocator, "{s}", .{req.url}); + defer self.allocator.free(url); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_URL, url.ptr)); + + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_MAXREDIRS, @as(c_long, req.redirects))); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_CUSTOMREQUEST, req.method.asString().ptr)); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_VERBOSE, req.getVerbose())); + + const body = try req.getBody(self.allocator); + defer if (body) |b| self.allocator.free(b); + + if (body) |b| { + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_POSTFIELDS, b.ptr)); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_POSTFIELDSIZE, b.len)); + } else { + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_POSTFIELDSIZE, @as(c_long, 0))); + } + + var header: ?*c.struct_curl_slist = null; + if (req.header) |h| { + header = try h.asCHeader(); + } + defer if (header) |h| RequestHeader.freeCHeader(h); + + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_HTTPHEADER, header)); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_WRITEFUNCTION, write_callback)); + + var resp_buffer = Buffer.init(self.allocator); + errdefer resp_buffer.deinit(); + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_WRITEDATA, &resp_buffer)); + + try checkCode(c.curl_easy_perform(self.handle)); + + var status_code: i32 = 0; + try checkCode(c.curl_easy_getinfo(self.handle, c.CURLINFO_RESPONSE_CODE, &status_code)); + + return .{ + .status_code = status_code, + .body = resp_buffer, + .handle = self.handle, + .allocator = self.allocator, + }; +} + +/// Get issues a GET to the specified URL. +pub fn get(self: Self, url: []const u8) !Response { + var req = request({}, url); + defer req.deinit(); + + return self.do(req); +} + +/// Head issues a HEAD to the specified URL. +pub fn head(self: Self, url: []const u8) !Response { + var req = request({}, url); + req.method = .HEAD; + defer req.deinit(); + + return self.do(req); +} + +/// Post issues a POST to the specified URL. +pub fn post(self: Self, url: []const u8, content_type: []const u8, body: anytype) !Response { + const header = blk: { + var h = RequestHeader.init(self.allocator); + errdefer h.deinit(); + try h.add(HEADER_CONTENT_TYPE, content_type); + break :blk h; + }; + var req = request(body, url); + req.method = .POST; + req.header = header; + defer req.deinit(); + + return self.do(req); +} + +/// Used for https://curl.se/libcurl/c/CURLOPT_WRITEFUNCTION.html +// size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata); +fn write_callback(ptr: [*c]c_char, size: c_uint, nmemb: c_uint, user_data: *anyopaque) callconv(.C) c_uint { + const real_size = size * nmemb; + + var buffer: *Buffer = @alignCast(@ptrCast(user_data)); + var typed_data: [*]u8 = @ptrCast(ptr); + buffer.appendSlice(typed_data[0..real_size]) catch return 0; + + return real_size; +} + +fn checkCode(code: c.CURLcode) !void { + if (code == c.CURLE_OK) { + return; + } + + // https://curl.se/libcurl/c/libcurl-errors.html + std.log.debug("curl err code:{d}, msg:{s}\n", .{ code, c.curl_easy_strerror(code) }); + + return error.Unepxected; +} + +fn set_common_opts(self: Self) !void { + try checkCode(c.curl_easy_setopt(self.handle, c.CURLOPT_TIMEOUT_MS, self.timeout_ms)); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..8e7537b --- /dev/null +++ b/src/main.zig @@ -0,0 +1,4 @@ +pub const Easy = @import("easy.zig"); +pub const request = Easy.request; +pub const Request = Easy.Request; +pub const Response = Easy.Response;