Refactored to use @PathMapping everywhere, and add rate-limiter
Build and Deploy API / build-and-deploy (push) Successful in 1m45s Details

This commit is contained in:
andrewlalis 2026-01-14 13:45:03 -05:00
parent 7ef9d63de7
commit a142a847da
10 changed files with 168 additions and 92 deletions

View File

@ -11,6 +11,7 @@ import std.datetime;
import profile.service; import profile.service;
import profile.data; import profile.data;
import profile.api : PROFILE_PATH;
import account.model; import account.model;
import account.service; import account.service;
import account.dto; import account.dto;
@ -21,6 +22,9 @@ import account.data;
import attachment.data; import attachment.data;
import attachment.dto; import attachment.dto;
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/accounts")
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm; import std.algorithm;
import std.array; import std.array;
@ -30,6 +34,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res
writeJsonBody(response, accounts); writeJsonBody(response, accounts);
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH)
void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
@ -38,6 +43,7 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id))); writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/accounts")
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
@ -54,6 +60,7 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id))); writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
} }
@PathMapping(HttpMethod.PUT, ACCOUNT_PATH)
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
@ -73,12 +80,14 @@ void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id))); writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id)));
} }
@PathMapping(HttpMethod.DELETE, ACCOUNT_PATH)
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
ds.getAccountRepository().deleteById(accountId); ds.getAccountRepository().deleteById(accountId);
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/balance")
void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
@ -96,6 +105,7 @@ void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpRespon
} }
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/history")
void handleGetAccountHistory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAccountHistory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamOrThrow!ulong("accountId"); ulong accountId = request.getPathParamOrThrow!ulong("accountId");
PageRequest pagination = PageRequest.parse(request, PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)])); PageRequest pagination = PageRequest.parse(request, PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]));
@ -132,6 +142,7 @@ private void writeHistoryResponse(ref ServerHttpResponse response, in Page!Accou
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON); response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/account-balances")
void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
auto balances = getTotalBalanceForAllAccounts(ds); auto balances = getTotalBalanceForAllAccounts(ds);
@ -142,6 +153,7 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records")
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
@ -152,6 +164,7 @@ void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, page); writeJsonBody(response, page);
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong")
void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId"); ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
@ -162,6 +175,7 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo)); writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
} }
@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records")
void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -198,6 +212,7 @@ void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpRespon
); );
} }
@PathMapping(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong")
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId"); ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId"); ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");

View File

@ -1,14 +1,19 @@
module analytics.api; module analytics.api;
import handy_http_primitives; import handy_http_primitives;
import handy_http_handlers.path_handler : PathMapping;
import profile.data; import profile.data;
import profile.service; import profile.service;
import profile.api : PROFILE_PATH;
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/analytics/balance-time-series")
void handleGetBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
serveJsonFromProperty(response, ds, "analytics.balanceTimeSeries"); serveJsonFromProperty(response, ds, "analytics.balanceTimeSeries");
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/analytics/category-spend-time-series")
void handleGetCategorySpendTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetCategorySpendTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request); auto ds = getProfileDataSource(request);
serveJsonFromProperty(response, ds, "analytics.categorySpendTimeSeries"); serveJsonFromProperty(response, ds, "analytics.categorySpendTimeSeries");

View File

@ -5,9 +5,6 @@ import handy_http_handlers.path_handler;
import handy_http_handlers.filtered_handler; import handy_http_handlers.filtered_handler;
import slf4d; import slf4d;
/// The base path to all API endpoints.
private const API_PATH = "/api";
/** /**
* Defines the Finnow API mapping with a main PathHandler. * Defines the Finnow API mapping with a main PathHandler.
* Params: * Params:
@ -15,92 +12,41 @@ private const API_PATH = "/api";
* Returns: The handler to plug into an HttpServer. * Returns: The handler to plug into an HttpServer.
*/ */
HttpRequestHandler mapApiHandlers(string webOrigin) { HttpRequestHandler mapApiHandlers(string webOrigin) {
PathHandler h = new PathHandler(); PathHandler publicHandler = new PathHandler();
PathHandler authenticatedHandler = new PathHandler();
// Generic, public endpoints: // Public endpoints:
h.map(HttpMethod.GET, "/status", &getStatus); publicHandler.addMapping(HttpMethod.GET, "/api/status", HttpRequestHandler.of(&getStatus));
h.map(HttpMethod.OPTIONS, "/**", &getOptions); publicHandler.addMapping(HttpMethod.OPTIONS, "/**", HttpRequestHandler.of(&getOptions));
// Note: the download endpoint is public! We authenticate via token in query params instead of header here.
import attachment.api;
publicHandler.registerHandlers!(attachment.api);
import auth.api_public;
publicHandler.registerHandlers!(auth.api_public);
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
// h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); // h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint);
// Auth endpoints:
import auth.api;
import auth.api_public;
h.registerHandlers!(auth.api_public);
// Authenticated endpoints: // Authenticated endpoints:
PathHandler a = new PathHandler(); import auth.api;
a.registerHandlers!(auth.api); authenticatedHandler.registerHandlers!(auth.api);
import profile.api; import profile.api;
a.map(HttpMethod.GET, "/profiles", &handleGetProfiles); authenticatedHandler.registerHandlers!(profile.api);
a.map(HttpMethod.POST, "/profiles", &handleCreateNewProfile);
/// URL path to a specific profile, with the :profile path parameter.
const PROFILE_PATH = "/profiles/:profile";
a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile);
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/download", &handleDownloadProfile);
import attachment.api;
// Note: the download endpoint is public! We authenticate via token in query params instead of header here.
h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment);
// Account endpoints:
import account.api; import account.api;
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); authenticatedHandler.registerHandlers!(account.api);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/account-balances", &handleGetTotalBalances);
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/balance", &handleGetAccountBalance);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/history", &handleGetAccountHistory);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records", &handleGetValueRecords);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
import transaction.api; import transaction.api;
// Transaction vendor endpoints: authenticatedHandler.registerHandlers!(transaction.api);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &handleGetVendors);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleGetVendor);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor);
// Transaction category endpoints:
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong/children", &handleGetChildCategories);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory);
// Transaction endpoints:
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search", &handleSearchTransactions);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleGetTransaction);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleDeleteTransaction);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags", &handleGetAllTags);
// Analytics endpoints:
import analytics.api; import analytics.api;
a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/balance-time-series", &handleGetBalanceTimeSeries); authenticatedHandler.registerHandlers!(analytics.api);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/category-spend-time-series", &handleGetCategorySpendTimeSeries);
import data_api; import data_api;
// Various other data endpoints: authenticatedHandler.registerHandlers!(data_api);
a.map(HttpMethod.GET, "/currencies", &handleGetCurrencies);
// Protect all authenticated paths with a filter. // Protect all authenticated paths with a filter.
import auth.service : AuthenticationFilter; import auth.service : AuthenticationFilter;
HttpRequestFilter authenticationFilter = new AuthenticationFilter(); HttpRequestFilter authenticationFilter = new AuthenticationFilter();
h.addMapping(API_PATH ~ "/**", new FilteredHandler( publicHandler.addMapping("/api/**", new FilteredHandler(
[authenticationFilter], [authenticationFilter],
a authenticatedHandler
)); ));
// Build the main handler into a filter chain: // Build the main handler into a filter chain:
@ -108,9 +54,10 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
[ [
cast(HttpRequestFilter) new CorsFilter(webOrigin), cast(HttpRequestFilter) new CorsFilter(webOrigin),
cast(HttpRequestFilter) new ContentLengthFilter(), cast(HttpRequestFilter) new ContentLengthFilter(),
cast(HttpRequestFilter) new TokenBucketRateLimitingFilter(10, 50),
cast(HttpRequestFilter) new ExceptionHandlingFilter() cast(HttpRequestFilter) new ExceptionHandlingFilter()
], ],
h publicHandler
); );
} }
@ -137,15 +84,6 @@ private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpRes
info("Started new thread to generate sample data."); info("Started new thread to generate sample data.");
} }
private void map(
PathHandler handler,
HttpMethod method,
string subPath,
void function(ref ServerHttpRequest, ref ServerHttpResponse) fn
) {
handler.addMapping(method, API_PATH ~ subPath, HttpRequestHandler.of(fn));
}
/** /**
* A filter that adds CORS response headers. * A filter that adds CORS response headers.
*/ */
@ -216,3 +154,90 @@ private class ExceptionHandlingFilter : HttpRequestFilter {
} }
} }
} }
/**
* A filter that uses a shared token bucket to limit clients' requests. Each
* client's IP address is used as the identifier, and each client is given a
* maximum of N requests to make, and a rate at which that limit replenishes
* over time.
*/
private class TokenBucketRateLimitingFilter : HttpRequestFilter {
import std.datetime;
import std.math : floor;
import std.algorithm : min;
private static struct TokenBucket {
uint tokens;
SysTime lastRequest;
}
TokenBucket[string] tokenBuckets;
const uint tokensPerSecond;
const uint maxTokens;
this(uint tokensPerSecond, uint maxTokens) {
this.tokensPerSecond = tokensPerSecond;
this.maxTokens = maxTokens;
}
void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) {
string clientAddr = getClientId(request);
bool shouldBlockRequest = false;
synchronized {
const now = Clock.currTime();
TokenBucket* bucket = getOrCreateBucket(clientAddr, now);
incrementTokensForElapsedTime(bucket, now);
if (bucket.tokens < 1) {
shouldBlockRequest = true;
} else {
bucket.tokens--;
}
bucket.lastRequest = now;
clearOldBuckets();
}
if (shouldBlockRequest) {
infoF!"Rate-limiting client %s because they have made too many requests."(clientAddr);
response.status = HttpStatus.TOO_MANY_REQUESTS;
response.writeBodyString("You have made too many requests to the API.");
} else {
filterChain.doFilter(request, response);
}
}
private string getClientId(in ServerHttpRequest req) {
import handy_http_transport.helpers : indexOf;
string clientAddr = req.clientAddress.toString();
auto portIdx = indexOf(clientAddr, ':');
if (portIdx != -1) {
clientAddr = clientAddr[0..portIdx];
}
return clientAddr;
}
private TokenBucket* getOrCreateBucket(string clientAddr, SysTime now) {
TokenBucket* bucket = clientAddr in tokenBuckets;
if (bucket is null) {
tokenBuckets[clientAddr] = TokenBucket(maxTokens, now);
bucket = clientAddr in tokenBuckets;
}
return bucket;
}
private void incrementTokensForElapsedTime(TokenBucket* bucket, SysTime now) {
Duration timeSinceLastRequest = now - bucket.lastRequest;
const tokensAddedSinceLastRequest = floor((timeSinceLastRequest.total!"msecs") * (tokensPerSecond / 1000.0));
bucket.tokens = cast(uint) min(bucket.tokens + tokensAddedSinceLastRequest, maxTokens);
}
private void clearOldBuckets() {
const Duration fillTime = seconds(maxTokens * tokensPerSecond);
foreach (id; tokenBuckets.byKey()) {
TokenBucket bucket = tokenBuckets[id];
const Duration timeSinceLastRequest = Clock.currTime() - bucket.lastRequest;
if (timeSinceLastRequest > fillTime) {
tokenBuckets.remove(id);
}
}
}
}

