finnow/finnow-api/source/api_mapping.d

219 lines
8.5 KiB
D

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;
import auth.api_public;
h.registerHandlers!(auth.api_public);
// Authenticated endpoints:
PathHandler a = new PathHandler();
a.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:
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;
}
}
}