Another major refactor, into modular monolith style application.
This commit is contained in:
parent
e198f45b92
commit
31b7a929f6
|
@ -31,7 +31,7 @@ CREATE TABLE account (
|
||||||
|
|
||||||
CREATE TABLE account_credit_card_properties (
|
CREATE TABLE account_credit_card_properties (
|
||||||
account_id INTEGER PRIMARY KEY,
|
account_id INTEGER PRIMARY KEY,
|
||||||
credit_limit TEXT,
|
credit_limit INTEGER,
|
||||||
CONSTRAINT fk_account_credit_card_properties_account
|
CONSTRAINT fk_account_credit_card_properties_account
|
||||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
@ -64,7 +64,7 @@ CREATE TABLE "transaction" (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
amount TEXT NOT NULL,
|
amount INTEGER NOT NULL,
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
vendor_id INTEGER,
|
vendor_id INTEGER,
|
||||||
|
@ -77,4 +77,132 @@ CREATE TABLE "transaction" (
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
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
|
||||||
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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) {}
|
|
@ -1,50 +1,10 @@
|
||||||
import slf4d;
|
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
import handy_httpd.handlers.path_handler;
|
import api_mapping;
|
||||||
import handy_httpd.handlers.filtered_handler;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
ServerConfig cfg;
|
ServerConfig cfg;
|
||||||
cfg.workerPoolSize = 5;
|
cfg.workerPoolSize = 5;
|
||||||
cfg.port = 8080;
|
cfg.port = 8080;
|
||||||
HttpServer server = new HttpServer(buildHandlers(), cfg);
|
HttpServer server = new HttpServer(mapApiHandlers(), cfg);
|
||||||
server.start();
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
module model.base;
|
module attachment.model;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
|
||||||
struct ProfileProperty {
|
|
||||||
string property;
|
|
||||||
string value;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Attachment {
|
struct Attachment {
|
||||||
ulong id;
|
ulong id;
|
||||||
SysTime uploadedAt;
|
SysTime uploadedAt;
|
|
@ -4,16 +4,17 @@ module auth.api;
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
import asdf;
|
||||||
|
|
||||||
import auth.model;
|
import auth.model;
|
||||||
import auth.dao;
|
import auth.data;
|
||||||
import auth.dto;
|
|
||||||
import auth.service;
|
import auth.service;
|
||||||
|
import auth.data_impl_fs;
|
||||||
|
|
||||||
void postLogin(ref HttpRequestContext ctx) {
|
void postLogin(ref HttpRequestContext ctx) {
|
||||||
LoginCredentials loginCredentials;
|
LoginCredentials loginCredentials;
|
||||||
try {
|
try {
|
||||||
loginCredentials = LoginCredentials.parse(ctx.request.readBodyAsJson());
|
loginCredentials = deserialize!(LoginCredentials)(ctx.request.readBodyAsString());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
}
|
}
|
||||||
|
@ -34,13 +35,14 @@ void postLogin(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
string token = generateAccessToken(optionalUser.value);
|
string token = generateAccessToken(optionalUser.value);
|
||||||
ctx.response.status = HttpStatus.OK;
|
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) {
|
void postRegister(ref HttpRequestContext ctx) {
|
||||||
RegistrationData registrationData;
|
RegistrationData registrationData;
|
||||||
try {
|
try {
|
||||||
registrationData = RegistrationData.parse(ctx.request.readBodyAsJson());
|
registrationData = deserialize!(RegistrationData)(ctx.request.readBodyAsString());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,13 +1,9 @@
|
||||||
module auth.dao;
|
module auth.data_impl_fs;
|
||||||
|
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
import auth.model;
|
|
||||||
|
|
||||||
interface UserRepository {
|
import auth.data;
|
||||||
Optional!User findByUsername(string username);
|
import auth.model;
|
||||||
User createUser(string username, string passwordHash);
|
|
||||||
void deleteByUsername(string username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User implementation that stores each user's data in a separate directory.
|
* User implementation that stores each user's data in a separate directory.
|
||||||
|
@ -63,4 +59,4 @@ class FileSystemUserRepository : UserRepository {
|
||||||
private string getUserDataFile(string username) {
|
private string getUserDataFile(string username) {
|
||||||
return buildPath(this.usersDir, username, "user-data.json");
|
return buildPath(this.usersDir, username, "user-data.json");
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,11 +2,12 @@ module auth.service;
|
||||||
|
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
|
import handy_httpd.handlers.filtered_handler;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
|
||||||
import auth.model;
|
import auth.model;
|
||||||
import auth.dao;
|
import auth.data;
|
||||||
import handy_httpd.handlers.filtered_handler;
|
import auth.data_impl_fs;
|
||||||
|
|
||||||
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
|
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@ class AuthContext {
|
||||||
* ctx = The request context to get.
|
* ctx = The request context to get.
|
||||||
* Returns: The auth context that has been set.
|
* 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];
|
return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
module data.account;
|
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
module data;
|
|
||||||
|
|
||||||
public import data.base;
|
|
||||||
|
|
||||||
// Utility imports for items commonly used alongside data.
|
|
||||||
public import handy_httpd.components.optional;
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -1,4 +1,4 @@
|
||||||
module model.currency;
|
module money.currency;
|
||||||
|
|
||||||
struct Currency {
|
struct Currency {
|
||||||
const char[3] code;
|
const char[3] code;
|
|
@ -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));
|
|
||||||
}
|
|
|
@ -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");
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
module model.transaction;
|
module transaction.model;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import model.currency;
|
|
||||||
|
import money.currency;
|
||||||
|
|
||||||
struct TransactionVendor {
|
struct TransactionVendor {
|
||||||
ulong id;
|
ulong id;
|
||||||
|
@ -25,9 +26,9 @@ struct Transaction {
|
||||||
ulong id;
|
ulong id;
|
||||||
SysTime timestamp;
|
SysTime timestamp;
|
||||||
SysTime addedAt;
|
SysTime addedAt;
|
||||||
string amount;
|
ulong amount;
|
||||||
Currency currency;
|
Currency currency;
|
||||||
string description;
|
string description;
|
||||||
ulong vendorId;
|
ulong vendorId;
|
||||||
ulong categoryId;
|
ulong categoryId;
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue