Added pagination.

This commit is contained in:
Andrew Lalis 2024-10-01 14:06:10 -04:00
parent 3fa2938f48
commit 33089b3b75
10 changed files with 257 additions and 28 deletions

View File

@ -39,12 +39,23 @@ PathHandler mapApiHandlers() {
a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile); a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties); a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
// Account endpoints:
import account.api; import account.api;
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount); a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount); a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount); a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
import transaction.api;
// Transaction vendor endpoints:
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors", &getVendors);
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
a.addMapping(Method.POST, PROFILE_PATH ~ "/vendors", &createVendor);
a.addMapping(Method.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
a.addMapping(Method.GET, PROFILE_PATH ~ "/transactions", &getTransactions);
// Protect all authenticated paths with a token filter. // Protect all authenticated paths with a token filter.
import auth.service : TokenAuthenticationFilter, SECRET; import auth.service : TokenAuthenticationFilter, SECRET;
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET); HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
@ -60,8 +71,7 @@ private void getStatus(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online"); ctx.response.writeBodyString("online");
} }
private void getOptions(ref HttpRequestContext ctx) { private void getOptions(ref HttpRequestContext ctx) {}
}
private void sampleDataEndpoint(ref HttpRequestContext ctx) { private void sampleDataEndpoint(ref HttpRequestContext ctx) {
import util.sample_data; import util.sample_data;

View File

@ -8,8 +8,8 @@ module auth.model;
* or more profiles. * or more profiles.
*/ */
struct User { struct User {
const string username; immutable string username;
const string passwordHash; immutable string passwordHash;
} }
/** /**

View File

@ -68,19 +68,16 @@ SQL";
} }
static HistoryItem parseItem(Row row) { static HistoryItem parseItem(Row row) {
HistoryItem item; return HistoryItem(
item.id = row.peek!ulong(0); row.peek!ulong(0),
item.historyId = row.peek!ulong(1); row.peek!ulong(1),
item.timestamp = SysTime.fromISOExtString(row.peek!string(2)); parseISOTimestamp(row, 2),
item.type = getHistoryItemType(row.peek!string(3)); getHistoryItemType(row.peek!(string, PeekMode.slice)(3))
return item; );
} }
static HistoryItemText parseTextItem(Row row) { static HistoryItemText parseTextItem(Row row) {
HistoryItemText item; return HistoryItemText(row.peek!ulong(0), row.peek!string(1));
item.itemId = row.peek!ulong(0);
item.content = row.peek!string(1);
return item;
} }
private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) { private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) {

View File

@ -2,10 +2,18 @@ module history.model;
import std.datetime.systime; import std.datetime.systime;
/**
* A history containing a series of items, which all usually pertain to a
* certain target entity.
*/
struct History { struct History {
ulong id; immutable ulong id;
} }
/**
* The type of history item. This can be used as a discriminator value to treat
* different history types separately.
*/
enum HistoryItemType : string { enum HistoryItemType : string {
TEXT = "TEXT" TEXT = "TEXT"
} }
@ -18,14 +26,21 @@ HistoryItemType getHistoryItemType(string text) {
throw new Exception("Unknown history item type: " ~ text); throw new Exception("Unknown history item type: " ~ text);
} }
/**
* A single item in a history. It has a UTC timestamp and a type. From the type,
* one can get more specific information.
*/
struct HistoryItem { struct HistoryItem {
ulong id; immutable ulong id;
ulong historyId; immutable ulong historyId;
SysTime timestamp; immutable SysTime timestamp;
HistoryItemType type; immutable HistoryItemType type;
} }
/**
* Additional data for history items with the TEXT type.
*/
struct HistoryItemText { struct HistoryItemText {
ulong itemId; immutable ulong itemId;
string content; immutable string content;
} }

View File

@ -125,9 +125,10 @@ class SqlitePropertiesRepository : PropertiesRepository {
ResultRange result = stmt.execute(); ResultRange result = stmt.execute();
ProfileProperty[] props; ProfileProperty[] props;
foreach (Row row; result) { foreach (Row row; result) {
ProfileProperty prop; ProfileProperty prop = ProfileProperty(
prop.property = row.peek!string("property"); row.peek!string("property"),
prop.value = row.peek!string("value"); row.peek!string("value")
);
props ~= prop; props ~= prop;
} }
return props; return props;

View File

@ -22,7 +22,12 @@ class Profile {
} }
} }
/**
* A string-based key-value-pair used to store an arbitrary piece of
* information in a profile's database, intended for settings and other one-off
* data that a normal table would be overkill for.
*/
struct ProfileProperty { struct ProfileProperty {
string property; immutable string property;
string value; immutable string value;
} }

View File

