From 33089b3b758a457837da83828792c32ae1ad12ee Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Tue, 1 Oct 2024 14:06:10 -0400 Subject: [PATCH] Added pagination. --- finnow-api/source/api_mapping.d | 14 ++- finnow-api/source/auth/model.d | 4 +- finnow-api/source/history/data_impl_sqlite.d | 17 ++-- finnow-api/source/history/model.d | 29 ++++-- finnow-api/source/profile/data_impl_sqlite.d | 7 +- finnow-api/source/profile/model.d | 9 +- finnow-api/source/transaction/api.d | 97 +++++++++++++++++++ finnow-api/source/transaction/service.d | 7 +- finnow-api/source/util/pagination.d | 99 ++++++++++++++++++++ finnow-api/source/util/sample_data.d | 2 +- 10 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 finnow-api/source/transaction/api.d create mode 100644 finnow-api/source/util/pagination.d diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 0aa3531..c2504aa 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -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; diff --git a/finnow-api/source/auth/model.d b/finnow-api/source/auth/model.d index 739873d..046a5df 100644 --- a/finnow-api/source/auth/model.d +++ b/finnow-api/source/auth/model.d @@ -8,8 +8,8 @@ module auth.model; * or more profiles. */ struct User { - const string username; - const string passwordHash; + immutable string username; + immutable string passwordHash; } /** diff --git a/finnow-api/source/history/data_impl_sqlite.d b/finnow-api/source/history/data_impl_sqlite.d index 95e3f3b..b0ea7f6 100644 --- a/finnow-api/source/history/data_impl_sqlite.d +++ b/finnow-api/source/history/data_impl_sqlite.d @@ -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) { diff --git a/finnow-api/source/history/model.d b/finnow-api/source/history/model.d index 5ba850e..84e914e 100644 --- a/finnow-api/source/history/model.d +++ b/finnow-api/source/history/model.d @@ -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; } diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 02defe8..dae0b20 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -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; diff --git a/finnow-api/source/profile/model.d b/finnow-api/source/profile/model.d index 7952f8a..741afd7 100644 --- a/finnow-api/source/profile/model.d +++ b/finnow-api/source/profile/model.d @@ -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; } diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d new file mode 100644 index 0000000..2130230 --- /dev/null +++ b/finnow-api/source/transaction/api.d @@ -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); +} + + diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 1edc496..9c34416 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -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) { + +} diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d new file mode 100644 index 0000000..883aece --- /dev/null +++ b/finnow-api/source/util/pagination.d @@ -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; +} diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 7c95498..36d0187 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -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);