module api_mapping; import handy_http_primitives; 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: * webOrigin = The origin to use when configuring CORS headers. * Returns: The handler to plug into an HttpServer. */ HttpRequestHandler mapApiHandlers(string webOrigin) { PathHandler h = new PathHandler(); // Generic, public endpoints: h.map(HttpMethod.GET, "/status", &getStatus); h.map(HttpMethod.OPTIONS, "/**", &getOptions); // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! // h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); // Auth endpoints: import auth.api; h.map(HttpMethod.POST, "/login", &postLogin); h.map(HttpMethod.POST, "/register", &postRegister); h.map(HttpMethod.GET, "/register/username-availability", &getUsernameAvailability); // Authenticated endpoints: PathHandler a = new PathHandler(); a.map(HttpMethod.GET, "/me", &getMyUser); a.map(HttpMethod.DELETE, "/me", &deleteMyUser); a.map(HttpMethod.GET, "/me/token", &getNewToken); a.map(HttpMethod.POST, "/me/password", &changeMyPassword); 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: 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); 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: 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); import data_api; // Various other data endpoints: a.map(HttpMethod.GET, "/currencies", &handleGetCurrencies); // Protect all authenticated paths with a filter. import auth.service : AuthenticationFilter; HttpRequestFilter authenticationFilter = new AuthenticationFilter(); h.addMapping(API_PATH ~ "/**", new FilteredHandler( [authenticationFilter], a )); // Build the main handler into a filter chain: return new FilteredHandler( [ cast(HttpRequestFilter) new CorsFilter(webOrigin), cast(HttpRequestFilter) new ContentLengthFilter(), cast(HttpRequestFilter) new ExceptionHandlingFilter() ], h ); } private void getStatus(ref ServerHttpRequest request, ref ServerHttpResponse response) { response.writeBodyString("online", ContentTypes.TEXT_PLAIN); } private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse response) { // Do nothing, just return 200 OK. } private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) { import slf4d; import util.sample_data; import core.thread; Thread t = new Thread(() { try { generateSampleData(); } catch (Exception e) { error("Error while generating sample data.", e); } }); t.start(); 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. */ private class CorsFilter : HttpRequestFilter { private string webOrigin; this(string webOrigin) { this.webOrigin = webOrigin; } void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { response.headers.add("Access-Control-Allow-Origin", webOrigin); response.headers.add("Access-Control-Allow-Methods", "*"); response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.headers.add("Access-Control-Expose-Headers", "Content-Disposition"); filterChain.doFilter(request, response); } } /** * A filter that rejects requests with a body that's too large, to avoid issues * later with handling such large objects in memory. */ private class ContentLengthFilter : HttpRequestFilter { const MAX_LENGTH = 1024 * 1024 * 20; // 2MB limit void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { if ("Content-Length" in request.headers) { ulong contentLength = request.getHeaderAs!ulong("Content-Length"); if (contentLength > MAX_LENGTH) { warnF!"Received request with content length of %d, larger than max allowed %d bytes."( contentLength, MAX_LENGTH ); import std.conv; response.status = HttpStatus.PAYLOAD_TOO_LARGE; response.writeBodyString( "Request body is too large. Must be at most " ~ MAX_LENGTH.to!string ~ " bytes." ); return; // Don't propagate the filter. } } filterChain.doFilter(request, response); } } /** * A filter that catches any exception thrown by the filter chain, and nicely * formats the response status and message. */ private class ExceptionHandlingFilter : HttpRequestFilter { void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { try { filterChain.doFilter(request, response); } catch (HttpStatusException e) { response.status = e.status; response.writeBodyString(e.message.idup); } catch (Exception e) { error(e); response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.writeBodyString("An error occurred: " ~ e.msg); } catch (Throwable e) { errorF!"A throwable was caught! %s %s"(e.msg, e.info); response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.writeBodyString("An error occurred."); throw e; } } }