search for standard sites
pub-search.waow.tech
search
zig
blog
atproto
1//! Turso HTTP API client
2//! https://docs.turso.tech/sdk/http/reference
3
4const std = @import("std");
5const http = std.http;
6const json = std.json;
7const mem = std.mem;
8const Allocator = mem.Allocator;
9
10const result = @import("result.zig");
11pub const Result = result.Result;
12pub const Row = result.Row;
13pub const BatchResult = result.BatchResult;
14
15const Client = @This();
16
17// Hrana protocol types (https://github.com/tursodatabase/libsql/blob/main/docs/HRANA_3_SPEC.md)
18const Value = struct { type: []const u8 = "text", value: []const u8 };
19const Stmt = struct { sql: []const u8, args: ?[]const Value = null };
20const ExecuteReq = struct { type: []const u8 = "execute", stmt: Stmt };
21const CloseReq = struct { type: []const u8 = "close" };
22
23const URL_BUF_SIZE = 512;
24const AUTH_BUF_SIZE = 512;
25
26allocator: Allocator,
27url: []const u8,
28token: []const u8,
29mutex: std.Thread.Mutex = .{},
30http_client: http.Client,
31
32pub fn init(allocator: Allocator) !Client {
33 const url = std.posix.getenv("TURSO_URL") orelse {
34 std.debug.print("TURSO_URL not set\n", .{});
35 return error.MissingEnv;
36 };
37 const token = std.posix.getenv("TURSO_TOKEN") orelse {
38 std.debug.print("TURSO_TOKEN not set\n", .{});
39 return error.MissingEnv;
40 };
41
42 const libsql_prefix = "libsql://";
43 const host = if (mem.startsWith(u8, url, libsql_prefix))
44 url[libsql_prefix.len..]
45 else
46 url;
47
48 std.debug.print("turso client initialized: {s}\n", .{host});
49
50 return .{
51 .allocator = allocator,
52 .url = host,
53 .token = token,
54 .http_client = .{ .allocator = allocator },
55 };
56}
57
58pub fn deinit(self: *Client) void {
59 self.http_client.deinit();
60}
61
62pub fn query(self: *Client, comptime sql: []const u8, args: anytype) !Result {
63 comptime validateArgs(sql, @TypeOf(args));
64 const args_slice = try self.argsToSlice(args);
65 defer self.allocator.free(args_slice);
66 const response = try self.executeRaw(sql, args_slice);
67 defer self.allocator.free(response);
68 return Result.parse(self.allocator, response);
69}
70
71pub fn exec(self: *Client, comptime sql: []const u8, args: anytype) !void {
72 comptime validateArgs(sql, @TypeOf(args));
73 const args_slice = try self.argsToSlice(args);
74 defer self.allocator.free(args_slice);
75 const response = try self.executeRaw(sql, args_slice);
76 self.allocator.free(response);
77}
78
79pub const Statement = struct {
80 sql: []const u8,
81 args: []const []const u8 = &.{},
82};
83
84pub fn queryBatch(self: *Client, statements: []const Statement) !BatchResult {
85 const response = try self.executeBatchRaw(statements);
86 defer self.allocator.free(response);
87 return BatchResult.parse(self.allocator, response, statements.len);
88}
89
90fn argsToSlice(self: *Client, args: anytype) ![]const []const u8 {
91 const ArgsType = @TypeOf(args);
92 const info = @typeInfo(ArgsType);
93
94 if (info == .pointer) {
95 const child = @typeInfo(info.pointer.child);
96 if (child == .@"struct") {
97 const fields = child.@"struct".fields;
98 const slice = try self.allocator.alloc([]const u8, fields.len);
99 inline for (fields, 0..) |field, i| {
100 slice[i] = @field(args.*, field.name);
101 }
102 return slice;
103 }
104 }
105
106 if (info == .@"struct") {
107 const fields = info.@"struct".fields;
108 const slice = try self.allocator.alloc([]const u8, fields.len);
109 inline for (fields, 0..) |field, i| {
110 slice[i] = @field(args, field.name);
111 }
112 return slice;
113 }
114
115 @compileError("args must be a tuple or pointer to tuple");
116}
117
118fn executeRaw(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 {
119 self.mutex.lock();
120 defer self.mutex.unlock();
121
122 var url_buf: [URL_BUF_SIZE]u8 = undefined;
123 const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch
124 return error.UrlTooLong;
125
126 const body = try self.buildRequestBody(sql, args);
127 defer self.allocator.free(body);
128
129 var auth_buf: [AUTH_BUF_SIZE]u8 = undefined;
130 const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch
131 return error.AuthTooLong;
132
133 var response_body: std.Io.Writer.Allocating = .init(self.allocator);
134 errdefer response_body.deinit();
135
136 const res = self.http_client.fetch(.{
137 .location = .{ .url = url },
138 .method = .POST,
139 .headers = .{
140 .content_type = .{ .override = "application/json" },
141 .authorization = .{ .override = auth },
142 },
143 .payload = body,
144 .response_writer = &response_body.writer,
145 }) catch |err| {
146 std.debug.print("turso request failed: {}\n", .{err});
147 return error.HttpError;
148 };
149
150 if (res.status != .ok) {
151 std.debug.print("turso error: {}\n", .{res.status});
152 return error.TursoError;
153 }
154
155 return try response_body.toOwnedSlice();
156}
157
158fn executeBatchRaw(self: *Client, statements: []const Statement) ![]const u8 {
159 self.mutex.lock();
160 defer self.mutex.unlock();
161
162 var url_buf: [URL_BUF_SIZE]u8 = undefined;
163 const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch
164 return error.UrlTooLong;
165
166 const body = try self.buildBatchRequestBody(statements);
167 defer self.allocator.free(body);
168
169 var auth_buf: [AUTH_BUF_SIZE]u8 = undefined;
170 const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch
171 return error.AuthTooLong;
172
173 var response_body: std.Io.Writer.Allocating = .init(self.allocator);
174 errdefer response_body.deinit();
175
176 const res = self.http_client.fetch(.{
177 .location = .{ .url = url },
178 .method = .POST,
179 .headers = .{
180 .content_type = .{ .override = "application/json" },
181 .authorization = .{ .override = auth },
182 },
183 .payload = body,
184 .response_writer = &response_body.writer,
185 }) catch |err| {
186 std.debug.print("turso batch request failed: {}\n", .{err});
187 return error.HttpError;
188 };
189
190 if (res.status != .ok) {
191 std.debug.print("turso batch error: {}\n", .{res.status});
192 return error.TursoError;
193 }
194
195 return try response_body.toOwnedSlice();
196}
197
198fn buildBatchRequestBody(self: *Client, statements: []const Statement) ![]const u8 {
199 var body: std.Io.Writer.Allocating = .init(self.allocator);
200 errdefer body.deinit();
201 var jw: json.Stringify = .{ .writer = &body.writer, .options = .{ .emit_null_optional_fields = false } };
202
203 try jw.beginObject();
204 try jw.objectField("requests");
205 try jw.beginArray();
206
207 for (statements) |stmt| {
208 const values = try self.toValues(stmt.args);
209 defer self.allocator.free(values);
210 try jw.write(ExecuteReq{
211 .stmt = .{ .sql = stmt.sql, .args = if (values.len > 0) values else null },
212 });
213 }
214
215 try jw.write(CloseReq{});
216 try jw.endArray();
217 try jw.endObject();
218
219 return try body.toOwnedSlice();
220}
221
222fn buildRequestBody(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 {
223 var body: std.Io.Writer.Allocating = .init(self.allocator);
224 errdefer body.deinit();
225 var jw: json.Stringify = .{ .writer = &body.writer, .options = .{ .emit_null_optional_fields = false } };
226
227 const values = try self.toValues(args);
228 defer self.allocator.free(values);
229
230 try jw.beginObject();
231 try jw.objectField("requests");
232 try jw.beginArray();
233 try jw.write(ExecuteReq{
234 .stmt = .{ .sql = sql, .args = if (values.len > 0) values else null },
235 });
236 try jw.write(CloseReq{});
237 try jw.endArray();
238 try jw.endObject();
239
240 return try body.toOwnedSlice();
241}
242
243fn toValues(self: *Client, args: []const []const u8) ![]const Value {
244 if (args.len == 0) return &.{};
245 const values = try self.allocator.alloc(Value, args.len);
246 for (args, 0..) |arg, i| {
247 values[i] = .{ .value = arg };
248 }
249 return values;
250}
251
252fn validateArgs(comptime sql: []const u8, comptime ArgsType: type) void {
253 const expected = countPlaceholders(sql);
254 const provided = countArgsType(ArgsType);
255 if (expected != provided) {
256 @compileError(std.fmt.comptimePrint(
257 "SQL has {} placeholders but {} args provided",
258 .{ expected, provided },
259 ));
260 }
261}
262
263fn countPlaceholders(comptime sql: []const u8) usize {
264 var count: usize = 0;
265 for (sql) |c| {
266 if (c == '?') count += 1;
267 }
268 return count;
269}
270
271fn countArgsType(comptime ArgsType: type) usize {
272 const info = @typeInfo(ArgsType);
273
274 if (info == .pointer) {
275 const child = @typeInfo(info.pointer.child);
276 if (child == .@"struct") {
277 return child.@"struct".fields.len;
278 }
279 }
280
281 if (info == .@"struct") {
282 return info.@"struct".fields.len;
283 }
284
285 return 0;
286}