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 (
|
||||
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
|
||||
);
|
||||
|
|
|
@ -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.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;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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 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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.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];
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
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 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;
|
||||
}
|
||||
}
|
|
@ -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