View File

@ -18,7 +18,7 @@ void main() {
void configureSlf4d(in AppConfig config) { void configureSlf4d(in AppConfig config) {
Level logLevel = getConfiguredLoggingLevel(config); Level logLevel = getConfiguredLoggingLevel(config);
logLevel = Levels.DEBUG; // logLevel = Levels.DEBUG;
auto provider = new DefaultProvider(logLevel); auto provider = new DefaultProvider(logLevel);
configureLoggingProvider(provider); configureLoggingProvider(provider);
} }

View File

@ -2,6 +2,7 @@ module attachment.api;
import handy_http_primitives; import handy_http_primitives;
import handy_http_data.json; import handy_http_data.json;
import handy_http_handlers.path_handler : PathMapping;
import std.conv; import std.conv;
import profile.data; import profile.data;
@ -22,6 +23,7 @@ import attachment.model;
* request = The HTTP request. * request = The HTTP request.
* response = The HTTP response. * response = The HTTP response.
*/ */
@PathMapping(HttpMethod.GET, "/api/profiles/:profile/attachments/:attachmentId/download")
void handleDownloadAttachment(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDownloadAttachment(ref ServerHttpRequest request, ref ServerHttpResponse response) {
Optional!AuthContext authCtx = extractAuthContextFromQueryParam(request, response); Optional!AuthContext authCtx = extractAuthContextFromQueryParam(request, response);
if (authCtx.isNull) return; if (authCtx.isNull) return;

View File

@ -11,13 +11,13 @@ import auth.data;
import auth.service; import auth.service;
import auth.data_impl_fs; import auth.data_impl_fs;
@PathMapping(HttpMethod.GET, "/me") @PathMapping(HttpMethod.GET, "/api/me")
void getMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) { void getMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request); AuthContext auth = getAuthContext(request);
response.writeBodyString(auth.user.username); response.writeBodyString(auth.user.username);
} }
@PathMapping(HttpMethod.DELETE, "/me") @PathMapping(HttpMethod.DELETE, "/api/me")
void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) { void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request); AuthContext auth = getAuthContext(request);
UserRepository userRepo = new FileSystemUserRepository(); UserRepository userRepo = new FileSystemUserRepository();
@ -25,7 +25,7 @@ void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response
infoF!"Deleted user: %s"(auth.user.username); infoF!"Deleted user: %s"(auth.user.username);
} }
@PathMapping(HttpMethod.GET, "/me/token") @PathMapping(HttpMethod.GET, "/api/me/token")
void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response) { void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request); AuthContext auth = getAuthContext(request);
string token = generateTokenForUser(auth.user); string token = generateTokenForUser(auth.user);
@ -38,7 +38,7 @@ struct PasswordChangeRequest {
string newPassword; string newPassword;
} }
@PathMapping(HttpMethod.POST, "/me/password") @PathMapping(HttpMethod.POST, "/api/me/password")
void changeMyPassword(ref ServerHttpRequest request, ref ServerHttpResponse response) { void changeMyPassword(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request); AuthContext auth = getAuthContext(request);
PasswordChangeRequest data = readJsonBodyAs!PasswordChangeRequest(request); PasswordChangeRequest data = readJsonBodyAs!PasswordChangeRequest(request);

View File

@ -10,7 +10,7 @@ import auth.data;
import auth.service; import auth.service;
import auth.data_impl_fs; import auth.data_impl_fs;
@PathMapping(HttpMethod.POST, "/login") @PathMapping(HttpMethod.POST, "/api/login")
void postLogin(ref ServerHttpRequest request, ref ServerHttpResponse response) { void postLogin(ref ServerHttpRequest request, ref ServerHttpResponse response) {
struct LoginData { struct LoginData {
string username; string username;
@ -26,7 +26,7 @@ struct UsernameAvailabilityResponse {
const bool available; const bool available;
} }
@PathMapping(HttpMethod.GET, "/register/username-availability") @PathMapping(HttpMethod.GET, "/api/register/username-availability")
void getUsernameAvailability(ref ServerHttpRequest request, ref ServerHttpResponse response) { void getUsernameAvailability(ref ServerHttpRequest request, ref ServerHttpResponse response) {
string username = null; string username = null;
foreach (param; request.queryParams) { foreach (param; request.queryParams) {
@ -50,7 +50,7 @@ struct RegistrationData {
string password; string password;
} }
@PathMapping(HttpMethod.POST, "/register") @PathMapping(HttpMethod.POST, "/api/register")
void postRegister(ref ServerHttpRequest request, ref ServerHttpResponse response) { void postRegister(ref ServerHttpRequest request, ref ServerHttpResponse response) {
RegistrationData registrationData = readJsonBodyAs!RegistrationData(request); RegistrationData registrationData = readJsonBodyAs!RegistrationData(request);
if (!validateUsername(registrationData.username)) { if (!validateUsername(registrationData.username)) {

View File

@ -1,9 +1,11 @@
module data_api; module data_api;
import handy_http_primitives; import handy_http_primitives;
import handy_http_handlers.path_handler : PathMapping;
import handy_http_data; import handy_http_data;
import util.money; import util.money;
@PathMapping(HttpMethod.GET, "/api/currencies")
void handleGetCurrencies(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetCurrencies(ref ServerHttpRequest request, ref ServerHttpResponse response) {
writeJsonBody(response, ALL_CURRENCIES); writeJsonBody(response, ALL_CURRENCIES);
} }

View File

@ -4,7 +4,7 @@ import std.json;
import asdf; import asdf;
import handy_http_primitives; import handy_http_primitives;
import handy_http_data.json; import handy_http_data.json;
import handy_http_handlers.path_handler : getPathParamAs; import handy_http_handlers.path_handler : getPathParamAs, PathMapping;
import slf4d; import slf4d;
import profile.model; import profile.model;
@ -14,10 +14,13 @@ import profile.data_impl_sqlite;
import auth.model; import auth.model;
import auth.service; import auth.service;
const PROFILE_PATH = "/api/profiles/:profile";
struct NewProfilePayload { struct NewProfilePayload {
string name; string name;
} }
@PathMapping(HttpMethod.POST, "/api/profiles")
void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto payload = readJsonBodyAs!NewProfilePayload(request); auto payload = readJsonBodyAs!NewProfilePayload(request);
string name = payload.name; string name = payload.name;
@ -32,6 +35,7 @@ void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpRespons
writeJsonBody(response, p); writeJsonBody(response, p);
} }
@PathMapping(HttpMethod.GET, "/api/profiles")
void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request); AuthContext auth = getAuthContext(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
@ -39,11 +43,13 @@ void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse res
writeJsonBody(response, profiles); writeJsonBody(response, profiles);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH)
void handleGetProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileContext profileCtx = getProfileContextOrThrow(request); ProfileContext profileCtx = getProfileContextOrThrow(request);
writeJsonBody(response, profileCtx.profile); writeJsonBody(response, profileCtx.profile);
} }
@PathMapping(HttpMethod.DELETE, PROFILE_PATH)
void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
string name = request.getPathParamAs!string("profile"); string name = request.getPathParamAs!string("profile");
if (!validateProfileName(name)) { if (!validateProfileName(name)) {
@ -56,6 +62,7 @@ void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse r
profileRepo.deleteByName(name); profileRepo.deleteByName(name);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/properties")
void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileContext profileCtx = getProfileContextOrThrow(request); ProfileContext profileCtx = getProfileContextOrThrow(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
@ -65,6 +72,7 @@ void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse r
writeJsonBody(response, props); writeJsonBody(response, props);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/download")
void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileContext profileCtx = getProfileContextOrThrow(request); ProfileContext profileCtx = getProfileContextOrThrow(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);

View File

@ -12,6 +12,7 @@ import transaction.service;
import transaction.dto; import transaction.dto;
import profile.data; import profile.data;
import profile.service; import profile.service;
import profile.api : PROFILE_PATH;
import account.api; import account.api;
import util.money; import util.money;
import util.pagination; import util.pagination;
@ -21,6 +22,7 @@ import util.data;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]); immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]);
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions")
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
@ -28,6 +30,7 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, responsePage); writeJsonBody(response, responsePage);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search")
void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
@ -35,6 +38,7 @@ void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpRespo
writeJsonBody(response, page); writeJsonBody(response, page);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong")
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
@ -43,6 +47,7 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
response.writeBodyString(jsonStr, "application/json"); response.writeBodyString(jsonStr, "application/json");
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/transactions")
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import asdf : serializeToJson; import asdf : serializeToJson;
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request); auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
@ -52,6 +57,7 @@ void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
response.writeBodyString(jsonStr, "application/json"); response.writeBodyString(jsonStr, "application/json");
} }
@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong")
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import asdf : serializeToJson; import asdf : serializeToJson;
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -62,12 +68,14 @@ void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpRespon
response.writeBodyString(jsonStr, "application/json"); response.writeBodyString(jsonStr, "application/json");
} }
@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong")
void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
ulong txnId = getTransactionIdOrThrow(request); ulong txnId = getTransactionIdOrThrow(request);
deleteTransaction(ds, txnId); deleteTransaction(ds, txnId);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags")
void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
string[] tags = ds.getTransactionTagRepository().findAll(); string[] tags = ds.getTransactionTagRepository().findAll();
@ -80,12 +88,14 @@ private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
// Vendors API // Vendors API
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors")
void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionVendor[] vendors = getAllVendors(ds); TransactionVendor[] vendors = getAllVendors(ds);
writeJsonBody(response, vendors); writeJsonBody(response, vendors);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong")
void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
TransactionVendor vendor = getVendor(ds, getVendorId(request)); TransactionVendor vendor = getVendor(ds, getVendorId(request));
@ -97,6 +107,7 @@ struct VendorPayload {
string description; string description;
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/vendors")
void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -104,6 +115,7 @@ void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re
writeJsonBody(response, vendor); writeJsonBody(response, vendor);
} }
@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong")
void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request); VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -111,6 +123,7 @@ void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re
writeJsonBody(response, updated); writeJsonBody(response, updated);
} }
@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong")
void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
deleteVendor(ds, getVendorId(request)); deleteVendor(ds, getVendorId(request));
@ -122,16 +135,19 @@ private ulong getVendorId(in ServerHttpRequest request) {
// Categories API // Categories API
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories")
void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request)); TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request));
writeJsonBody(response, categories); writeJsonBody(response, categories);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong")
void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto category = getCategory(getProfileDataSource(request), getCategoryId(request)); auto category = getCategory(getProfileDataSource(request), getCategoryId(request));
writeJsonBody(response, category); writeJsonBody(response, category);
} }
@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong/children")
void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto children = getChildCategories(getProfileDataSource(request), getCategoryId(request)); auto children = getChildCategories(getProfileDataSource(request), getCategoryId(request));
writeJsonBody(response, children); writeJsonBody(response, children);
@ -144,6 +160,7 @@ struct CategoryPayload {
Nullable!ulong parentId; Nullable!ulong parentId;
} }
@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/categories")
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -151,6 +168,7 @@ void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, category); writeJsonBody(response, category);
} }
@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong")
void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
@ -159,6 +177,7 @@ void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, category); writeJsonBody(response, category);
} }
@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong")
void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
ulong categoryId = getCategoryId(request); ulong categoryId = getCategoryId(request);