@ -0,0 +1,97 @@
module transaction.api;
import handy_httpd;
import transaction.model;
import transaction.data;
import transaction.service;
import profile.data;
import profile.service;
import util.json;
import util.money;
import util.pagination;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]);
void getTransactions(ref HttpRequestContext ctx) {
ProfileDataSource ds = getProfileDataSource(ctx);
PageRequest pr = PageRequest.parse(ctx, DEFAULT_TRANSACTION_PAGE);
Page!Transaction page = searchTransactions(ds, pr);
}
void getVendors(ref HttpRequestContext ctx) {
ProfileDataSource ds = getProfileDataSource(ctx);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor[] vendors = vendorRepo.findAll();
writeJsonBody(ctx, vendors);
}
void getVendor(ref HttpRequestContext ctx) {
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor vendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(ctx, vendor);
}
struct VendorPayload {
string name;
string description;
}
void createVendor(ref HttpRequestContext ctx) {
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
ProfileDataSource ds = getProfileDataSource(ctx);
auto vendorRepo = ds.getTransactionVendorRepository();
if (vendorRepo.existsByName(payload.name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Vendor name is already in use.");
return;
}
TransactionVendor vendor = vendorRepo.insert(payload.name, payload.description);
writeJsonBody(ctx, vendor);
}
void updateVendor(ref HttpRequestContext ctx) {
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor existingVendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
if (payload.name != existingVendor.name && vendorRepo.existsByName(payload.name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Vendor name is already in use.");
}
TransactionVendor updated = vendorRepo.updateById(
vendorId,
payload.name,
payload.description
);
writeJsonBody(ctx, updated);
}
void deleteVendor(ref HttpRequestContext ctx) {
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
auto vendorRepo = ds.getTransactionVendorRepository();
vendorRepo.deleteById(vendorId);
}

View File

@ -6,8 +6,9 @@ import std.datetime;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
import profile.data; import profile.data;
import util.money;
import account.model; import account.model;
import util.money;
import util.pagination;
void addTransaction( void addTransaction(
ProfileDataSource ds, ProfileDataSource ds,
@ -60,3 +61,7 @@ void addTransaction(
} }
}); });
} }
Page!Transaction searchTransactions(ProfileDataSource ds, PageRequest pr) {
}

View File

@ -0,0 +1,99 @@
module util.pagination;
import handy_httpd;
import handy_httpd.components.multivalue_map;
import handy_httpd.components.optional;
import std.conv;
enum SortDir : string {
ASC = "ASC",
DESC = "DESC"
}
struct Sort {
immutable string attribute;
immutable SortDir dir;
static Optional!Sort parse(string expr) {
import std.string;
string[] parts = expr.split(",");
if (parts.length == 1) return Optional!Sort.of(Sort(parts[0], SortDir.ASC));
if (parts.length != 2) return Optional!Sort.empty;
string attr = parts[0];
string dirExpr = parts[1];
SortDir d;
if (dirExpr == SortDir.ASC) {
d = SortDir.ASC;
} else if (dirExpr == SortDir.DESC) {
d = SortDir.DESC;
} else {
return Optional!Sort.empty;
}
return Optional!Sort.of(Sort(attr, d));
}
}
struct PageRequest {
immutable uint page;
immutable ushort size;
immutable Sort[] sorts;
bool isUnpaged() const {
return page < 1;
}
static PageRequest unpaged() {
return PageRequest(0, 0, []);
}
static PageRequest parse(ref HttpRequestContext ctx, PageRequest defaults) {
import std.algorithm;
import std.array;
const(StringMultiValueMap) params = ctx.request.queryParams;
uint pg = ctx.request.getParamAs!uint("page", defaults.page);
ushort sz = ctx.request.getParamAs!ushort("size", defaults.size);
Sort[] s = params.getAll("sort")
.map!(Sort.parse)
.filter!(o => !o.isNull)
.map!(o => o.value)
.array;
return PageRequest(pg, sz, s.idup);
}
string toSql() const {
import std.array;
auto app = appender!string;
if (sorts.length > 0) {
app ~= "ORDER BY ";
for (size_t i = 0; i < sorts.length; i++) {
app ~= sorts[i].attribute;
app ~= " ";
app ~= cast(string) sorts[i].dir;
if (i + 1 < sorts.length) app ~= ",";
}
app ~= " ";
}
app ~= "LIMIT ";
app ~= size.to!string;
app ~= " OFFSET ";
app ~= page.to!string;
return app[];
}
PageRequest next() const {
if (isUnpaged) return this;
return PageRequest(page + 1, size, sorts);
}
PageRequest prev() const {
if (isUnpaged) return this;
return PageRequest(page - 1, size, sorts);
}
}
struct Page(T) {
T[] items;
PageRequest pageRequest;
}

View File

@ -112,7 +112,7 @@ void generateRandomTransactions(ProfileDataSource ds) {
SysTime now = Clock.currTime(UTC()); SysTime now = Clock.currTime(UTC());
SysTime timestamp = Clock.currTime(UTC()) - seconds(1); SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
for (int i = 0; i < 1000; i++) { for (int i = 0; i < 100; i++) {
Optional!ulong vendorId; Optional!ulong vendorId;
if (hasVendor) { if (hasVendor) {
vendorId = Optional!ulong.of(choice(vendors).id); vendorId = Optional!ulong.of(choice(vendors).id);