From a142a847da5f7a9115a6ff2a110dfd40649d443b Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 14 Jan 2026 13:45:03 -0500 Subject: [PATCH] Refactored to use @PathMapping everywhere, and add rate-limiter --- finnow-api/source/account/api.d | 15 +++ finnow-api/source/analytics/api.d | 5 + finnow-api/source/api_mapping.d | 191 ++++++++++++++++------------ finnow-api/source/app.d | 2 +- finnow-api/source/attachment/api.d | 2 + finnow-api/source/auth/api.d | 8 +- finnow-api/source/auth/api_public.d | 6 +- finnow-api/source/data_api.d | 2 + finnow-api/source/profile/api.d | 10 +- finnow-api/source/transaction/api.d | 19 +++ 10 files changed, 168 insertions(+), 92 deletions(-) diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 781a1f3..bcdb6c5 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -11,6 +11,7 @@ import std.datetime; import profile.service; import profile.data; +import profile.api : PROFILE_PATH; import account.model; import account.service; import account.dto; @@ -21,6 +22,9 @@ import account.data; import attachment.data; 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) { import std.algorithm; import std.array; @@ -30,6 +34,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res writeJsonBody(response, accounts); } +@PathMapping(HttpMethod.GET, ACCOUNT_PATH) void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); 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))); } +@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/accounts") void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto ds = getProfileDataSource(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))); } +@PathMapping(HttpMethod.PUT, ACCOUNT_PATH) void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); 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))); } +@PathMapping(HttpMethod.DELETE, ACCOUNT_PATH) void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); ds.getAccountRepository().deleteById(accountId); } +@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/balance") void handleGetAccountBalance(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); 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) { ulong accountId = request.getPathParamOrThrow!ulong("accountId"); 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); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/account-balances") void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto ds = getProfileDataSource(request); 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)]); +@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records") void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); @@ -152,6 +164,7 @@ void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, page); } +@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong") void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId"); @@ -162,6 +175,7 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo)); } +@PathMapping(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records") void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); 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) { ulong accountId = request.getPathParamAs!ulong("accountId"); ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId"); diff --git a/finnow-api/source/analytics/api.d b/finnow-api/source/analytics/api.d index 57e65ed..5fadd5e 100644 --- a/finnow-api/source/analytics/api.d +++ b/finnow-api/source/analytics/api.d @@ -1,14 +1,19 @@ module analytics.api; import handy_http_primitives; +import handy_http_handlers.path_handler : PathMapping; + import profile.data; 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) { auto ds = getProfileDataSource(request); serveJsonFromProperty(response, ds, "analytics.balanceTimeSeries"); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/analytics/category-spend-time-series") void handleGetCategorySpendTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto ds = getProfileDataSource(request); serveJsonFromProperty(response, ds, "analytics.categorySpendTimeSeries"); diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index d638462..896df21 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -5,9 +5,6 @@ import handy_http_handlers.path_handler; import handy_http_handlers.filtered_handler; import slf4d; -/// The base path to all API endpoints. -private const API_PATH = "/api"; - /** * Defines the Finnow API mapping with a main PathHandler. * Params: @@ -15,92 +12,41 @@ private const API_PATH = "/api"; * Returns: The handler to plug into an HttpServer. */ HttpRequestHandler mapApiHandlers(string webOrigin) { - PathHandler h = new PathHandler(); + PathHandler publicHandler = new PathHandler(); + PathHandler authenticatedHandler = new PathHandler(); - // Generic, public endpoints: - h.map(HttpMethod.GET, "/status", &getStatus); - h.map(HttpMethod.OPTIONS, "/**", &getOptions); + // Public endpoints: + publicHandler.addMapping(HttpMethod.GET, "/api/status", HttpRequestHandler.of(&getStatus)); + 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!!! // h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); - // Auth endpoints: - import auth.api; - import auth.api_public; - h.registerHandlers!(auth.api_public); - // Authenticated endpoints: - PathHandler a = new PathHandler(); - a.registerHandlers!(auth.api); - + import auth.api; + authenticatedHandler.registerHandlers!(auth.api); import profile.api; - a.map(HttpMethod.GET, "/profiles", &handleGetProfiles); - 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: + authenticatedHandler.registerHandlers!(profile.api); import account.api; - a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); - 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); - + authenticatedHandler.registerHandlers!(account.api); import transaction.api; - // Transaction vendor endpoints: - 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: + authenticatedHandler.registerHandlers!(transaction.api); import analytics.api; - a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/balance-time-series", &handleGetBalanceTimeSeries); - a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/category-spend-time-series", &handleGetCategorySpendTimeSeries); - + authenticatedHandler.registerHandlers!(analytics.api); import data_api; - // Various other data endpoints: - a.map(HttpMethod.GET, "/currencies", &handleGetCurrencies); + authenticatedHandler.registerHandlers!(data_api); // Protect all authenticated paths with a filter. import auth.service : AuthenticationFilter; HttpRequestFilter authenticationFilter = new AuthenticationFilter(); - h.addMapping(API_PATH ~ "/**", new FilteredHandler( + publicHandler.addMapping("/api/**", new FilteredHandler( [authenticationFilter], - a + authenticatedHandler )); // Build the main handler into a filter chain: @@ -108,9 +54,10 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { [ cast(HttpRequestFilter) new CorsFilter(webOrigin), cast(HttpRequestFilter) new ContentLengthFilter(), + cast(HttpRequestFilter) new TokenBucketRateLimitingFilter(10, 50), 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."); } -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. */ @@ -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); + } + } + } +} diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index 9bab971..a933e23 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -18,7 +18,7 @@ void main() { void configureSlf4d(in AppConfig config) { Level logLevel = getConfiguredLoggingLevel(config); - logLevel = Levels.DEBUG; + // logLevel = Levels.DEBUG; auto provider = new DefaultProvider(logLevel); configureLoggingProvider(provider); } diff --git a/finnow-api/source/attachment/api.d b/finnow-api/source/attachment/api.d index 662e345..ec42242 100644 --- a/finnow-api/source/attachment/api.d +++ b/finnow-api/source/attachment/api.d @@ -2,6 +2,7 @@ module attachment.api; import handy_http_primitives; import handy_http_data.json; +import handy_http_handlers.path_handler : PathMapping; import std.conv; import profile.data; @@ -22,6 +23,7 @@ import attachment.model; * request = The HTTP request. * response = The HTTP response. */ +@PathMapping(HttpMethod.GET, "/api/profiles/:profile/attachments/:attachmentId/download") void handleDownloadAttachment(ref ServerHttpRequest request, ref ServerHttpResponse response) { Optional!AuthContext authCtx = extractAuthContextFromQueryParam(request, response); if (authCtx.isNull) return; diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d index ef2685d..897a7ba 100644 --- a/finnow-api/source/auth/api.d +++ b/finnow-api/source/auth/api.d @@ -11,13 +11,13 @@ import auth.data; import auth.service; import auth.data_impl_fs; -@PathMapping(HttpMethod.GET, "/me") +@PathMapping(HttpMethod.GET, "/api/me") void getMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) { AuthContext auth = getAuthContext(request); response.writeBodyString(auth.user.username); } -@PathMapping(HttpMethod.DELETE, "/me") +@PathMapping(HttpMethod.DELETE, "/api/me") void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) { AuthContext auth = getAuthContext(request); UserRepository userRepo = new FileSystemUserRepository(); @@ -25,7 +25,7 @@ void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response 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) { AuthContext auth = getAuthContext(request); string token = generateTokenForUser(auth.user); @@ -38,7 +38,7 @@ struct PasswordChangeRequest { string newPassword; } -@PathMapping(HttpMethod.POST, "/me/password") +@PathMapping(HttpMethod.POST, "/api/me/password") void changeMyPassword(ref ServerHttpRequest request, ref ServerHttpResponse response) { AuthContext auth = getAuthContext(request); PasswordChangeRequest data = readJsonBodyAs!PasswordChangeRequest(request); diff --git a/finnow-api/source/auth/api_public.d b/finnow-api/source/auth/api_public.d index 88991e3..f98d9b4 100644 --- a/finnow-api/source/auth/api_public.d +++ b/finnow-api/source/auth/api_public.d @@ -10,7 +10,7 @@ import auth.data; import auth.service; import auth.data_impl_fs; -@PathMapping(HttpMethod.POST, "/login") +@PathMapping(HttpMethod.POST, "/api/login") void postLogin(ref ServerHttpRequest request, ref ServerHttpResponse response) { struct LoginData { string username; @@ -26,7 +26,7 @@ struct UsernameAvailabilityResponse { const bool available; } -@PathMapping(HttpMethod.GET, "/register/username-availability") +@PathMapping(HttpMethod.GET, "/api/register/username-availability") void getUsernameAvailability(ref ServerHttpRequest request, ref ServerHttpResponse response) { string username = null; foreach (param; request.queryParams) { @@ -50,7 +50,7 @@ struct RegistrationData { string password; } -@PathMapping(HttpMethod.POST, "/register") +@PathMapping(HttpMethod.POST, "/api/register") void postRegister(ref ServerHttpRequest request, ref ServerHttpResponse response) { RegistrationData registrationData = readJsonBodyAs!RegistrationData(request); if (!validateUsername(registrationData.username)) { diff --git a/finnow-api/source/data_api.d b/finnow-api/source/data_api.d index 6397bbc..94b5821 100644 --- a/finnow-api/source/data_api.d +++ b/finnow-api/source/data_api.d @@ -1,9 +1,11 @@ module data_api; import handy_http_primitives; +import handy_http_handlers.path_handler : PathMapping; import handy_http_data; import util.money; +@PathMapping(HttpMethod.GET, "/api/currencies") void handleGetCurrencies(ref ServerHttpRequest request, ref ServerHttpResponse response) { writeJsonBody(response, ALL_CURRENCIES); } \ No newline at end of file diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d index 5dcfee3..5c70411 100644 --- a/finnow-api/source/profile/api.d +++ b/finnow-api/source/profile/api.d @@ -4,7 +4,7 @@ import std.json; import asdf; import handy_http_primitives; import handy_http_data.json; -import handy_http_handlers.path_handler : getPathParamAs; +import handy_http_handlers.path_handler : getPathParamAs, PathMapping; import slf4d; import profile.model; @@ -14,10 +14,13 @@ import profile.data_impl_sqlite; import auth.model; import auth.service; +const PROFILE_PATH = "/api/profiles/:profile"; + struct NewProfilePayload { string name; } +@PathMapping(HttpMethod.POST, "/api/profiles") void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto payload = readJsonBodyAs!NewProfilePayload(request); string name = payload.name; @@ -32,6 +35,7 @@ void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpRespons writeJsonBody(response, p); } +@PathMapping(HttpMethod.GET, "/api/profiles") void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) { AuthContext auth = getAuthContext(request); ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); @@ -39,11 +43,13 @@ void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse res writeJsonBody(response, profiles); } +@PathMapping(HttpMethod.GET, PROFILE_PATH) void handleGetProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileContext profileCtx = getProfileContextOrThrow(request); writeJsonBody(response, profileCtx.profile); } +@PathMapping(HttpMethod.DELETE, PROFILE_PATH) void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { string name = request.getPathParamAs!string("profile"); if (!validateProfileName(name)) { @@ -56,6 +62,7 @@ void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse r profileRepo.deleteByName(name); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/properties") void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileContext profileCtx = getProfileContextOrThrow(request); ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); @@ -65,6 +72,7 @@ void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse r writeJsonBody(response, props); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/download") void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileContext profileCtx = getProfileContextOrThrow(request); ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 839e8a3..79e40d5 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -12,6 +12,7 @@ import transaction.service; import transaction.dto; import profile.data; import profile.service; +import profile.api : PROFILE_PATH; import account.api; import util.money; import util.pagination; @@ -21,6 +22,7 @@ import util.data; 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) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); @@ -28,6 +30,7 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, responsePage); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search") void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); @@ -35,6 +38,7 @@ void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpRespo writeJsonBody(response, page); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); @@ -43,6 +47,7 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response.writeBodyString(jsonStr, "application/json"); } +@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/transactions") void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { import asdf : serializeToJson; auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request); @@ -52,6 +57,7 @@ void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response.writeBodyString(jsonStr, "application/json"); } +@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { import asdf : serializeToJson; ProfileDataSource ds = getProfileDataSource(request); @@ -62,12 +68,14 @@ void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpRespon response.writeBodyString(jsonStr, "application/json"); } +@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong") void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); ulong txnId = getTransactionIdOrThrow(request); deleteTransaction(ds, txnId); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags") void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); string[] tags = ds.getTransactionTagRepository().findAll(); @@ -80,12 +88,14 @@ private ulong getTransactionIdOrThrow(in ServerHttpRequest request) { // Vendors API +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors") void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionVendor[] vendors = getAllVendors(ds); writeJsonBody(response, vendors); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionVendor vendor = getVendor(ds, getVendorId(request)); @@ -97,6 +107,7 @@ struct VendorPayload { string description; } +@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/vendors") void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -104,6 +115,7 @@ void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re writeJsonBody(response, vendor); } +@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -111,6 +123,7 @@ void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse re writeJsonBody(response, updated); } +@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong") void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); deleteVendor(ds, getVendorId(request)); @@ -122,16 +135,19 @@ private ulong getVendorId(in ServerHttpRequest request) { // Categories API +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories") void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) { TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request)); writeJsonBody(response, categories); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong") void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto category = getCategory(getProfileDataSource(request), getCategoryId(request)); writeJsonBody(response, category); } +@PathMapping(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong/children") void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto children = getChildCategories(getProfileDataSource(request), getCategoryId(request)); writeJsonBody(response, children); @@ -144,6 +160,7 @@ struct CategoryPayload { Nullable!ulong parentId; } +@PathMapping(HttpMethod.POST, PROFILE_PATH ~ "/categories") void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -151,6 +168,7 @@ void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, category); } +@PathMapping(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong") void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { CategoryPayload payload = readJsonBodyAs!CategoryPayload(request); ProfileDataSource ds = getProfileDataSource(request); @@ -159,6 +177,7 @@ void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, category); } +@PathMapping(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong") void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); ulong categoryId = getCategoryId(request);