Add internal transfer label, convert to filters for request handling.
Build and Deploy Web App / build-and-deploy (push) Successful in 17s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m32s Details

This commit is contained in:
andrewlalis 2025-10-24 10:34:20 -04:00
parent cb690702cc
commit ad92f6f9dd
2 changed files with 68 additions and 82 deletions

View File

@ -3,6 +3,7 @@ module api_mapping;
import handy_http_primitives; import handy_http_primitives;
import handy_http_handlers.path_handler; import handy_http_handlers.path_handler;
import handy_http_handlers.filtered_handler; import handy_http_handlers.filtered_handler;
import slf4d;
/// The base path to all API endpoints. /// The base path to all API endpoints.
private const API_PATH = "/api"; private const API_PATH = "/api";
@ -101,7 +102,15 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
a a
)); ));
return new CorsHandler(h, webOrigin); // 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) { private void getStatus(ref ServerHttpRequest request, ref ServerHttpResponse response) {
@ -136,26 +145,53 @@ private void map(
handler.addMapping(method, API_PATH ~ subPath, HttpRequestHandler.of(fn)); handler.addMapping(method, API_PATH ~ subPath, HttpRequestHandler.of(fn));
} }
private class CorsHandler : HttpRequestHandler { private class CorsFilter : HttpRequestFilter {
private HttpRequestHandler handler;
private string webOrigin; private string webOrigin;
this(HttpRequestHandler handler, string webOrigin) { this(string webOrigin) {
this.handler = handler;
this.webOrigin = webOrigin; this.webOrigin = webOrigin;
} }
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) { void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) {
response.headers.add("Access-Control-Allow-Origin", webOrigin); response.headers.add("Access-Control-Allow-Origin", webOrigin);
response.headers.add("Access-Control-Allow-Methods", "*"); response.headers.add("Access-Control-Allow-Methods", "*");
response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
filterChain.doFilter(request, response);
}
}
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);
}
}
private class ExceptionHandlingFilter : HttpRequestFilter {
void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) {
try { try {
this.handler.handle(request, response); filterChain.doFilter(request, response);
} catch (HttpStatusException e) { } catch (HttpStatusException e) {
response.status = e.status; response.status = e.status;
response.writeBodyString(e.message.idup); response.writeBodyString(e.message.idup);
} catch (Exception e) { } catch (Exception e) {
import slf4d;
error(e); error(e);
response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.status = HttpStatus.INTERNAL_SERVER_ERROR;
response.writeBodyString("An error occurred: " ~ e.msg); response.writeBodyString("An error occurred: " ~ e.msg);

View File

@ -87,58 +87,34 @@ async function deleteTransaction() {
} }
</script> </script>
<template> <template>
<AppPage <AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
:title="'Transaction ' + transaction.id"
v-if="transaction"
>
<!-- Top-row with some badges for amount, vendor, and category. --> <!-- Top-row with some badges for amount, vendor, and category. -->
<div> <div>
<AppBadge <AppBadge size="lg" class="font-mono">
size="lg"
class="font-mono"
>
{{ transaction.currency.code }} {{ formatMoney(transaction.amount, transaction.currency) }} {{ transaction.currency.code }} {{ formatMoney(transaction.amount, transaction.currency) }}
</AppBadge> </AppBadge>
<AppBadge <AppBadge size="md" v-if="transaction.vendor">
size="md"
v-if="transaction.vendor"
>
{{ transaction.vendor.name }} {{ transaction.vendor.name }}
</AppBadge> </AppBadge>
<CategoryLabel <CategoryLabel v-if="transaction.category" :category="transaction.category" :clickable="true" />
v-if="transaction.category" <AppBadge size="sm" v-if="transaction.internalTransfer">
:category="transaction.category" <font-awesome-icon icon="fa-rotate"></font-awesome-icon>
:clickable="true" Internal Transfer
/> </AppBadge>
</div> </div>
<!-- Second row that lists all tags. --> <!-- Second row that lists all tags. -->
<div <div v-if="transaction.tags.length > 0" class="mt-1">
v-if="transaction.tags.length > 0" <TagLabel v-for="t in transaction.tags" :key="t" :tag="t" />
class="mt-1"
>
<TagLabel
v-for="t in transaction.tags"
:key="t"
:tag="t"
/>
</div> </div>
<p>{{ transaction.description }}</p> <p>{{ transaction.description }}</p>
<div <div v-if="transaction.creditedAccount" class="my-1">
v-if="transaction.creditedAccount"
class="my-1"
>
<strong class="text-negative">Credited</strong> from <strong class="text-negative">Credited</strong> from
<RouterLink <RouterLink :to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.creditedAccount.id}`">
:to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.creditedAccount.id}`"
>
{{ transaction.creditedAccount.name }} (#{{ transaction.creditedAccount.numberSuffix }}) {{ transaction.creditedAccount.name }} (#{{ transaction.creditedAccount.numberSuffix }})
</RouterLink> </RouterLink>
<div <div v-if="creditedAccountBalanceDiff" class="font-size-xsmall">
v-if="creditedAccountBalanceDiff"
class="font-size-xsmall"
>
Balance Before: Balance Before:
<span class="font-mono"> <span class="font-mono">
{{ formatMoney(creditedAccountBalanceDiff.before, transaction.currency) }} {{ formatMoney(creditedAccountBalanceDiff.before, transaction.currency) }}
@ -150,20 +126,12 @@ async function deleteTransaction() {
</div> </div>
</div> </div>
<div <div v-if="transaction.debitedAccount" class="my-1">
v-if="transaction.debitedAccount"
class="my-1"
>
<strong class="text-positive">Debited</strong> to <strong class="text-positive">Debited</strong> to
<RouterLink <RouterLink :to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.debitedAccount.id}`">
:to="`/profiles/${getSelectedProfile(route)}/accounts/${transaction.debitedAccount.id}`"
>
{{ transaction.debitedAccount.name }} (#{{ transaction.debitedAccount.numberSuffix }}) {{ transaction.debitedAccount.name }} (#{{ transaction.debitedAccount.numberSuffix }})
</RouterLink> </RouterLink>
<div <div v-if="debitedAccountBalanceDiff" class="font-size-xsmall">
v-if="debitedAccountBalanceDiff"
class="font-size-xsmall"
>
Balance Before: Balance Before:
<span class="font-mono"> <span class="font-mono">
{{ formatMoney(debitedAccountBalanceDiff.before, transaction.currency) }} {{ formatMoney(debitedAccountBalanceDiff.before, transaction.currency) }}
@ -189,39 +157,21 @@ async function deleteTransaction() {
<div v-if="transaction.lineItems.length > 0"> <div v-if="transaction.lineItems.length > 0">
<h3>Line Items</h3> <h3>Line Items</h3>
<LineItemCard <LineItemCard v-for="item of transaction.lineItems" :key="item.idx" :line-item="item"
v-for="item of transaction.lineItems" :currency="transaction.currency" :total-count="transaction.lineItems.length" :editable="false" />
:key="item.idx"
:line-item="item"
:currency="transaction.currency"
:total-count="transaction.lineItems.length"
:editable="false"
/>
</div> </div>
<div v-if="transaction.attachments.length > 0"> <div v-if="transaction.attachments.length > 0">
<h3>Attachments</h3> <h3>Attachments</h3>
<AttachmentRow <AttachmentRow v-for="a in transaction.attachments" :attachment="a" :key="a.id" disabled />
v-for="a in transaction.attachments"
:attachment="a"
:key="a.id"
disabled
/>
</div> </div>
<ButtonBar> <ButtonBar>
<AppButton <AppButton icon="wrench" @click="
icon="wrench" router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
@click=" ">
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
"
>
Edit Edit
</AppButton> </AppButton>
<AppButton <AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton>
icon="trash"
@click="deleteTransaction()"
>Delete</AppButton
>
</ButtonBar> </ButtonBar>
</AppPage> </AppPage>
</template> </template>