controllers refactor

This commit is contained in:
jaina heartles 2022-10-09 02:05:01 -07:00
parent 581159963f
commit 2aa9569050
6 changed files with 530 additions and 270 deletions

View File

@ -4,6 +4,7 @@ const builtin = @import("builtin");
const http = @import("http");
const api = @import("api");
const util = @import("util");
const query_utils = @import("./query.zig");
pub const auth = @import("./controllers/auth.zig");
pub const communities = @import("./controllers/communities.zig");
@ -11,166 +12,185 @@ pub const invites = @import("./controllers/invites.zig");
pub const users = @import("./controllers/users.zig");
pub const notes = @import("./controllers/notes.zig");
pub const utils = struct {
const json_options = if (builtin.mode == .Debug) .{
.whitespace = .{
.indent = .{ .Space = 2 },
.separator = true,
},
.string = .{ .String = .{} },
} else .{
.whitespace = .{
.indent = .None,
.separator = false,
},
.string = .{ .String = .{} },
pub const sample_api = struct {
const Self = @This();
pub const method = .POST;
pub const path = "/notes/:id/reacts";
pub const content_type = "application/json";
pub const Args = struct {
id: []const u8,
};
// Responds to a request with a json value
pub fn respondJson(ctx: *http.server.Context, status: http.Status, value: anytype) !void {
var headers = http.Headers.init(ctx.alloc);
defer headers.deinit();
pub const Body = struct {
content: []const u8,
};
// Don't need to free this k/v pair because they aren't dynamically allocated
try headers.put("Content-Type", "application/json");
pub const Query = struct {
arg: []const u8 = "",
};
var stream = try ctx.openResponse(&headers, status);
defer stream.close();
const writer = stream.writer();
try std.json.stringify(value, json_options, writer);
try stream.finish();
}
pub fn respondError(ctx: *http.server.Context, status: http.Status, err: []const u8) void {
respondJson(ctx, status, .{ .@"error" = err }) catch |write_err| {
std.log.err("Unable to print error: {}", .{write_err});
};
}
pub fn parseRequestBody(comptime T: type, ctx: *http.server.Context) !T {
const body = ctx.request.body orelse return error.BodyRequired;
var tokens = std.json.TokenStream.init(body);
const parsed = try std.json.parse(T, &tokens, .{ .allocator = ctx.alloc });
return parsed;
}
pub fn parseQueryParams(comptime T: type, ctx: *http.server.Context) !T {
// TODO: clean up parsing
const path = ctx.request.path;
const start = (std.mem.indexOfScalar(u8, path, '?') orelse return error.NoQuery) + 1;
const rest = path[start..];
const query = std.mem.sliceTo(rest, '#');
const fake_url = util.Url{
.scheme = "",
.hostport = "",
.path = "",
.query = query,
.fragment = "",
};
var result: T = .{};
inline for (std.meta.fields(T)) |f| {
if (fake_url.getQuery(f.name)) |param| {
const F = if (comptime @typeInfo(f.field_type) == .Optional) std.meta.Child(f.field_type) else f.field_type;
std.log.debug("{}: {s}", .{ F, param });
@field(result, f.name) = switch (F) {
[]const u8 => param,
else => switch (@typeInfo(F)) {
.Struct => if (@hasDecl(F, "parse")) try F.parse(param) else @compileError("Invalid type " ++ @typeName(F)),
.Enum => std.meta.stringToEnum(F, param) orelse return error.ParseError,
.Int => try std.fmt.parseInt(F, param, 10),
else => {},
},
};
}
}
if (false) {
inline for (std.meta.fields(T)) |f| {
if (fake_url.getQuery(f.name)) |param| {
const F = if (comptime @typeInfo(f.field_type) == .Optional) std.meta.Child(f.field_type) else f.field_type;
switch (F) {
[]const u8 => @field(result, f.name) = param,
else => switch (@typeInfo(F)) {
.Struct,
.Opaque,
//.Union,
=> if (@hasDecl(F, "parse")) {
@compileLog(F);
if (true) @compileError(F);
@field(result, f.name) = try F.parse(param);
},
//.Int => @field(result, f.name) = try std.fmt.parseInt(F, param),
},
}
}
}
}
return result;
}
fn parseTypeFromQueryParams(comptime T: type, comptime name_prefix: []const u8, url: util.Url) !T {
var result: T = .{};
inline for (std.meta.fields(T)) |field| {
const FieldType = switch (@typeInfo(field.field_type)) {
.Optional => |info| info.child,
else => field.field_type,
};
_ = FieldType;
_ = result;
const qualified_name = name_prefix ++ field.name;
if (url.getQuery(qualified_name)) |param| {
_ = param;
}
}
}
pub fn freeRequestBody(value: anytype, alloc: std.mem.Allocator) void {
std.json.parseFree(@TypeOf(value), value, .{ .allocator = alloc });
}
pub fn getApiConn(srv: *RequestServer, ctx: *http.server.Context) !api.ApiSource.Conn {
const host = ctx.request.headers.get("Host") orelse return error.NoHost;
return authorizeApiConn(srv, ctx, host) catch |err| switch (err) {
error.NoToken => srv.api.connectUnauthorized(host, ctx.alloc),
error.InvalidToken => return error.InvalidToken,
else => @panic("TODO"), // doing this to resolve some sort of compiler analysis dependency issue
};
}
fn authorizeApiConn(srv: *RequestServer, ctx: *http.server.Context, host: []const u8) !api.ApiSource.Conn {
const header = ctx.request.headers.get("authorization") orelse return error.NoToken;
if (header.len < ("bearer ").len) return error.InvalidToken;
const token = header[("bearer ").len..];
return try srv.api.connectToken(host, token, ctx.alloc);
pub fn handler(ctx: Context(Self), response: *Response, _: api.ApiSource.Conn) !void {
try response.writeJson(.created, ctx.query);
}
};
const RequestServer = root.RequestServer;
const RouteArgs = http.RouteArgs;
pub fn Context(comptime Route: type) type {
return struct {
const Self = @This();
pub fn healthcheck(_: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void {
try utils.respondJson(ctx, .ok, .{ .status = "ok" });
pub const Args = if (@hasDecl(Route, "Args")) Route.Args else void;
pub const Body = if (@hasDecl(Route, "Body")) Route.Body else void;
pub const Query = if (@hasDecl(Route, "Query")) Route.Query else void;
allocator: std.mem.Allocator,
method: http.Method,
request_line: []const u8,
headers: http.Headers,
args: Args,
body: Body,
query: Query,
fn parseArgs(path: []const u8) ?Args {
var args: Route.Args = undefined;
var path_iter = util.PathIter.from(path);
comptime var route_iter = util.PathIter.from(Route.path);
inline while (comptime route_iter.next()) |route_segment| {
const path_segment = path_iter.next() orelse return null;
if (route_segment[0] == ':') {
@field(args, route_segment[1..]) = path_segment;
} else {
if (!std.ascii.eqlIgnoreCase(route_segment, path_segment)) return null;
}
}
return args;
}
pub fn matchAndHandle(api_source: *api.ApiSource, ctx: http.server.Context, alloc: std.mem.Allocator) bool {
const req = ctx.request;
if (req.method != Route.method) return false;
var path = std.mem.sliceTo(std.mem.sliceTo(req.path, '#'), '?');
var args: Route.Args = parseArgs(path) orelse return false;
var response = Response{ .headers = http.Headers.init(alloc), .ctx = ctx };
defer response.headers.deinit();
var self = Self{
.allocator = alloc,
.method = req.method,
.request_line = req.path,
.headers = req.headers,
.args = args,
.body = undefined,
.query = undefined,
};
self.prepareAndHandle(api_source, req, &response);
return true;
}
fn errorHandler(response: *Response, status: http.Status) void {
response.writeStatus(status) catch unreachable;
}
fn prepareAndHandle(self: *Self, api_source: anytype, req: http.Request, response: *Response) void {
self.parseBody(req) catch return errorHandler(response, .bad_request);
defer self.freeBody();
self.parseQuery() catch return errorHandler(response, .bad_request);
var api_conn = self.getApiConn(api_source) catch return errorHandler(response, .internal_server_error); // TODO
defer api_conn.close();
self.handle(response, api_conn);
}
fn parseBody(self: *Self, req: http.Request) !void {
if (Body != void) {
const body = req.body orelse return error.NoBody;
var tokens = std.json.TokenStream.init(body);
self.body = try std.json.parse(Body, &tokens, .{ .allocator = self.allocator });
}
}
fn freeBody(self: *Self) void {
if (Body != void) {
std.json.parseFree(Body, self.body, .{ .allocator = self.allocator });
}
}
fn parseQuery(self: *Self) !void {
if (Query != void) {
const path = std.mem.sliceTo(self.request_line, '?');
const q = std.mem.sliceTo(self.request_line[path.len..], '#');
self.query = try query_utils.parseQuery(Query, q);
}
}
fn handle(self: Self, response: *Response, api_conn: anytype) void {
Route.handler(self, response, api_conn) catch |err| std.log.err("{}", .{err});
}
fn getApiConn(self: *Self, api_source: anytype) !api.ApiSource.Conn {
const host = self.headers.get("Host") orelse return error.NoHost;
const auth_header = self.headers.get("Authorization");
const token = if (auth_header) |header| blk: {
const prefix = "bearer ";
if (header.len < prefix.len) break :blk null;
if (!std.ascii.eqlIgnoreCase(prefix, header[0..prefix.len])) break :blk null;
break :blk header[prefix.len..];
} else null;
if (token) |t| return try api_source.connectToken(host, t, self.allocator);
return try api_source.connectUnauthorized(host, self.allocator);
}
};
}
pub fn notFound(_: *RequestServer, ctx: *http.server.Context) void {
utils.respondError(ctx, .not_found, "Not Found");
}
pub const Response = struct {
const Self = @This();
headers: http.Headers,
ctx: http.server.Context,
pub fn internalServerError(_: *RequestServer, ctx: *http.server.Context) void {
utils.respondError(ctx, .internal_server_error, "Internal Server Error");
}
pub fn writeStatus(self: *Self, status: http.Status) !void {
var stream = try self.ctx.openResponse(&self.headers, status);
defer stream.close();
try stream.finish();
}
pub fn writeJson(self: *Self, status: http.Status, response_body: anytype) !void {
try self.headers.put("Content-Type", "application/json");
var stream = try self.ctx.openResponse(&self.headers, status);
defer stream.close();
const writer = stream.writer();
try std.json.stringify(response_body, json_options, writer);
try stream.finish();
}
};
const json_options = if (builtin.mode == .Debug)
.{
.whitespace = .{
.indent = .{ .Space = 2 },
.separator = true,
},
.string = .{ .String = .{} },
} else .{
.whitespace = .{
.indent = .None,
.separator = false,
},
.string = .{ .String = .{} },
};

View File

@ -15,19 +15,19 @@ const Route = Router.Route;
const RouteArgs = http.RouteArgs;
const router = Router{
.routes = &[_]Route{
Route.new(.GET, "/healthcheck", &c.healthcheck),
//Route.new(.GET, "/healthcheck", &c.healthcheck),
prepare(c.auth.login),
prepare(c.auth.verify_login),
//prepare(c.auth.login),
//prepare(c.auth.verify_login),
prepare(c.communities.create),
//prepare(c.communities.create),
prepare(c.invites.create),
//prepare(c.invites.create),
prepare(c.users.create),
//prepare(c.users.create),
prepare(c.notes.create),
prepare(c.notes.get),
//prepare(c.notes.create),
//prepare(c.notes.get),
//prepare(c.communities.query),
@ -72,6 +72,9 @@ pub const RequestServer = struct {
var ctx = try srv.accept(alloc);
defer ctx.close();
_ = c.Context(c.sample_api).matchAndHandle(self.api, ctx, self.alloc);
if (true) continue;
router.dispatch(self, &ctx, ctx.request.method, ctx.request.path) catch |err| switch (err) {
error.NotFound, error.RouteNotApplicable => c.notFound(self, &ctx),
else => {

View File

@ -1,53 +1,178 @@
const std = @import("std");
const ParamIter = struct {
remaining: []const u8,
target: []const u8,
fn next(self: *ParamIter) ?[]const u8 {
//
_ = self;
unreachable;
const QueryIter = @import("util").QueryIter;
/// Parses a set of query parameters described by the struct `T`.
///
/// To specify query parameters, provide a struct similar to the following:
/// ```
/// struct {
/// foo: bool = false,
/// bar: ?[]const u8 = null,
/// baz: usize = 10,
/// qux: enum { quux, snap } = .quux,
/// }
/// ```
///
/// This will allow it to parse a query string like the following:
/// `?foo&bar=abc&qux=snap`
///
/// Every parameter must have a default value that will be used when the
/// parameter is not provided, and parameter keys.
/// Numbers are parsed from their string representations, and a parameter
/// provided in the query string without a value is parsed either as a bool
/// `true` flag or as `null` depending on the type of its param.
///
/// Parameter types supported:
/// - []const u8
/// - numbers (both integer and float)
/// + Numbers are parsed in base 10
/// - bool
/// + See below for detals
/// - exhaustive enums
/// + Enums are treated as strings with values equal to the enum fields
/// - ?F (where isScalar(F) and F != bool)
/// - Any type that implements:
/// + pub fn parse([]const u8) !F
///
/// Boolean Parameters:
/// The following query strings will all parse a `true` value for the
/// parameter `foo: bool = false`:
/// - `?foo`
/// - `?foo=true`
/// - `?foo=t`
/// - `?foo=yes`
/// - `?foo=y`
/// - `?foo=1`
/// And the following query strings all parse a `false` value:
/// - `?`
/// - `?foo=false`
/// - `?foo=f`
/// - `?foo=no`
/// - `?foo=n`
/// - `?foo=0`
///
/// Compound Types:
/// Compound (struct) types are also supported, with the parameter key
/// for its parameters consisting of the struct's field + '.' + parameter
/// field. For example:
/// ```
/// struct {
/// foo: struct {
/// baz: usize = 0,
/// } = .{},
/// }
/// ```
/// Would be used to parse a query string like
/// `?foo.baz=12345`
///
/// Compound types cannot currently be nullable, and must be structs.
///
/// TODO: values are currently case-sensitive, and are not url-decoded properly.
/// This should be fixed.
pub fn parseQuery(comptime T: type, query: []const u8) !T {
//if (!std.meta.trait.isContainer(T)) @compileError("T must be a struct");
var iter = QueryIter.from(query);
var result = T{};
while (iter.next()) |pair| {
try parseQueryPair(T, &result, pair.key, pair.value);
}
};
pub fn getParam(str: []const u8, param: []const u8) !?[]const u8 {
var iter = ParamIter{ .remaining = str, .target = param };
const result = iter.next() orelse return null;
if (iter.next() != null) return error.TooMany;
return result;
}
fn isScalarType(comptime T: type) bool {
return switch (T) {
[]const u8 => true,
fn parseQueryPair(comptime T: type, result: *T, key: []const u8, value: ?[]const u8) !void {
const key_part = std.mem.sliceTo(key, '.');
const field_idx = std.meta.stringToEnum(std.meta.FieldEnum(T), key_part) orelse return error.UnknownField;
else => switch (@typeInfo(T)) {
.Int, .Float, .Bool => true,
inline for (std.meta.fields(T)) |info, idx| {
if (@enumToInt(field_idx) == idx) {
if (comptime isScalar(info.field_type)) {
if (key_part.len == key.len) {
@field(result, info.name) = try parseQueryValue(info.field_type, value);
return;
} else {
return error.UnknownField;
}
} else {
const remaining = std.mem.trimLeft(u8, key[key_part.len..], ".");
return try parseQueryPair(info.field_type, &@field(result, info.name), remaining, value);
}
}
}
.Optional => |info| isScalarType(info.child),
.Enum => |info| if (info.is_exhaustive)
true
else
@compileError("Unsupported type " ++ @typeName(T)),
return error.UnknownField;
}
.Struct => false,
else => @compileError("Unsupported type " ++ @typeName(T)),
},
fn parseQueryValue(comptime T: type, value: ?[]const u8) !T {
const is_optional = comptime std.meta.trait.is(.Optional)(T);
// If param is present, but without an associated value
if (value == null) {
return if (is_optional)
null
else if (T == bool)
true
else
error.InvalidValue;
}
return try parseQueryValueNotNull(if (is_optional) std.meta.Child(T) else T, value.?);
}
const bool_map = std.ComptimeStringMap(bool, .{
.{ "true", true },
.{ "t", true },
.{ "yes", true },
.{ "y", true },
.{ "1", true },
.{ "false", false },
.{ "f", false },
.{ "no", false },
.{ "n", false },
.{ "0", false },
});
fn parseQueryValueNotNull(comptime T: type, value: []const u8) !T {
if (comptime std.meta.trait.isZigString(T)) return value;
if (comptime std.meta.trait.isIntegral(T)) return try std.fmt.parseInt(T, value, 0);
if (comptime std.meta.trait.isFloat(T)) return try std.fmt.parseFloat(T, value);
if (comptime std.meta.trait.is(.Enum)(T)) return std.meta.stringToEnum(T, value) orelse error.InvalidEnumValue;
if (T == bool) return bool_map.get(value) orelse error.InvalidBool;
if (comptime std.meta.trait.hasFn("parse")(T)) return try T.parse(value);
@compileError("Invalid type " ++ @typeName(T));
}
fn isScalar(comptime T: type) bool {
if (comptime std.meta.trait.isZigString(T)) return true;
if (comptime std.meta.trait.isIntegral(T)) return true;
if (comptime std.meta.trait.isFloat(T)) return true;
if (comptime std.meta.trait.is(.Enum)(T)) return true;
if (T == bool) return true;
if (comptime std.meta.trait.hasFn("parse")(T)) return true;
if (comptime std.meta.trait.is(.Optional)(T) and isScalar(std.meta.Child(T))) return true;
return false;
}
test {
const TestQuery = struct {
int: usize = 3,
boolean: bool = false,
str_enum: ?enum { foo, bar } = null,
};
}
pub fn parseQueryArgs(comptime T: type, str: []const u8) !T {
var result = std.mem.zeroInit(T, .{});
_ = str;
for (std.meta.fields(T)) |field| {
const ParseType = switch (@typeInfo(field.field_type)) {
.Optional => |info| info.child,
else => field.field_type,
};
_ = ParseType;
}
return result;
try std.testing.expectEqual(TestQuery{
.int = 3,
.boolean = false,
.str_enum = null,
}, try parseQuery(TestQuery, ""));
try std.testing.expectEqual(TestQuery{
.int = 5,
.boolean = true,
.str_enum = .foo,
}, try parseQuery(TestQuery, "?int=5&boolean=yes&str_enum=foo"));
}

View File

@ -1,68 +0,0 @@
const Self = @This();
const std = @import("std");
is_first: bool,
path: []const u8,
pub fn from(path: []const u8) Self {
return .{ .path = path, .is_first = true };
}
pub fn next(self: *Self) ?[]const u8 {
if (self.path.len == 0) {
if (self.is_first) {
self.is_first = false;
return self.path;
} else {
return null;
}
}
var start: usize = 0;
var end: usize = start;
while (end < self.path.len) : (end += 1) {
// skip leading slash
if (end == start and self.path[start] == '/') {
start += 1;
continue;
} else if (self.path[end] == '/') {
break;
}
}
if (start == end) {
self.path = self.path[end..end];
self.is_first = false;
return null;
}
const result = self.path[start..end];
self.path = self.path[end..];
self.is_first = false;
return result;
}
test "PathIter /ab/cd/" {
const path = "/ab/cd/";
var it = from(path);
try std.testing.expectEqualStrings("ab", it.next().?);
try std.testing.expectEqualStrings("cd", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}
test "PathIter ''" {
const path = "";
var it = from(path);
try std.testing.expectEqualStrings("", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}
test "PathIter ab/c//defg/" {
const path = "ab/c//defg/";
var it = from(path);
try std.testing.expectEqualStrings("ab", it.next().?);
try std.testing.expectEqualStrings("c", it.next().?);
try std.testing.expectEqualStrings("defg", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}

178
src/util/iters.zig Normal file
View File

@ -0,0 +1,178 @@
const std = @import("std");
pub fn Separator(comptime separator: u8) type {
return struct {
const Self = @This();
str: []const u8,
pub fn from(str: []const u8) Self {
return .{ .str = std.mem.trim(u8, str, &.{separator}) };
}
pub fn next(self: *Self) ?[]const u8 {
if (self.str.len == 0) return null;
const part = std.mem.sliceTo(self.str, separator);
self.str = std.mem.trimLeft(u8, self.str[part.len..], &.{separator});
return part;
}
};
}
pub const QueryIter = struct {
const Pair = struct {
key: []const u8,
value: ?[]const u8,
};
iter: Separator('&'),
pub fn from(q: []const u8) QueryIter {
return QueryIter{ .iter = Separator('&').from(std.mem.trimLeft(u8, q, "?")) };
}
pub fn next(self: *QueryIter) ?Pair {
const part = self.iter.next() orelse return null;
const key = std.mem.sliceTo(part, '=');
if (key.len == part.len) return Pair{
.key = key,
.value = null,
};
return Pair{
.key = key,
.value = part[key.len + 1 ..],
};
}
};
pub const PathIter = struct {
is_first: bool,
iter: Separator('/'),
pub fn from(path: []const u8) PathIter {
return .{ .is_first = true, .iter = Separator('/').from(path) };
}
pub fn next(self: *PathIter) ?[]const u8 {
if (self.is_first) {
self.is_first = false;
return self.iter.next() orelse "";
}
return self.iter.next();
}
};
test "QueryIter" {
const t = @import("std").testing;
if (true) return error.SkipZigTest;
{
var iter = QueryIter.from("");
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?");
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?abc");
try t.expectEqual(QueryIter.Pair{
.key = "abc",
.value = null,
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?abc=");
try t.expectEqual(QueryIter.Pair{
.key = "abc",
.value = "",
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?abc=def");
try t.expectEqual(QueryIter.Pair{
.key = "abc",
.value = "def",
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?abc=def&");
try t.expectEqual(QueryIter.Pair{
.key = "abc",
.value = "def",
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?abc=def&foo&bar=baz&qux=");
try t.expectEqual(QueryIter.Pair{
.key = "abc",
.value = "def",
}, iter.next().?);
try t.expectEqual(QueryIter.Pair{
.key = "foo",
.value = null,
}, iter.next().?);
try t.expectEqual(QueryIter.Pair{
.key = "bar",
.value = "baz",
}, iter.next().?);
try t.expectEqual(QueryIter.Pair{
.key = "qux",
.value = "",
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
{
var iter = QueryIter.from("?=def&");
try t.expectEqual(QueryIter.Pair{
.key = "",
.value = "def",
}, iter.next().?);
try t.expect(iter.next() == null);
try t.expect(iter.next() == null);
}
}
test "PathIter /ab/cd/" {
const path = "/ab/cd/";
var it = PathIter.from(path);
try std.testing.expectEqualStrings("ab", it.next().?);
try std.testing.expectEqualStrings("cd", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}
test "PathIter ''" {
const path = "";
var it = PathIter.from(path);
try std.testing.expectEqualStrings("", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}
test "PathIter ab/c//defg/" {
const path = "ab/c//defg/";
var it = PathIter.from(path);
try std.testing.expectEqualStrings("ab", it.next().?);
try std.testing.expectEqualStrings("c", it.next().?);
try std.testing.expectEqualStrings("defg", it.next().?);
try std.testing.expectEqual(@as(?[]const u8, null), it.next());
}

View File

@ -1,10 +1,12 @@
const std = @import("std");
const iters = @import("./iters.zig");
pub const ciutf8 = @import("./ciutf8.zig");
pub const Uuid = @import("./Uuid.zig");
pub const DateTime = @import("./DateTime.zig");
pub const PathIter = @import("./PathIter.zig");
pub const Url = @import("./Url.zig");
pub const PathIter = iters.PathIter;
pub const QueryIter = iters.QueryIter;
/// Joins an array of strings, prefixing every entry with `prefix`,
/// and putting `separator` in between each pair