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.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
// Account endpoints:
import account.api;
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
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.
import auth.service : TokenAuthenticationFilter, SECRET;
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
@ -60,8 +71,7 @@ private void getStatus(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online");
}
private void getOptions(ref HttpRequestContext ctx) {
}
private void getOptions(ref HttpRequestContext ctx) {}
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
import util.sample_data;

View File

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

View File

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

View File

@ -2,10 +2,18 @@ module history.model;
import std.datetime.systime;
/**
* A history containing a series of items, which all usually pertain to a
* certain target entity.
*/
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 {
TEXT = "TEXT"
}
@ -18,14 +26,21 @@ HistoryItemType getHistoryItemType(string 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 {
ulong id;
ulong historyId;
SysTime timestamp;
HistoryItemType type;
immutable ulong id;
immutable ulong historyId;
immutable SysTime timestamp;
immutable HistoryItemType type;
}
/**
* Additional data for history items with the TEXT type.
*/
struct HistoryItemText {
ulong itemId;
string content;
immutable ulong itemId;
immutable string content;
}

View File

@ -125,9 +125,10 @@ class SqlitePropertiesRepository : PropertiesRepository {
ResultRange result = stmt.execute();
ProfileProperty[] props;
foreach (Row row; result) {
ProfileProperty prop;
prop.property = row.peek!string("property");
prop.value = row.peek!string("value");
ProfileProperty prop = ProfileProperty(
row.peek!string("property"),
row.peek!string("value")
);
props ~= prop;
}
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 {
string property;
string value;
immutable string property;
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.data;
import profile.data;
import util.money;
import account.model;
import util.money;
import util.pagination;
void addTransaction(
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 timestamp = Clock.currTime(UTC()) - seconds(1);
for (int i = 0; i < 1000; i++) {
for (int i = 0; i < 100; i++) {
Optional!ulong vendorId;
if (hasVendor) {
vendorId = Optional!ulong.of(choice(vendors).id);