Added pagination.
This commit is contained in:
parent
3fa2938f48
commit
33089b3b75
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in New Issue