Initial commit, basic unauthenticated score system
This commit is contained in:
commit
a818d383ec
7 changed files with 596 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
# zig build artifacts
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
|
||||
# testing artifacts
|
||||
scores/
|
97
build.zig
Normal file
97
build.zig
Normal file
|
@ -0,0 +1,97 @@
|
|||
const std = @import("std");
|
||||
|
||||
// Although this function looks imperative, note that its job is to
|
||||
// declaratively construct a build graph that will be executed by an external
|
||||
// runner.
|
||||
pub fn build(b: *std.Build) void {
|
||||
// Standard target options allows the person running `zig build` to choose
|
||||
// what target to build for. Here we do not override the defaults, which
|
||||
// means any target is allowed, and the default is native. Other options
|
||||
// for restricting supported target set are available.
|
||||
const target = b.standardTargetOptions(.{});
|
||||
|
||||
// Standard optimization options allow the person running `zig build` to select
|
||||
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
|
||||
// set a preferred release mode, allowing the user to decide how to optimize.
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "revo-scores",
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const clap = b.dependency("clap", .{});
|
||||
exe.root_module.addImport("clap", clap.module("clap"));
|
||||
|
||||
const zap = b.dependency("zap", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.openssl = false,
|
||||
});
|
||||
exe.root_module.addImport("zap", zap.module("zap"));
|
||||
|
||||
const lmdb_zig = b.dependency("lmdb-zig", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe.root_module.addImport("lmdb-zig", lmdb_zig.module("lmdb-zig-mod"));
|
||||
exe.linkLibrary(lmdb_zig.artifact("lmdb-zig"));
|
||||
|
||||
// This declares intent for the executable to be installed into the
|
||||
// standard location when the user invokes the "install" step (the default
|
||||
// step when running `zig build`).
|
||||
b.installArtifact(exe);
|
||||
|
||||
// This *creates* a Run step in the build graph, to be executed when another
|
||||
// step is evaluated that depends on it. The next line below will establish
|
||||
// such a dependency.
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
|
||||
// By making the run step depend on the install step, it will be run from the
|
||||
// installation directory rather than directly from within the cache directory.
|
||||
// This is not necessary, however, if the application depends on other installed
|
||||
// files, this ensures they will be present and in the expected location.
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
|
||||
// This allows the user to pass arguments to the application in the build
|
||||
// command itself, like this: `zig build run -- arg1 arg2 etc`
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
// This creates a build step. It will be visible in the `zig build --help` menu,
|
||||
// and can be selected like this: `zig build run`
|
||||
// This will evaluate the `run` step rather than the default, which is "install".
|
||||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const exe_unit_tests = b.addTest(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
|
||||
|
||||
// Similar to creating the run step earlier, this exposes a `test` step to
|
||||
// the `zig build --help` menu, providing a way for the user to request
|
||||
// running the unit tests.
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_exe_unit_tests.step);
|
||||
|
||||
// make zls work better
|
||||
const exe_check = b.addExecutable(.{
|
||||
.name = "revo-scores",
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_check.root_module.addImport("zap", zap.module("zap"));
|
||||
exe_check.root_module.addImport("lmdb-zig", lmdb_zig.module("lmdb-zig-mod"));
|
||||
exe_check.root_module.addImport("clap", clap.module("clap"));
|
||||
|
||||
const check = b.step("check", "Check if exe compiles");
|
||||
check.dependOn(&exe_check.step);
|
||||
}
|
47
build.zig.zon
Normal file
47
build.zig.zon
Normal file
|
@ -0,0 +1,47 @@
|
|||
.{
|
||||
// This is the default name used by packages depending on this one. For
|
||||
// example, when a user runs `zig fetch --save <url>`, this field is used
|
||||
// as the key in the `dependencies` table. Although the user can choose a
|
||||
// different name, most users will stick with this provided value.
|
||||
//
|
||||
// It is redundant to include "zig" in this name because it is already
|
||||
// within the Zig package namespace.
|
||||
.name = "revo-scores",
|
||||
|
||||
// This is a [Semantic Version](https://semver.org/).
|
||||
// In a future version of Zig it will be used for package deduplication.
|
||||
.version = "0.0.0",
|
||||
|
||||
// This field is optional.
|
||||
// This is currently advisory only; Zig does not yet do anything
|
||||
// with this value.
|
||||
//.minimum_zig_version = "0.11.0",
|
||||
|
||||
// This field is optional.
|
||||
// Each dependency must either provide a `url` and `hash`, or a `path`.
|
||||
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
|
||||
// Once all dependencies are fetched, `zig build` no longer requires
|
||||
// internet connectivity.
|
||||
.dependencies = .{
|
||||
.zap = .{
|
||||
.url = "git+https://github.com/zigzap/zap?ref=v0.9.0#9543ede15fc9ad038ec4839628c886b124580983",
|
||||
.hash = "1220bb12de9055d585ee0b19c8c9da41ad49a660f5da8be1372c7b85ba43f6e47372",
|
||||
},
|
||||
.@"lmdb-zig" = .{
|
||||
.url = "git+https://github.com/john-g4lt/lmdb-zig?ref=v0.1.0#3ec55b07424ed1f4faba18a6d7d7b4360936d4c5",
|
||||
.hash = "1220a44f2880d150589664ee936da3b856674d732cbf02b4714a7282fa752facad97",
|
||||
},
|
||||
.clap = .{
|
||||
.url = "git+https://github.com/hejsil/zig-clap?ref=0.9.1#d71cc39a94f3e6ccbad00c25d350c9147de4df9f",
|
||||
.hash = "122062d301a203d003547b414237229b09a7980095061697349f8bef41be9c30266b",
|
||||
},
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
// For example...
|
||||
//"LICENSE",
|
||||
//"README.md",
|
||||
},
|
||||
}
|
9
config.json
Normal file
9
config.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"listen_port": 3000,
|
||||
"db_path": "scores/",
|
||||
"levels": [
|
||||
"hills",
|
||||
"canopy",
|
||||
"mountain"
|
||||
]
|
||||
}
|
68
src/main.zig
Normal file
68
src/main.zig
Normal file
|
@ -0,0 +1,68 @@
|
|||
const std = @import("std");
|
||||
const json = std.json;
|
||||
|
||||
const clap = @import("clap");
|
||||
const zap = @import("zap");
|
||||
const lmdb = @import("lmdb-zig");
|
||||
|
||||
const Scores = @import("scores.zig");
|
||||
const ScoreWeb = @import("scoreweb.zig");
|
||||
|
||||
/// fallback request handler
|
||||
fn onRequest(r: zap.Request) void {
|
||||
r.setStatus(.not_found);
|
||||
r.sendBody("Invalid path!") catch return;
|
||||
}
|
||||
|
||||
pub const Config = struct {
|
||||
listen_port: usize,
|
||||
db_path: []const u8,
|
||||
levels: []const []const u8,
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{
|
||||
.thread_safe = true,
|
||||
}){};
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
// scope everything so we can detect leaks.
|
||||
{
|
||||
// load config file
|
||||
var file = try std.fs.cwd().openFile("config.json", .{});
|
||||
defer file.close();
|
||||
const json_str = try file.readToEndAlloc(allocator, 2048);
|
||||
defer allocator.free(json_str);
|
||||
const cfg_v = try json.parseFromSlice(Config, allocator, json_str, .{ .ignore_unknown_fields = true });
|
||||
defer cfg_v.deinit();
|
||||
const config = cfg_v.value;
|
||||
|
||||
// set up HTTP listener
|
||||
var listener = zap.Endpoint.Listener.init(
|
||||
allocator,
|
||||
.{
|
||||
.port = config.listen_port,
|
||||
.on_request = onRequest,
|
||||
.max_clients = 100_000,
|
||||
.max_body_size = 100 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
defer listener.deinit();
|
||||
|
||||
var score_web = try ScoreWeb.init(allocator, "/api/scores", config);
|
||||
defer score_web.deinit();
|
||||
|
||||
try listener.register(score_web.endpoint());
|
||||
|
||||
try listener.listen();
|
||||
std.debug.print("Listening on 0.0.0.0:{d}\n", .{config.listen_port});
|
||||
|
||||
zap.start(.{
|
||||
.threads = 2,
|
||||
.workers = 1,
|
||||
});
|
||||
}
|
||||
|
||||
const has_leaked = gpa.detectLeaks();
|
||||
std.log.debug("Has leaked: {}\n", .{has_leaked});
|
||||
}
|
147
src/scores.zig
Normal file
147
src/scores.zig
Normal file
|
@ -0,0 +1,147 @@
|
|||
const std = @import("std");
|
||||
const json = std.json;
|
||||
const lmdb = @import("lmdb-zig");
|
||||
const pwhash = std.crypto.pwhash;
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
env: lmdb.Env,
|
||||
players: lmdb.Db,
|
||||
levels: std.StringHashMap(lmdb.Db),
|
||||
|
||||
pub const Self = @This();
|
||||
|
||||
const ScoreAccessError = error{
|
||||
LevelNotfound,
|
||||
NoScoreForPlayer,
|
||||
};
|
||||
|
||||
pub const Difficulty = enum(u8) {
|
||||
sweet,
|
||||
salty,
|
||||
spicy,
|
||||
pungent,
|
||||
|
||||
pub fn jsonStringify(self: *const Difficulty, jw: anytype) !void {
|
||||
const n: u8 = @intFromEnum(self.*);
|
||||
try jw.write(n);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Score = packed struct {
|
||||
score: u64 = 0,
|
||||
time: u64 = 0,
|
||||
difficulty: Difficulty = .salty,
|
||||
};
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, level_ids: []const []const u8) !Self {
|
||||
const env = try lmdb.Env.init(db_path, .{
|
||||
.max_num_dbs = level_ids.len + 1,
|
||||
});
|
||||
errdefer env.deinit();
|
||||
|
||||
const tx = try env.begin(.{});
|
||||
|
||||
const players = try tx.open("players", .{ .create_if_not_exists = true });
|
||||
|
||||
var levels = std.StringHashMap(lmdb.Db).init(allocator);
|
||||
errdefer levels.deinit();
|
||||
|
||||
errdefer tx.deinit();
|
||||
for (level_ids) |lvl| {
|
||||
if (levels.contains(lvl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const db_name = try std.mem.concatWithSentinel(allocator, u8, &.{ "level/", lvl }, 0);
|
||||
defer allocator.free(db_name);
|
||||
|
||||
const db = try tx.open(db_name, .{ .create_if_not_exists = true });
|
||||
errdefer db.close(env);
|
||||
try levels.put(lvl, db);
|
||||
}
|
||||
try tx.commit();
|
||||
|
||||
return Self{
|
||||
.allocator = allocator,
|
||||
.env = env,
|
||||
.players = players,
|
||||
.levels = levels,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.levels.deinit();
|
||||
|
||||
self.players.close(self.env);
|
||||
|
||||
self.env.deinit();
|
||||
}
|
||||
|
||||
/// Get a player's score for the given level.
|
||||
pub fn getScore(self: *Self, level: []const u8, player: []const u8) !Score {
|
||||
const tx = try self.env.begin(.{ .read_only = true });
|
||||
errdefer tx.deinit();
|
||||
const db = self.levels.get(level) orelse {
|
||||
return error.LevelNotfound;
|
||||
};
|
||||
const v = tx.get(db, player) catch |err| switch (err) {
|
||||
error.not_found => return error.NoScoreForPlayer,
|
||||
else => return err,
|
||||
};
|
||||
const score = std.mem.bytesToValue(Score, v);
|
||||
try tx.commit();
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Set a player's score for the given level.
|
||||
pub fn setScore(self: *Self, level: []const u8, player: []const u8, score: Score) !void {
|
||||
const tx = try self.env.begin(.{});
|
||||
errdefer tx.deinit();
|
||||
const db = self.levels.get(level) orelse {
|
||||
return error.LevelNotFound;
|
||||
};
|
||||
try tx.put(db, player, std.mem.asBytes(&score), .{});
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
/// Delete a player's score for the given level.
|
||||
pub fn deleteScore(self: *Self, level: []const u8, player: []const u8) !void {
|
||||
const tx = try self.env.begin(.{});
|
||||
errdefer tx.deinit();
|
||||
const db = self.levels.get(level) orelse {
|
||||
return error.LevelNotFound;
|
||||
};
|
||||
try tx.del(db, player, .key);
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
pub fn listLevels(self: *Self) !std.ArrayList([]const u8) {
|
||||
var levels = try std.ArrayList([]const u8).initCapacity(self.allocator, self.levels.count());
|
||||
errdefer levels.deinit();
|
||||
|
||||
var lvl_iter = self.levels.keyIterator();
|
||||
while (lvl_iter.next()) |lvl| {
|
||||
try levels.append(lvl.*);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
pub fn listScores(self: *Self, level: []const u8) !std.StringArrayHashMap(Score) {
|
||||
var scores = std.StringArrayHashMap(Score).init(self.allocator);
|
||||
errdefer scores.deinit();
|
||||
|
||||
const tx = try self.env.begin(.{ .read_only = true });
|
||||
errdefer tx.deinit();
|
||||
const db = self.levels.get(level) orelse {
|
||||
return error.LevelNotfound;
|
||||
};
|
||||
|
||||
var cursor = try tx.cursor(db);
|
||||
while (try cursor.next_key()) |entry| {
|
||||
const score = std.mem.bytesToValue(Score, entry.val);
|
||||
try scores.put(entry.key, score);
|
||||
}
|
||||
|
||||
try tx.commit();
|
||||
return scores;
|
||||
}
|
222
src/scoreweb.zig
Normal file
222
src/scoreweb.zig
Normal file
|
@ -0,0 +1,222 @@
|
|||
const std = @import("std");
|
||||
const json = std.json;
|
||||
|
||||
const zap = @import("zap");
|
||||
|
||||
const Scores = @import("scores.zig");
|
||||
const Config = @import("main.zig").Config;
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
parent_path_parts: usize,
|
||||
scores: Scores,
|
||||
ep: zap.Endpoint,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, path: []const u8, config: Config) !Self {
|
||||
return Self{
|
||||
.allocator = allocator,
|
||||
.scores = try Scores.init(allocator, config.db_path, config.levels),
|
||||
.parent_path_parts = blk: {
|
||||
var parts: usize = 0;
|
||||
var split = std.mem.splitScalar(u8, path, '/');
|
||||
while (split.next()) |_| {
|
||||
parts += 1;
|
||||
}
|
||||
break :blk parts;
|
||||
},
|
||||
.ep = zap.Endpoint.init(.{
|
||||
.path = path,
|
||||
.get = getScores,
|
||||
.post = postScore,
|
||||
.delete = deleteScore,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.scores.deinit();
|
||||
}
|
||||
|
||||
pub fn endpoint(self: *Self) *zap.Endpoint {
|
||||
return &self.ep;
|
||||
}
|
||||
|
||||
fn partsOfPath(self: *Self, path: []const u8) !std.ArrayList([]const u8) {
|
||||
var parts = std.ArrayList([]const u8).init(self.allocator);
|
||||
errdefer parts.deinit();
|
||||
var split = std.mem.splitScalar(u8, path, '/');
|
||||
var n: usize = 0;
|
||||
while (split.next()) |part| {
|
||||
if (n >= self.parent_path_parts and part.len > 0) {
|
||||
try parts.append(part);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
fn serverError(r: zap.Request, body: []const u8) void {
|
||||
r.setStatus(.internal_server_error);
|
||||
r.sendBody(body) catch return;
|
||||
}
|
||||
|
||||
fn notFound(r: zap.Request, body: []const u8) void {
|
||||
r.setStatus(.not_found);
|
||||
r.sendBody(body) catch return;
|
||||
}
|
||||
|
||||
fn badRequest(r: zap.Request, body: []const u8) void {
|
||||
r.setStatus(.bad_request);
|
||||
r.sendBody(body) catch return;
|
||||
}
|
||||
|
||||
fn getScores(e: *zap.Endpoint, r: zap.Request) void {
|
||||
const self: *Self = @fieldParentPtr("ep", e);
|
||||
|
||||
if (r.path) |path| {
|
||||
const parts = self.partsOfPath(path) catch {
|
||||
return serverError(r, "Path processing error.");
|
||||
};
|
||||
defer parts.deinit();
|
||||
|
||||
switch (parts.items.len) {
|
||||
0 => return self.listLevels(r),
|
||||
1 => return self.listScores(r, parts.items[0]),
|
||||
2 => return self.getScore(r, parts.items[0], parts.items[1]),
|
||||
else => return badRequest(r, "Invalid request."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn postScore(e: *zap.Endpoint, r: zap.Request) void {
|
||||
const self: *Self = @fieldParentPtr("ep", e);
|
||||
|
||||
if (r.path) |path| {
|
||||
const parts = self.partsOfPath(path) catch {
|
||||
return serverError(r, "Path processing error.");
|
||||
};
|
||||
defer parts.deinit();
|
||||
|
||||
if (parts.items.len != 5) {
|
||||
return badRequest(r, "Invalid request.");
|
||||
}
|
||||
|
||||
const level = parts.items[0];
|
||||
const player = parts.items[1];
|
||||
const score = parts.items[2];
|
||||
const time = parts.items[3];
|
||||
const difficulty = parts.items[4];
|
||||
|
||||
const new_score = Scores.Score{
|
||||
.score = std.fmt.parseInt(u64, score, 10) catch {
|
||||
return badRequest(r, "Invalid score value.");
|
||||
},
|
||||
.time = std.fmt.parseInt(u64, time, 10) catch {
|
||||
return badRequest(r, "Invalid time value.");
|
||||
},
|
||||
.difficulty = blk: {
|
||||
const n = std.fmt.parseInt(u8, difficulty, 10) catch {
|
||||
return badRequest(r, "Invalid difficulty value.");
|
||||
};
|
||||
|
||||
if (n > @intFromEnum(Scores.Difficulty.pungent)) {
|
||||
return badRequest(r, "Invalid difficulty value.");
|
||||
}
|
||||
|
||||
break :blk @enumFromInt(n);
|
||||
},
|
||||
};
|
||||
|
||||
// const old_score = switch (self.scores.getScore(level, player)) {
|
||||
// .ok => |s| s,
|
||||
// .err => |err| switch (err) {
|
||||
// error.NoScoreForPlayer => Scores.Score{},
|
||||
// error.LevelNotfound => return notFound(r, "Level not found."),
|
||||
// else => return serverError(r, "Error getting score."),
|
||||
// },
|
||||
// };
|
||||
|
||||
const old_score = self.scores.getScore(level, player) catch |err| switch (err) {
|
||||
error.NoScoreForPlayer => {
|
||||
self.scores.setScore(level, player, new_score) catch {
|
||||
return serverError(r, "Error setting score.");
|
||||
};
|
||||
r.setStatus(.accepted);
|
||||
return r.sendBody("Score set!") catch return;
|
||||
},
|
||||
error.LevelNotfound => return notFound(r, "Level not found."),
|
||||
else => return serverError(r, "Error getting score."),
|
||||
};
|
||||
|
||||
if (new_score.score < old_score.score) {
|
||||
return;
|
||||
}
|
||||
if (new_score.score == old_score.score) {
|
||||
if (@intFromEnum(new_score.difficulty) < @intFromEnum(old_score.difficulty)) {
|
||||
return;
|
||||
}
|
||||
if (new_score.time >= old_score.time) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.scores.setScore(level, player, new_score) catch {
|
||||
return serverError(r, "Error setting score.");
|
||||
};
|
||||
r.setStatus(.accepted);
|
||||
return r.sendBody("Score set!") catch return;
|
||||
}
|
||||
}
|
||||
|
||||
fn deleteScore(e: *zap.Endpoint, r: zap.Request) void {
|
||||
_ = e;
|
||||
_ = r;
|
||||
}
|
||||
|
||||
fn listLevels(self: *Self, r: zap.Request) void {
|
||||
const levels = self.scores.listLevels() catch {
|
||||
return serverError(r, "Error listing levels.");
|
||||
};
|
||||
defer levels.deinit();
|
||||
|
||||
const str = json.stringifyAlloc(self.allocator, levels.items, .{}) catch {
|
||||
return serverError(r, "Error listing levels.");
|
||||
};
|
||||
defer self.allocator.free(str);
|
||||
|
||||
r.sendJson(str) catch return;
|
||||
}
|
||||
|
||||
fn listScores(self: *Self, r: zap.Request, level: []const u8) void {
|
||||
var scores = self.scores.listScores(level) catch |err| switch (err) {
|
||||
error.LevelNotfound => return notFound(r, "Level not found."),
|
||||
else => return serverError(r, "Error listing scores."),
|
||||
};
|
||||
defer scores.deinit();
|
||||
|
||||
var scores_json = json.ArrayHashMap(Scores.Score){ .map = scores.unmanaged };
|
||||
var str = std.ArrayList(u8).init(self.allocator);
|
||||
defer str.deinit();
|
||||
var write_stream = json.writeStream(str.writer(), .{});
|
||||
scores_json.jsonStringify(&write_stream) catch {
|
||||
return serverError(r, "Error listing scores.");
|
||||
};
|
||||
|
||||
r.sendJson(str.items) catch return;
|
||||
}
|
||||
|
||||
fn getScore(self: *Self, r: zap.Request, level: []const u8, player: []const u8) void {
|
||||
const score = self.scores.getScore(level, player) catch |err| switch (err) {
|
||||
error.LevelNotfound => return notFound(r, "Level not found."),
|
||||
error.NoScoreForPlayer => return notFound(r, "No score for player."),
|
||||
else => return serverError(r, "Error getting score."),
|
||||
};
|
||||
|
||||
const str = json.stringifyAlloc(self.allocator, score, .{}) catch {
|
||||
return serverError(r, "Error getting score.");
|
||||
};
|
||||
defer self.allocator.free(str);
|
||||
|
||||
r.sendJson(str) catch return;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue