Another major refactor, into modular monolith style application.

This commit is contained in:
Andrew Lalis 2024-08-01 13:01:50 -04:00
parent e198f45b92
commit 31b7a929f6
32 changed files with 1109 additions and 464 deletions

View File

@ -31,7 +31,7 @@ CREATE TABLE account (
CREATE TABLE account_credit_card_properties (
account_id INTEGER PRIMARY KEY,
credit_limit TEXT,
credit_limit INTEGER,
CONSTRAINT fk_account_credit_card_properties_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
@ -64,7 +64,7 @@ CREATE TABLE "transaction" (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
added_at TEXT NOT NULL,
amount TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL,
description TEXT,
vendor_id INTEGER,
@ -77,4 +77,132 @@ CREATE TABLE "transaction" (
ON UPDATE CASCADE ON DELETE SET NULL
);
CREATE TABLE transaction_attachment (
transaction_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL,
PRIMARY KEY (transaction_id, attachment_id),
CONSTRAINT fk_transaction_attachment_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_tag_join (
transaction_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
CONSTRAINT fk_transaction_tag_join_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_tag_join_tag
FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_line_item (
id INTEGER PRIMARY KEY,
transaction_id INTEGER NOT NULL,
value_per_item INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
idx INTEGER NOT NULL DEFAULT 0,
description TEXT NOT NULL,
category_id INTEGER,
CONSTRAINT fk_transaction_line_item_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_journal_entry (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
type TEXT NOT NULL,
currency TEXT NOT NULL,
CONSTRAINT fk_account_journal_entry_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_journal_entry_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
-- Value records
CREATE TABLE account_value_record (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
account_id INTEGER NOT NULL,
type TEXT NOT NULL DEFAULT 'BALANCE',
balance INTEGER NOT NULL,
currency TEXT NOT NULL,
CONSTRAINT fk_balance_record_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_value_record_attachment (
value_record_id BIGINT NOT NULL,
attachment_id BIGINT NOT NULL,
PRIMARY KEY (value_record_id, attachment_id),
CONSTRAINT fk_account_value_record_attachment_value_record
FOREIGN KEY (value_record_id) REFERENCES account_value_record(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_value_record_attachment_attachment
FOREIGN KEY (attachment_id) REFERENCES attachment(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
-- History Entities
CREATE TABLE history (
id INTEGER PRIMARY KEY
);
CREATE TABLE history_item (
id INTEGER PRIMARY KEY,
history_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
CONSTRAINT fk_history_item_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_item_text (
item_id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
CONSTRAINT fk_history_item_text_item
FOREIGN KEY (item_id) REFERENCES history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE account_history (
account_id INTEGER NOT NULL UNIQUE,
history_id INTEGER NOT NULL,
PRIMARY KEY (account_id, history_id),
CONSTRAINT fk_account_history_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_history (
transaction_id INTEGER NOT NULL UNIQUE,
history_id INTEGER NOT NULL,
PRIMARY KEY (transaction_id, history_id),
CONSTRAINT fk_history_transaction_transaction
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_history_transaction_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -0,0 +1,84 @@
module account.api;
import handy_httpd;
import asdf;
import profile.service;
import account.model;
import money.currency;
struct AccountResponse {
ulong id;
string createdAt;
bool archived;
string type;
string numberSuffix;
string name;
string currency;
string description;
static AccountResponse of(in Account account) {
AccountResponse r;
r.id = account.id;
r.createdAt = account.createdAt.toISOExtString();
r.archived = account.archived;
r.type = account.type.id;
r.numberSuffix = account.numberSuffix;
r.name = account.name;
r.currency = account.currency.code.dup;
r.description = account.description;
return r;
}
}
void handleGetAccounts(ref HttpRequestContext ctx) {
import std.algorithm;
auto ds = getProfileDataSource(ctx);
auto accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a));
ctx.response.writeBodyString(serializeToJson(accounts), "application/json");
}
void handleGetAccount(ref HttpRequestContext ctx) {
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(ctx);
auto account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json");
}
struct AccountCreationPayload {
string type;
string numberSuffix;
string name;
string currency;
string description;
}
void handleCreateAccount(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
AccountCreationPayload payload;
try {
payload = deserialize!(AccountCreationPayload)(ctx.request.readBodyAsString());
} catch (SerdeException e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid account payload.");
return;
}
AccountType type = AccountType.fromId(payload.type);
Currency currency = Currency.ofCode(payload.currency);
Account account = ds.getAccountRepository().insert(
type,
payload.numberSuffix,
payload.name,
currency,
payload.description
);
ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json");
}
void handleDeleteAccount(ref HttpRequestContext ctx) {
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(ctx);
ds.getAccountRepository().deleteById(accountId);
}

View File

@ -0,0 +1,19 @@
module account.data;
import handy_httpd.components.optional;
import account.model;
import money.currency;
import history.model;
interface AccountRepository {
Optional!Account findById(ulong id);
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
void setArchived(ulong id, bool archived);
Account update(ulong id, in Account newData);
void deleteById(ulong id);
Account[] findAll();
AccountCreditCardProperties getCreditCardProperties(ulong id);
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props);
History getHistory(ulong id);
}

View File

@ -0,0 +1,138 @@
module account.data_impl_sqlite;
import std.datetime;
import d2sqlite3;
import handy_httpd.components.optional;
import account.data;
import account.model;
import money.currency;
import history.model;
import util.sqlite;
class SqliteAccountRepository : AccountRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!Account findById(ulong id) {
return findOne(
db,
"SELECT * FROM account WHERE id = ?",
&parseAccount,
id
);
}
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
Statement stmt = db.prepare(q"SQL
INSERT INTO account
(created_at, type, number_suffix, name, currency, description)
VALUES (?, ?, ?, ?, ?, ?)
SQL");
stmt.bind(1, Clock.currTime(UTC()).toISOExtString());
stmt.bind(2, type.id);
stmt.bind(3, numberSuffix);
stmt.bind(4, name);
stmt.bind(5, currency.code);
stmt.bind(6, description);
stmt.execute();
ulong accountId = db.lastInsertRowid();
return findById(accountId).orElseThrow("Couldn't find account!");
}
void setArchived(ulong id, bool archived) {
util.sqlite.update(
db,
"UPDATE account SET archived = ? WHERE id = ?",
archived ? 1 : 0,
id
);
}
Account update(ulong id, in Account newData) {
return newData; // TODO:
}
void deleteById(ulong id) {
doTransaction(db, () {
util.sqlite.update(
db,
"DELETE FROM history
WHERE id IN (
SELECT history_id FROM account_history
WHERE account_id = ?
)",
id
);
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
});
}
Account[] findAll() {
return util.sqlite.findAll(db, "SELECT * FROM account", &parseAccount);
}
AccountCreditCardProperties getCreditCardProperties(ulong id) {
Statement stmt = db.prepare("SELECT * FROM account_credit_card_properties WHERE account_id = ?");
stmt.bind(1, id);
ResultRange result = stmt.execute();
return parseCreditCardProperties(result.front);
}
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
// TODO:
}
History getHistory(ulong id) {
if (!exists(db, "SELECT id FROM account WHERE id = ?", id)) {
throw new Exception("Account doesn't exist.");
}
Optional!History history = findOne(
db,
q"SQL
SELECT * FROM history
LEFT JOIN account_history ah ON ah.history_id = history.id
WHERE ah.account_id = ?
SQL",
r => History(r.peek!ulong(0)),
id
);
if (!history.empty) {
return history.value;
}
// No history exists yet, so add it.
ulong historyId = doTransaction(db, () {
util.sqlite.update(db, "INSERT INTO history DEFAULT VALUES");
ulong historyId = db.lastInsertRowid();
util.sqlite.update(db, "INSERT INTO account_history (account_id, history_id) VALUES (?, ?)", id, historyId);
return historyId;
});
return History(historyId);
}
static Account parseAccount(Row row) {
return Account(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1)),
row.peek!bool(2),
AccountType.fromId(row.peek!string(3)),
row.peek!string(4),
row.peek!string(5),
Currency.ofCode(row.peek!string(6)),
row.peek!string(7)
);
}
static AccountCreditCardProperties parseCreditCardProperties(Row row) {
import std.typecons : Nullable;
ulong accountId = row.peek!ulong(0);
Nullable!ulong creditLimit = row.peek!ulong(1);
return AccountCreditCardProperties(
accountId,
creditLimit.isNull ? -1 : creditLimit.get()
);
}
}

View File

@ -0,0 +1,42 @@
module account.model;
import std.datetime;
import money.currency;
struct AccountType {
const string id;
const string name;
const bool debitsPositive;
import std.traits : EnumMembers;
static AccountType fromId(string id) {
static foreach (t; EnumMembers!AccountTypes) {
if (id == t.id) return t;
}
throw new Exception("Invalid account type id " ~ id);
}
}
enum AccountTypes : AccountType {
CHECKING = AccountType("CHECKING", "Checking", true),
SAVINGS = AccountType("SAVINGS", "Savings", true),
CREDIT_CARD = AccountType("CREDIT_CARD", "Credit Card", false),
BROKERAGE = AccountType("BROKERAGE", "Brokerage", true)
}
struct Account {
ulong id;
SysTime createdAt;
bool archived;
AccountType type;
string numberSuffix;
string name;
Currency currency;
string description;
}
struct AccountCreditCardProperties {
ulong account_id;
long creditLimit;
}

View File

@ -0,0 +1,58 @@
module api_mapping;
import handy_httpd;
import handy_httpd.handlers.path_handler;
import handy_httpd.handlers.filtered_handler;
/**
* Defines the Finnow API mapping with a main PathHandler.
* Returns: The handler to plug into an HttpServer.
*/
PathHandler mapApiHandlers() {
/// The base path to all API endpoints.
const API_PATH = "/api";
PathHandler h = new PathHandler();
// Generic, public endpoints:
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
// Auth endpoints:
import auth.api;
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister);
// Authenticated endpoints:
PathHandler a = new PathHandler();
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
import profile.api;
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile);
/// URL path to a specific profile, with the :profile path parameter.
const PROFILE_PATH = API_PATH ~ "/profiles/:profile";
a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
import account.api;
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
// Protect all authenticated paths with a token filter.
import auth.service : TokenAuthenticationFilter, SECRET;
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
h.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(
a,
[tokenAuthenticationFilter]
));
return h;
}
private void getStatus(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online");
}
private void getOptions(ref HttpRequestContext ctx) {}

View File

@ -1,50 +1,10 @@
import slf4d;
import handy_httpd;
import handy_httpd.handlers.path_handler;
import handy_httpd.handlers.filtered_handler;
import api_mapping;
void main() {
ServerConfig cfg;
cfg.workerPoolSize = 5;
cfg.port = 8080;
HttpServer server = new HttpServer(buildHandlers(), cfg);
HttpServer server = new HttpServer(mapApiHandlers(), cfg);
server.start();
}
PathHandler buildHandlers() {
import profile;
const API_PATH = "/api";
PathHandler pathHandler = new PathHandler();
// Generic, public endpoints:
pathHandler.addMapping(Method.GET, API_PATH ~ "/status", (ref ctx) {
ctx.response.writeBodyString("online");
});
pathHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", (ref ctx) {});
// Auth Entrypoints:
import auth.api;
import auth.service;
pathHandler.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
pathHandler.addMapping(Method.POST, API_PATH ~ "/register", &postRegister);
// Authenticated endpoints:
PathHandler a = new PathHandler();
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile);
a.addMapping(Method.DELETE, API_PATH ~ "/profiles/:name", &handleDeleteProfile);
a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/properties", &handleGetProperties);
a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/accounts", (ref ctx) {
ctx.response.writeBodyString("your accounts!");
});
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
pathHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(
a,
[tokenAuthenticationFilter]
));
return pathHandler;
}

View File

@ -1,12 +1,7 @@
module model.base;
module attachment.model;
import std.datetime;
struct ProfileProperty {
string property;
string value;
}
struct Attachment {
ulong id;
SysTime uploadedAt;

View File

@ -4,16 +4,17 @@ module auth.api;
import handy_httpd;
import handy_httpd.components.optional;
import slf4d;
import asdf;
import auth.model;
import auth.dao;
import auth.dto;
import auth.data;
import auth.service;
import auth.data_impl_fs;
void postLogin(ref HttpRequestContext ctx) {
LoginCredentials loginCredentials;
try {
loginCredentials = LoginCredentials.parse(ctx.request.readBodyAsJson());
loginCredentials = deserialize!(LoginCredentials)(ctx.request.readBodyAsString());
} catch (Exception e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
}
@ -34,13 +35,14 @@ void postLogin(ref HttpRequestContext ctx) {
}
string token = generateAccessToken(optionalUser.value);
ctx.response.status = HttpStatus.OK;
ctx.response.writeBodyString(TokenResponse(token).toJson(), "application/json");
TokenResponse resp = TokenResponse(token);
ctx.response.writeBodyString(serializeToJson(resp), "application/json");
}
void postRegister(ref HttpRequestContext ctx) {
RegistrationData registrationData;
try {
registrationData = RegistrationData.parse(ctx.request.readBodyAsJson());
registrationData = deserialize!(RegistrationData)(ctx.request.readBodyAsString());
} catch (Exception e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
return;

View File

@ -0,0 +1,26 @@
module auth.data;
import handy_httpd.components.optional;
import auth.model;
/// The credentials provided by a user to login.
struct LoginCredentials {
string username;
string password;
}
/// A token response sent to the user if they've been authenticated.
struct TokenResponse {
string token;
}
struct RegistrationData {
string username;
string password;
}
interface UserRepository {
Optional!User findByUsername(string username);
User createUser(string username, string passwordHash);
void deleteByUsername(string username);
}

View File

@ -1,13 +1,9 @@
module auth.dao;
module auth.data_impl_fs;
import handy_httpd.components.optional;
import auth.model;
interface UserRepository {
Optional!User findByUsername(string username);
User createUser(string username, string passwordHash);
void deleteByUsername(string username);
}
import auth.data;
import auth.model;
/**
* User implementation that stores each user's data in a separate directory.
@ -63,4 +59,4 @@ class FileSystemUserRepository : UserRepository {
private string getUserDataFile(string username) {
return buildPath(this.usersDir, username, "user-data.json");
}
}
}

View File

@ -1,46 +0,0 @@
/// Defines data-transfer objects for the API's authentication mechanisms.
module auth.dto;
import handy_httpd;
import std.json;
struct LoginCredentials {
string username;
string password;
static LoginCredentials parse(JSONValue obj) {
if (
obj.type != JSONType.OBJECT ||
"username" !in obj.object ||
"password" !in obj.object ||
obj.object["username"].type != JSONType.STRING ||
obj.object["password"].type != JSONType.STRING
) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Malformed login credentials.");
}
return LoginCredentials(
obj.object["username"].str,
obj.object["password"].str
);
}
}
struct TokenResponse {
string token;
string toJson() {
JSONValue obj = JSONValue.emptyObject;
obj.object["token"] = JSONValue(token);
return obj.toString();
}
}
struct RegistrationData {
string username;
string password;
static RegistrationData parse(JSONValue obj) {
LoginCredentials lc = LoginCredentials.parse(obj);
return RegistrationData(lc.username, lc.password);
}
}

View File

@ -2,11 +2,12 @@ module auth.service;
import handy_httpd;
import handy_httpd.components.optional;
import handy_httpd.handlers.filtered_handler;
import slf4d;
import auth.model;
import auth.dao;
import handy_httpd.handlers.filtered_handler;
import auth.data;
import auth.data_impl_fs;
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
@ -66,7 +67,7 @@ class AuthContext {
* ctx = The request context to get.
* Returns: The auth context that has been set.
*/
AuthContext getAuthContext(ref HttpRequestContext ctx) {
AuthContext getAuthContext(in HttpRequestContext ctx) {
return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY];
}

View File

@ -1,2 +0,0 @@
module data.account;

View File

@ -1,15 +0,0 @@
module data.base;
import model;
import handy_httpd.components.optional;
interface DataSource {
PropertiesRepository getPropertiesRepository();
}
interface PropertiesRepository {
Optional!string findProperty(string propertyName);
void setProperty(string name, string value);
void deleteProperty(string name);
ProfileProperty[] findAll();
}

View File

@ -1,6 +0,0 @@
module data;
public import data.base;
// Utility imports for items commonly used alongside data.
public import handy_httpd.components.optional;

View File

@ -1,82 +0,0 @@
module data.sqlite;
import d2sqlite3;
import slf4d;
import data.base;
import model;
class SqliteDataSource : DataSource {
const SCHEMA = import("schema.sql");
private const string dbPath;
private Database db;
this(string path) {
this.dbPath = path;
import std.file : exists;
bool needsInit = !exists(path);
this.db = Database(path);
if (needsInit) {
infoF!"Initializing database: %s"(dbPath);
db.run(SCHEMA);
}
}
PropertiesRepository getPropertiesRepository() {
return new SqlitePropertiesRepository(db);
}
}
class SqliteRepository {
private Database db;
this(Database db) {
this.db = db;
}
}
class SqlitePropertiesRepository : SqliteRepository, PropertiesRepository {
this(Database db) {
super(db);
}
Optional!string findProperty(string propertyName) {
Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?");
stmt.bind(1, propertyName);
ResultRange result = stmt.execute();
if (result.empty) return Optional!string.empty;
return Optional!string.of(result.front.peek!string(0));
}
void setProperty(string name, string value) {
if (findProperty(name).isNull) {
Statement stmt = this.db.prepare("INSERT INTO profile_property (property, value) VALUES (?, ?)");
stmt.bind(1, name);
stmt.bind(2, value);
stmt.execute();
} else {
Statement stmt = this.db.prepare("UPDATE profile_property SET value = ? WHERE property = ?");
stmt.bind(1, value);
stmt.bind(2, name);
stmt.execute();
}
}
void deleteProperty(string name) {
Statement stmt = this.db.prepare("DELETE FROM profile_property WHERE property = ?");
stmt.bind(1, name);
stmt.execute();
}
ProfileProperty[] findAll() {
Statement stmt = this.db.prepare("SELECT * FROM profile_property ORDER BY property ASC");
ResultRange result = stmt.execute();
ProfileProperty[] props;
foreach (Row row; result) {
ProfileProperty prop;
prop.property = row.peek!string("property");
prop.value = row.peek!string("value");
props ~= prop;
}
return props;
}
}

View File

@ -0,0 +1,14 @@
module history.data;
import std.datetime;
import handy_httpd.components.optional;
import history.model;
interface HistoryRepository {
Optional!History findById(ulong id);
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit);
HistoryItemText getTextItem(ulong itemId);
void deleteById(ulong id);
}

View File

@ -0,0 +1,75 @@
module history.data_impl_sqlite;
import std.datetime;
import handy_httpd.components.optional;
import d2sqlite3;
import history.data;
import history.model;
class SqliteHistoryRepository : HistoryRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!History findById(ulong id) {
Statement stmt = db.prepare("SELECT * FROM history WHERE id = ?");
stmt.bind(1, id);
ResultRange result = stmt.execute();
if (result.empty) return Optional!History.empty;
return Optional!History.of(parseHistory(result.front));
}
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit) {
import std.conv;
import std.algorithm;
import std.array;
const query = q"SQL
SELECT * FROM history_item
WHERE history_id = ? AND timestamp <= ?
ORDER BY timestamp DESC
LIMIT
SQL";
Statement stmt = db.prepare(query ~ " " ~ to!string(limit));
stmt.bind(1, historyId);
stmt.bind(2, timestamp.toISOExtString());
ResultRange result = stmt.execute();
return result.map!(r => parseItem(r)).array;
}
HistoryItemText getTextItem(ulong itemId) {
Statement stmt = db.prepare("SELECT * FROM history_item_text WHERE item_id = ?");
stmt.bind(1, itemId);
ResultRange result = stmt.execute();
if (result.empty) throw new Exception("No history item exists.");
return parseTextItem(result.front);
}
void deleteById(ulong id) {
Statement stmt = db.prepare("DELETE FROM history WHERE id = ?");
stmt.bind(1, id);
stmt.execute();
}
static History parseHistory(Row row) {
return History(row.peek!ulong(0));
}
static HistoryItem parseItem(Row row) {
HistoryItem item;
item.id = row.peek!ulong(0);
item.historyId = row.peek!ulong(1);
item.timestamp = SysTime.fromISOExtString(row.peek!string(2));
item.type = getHistoryItemType(row.peek!string(3));
return item;
}
static HistoryItemText parseTextItem(Row row) {
HistoryItemText item;
item.itemId = row.peek!ulong(0);
item.content = row.peek!string(1);
return item;
}
}

View File

@ -0,0 +1,31 @@
module history.model;
import std.datetime.systime;
struct History {
ulong id;
}
enum HistoryItemType : string {
TEXT = "TEXT"
}
HistoryItemType getHistoryItemType(string text) {
import std.traits;
static foreach (t; EnumMembers!HistoryItemType) {
if (text == t) return t;
}
throw new Exception("Unknown history item type: " ~ text);
}
struct HistoryItem {
ulong id;
ulong historyId;
SysTime timestamp;
HistoryItemType type;
}
struct HistoryItemText {
ulong itemId;
string content;
}

View File

@ -1,20 +0,0 @@
module model.account;
import model.base;
import std.datetime;
struct Account {
ulong id;
SysTime createdAt;
bool archived;
string type;
string numberSuffix;
string name;
string currency;
string description;
}
struct AccountCreditCardProperties {
ulong account_id;
string creditLimit;
}

View File

@ -1,8 +0,0 @@
module model;
public import model.base;
public import model.account;
public import model.transaction;
// Additional utility imports used often alongside models.
public import handy_httpd.components.optional;

View File

@ -1,4 +1,4 @@
module model.currency;
module money.currency;
struct Currency {
const char[3] code;

View File

@ -1,214 +0,0 @@
/**
* This module contains everything related to a user's Profiles, including
* data repositories, API endpoints, models, and logic.
*/
module profile;
import handy_httpd;
import handy_httpd.components.optional;
import auth.model : User;
import auth.service : AuthContext, getAuthContext;
import data;
import model;
import std.json;
import asdf;
const DEFAULT_USERS_DIR = "users";
/**
* A profile is a complete set of Finnow financial data, all stored in one
* single database file. The profile's name is used to lookup the database
* partition for its data. A user may own multiple profiles.
*/
class Profile {
string name;
this(string name) {
this.name = name;
}
override int opCmp(Object other) const {
if (Profile p = cast(Profile) other) {
return this.name < p.name;
}
return 1;
}
}
/**
* Validates a profile name.
* Params:
* name = The name to check.
* Returns: True if the profile name is valid.
*/
bool validateProfileName(string name) {
import std.regex;
import std.uni : toLower;
if (name is null || name.length < 3) return false;
auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`);
return !matchFirst(name, r).empty;
}
interface ProfileRepository {
Optional!Profile findByName(string name);
Profile createProfile(string name);
Profile[] findAll();
void deleteByName(string name);
DataSource getDataSource(in Profile profile);
}
class FileSystemProfileRepository : ProfileRepository {
import std.path;
import std.file;
import data;
import data.sqlite;
private const string usersDir;
private const string username;
this(string usersDir, string username) {
this.usersDir = usersDir;
this.username = username;
}
this(string username) {
this(DEFAULT_USERS_DIR, username);
}
Optional!Profile findByName(string name) {
string path = getProfilePath(name);
if (!exists(path)) return Optional!Profile.empty;
return Optional!Profile.of(new Profile(name));
}
Profile createProfile(string name) {
string path = getProfilePath(name);
if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists.");
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
DataSource ds = new SqliteDataSource(path);
import std.datetime;
auto propsRepo = ds.getPropertiesRepository();
propsRepo.setProperty("name", name);
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
propsRepo.setProperty("user", username);
return new Profile(name);
}
Profile[] findAll() {
string profilesDir = getProfilesDir();
if (!exists(profilesDir)) return [];
Profile[] profiles;
foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) {
import std.string : endsWith;
const suffix = ".sqlite";
if (endsWith(entry.name, suffix)) {
string profileName = baseName(entry.name, suffix);
profiles ~= new Profile(profileName);
}
}
import std.algorithm.sorting : sort;
sort(profiles);
return profiles;
}
void deleteByName(string name) {
string path = getProfilePath(name);
if (exists(path)) {
std.file.remove(path);
}
}
DataSource getDataSource(in Profile profile) {
return new SqliteDataSource(getProfilePath(profile.name));
}
private string getProfilesDir() {
return buildPath(this.usersDir, username, "profiles");
}
private string getProfilePath(string name) {
return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite");
}
}
// API Endpoints Below Here!
void handleCreateNewProfile(ref HttpRequestContext ctx) {
JSONValue obj = ctx.request.readBodyAsJson();
string name = obj.object["name"].str;
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.createProfile(name);
}
void handleGetProfiles(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
Profile[] profiles = profileRepo.findAll();
ctx.response.writeBodyString(serializeToJson(profiles), "application/json");
}
void handleDeleteProfile(ref HttpRequestContext ctx) {
string name = ctx.request.getPathParamAs!string("name");
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.deleteByName(name);
}
void handleGetProperties(ref HttpRequestContext ctx) {
ProfileContext profileCtx = getProfileContextOrThrow(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
DataSource ds = profileRepo.getDataSource(profileCtx.profile);
auto propsRepo = ds.getPropertiesRepository();
ProfileProperty[] props = propsRepo.findAll();
ctx.response.writeBodyString(serializeToJson(props), "application/json");
}
/// Contextual information that's available when handling requests under a profile.
struct ProfileContext {
const Profile profile;
const User user;
}
/**
* Tries to get a profile context from a request context. This will attempt to
* extract a "profile" path parameter and the authenticated user, and combine
* them into the ProfileContext.
* Params:
* ctx = The request context to read.
* Returns: An optional profile context.
*/
Optional!ProfileContext getProfileContext(ref HttpRequestContext ctx) {
import auth.service : AuthContext, getAuthContext;
if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty;
string profileName = ctx.request.pathParams["profile"];
if (!validateProfileName(profileName)) return Optional!ProfileContext.empty;
AuthContext authCtx = getAuthContext(ctx);
if (authCtx is null) return Optional!ProfileContext.empty;
User user = authCtx.user;
ProfileRepository repo = new FileSystemProfileRepository("users", user.username);
return repo.findByName(profileName)
.mapIfPresent!(p => ProfileContext(p, user));
}
/**
* Similar to `getProfileContext`, but throws an HttpStatusException with a
* 404 NOT FOUND status if no profile context could be obtained.
* Params:
* ctx = The request context to read.
* Returns: The profile context that was obtained.
*/
ProfileContext getProfileContextOrThrow(ref HttpRequestContext ctx) {
return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}

View File

@ -0,0 +1,54 @@
module profile.api;
import std.json;
import handy_httpd;
import asdf;
import profile.model;
import profile.service;
import profile.data;
import profile.data_impl_sqlite;
import auth.model;
import auth.service;
void handleCreateNewProfile(ref HttpRequestContext ctx) {
JSONValue obj = ctx.request.readBodyAsJson();
string name = obj.object["name"].str;
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.createProfile(name);
}
void handleGetProfiles(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
Profile[] profiles = profileRepo.findAll();
ctx.response.writeBodyString(serializeToJson(profiles), "application/json");
}
void handleDeleteProfile(ref HttpRequestContext ctx) {
string name = ctx.request.getPathParamAs!string("name");
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.deleteByName(name);
}
void handleGetProperties(ref HttpRequestContext ctx) {
ProfileContext profileCtx = getProfileContextOrThrow(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile);
auto propsRepo = ds.getPropertiesRepository();
ProfileProperty[] props = propsRepo.findAll();
ctx.response.writeBodyString(serializeToJson(props), "application/json");
}

View File

@ -0,0 +1,30 @@
module profile.data;
import handy_httpd.components.optional;
import profile.model;
interface ProfileRepository {
Optional!Profile findByName(string name);
Profile createProfile(string name);
Profile[] findAll();
void deleteByName(string name);
ProfileDataSource getDataSource(in Profile profile);
}
interface PropertiesRepository {
Optional!string findProperty(string propertyName);
void setProperty(string name, string value);
void deleteProperty(string name);
ProfileProperty[] findAll();
}
/**
* A data source for all data contained within a profile. This serves as the
* gateway for all data access operations for a profile.
*/
interface ProfileDataSource {
import account.data : AccountRepository;
PropertiesRepository getPropertiesRepository();
AccountRepository getAccountRepository();
}

View File

@ -0,0 +1,171 @@
module profile.data_impl_sqlite;
import slf4d;
import handy_httpd.components.optional;
import handy_httpd.components.handler : HttpStatusException;
import handy_httpd.components.response : HttpStatus;
import d2sqlite3;
import profile.data;
import profile.model;
import util.sqlite;
const DEFAULT_USERS_DIR = "users";
class FileSystemProfileRepository : ProfileRepository {
import std.path;
import std.file;
private const string usersDir;
private const string username;
this(string usersDir, string username) {
this.usersDir = usersDir;
this.username = username;
}
this(string username) {
this(DEFAULT_USERS_DIR, username);
}
Optional!Profile findByName(string name) {
string path = getProfilePath(name);
if (!exists(path)) return Optional!Profile.empty;
return Optional!Profile.of(new Profile(name));
}
Profile createProfile(string name) {
string path = getProfilePath(name);
if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists.");
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
ProfileDataSource ds = new SqliteProfileDataSource(path);
import std.datetime;
auto propsRepo = ds.getPropertiesRepository();
propsRepo.setProperty("name", name);
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
propsRepo.setProperty("user", username);
return new Profile(name);
}
Profile[] findAll() {
string profilesDir = getProfilesDir();
if (!exists(profilesDir)) return [];
Profile[] profiles;
foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) {
import std.string : endsWith;
const suffix = ".sqlite";
if (endsWith(entry.name, suffix)) {
string profileName = baseName(entry.name, suffix);
profiles ~= new Profile(profileName);
}
}
import std.algorithm.sorting : sort;
sort(profiles);
return profiles;
}
void deleteByName(string name) {
string path = getProfilePath(name);
if (exists(path)) {
std.file.remove(path);
}
}
ProfileDataSource getDataSource(in Profile profile) {
return new SqliteProfileDataSource(getProfilePath(profile.name));
}
private string getProfilesDir() {
return buildPath(this.usersDir, username, "profiles");
}
private string getProfilePath(string name) {
return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite");
}
}
class SqlitePropertiesRepository : PropertiesRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!string findProperty(string propertyName) {
return findOne(
db,
"SELECT value FROM profile_property WHERE property = ?",
r => r.peek!string(0),
propertyName
);
// Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?");
// stmt.bind(1, propertyName);
// ResultRange result = stmt.execute();
// if (result.empty) return Optional!string.empty;
// return Optional!string.of(result.front.peek!string(0));
}
void setProperty(string name, string value) {
if (findProperty(name).isNull) {
Statement stmt = this.db.prepare("INSERT INTO profile_property (property, value) VALUES (?, ?)");
stmt.bind(1, name);
stmt.bind(2, value);
stmt.execute();
} else {
Statement stmt = this.db.prepare("UPDATE profile_property SET value = ? WHERE property = ?");
stmt.bind(1, value);
stmt.bind(2, name);
stmt.execute();
}
}
void deleteProperty(string name) {
Statement stmt = this.db.prepare("DELETE FROM profile_property WHERE property = ?");
stmt.bind(1, name);
stmt.execute();
}
ProfileProperty[] findAll() {
Statement stmt = this.db.prepare("SELECT * FROM profile_property ORDER BY property ASC");
ResultRange result = stmt.execute();
ProfileProperty[] props;
foreach (Row row; result) {
ProfileProperty prop;
prop.property = row.peek!string("property");
prop.value = row.peek!string("value");
props ~= prop;
}
return props;
}
}
/**
* An SQLite implementation of the ProfileDataSource that uses a single
* database connection to initialize various entity data access objects lazily.
*/
class SqliteProfileDataSource : ProfileDataSource {
import account.data;
import account.data_impl_sqlite;
const SCHEMA = import("schema.sql");
private const string dbPath;
private Database db;
this(string path) {
this.dbPath = path;
import std.file : exists;
bool needsInit = !exists(path);
this.db = Database(path);
if (needsInit) {
infoF!"Initializing database: %s"(dbPath);
db.run(SCHEMA);
}
}
PropertiesRepository getPropertiesRepository() {
return new SqlitePropertiesRepository(db);
}
AccountRepository getAccountRepository() {
return new SqliteAccountRepository(db);
}
}

View File

@ -0,0 +1,28 @@
module profile.model;
import profile.data;
/**
* A profile is a complete set of Finnow financial data, all stored in one
* single database file. The profile's name is used to lookup the database
* partition for its data. A user may own multiple profiles.
*/
class Profile {
string name;
this(string name) {
this.name = name;
}
override int opCmp(Object other) const {
if (Profile p = cast(Profile) other) {
return this.name < p.name;
}
return 1;
}
}
struct ProfileProperty {
string property;
string value;
}

View File

@ -0,0 +1,6 @@
/**
* This module defines all components pertaining to "Profiles", that is,
* isolated Finnow databases that belong to a single user. Each profile is
* completely separate from all others.
*/
module profile;

View File

@ -0,0 +1,85 @@
module profile.service;
import handy_httpd;
import handy_httpd.components.optional;
import profile.model;
import profile.data;
import profile.data_impl_sqlite;
import auth.model;
/**
* Validates a profile name.
* Params:
* name = The name to check.
* Returns: True if the profile name is valid.
*/
bool validateProfileName(string name) {
import std.regex;
import std.uni : toLower;
if (name is null || name.length < 3) return false;
auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`);
return !matchFirst(name, r).empty;
}
/// Contextual information that's available when handling requests under a profile.
struct ProfileContext {
const Profile profile;
const User user;
}
/**
* Tries to get a profile context from a request context. This will attempt to
* extract a "profile" path parameter and the authenticated user, and combine
* them into the ProfileContext.
* Params:
* ctx = The request context to read.
* Returns: An optional profile context.
*/
Optional!ProfileContext getProfileContext(in HttpRequestContext ctx) {
import auth.service : AuthContext, getAuthContext;
if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty;
string profileName = ctx.request.pathParams["profile"];
if (!validateProfileName(profileName)) return Optional!ProfileContext.empty;
AuthContext authCtx = getAuthContext(ctx);
if (authCtx is null) return Optional!ProfileContext.empty;
User user = authCtx.user;
ProfileRepository repo = new FileSystemProfileRepository(user.username);
return repo.findByName(profileName)
.mapIfPresent!(p => ProfileContext(p, user));
}
/**
* Similar to `getProfileContext`, but throws an HttpStatusException with a
* 404 NOT FOUND status if no profile context could be obtained.
* Params:
* ctx = The request context to read.
* Returns: The profile context that was obtained.
*/
ProfileContext getProfileContextOrThrow(in HttpRequestContext ctx) {
return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
/**
* Obtains a ProfileDataSource from a ProfileContext. Use this to get access
* to all profile data when handling an HTTP request, for example.
* Params:
* pc = The profile context.
* Returns: The profile data source.
*/
ProfileDataSource getProfileDataSource(in ProfileContext pc) {
ProfileRepository profileRepo = new FileSystemProfileRepository(pc.user.username);
return profileRepo.getDataSource(pc.profile);
}
/**
* Obtains a ProfileDataSource from an HTTP request context. Use this to
* access all profile data when handling the request.
* Params:
* ctx = The request context.
* Returns: The profile data source.
*/
ProfileDataSource getProfileDataSource(in HttpRequestContext ctx) {
ProfileContext pc = getProfileContextOrThrow(ctx);
return getProfileDataSource(pc);
}

View File

@ -1,7 +1,8 @@
module model.transaction;
module transaction.model;
import std.datetime;
import model.currency;
import money.currency;
struct TransactionVendor {
ulong id;
@ -25,9 +26,9 @@ struct Transaction {
ulong id;
SysTime timestamp;
SysTime addedAt;
string amount;
ulong amount;
Currency currency;
string description;
ulong vendorId;
ulong categoryId;
}
}

View File

@ -0,0 +1,94 @@
module util.sqlite;
import slf4d;
import handy_httpd.components.optional;
import d2sqlite3;
/**
* Tries to find a single row from a database.
* Params:
* db = The database to use.
* query = The query to execute.
* resultMapper = A function to map rows to the desired result type.
* args = Arguments for the query.
* Returns: An optional result.
*/
Optional!T findOne(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
ResultRange result = stmt.execute();
if (result.empty) return Optional!T.empty;
return Optional!T.of(resultMapper(result.front));
}
/**
* Finds a list of records from a database.
* Params:
* db = The database to use.
* query = The query to execute.
* resultMapper = A function to map rows to the desired result type.
* args = Arguments for the query.
* Returns: A list of results.
*/
T[] findAll(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
import std.algorithm : map;
import std.array : array;
return stmt.execute().map!(r => resultMapper(r)).array;
}
/**
* Determines if at least one record exists.
* Params:
* db = The database to use.
* query = The query to execute.
* args = The arguments for the query.
* Returns: True if at least one record is returned, or false if not.
*/
bool exists(Args...)(Database db, string query, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
return !stmt.execute().empty();
}
/**
* Performs an update (UPDATE/INSERT/DELETE).
* Params:
* db = The database to use.
* query = The query to execute.
* args = The arguments for the query.
* Returns: The number of rows that were affected.
*/
int update(Args...)(Database db, string query, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
stmt.execute();
return db.changes();
}
/**
* Wraps a given delegate block of code in an SQL transaction, so that all
* operations will be committed at once when done. If an exception is thrown,
* then the changes will be rolled back.
* Params:
* db = The database to use.
* dg = The delegate block of code to run in the transaction.
* Returns: The return value of the delegate.
*/
T doTransaction(T)(Database db, T delegate() dg) {
try {
db.begin();
static if (is(T : void)) {
dg();
} else {
T result = dg();
}
db.commit();
static if (!is(T : void)) return result;
} catch (Exception e) {
error("Rolling back transaction due to exception.", e);
db.rollback();
throw e;
}
}