Added bruno API thing, user account page, and user deletion logic.

This commit is contained in:
Andrew Lalis 2024-09-19 15:12:23 -04:00
parent 270dcc6020
commit f5c75ccf35
57 changed files with 1192 additions and 186 deletions

View File

@ -8,8 +8,8 @@ This project is set up as a _modular monolith_, where the API as a whole is brok
Within each module, you'll usually find some of the following submodules:
* `model.d` - Defines models for this module, often database entities.
* `model.d` - Defines models for this module, often database entities. Unless there's a **very** good reason for it, all entity attributes are marked as `const`. Entities are not mutable. They simply represent a view of what's in the database.
* `data.d` - Defines the data access interfaces and associated types, so that other modules can interact with it.
* `data_impl_*.d` - A concrete implementation of a submodule's data access interfaces, often using a specific technology or platform.
* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework.
* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules.
* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework, as well as data transfer objects that the API endpoints may consume or produce.
* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules. This is where transactional business logic lives.

View File

@ -0,0 +1,29 @@
meta {
name: Create Account
type: http
seq: 3
}
post {
url: {{base_url}}/profiles/:profile/accounts
body: json
auth: bearer
}
params:path {
profile: {{current_profile}}
}
auth:bearer {
token: {{access_token}}
}
body:json {
{
"type": "CHECKING",
"numberSuffix": "1234",
"name": "Testing Checking",
"currency": "USD",
"description": "A test account."
}
}

View File

@ -0,0 +1,20 @@
meta {
name: Delete Account
type: http
seq: 4
}
delete {
url: {{base_url}}/profiles/:profile/accounts/:accountId
body: none
auth: bearer
}
params:path {
profile: {{current_profile}}
accountId: 1
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,20 @@
meta {
name: Get Account
type: http
seq: 2
}
get {
url: {{base_url}}/profiles/:profile/accounts/:accountId
body: none
auth: bearer
}
params:path {
profile: {{current_profile}}
accountId: 1
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,19 @@
meta {
name: Get Accounts
type: http
seq: 1
}
get {
url: {{base_url}}/profiles/:name/accounts
body: none
auth: bearer
}
params:path {
name: {{profile}}
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,11 @@
meta {
name: Delete My User
type: http
seq: 4
}
delete {
url: {{base_url}}/me
body: none
auth: inherit
}

View File

@ -0,0 +1,19 @@
meta {
name: Login
type: http
seq: 1
}
post {
url: {{base_url}}/login
body: json
auth: none
}
body:json {
{
"username": "{{username}}",
"password": "{{password}}"
}
}

View File

@ -0,0 +1,11 @@
meta {
name: My User
type: http
seq: 3
}
get {
url: {{base_url}}/me
body: none
auth: inherit
}

View File

@ -0,0 +1,19 @@
meta {
name: Register
type: http
seq: 2
}
post {
url: {{base_url}}/register
body: json
auth: none
}
body:json {
{
"username": "testuser",
"password": "testpass"
}
}

View File

@ -0,0 +1,18 @@
meta {
name: Generate Sample Data
type: http
seq: 1
}
post {
url: {{base_url}}/sample-data
body: none
auth: none
}
docs {
Wipes and re-generates a set of sample accounts in the Finnow API. Only available in the development version.
The response is returned immediately, but the sample data generation takes a few seconds to complete.
}

View File

@ -0,0 +1,21 @@
meta {
name: Create Profile
type: http
seq: 1
}
post {
url: {{base_url}}/profiles
body: json
auth: bearer
}
auth:bearer {
token: {{access_token}}
}
body:json {
{
"name": "{{current_profile}}"
}
}

View File

@ -0,0 +1,19 @@
meta {
name: Delete Profile
type: http
seq: 2
}
delete {
url: {{base_url}}/profiles/:name
body: none
auth: bearer
}
params:path {
name: {{profile}}
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,15 @@
meta {
name: Get Profiles
type: http
seq: 3
}
get {
url: {{base_url}}/profiles
body: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,19 @@
meta {
name: Get Properties
type: http
seq: 4
}
get {
url: {{base_url}}/profiles/:name/properties
body: none
auth: bearer
}
params:path {
name: {{profile}}
}
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,9 @@
{
"version": "1",
"name": "Finnow",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@ -0,0 +1,59 @@
auth {
mode: bearer
}
auth:bearer {
token: {{access_token}}
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("base_url");
// Before each request, check and refresh access_token if needed.
if (req.getAuthMode() === "bearer") {
await checkAuth();
}
async function checkAuth() {
const access_token = bru.getEnvVar("access_token");
if (!access_token || access_token === "null") {
console.info("No access token is present, refreshing...");
await refreshAuth();
return;
}
// Access token exists, check that it's still valid.
try {
const resp = await axios.get(baseUrl + "/me", {
headers: {"Authorization": "Bearer " + access_token}
});
if (resp.status === 200) {
return;
} else if (resp.status === 401) {
await refreshAuth();
} else {
throw resp;
}
} catch (error) {
console.error(error);
}
}
async function refreshAuth() {
const payload = {
username: bru.getEnvVar("username"),
password: bru.getEnvVar("password")
};
const resp = await axios.post(baseUrl + "/login", payload, {
headers: {"Content-Type": "application/json"}
});
if (resp.status === 200) {
bru.setEnvVar("access_token", resp.data.token);
console.info("Refreshed access token.");
} else {
throw resp;
}
}
}

View File

@ -0,0 +1,9 @@
vars {
username: testuser0
password: testpass
profile: test-profile-0
base_url: http://localhost:8080/api
}
vars:secret [
access_token
]

View File

@ -5,6 +5,7 @@
"botan": "1.13.6",
"botan-math": "1.0.4",
"d2sqlite3": "1.0.0",
"handy-httpd": {"path":"../../../github-andrewlalis/handy-httpd"},
"httparsed": "1.2.1",
"jwt": "0.4.0",
"memutils": "1.0.10",

View File

@ -49,6 +49,7 @@ CREATE TABLE transaction_category (
id INTEGER PRIMARY KEY,
parent_id INTEGER,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT NOT NULL DEFAULT 'FFFFFF',
CONSTRAINT fk_transaction_category_parent
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)

View File

@ -1,12 +1,17 @@
/**
* This module defines the API endpoints for dealing with Accounts directly,
* including any data-transfer objects that are needed.
*/
module account.api;
import handy_httpd;
import profile.service;
import account.model;
import money.currency;
import util.money;
import util.json;
/// The data the API provides for an Account entity.
struct AccountResponse {
ulong id;
string createdAt;
@ -33,9 +38,10 @@ struct AccountResponse {
void handleGetAccounts(ref HttpRequestContext ctx) {
import std.algorithm;
import std.array;
auto ds = getProfileDataSource(ctx);
auto accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a));
.map!(a => AccountResponse.of(a)).array;
writeJsonBody(ctx, accounts);
}
@ -47,6 +53,7 @@ void handleGetAccount(ref HttpRequestContext ctx) {
writeJsonBody(ctx, AccountResponse.of(account));
}
// The data provided by a user to create a new account.
struct AccountCreationPayload {
string type;
string numberSuffix;
@ -58,6 +65,7 @@ struct AccountCreationPayload {
void handleCreateAccount(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
// TODO: Validate the account creation payload.
AccountType type = AccountType.fromId(payload.type);
Currency currency = Currency.ofCode(payload.currency);
Account account = ds.getAccountRepository().insert(

View File

@ -3,7 +3,7 @@ module account.data;
import handy_httpd.components.optional;
import account.model;
import money.currency;
import util.money;
import history.model;
interface AccountRepository {

View File

@ -7,9 +7,9 @@ import handy_httpd.components.optional;
import account.data;
import account.model;
import money.currency;
import history.model;
import util.sqlite;
import util.money;
class SqliteAccountRepository : AccountRepository {
private Database db;
@ -18,27 +18,22 @@ class SqliteAccountRepository : AccountRepository {
}
Optional!Account findById(ulong id) {
return findOne(
db,
"SELECT * FROM account WHERE id = ?",
&parseAccount,
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
util.sqlite.update(
db,
"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();
VALUES (?, ?, ?, ?, ?, ?)",
Clock.currTime(UTC()).toISOExtString(),
type.id,
numberSuffix,
name,
currency.code,
description
);
ulong accountId = db.lastInsertRowid();
return findById(accountId).orElseThrow("Couldn't find account!");
}
@ -111,9 +106,7 @@ SQL",
);
if (!optionalProps.isNull) return optionalProps.value;
// No properties exist, so set them and return the new data.
AccountCreditCardProperties props;
props.account_id = account.id;
props.creditLimit = -1;
const props = AccountCreditCardProperties(account.id, -1);
util.sqlite.update(
db,
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",

View File

@ -1,18 +1,18 @@
module account.model;
import std.datetime;
import std.traits : EnumMembers;
import money.currency;
import util.money;
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;
static foreach (t; ALL_ACCOUNT_TYPES) {
if (t.id == id) return t;
}
throw new Exception("Invalid account type id " ~ id);
}
@ -25,18 +25,20 @@ enum AccountTypes : AccountType {
BROKERAGE = AccountType("BROKERAGE", "Brokerage", true)
}
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
struct Account {
ulong id;
SysTime createdAt;
bool archived;
AccountType type;
string numberSuffix;
string name;
Currency currency;
string description;
const ulong id;
const SysTime createdAt;
const bool archived;
const AccountType type;
const string numberSuffix;
const string name;
const Currency currency;
const string description;
}
struct AccountCreditCardProperties {
ulong account_id;
long creditLimit;
const ulong account_id;
const long creditLimit;
}

View File

@ -0,0 +1,6 @@
module account;
public import account.api;
public import account.data;
public import account.data_impl_sqlite;
public import account.model;

View File

@ -17,6 +17,9 @@ PathHandler mapApiHandlers() {
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
h.addMapping(Method.POST, API_PATH ~ "/sample-data", &sampleDataEndpoint);
// Auth endpoints:
import auth.api;
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
@ -26,6 +29,7 @@ PathHandler mapApiHandlers() {
// Authenticated endpoints:
PathHandler a = new PathHandler();
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
import profile.api;
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
@ -58,3 +62,10 @@ private void getStatus(ref HttpRequestContext ctx) {
private void getOptions(ref HttpRequestContext ctx) {
}
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
import util.sample_data;
import core.thread;
Thread t = new Thread(() => generateSampleData());
t.start();
}

View File

@ -11,10 +11,22 @@ import auth.service;
import auth.data_impl_fs;
import util.json;
/// 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 {
const string token;
}
void postLogin(ref HttpRequestContext ctx) {
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
if (!validateUsername(loginCredentials.username)) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString("Username is not valid.");
return;
}
UserRepository userRepo = new FileSystemUserRepository();
@ -33,6 +45,10 @@ void postLogin(ref HttpRequestContext ctx) {
writeJsonBody(ctx, TokenResponse(token));
}
struct UsernameAvailabilityResponse {
const bool available;
}
void getUsernameAvailability(ref HttpRequestContext ctx) {
Optional!string username = ctx.request.queryParams.getFirst("username");
if (username.isNull) {
@ -45,6 +61,11 @@ void getUsernameAvailability(ref HttpRequestContext ctx) {
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
}
struct RegistrationData {
string username;
string password;
}
void postRegister(ref HttpRequestContext ctx) {
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
if (!validateUsername(registrationData.username)) {
@ -63,13 +84,8 @@ void postRegister(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("Username is taken.");
return;
}
import botan.passhash.bcrypt : generateBcrypt;
import botan.rng.auto_rng;
RandomNumberGenerator rng = new AutoSeededRNG();
string passwordHash = generateBcrypt(registrationData.password, rng, 12);
User user = userRepo.createUser(registrationData.username, passwordHash);
User user = createNewUser(userRepo, registrationData.username, registrationData.password);
infoF!"Created user: %s"(registrationData.username);
string token = generateAccessToken(user);
writeJsonBody(ctx, TokenResponse(token));
@ -79,3 +95,10 @@ void getMyUser(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
ctx.response.writeBodyString(auth.user.username);
}
void deleteMyUser(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
UserRepository userRepo = new FileSystemUserRepository();
deleteUser(auth.user, userRepo);
infoF!"Deleted user: %s"(auth.user.username);
}

View File

@ -3,28 +3,9 @@ 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 UsernameAvailabilityResponse {
bool available;
}
struct RegistrationData {
string username;
string password;
}
interface UserRepository {
Optional!User findByUsername(string username);
User[] findAll();
User createUser(string username, string passwordHash);
void deleteByUsername(string username);
}

View File

@ -27,11 +27,16 @@ class FileSystemUserRepository : UserRepository {
) {
return Optional!User.empty;
}
JSONValue userObj = parseJSON(readText(getUserDataFile(username)));
return Optional!User.of(User(
username,
userObj.object["passwordHash"].str
));
return Optional!User.of(readUser(username));
}
User[] findAll() {
User[] users;
foreach (DirEntry entry; dirEntries(this.usersDir, SpanMode.shallow, false)) {
string username = baseName(entry.name);
users ~= readUser(username);
}
return users;
}
User createUser(string username, string passwordHash) {
@ -59,4 +64,15 @@ class FileSystemUserRepository : UserRepository {
private string getUserDataFile(string username) {
return buildPath(this.usersDir, username, "user-data.json");
}
private User readUser(string username) {
if (!exists(getUserDataFile(username))) {
throw new Exception("User data file for " ~ username ~ " doesn't exist.");
}
JSONValue userObj = parseJSON(readText(getUserDataFile(username)));
return User(
username,
userObj.object["passwordHash"].str
);
}
}

View File

@ -0,0 +1,7 @@
module auth;
public import auth.api;
public import auth.data;
public import auth.data_impl_fs;
public import auth.model;
public import auth.service;

View File

@ -11,6 +11,18 @@ import auth.data_impl_fs;
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
User createNewUser(UserRepository repo, string username, string password) {
import botan.passhash.bcrypt : generateBcrypt;
import botan.rng.auto_rng;
RandomNumberGenerator rng = new AutoSeededRNG();
string passwordHash = generateBcrypt(password, rng, 12);
return repo.createUser(username, passwordHash);
}
void deleteUser(User user, UserRepository repo) {
repo.deleteByUsername(user.username);
}
/**
* Generates a new JWT access token for a user.
* Params:

View File

@ -10,5 +10,6 @@ interface HistoryRepository {
Optional!History findById(ulong id);
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit);
HistoryItemText getTextItem(ulong itemId);
void addTextItem(ulong historyId, SysTime timestamp, string text);
void deleteById(ulong id);
}

View File

@ -7,6 +7,7 @@ import d2sqlite3;
import history.data;
import history.model;
import util.sqlite;
class SqliteHistoryRepository : HistoryRepository {
private Database db;
@ -47,6 +48,15 @@ SQL";
return parseTextItem(result.front);
}
void addTextItem(ulong historyId, SysTime timestamp, string text) {
ulong itemId = addItem(historyId, timestamp, HistoryItemType.TEXT);
update(
db,
"INSERT INTO history_item_text (item_id, content) VALUES (?, ?)",
itemId, text
);
}
void deleteById(ulong id) {
Statement stmt = db.prepare("DELETE FROM history WHERE id = ?");
stmt.bind(1, id);
@ -72,4 +82,13 @@ SQL";
item.content = row.peek!string(1);
return item;
}
private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) {
update(
db,
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
historyId, timestamp, type
);
return db.lastInsertRowid();
}
}

View File

@ -1,33 +0,0 @@
module money.currency;
struct Currency {
const char[3] code;
const ubyte fractionalDigits;
const ushort numericCode;
import std.traits : isSomeString, EnumMembers;
static Currency ofCode(S)(S code) if (isSomeString!S) {
if (code.length != 3) {
throw new Exception("Invalid currency code: " ~ code);
}
static foreach (c; EnumMembers!Currencies) {
if (c.code == code) return c;
}
throw new Exception("Unknown currency code: " ~ code);
}
}
enum Currencies : Currency {
USD = Currency("USD", 2, 840),
CAD = Currency("CAD", 2, 124),
GBP = Currency("GBP", 2, 826),
EUR = Currency("EUR", 2, 978),
CHF = Currency("CHF", 2, 756),
ZAR = Currency("ZAR", 2, 710),
JPY = Currency("JPY", 0, 392),
INR = Currency("INR", 2, 356)
}
unittest {
assert(Currency.ofCode("USD") == Currencies.USD);
}

View File

@ -3,6 +3,7 @@ module profile.data;
import handy_httpd.components.optional;
import profile.model;
/// Repository for interacting with the set of profiles belonging to a user.
interface ProfileRepository {
Optional!Profile findByName(string name);
Profile createProfile(string name);

View File

@ -12,6 +12,7 @@ import util.sqlite;
const DEFAULT_USERS_DIR = "users";
/// Profile repository that uses an SQLite3 database file for each profile.
class FileSystemProfileRepository : ProfileRepository {
import std.path;
import std.file;
@ -97,11 +98,6 @@ class SqlitePropertiesRepository : PropertiesRepository {
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) {

View File

@ -4,3 +4,9 @@
* completely separate from all others.
*/
module profile;
public import profile.api;
public import profile.data;
public import profile.data_impl_sqlite;
public import profile.model;
public import profile.service;

View File

@ -18,7 +18,7 @@ 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_]+$`);
auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_-]+$`);
return !matchFirst(name, r).empty;
}

View File

@ -0,0 +1,46 @@
module transaction.data;
import handy_httpd.components.optional;
import std.datetime;
import transaction.model;
import util.money;
interface TransactionVendorRepository {
Optional!TransactionVendor findById(ulong id);
TransactionVendor[] findAll();
bool existsByName(string name);
TransactionVendor insert(string name, string description);
void deleteById(ulong id);
TransactionVendor updateById(ulong id, string name, string description);
}
interface TransactionCategoryRepository {
Optional!TransactionCategory findById(ulong id);
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
void deleteById(ulong id);
TransactionCategory updateById(ulong id, string name, string description, string color);
}
interface TransactionTagRepository {
Optional!TransactionTag findById(ulong id);
Optional!TransactionTag findByName(string name);
TransactionTag[] findAll();
TransactionTag insert(string name);
void deleteById(ulong id);
}
interface TransactionRepository {
Optional!Transaction findById(ulong id);
Transaction insert(
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId
);
void deleteById(ulong id);
}

View File

@ -0,0 +1,181 @@
module transaction.data_impl_sqlite;
import handy_httpd.components.optional;
import std.datetime;
import d2sqlite3;
import transaction.model;
import transaction.data;
import util.sqlite;
import util.money;
class SqliteTransactionVendorRepository : TransactionVendorRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!TransactionVendor findById(ulong id) {
return util.sqlite.findById(db, "transaction_vendor", &parseVendor, id);
}
TransactionVendor[] findAll() {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_vendor ORDER BY name ASC",
&parseVendor
);
}
bool existsByName(string name) {
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
}
TransactionVendor insert(string name, string description) {
util.sqlite.update(
db,
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
name, description
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "transaction_vendor", id);
}
TransactionVendor updateById(ulong id, string name, string description) {
util.sqlite.update(
db,
"UPDATE transaction_vendor SET name = ?, description = ? WHERE id = ?",
name, description, id
);
return findById(id).orElseThrow();
}
private static TransactionVendor parseVendor(Row row) {
return TransactionVendor(
row.peek!ulong(0),
row.peek!string(1),
row.peek!string(2)
);
}
}
class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!TransactionCategory findById(ulong id) {
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
}
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
if (parentId) {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
&parseCategory,
parentId.value
);
}
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
&parseCategory
);
}
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color) {
util.sqlite.update(
db,
"INSERT INTO transaction_category
(parent_id, name, description, color)
VALUES (?, ?, ?, ?)",
parentId.asNullable(), name, description, color
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "transaction_category", id);
}
TransactionCategory updateById(ulong id, string name, string description, string color) {
util.sqlite.update(
db,
"UPDATE transaction_category
SET name = ?, description = ?, color = ?
WHERE id = ?",
name, description, color, id
);
return findById(id).orElseThrow();
}
private static TransactionCategory parseCategory(Row row) {
import std.typecons;
return TransactionCategory(
row.peek!ulong(0),
Optional!ulong.of(row.peek!(Nullable!ulong)(1)),
row.peek!string(2),
row.peek!string(3),
row.peek!string(4)
);
}
}
class SqliteTransactionTagRepository : TransactionTagRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!TransactionTag findById(ulong id) {
return findOne(db, "SELECT * FROM transaction_tag WHERE id = ?", &parseTag, id);
}
Optional!TransactionTag findByName(string name) {
return findOne(db, "SELECT * FROM transaction_tag WHERE name = ?", &parseTag, name);
}
TransactionTag[] findAll() {
return util.sqlite.findAll(
db,
"SELECT * FROM transaction_tag ORDER BY name ASC",
&parseTag
);
}
TransactionTag insert(string name) {
auto existingTag = findByName(name);
if (existingTag) {
return existingTag.value;
}
util.sqlite.update(
db,
"INSERT INTO transaction_tag (name) VALUES (?)",
name
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.update(
db,
"DELETE FROM transaction_tag WHERE id = ?",
id
);
}
private static TransactionTag parseTag(Row row) {
return TransactionTag(
row.peek!ulong(0),
row.peek!string(1)
);
}
}

View File

@ -1,34 +1,48 @@
module transaction.model;
import handy_httpd.components.optional;
import std.datetime;
import money.currency;
import util.money;
struct TransactionVendor {
ulong id;
string name;
string description;
const ulong id;
const string name;
const string description;
}
struct TransactionCategory {
ulong id;
ulong parentId;
string name;
string color;
const ulong id;
const Optional!ulong parentId;
const string name;
const string description;
const string color;
}
struct TransactionTag {
ulong id;
string name;
const ulong id;
const string name;
}
struct Transaction {
ulong id;
SysTime timestamp;
SysTime addedAt;
ulong amount;
Currency currency;
string description;
ulong vendorId;
ulong categoryId;
}
const ulong id;
/// The time at which the transaction happened.
const SysTime timestamp;
/// The time at which the transaction entity was saved.
const SysTime addedAt;
const ulong amount;
const Currency currency;
const string description;
const Optional!ulong vendorId;
const Optional!ulong categoryId;
}
struct TransactionLineItem {
const ulong id;
const ulong transactionId;
const long valuePerItem;
const ulong quantity;
const uint idx;
const string description;
const Optional!ulong categoryId;
}

View File

@ -0,0 +1,4 @@
module transaction;
public import transaction.data;
public import transaction.model;

View File

@ -8,7 +8,7 @@ import asdf;
/**
* Reads a JSON payload into a type T. Throws an `HttpStatusException` if
* the data cannot be read or converted to the given type, with a 400 BAD
* REQUEST status.
* REQUEST status. The type T should not have `const` members.
* Params:
* ctx = The request context to read from.
* Returns: The data that was read.

View File

@ -0,0 +1,83 @@
module util.money;
import std.traits : isSomeString, EnumMembers;
/**
* Basic information about a monetary currency, as defined by ISO 4217.
* https://en.wikipedia.org/wiki/ISO_4217
*/
struct Currency {
/// The common 3-character code for the currency, like "USD".
const char[3] code;
/// The number of digits after the decimal place that the currency supports.
const ubyte fractionalDigits;
/// The ISO 4217 numeric code for the currency.
const ushort numericCode;
static Currency ofCode(S)(S code) if (isSomeString!S) {
if (code.length != 3) {
throw new Exception("Invalid currency code: " ~ code);
}
static foreach (c; ALL_CURRENCIES) {
if (c.code == code) return c;
}
throw new Exception("Unknown currency code: " ~ code);
}
}
/// An enumeration of all available currencies.
enum Currencies : Currency {
AUD = Currency("AUD", 2, 36),
USD = Currency("USD", 2, 840),
CAD = Currency("CAD", 2, 124),
GBP = Currency("GBP", 2, 826),
EUR = Currency("EUR", 2, 978),
CHF = Currency("CHF", 2, 756),
ZAR = Currency("ZAR", 2, 710),
JPY = Currency("JPY", 0, 392),
INR = Currency("INR", 2, 356)
}
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];
unittest {
assert(Currency.ofCode("USD") == Currencies.USD);
}
/**
* A monetary value consisting of an integer value, and a currency. The value
* is interpreted as a multiple of the smallest denomination of the currency,
* so for example, with USD currency, a value of 123 indicates $1.23.
*/
struct MoneyValue {
const Currency currency;
const long value;
int opCmp(in MoneyValue other) const {
if (other.currency != this.currency) return 0;
if (this.value < other.value) return -1;
if (this.value > other.value) return 1;
return 0;
}
MoneyValue opBinary(string op)(in MoneyValue rhs) const {
if (rhs.currency != this.currency)
throw new Exception("Cannot perform binary operations on MoneyValues with different currencies.");
static if (op == "+") return MoneyValue(currency, this.value + rhs.value);
static if (op == "-") return MoneyValue(currency, this.value - rhs.value);
static assert(false, "Operator " ~ op ~ " is not supported.");
}
MoneyValue opBinary(string op)(int rhs) const {
static if (op == "+") return MoneyValue(currency, this.value + rhs);
static if (op == "-") return MoneyValue(currency, this.value - rhs);
static if (op == "*") return MoneyValue(currency, this.value * rhs);
static if (op == "/") return MoneyValue(currency, this.value / rhs);
static assert(false, "Operator " ~ op ~ " is not supported.");
}
MoneyValue opUnary(string op)() const {
static if (op == "-") return MoneyValue(currency, -this.value);
static assert(false, "Operator " ~ op ~ " is not supported.");
}
}

View File

@ -0,0 +1,32 @@
module util.repository;
import handy_httpd.components.optional;
import d2sqlite3;
import util.sqlite;
class CrudRepository(T, string table)
if (__traits(hasMember, T, "id")) {
protected Database db;
this(Database db) {
this.db = db;
}
abstract T parseResult(Row r);
Optional!T findById(ulong id) {
return util.sqlite.findById(db, table, &parseResult, id);
}
bool existsById(ulong id) {
return util.sqlite.exists(
db,
"SELECT id FROM " ~ table ~ " WHERE id = ?",
id
);
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, table, id);
}
}

View File

@ -0,0 +1,69 @@
module util.sample_data;
import slf4d;
import auth;
import profile;
import account;
import util.money;
import std.random;
import std.conv;
import std.array;
void generateSampleData() {
UserRepository userRepo = new FileSystemUserRepository;
// Remove all existing user data.
foreach (User user; userRepo.findAll()) {
userRepo.deleteByUsername(user.username);
}
const int userCount = uniform(5, 10);
for (int i = 0; i < userCount; i++) {
generateRandomUser(i, userRepo);
}
info("Random sample data generation complete.");
}
void generateRandomUser(int idx, UserRepository userRepo) {
string username = "testuser" ~ idx.to!string;
string password = "testpass";
infoF!"Generating random user %s, password: %s."(username, password);
User user = createNewUser(userRepo, username, password);
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
const int profileCount = uniform(1, 5);
for (int i = 0; i < profileCount; i++) {
generateRandomProfile(i, profileRepo);
}
}
void generateRandomProfile(int idx, ProfileRepository profileRepo) {
string profileName = "test-profile-" ~ idx.to!string;
infoF!" Generating random profile %s."(profileName);
Profile profile = profileRepo.createProfile(profileName);
ProfileDataSource ds = profileRepo.getDataSource(profile);
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
const int accountCount = uniform(1, 10);
for (int i = 0; i < accountCount; i++) {
generateRandomAccount(i, ds);
}
}
void generateRandomAccount(int idx, ProfileDataSource ds) {
AccountRepository accountRepo = ds.getAccountRepository();
string idxStr = idx.to!string;
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
string name = "Test Account " ~ idxStr;
AccountType type = choice(ALL_ACCOUNT_TYPES);
Currency currency = choice(ALL_CURRENCIES);
string description = "This is a testing account generated by util.sample_data.generateRandomAccount().";
infoF!" Generating random account: %s, ...%s"(name, numberSuffix);
Account account = accountRepo.insert(
type,
numberSuffix,
name,
currency,
description
);
}

View File

@ -21,6 +21,23 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result
return Optional!T.of(resultMapper(result.front));
}
/**
* Tries to find a single entity by its id, selecting all properties.
* Params:
* db = The database to use.
* table = The table to select from.
* resultMapper = A function to map rows to the desired result type.
* id = The entity's id.
* Returns: An optional result.
*/
Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) {
Statement stmt = db.prepare("SELECT * FROM " ~ table);
stmt.bind(1, id);
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:
@ -67,6 +84,19 @@ int update(Args...)(Database db, string query, Args args) {
return db.changes();
}
/**
* Deletes an entity from a table.
* Params:
* db = The database to use.
* table = The table to delete from.
* id = The id of the entity to delete.
*/
void deleteById(Database db, string table, ulong id) {
Statement stmt = db.prepare("DELETE FROM " ~ table ~ " WHERE id = ?");
stmt.bind(1, id);
stmt.execute();
}
/**
* 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,

View File

@ -76,3 +76,15 @@ Future<String> postRegister(LoginCredentials credentials) async {
throw Exception('Registration failed.');
}
}
Future<void> deleteUser(String token) async {
final http.Response response = await http.delete(
Uri.parse('http://localhost:8080/api/me'),
headers: {
'Authorization': 'Bearer $token'
}
);
if (response.statusCode != 200) {
throw Exception('Deleting user failed.');
}
}

View File

@ -19,7 +19,7 @@ class FinnowApp extends StatelessWidget with WatchItMixin {
if (newValue.state.authenticated()) {
router.replace('/profiles');
} else {
while (router.canPop()) {
while (router.canPop()) {// Clear the navigation stack.
router.pop();
}
router.pushReplacement('/login');

View File

@ -0,0 +1,32 @@
import 'package:finnow_app/api/profile.dart';
import 'package:finnow_app/main.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// A list item that shows a profile in the user's list of all profiles.
class ProfileListItem extends StatelessWidget {
final Profile profile;
const ProfileListItem(this.profile, {super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
getIt<GoRouter>().go('/profiles/${profile.name}');
},
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(top: 10, bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: const Color.fromARGB(255, 168, 233, 170)),
child: Row(children: [
Expanded(child: Text(profile.name)),
TextButton(
onPressed: () {
print('Removing profile: ${profile.name}');
},
child: const Text('Remove'))
])));
}
}

View File

@ -3,12 +3,10 @@ import 'package:finnow_app/auth/model.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:watch_it/watch_it.dart';
import 'app.dart';
import 'login_page.dart';
import 'profiles_page.dart';
import 'register_page.dart';
import 'router.dart';
/// The global GetIt instance, used to get registered singletons.
final getIt = GetIt.instance;
void main() {
@ -16,49 +14,15 @@ void main() {
runApp(const FinnowApp());
}
/// Initializes some resources before we start the app.
void setup() {
// Register the FinnowApi so it can be called easily from anywhere.
getIt.registerSingleton<FinnowApi>(FinnowApi());
// Register the AuthenticationModel singleton to hold the app's current
// authentication information.
getIt.registerSingleton<AuthenticationModel>(AuthenticationModel());
// Register the GoRouter singleton to provide navigation in the app.
getIt.registerSingleton<GoRouter>(getRouterConfig());
}
GoRouter getRouterConfig() {
return GoRouter(routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(
path: '/register', builder: (context, state) => const RegisterPage()),
// Once a user has logged in, they're directed to a scaffold for the /profiles page.
ShellRoute(
builder: (context, state, child) => Scaffold(
body: child,
appBar: AppBar(
title: const Text('Finnow'),
backgroundColor: Colors.grey,
actions: [
TextButton(
onPressed: () => getIt<AuthenticationModel>().state =
Unauthenticated(),
child: const Text('Logout'))
],
),
),
redirect: (context, state) {
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? null : '/login';
},
routes: [
GoRoute(
path: '/',
redirect: (context, state) {
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? '/profiles' : '/login';
}),
GoRoute(
path: '/profiles',
builder: (context, state) => const ProfilesPage(),
)
]),
]);
}

View File

@ -4,8 +4,8 @@ import 'package:finnow_app/components/title_text.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api/auth.dart';
import 'main.dart';
import '../api/auth.dart';
import '../main.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});

View File

@ -0,0 +1,17 @@
import 'package:finnow_app/api/profile.dart';
import 'package:flutter/material.dart';
class ProfilePage extends StatelessWidget {
final Profile profile;
const ProfilePage(this.profile, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
padding: const EdgeInsets.all(10),
child: Text(profile.name)
)
);
}
}

View File

@ -1,8 +1,9 @@
import 'package:finnow_app/auth/model.dart';
import 'package:finnow_app/components/profile_list_item.dart';
import 'package:flutter/material.dart';
import 'api/profile.dart';
import 'main.dart';
import '../api/profile.dart';
import '../main.dart';
class ProfilesPage extends StatelessWidget {
const ProfilesPage({super.key});
@ -11,9 +12,9 @@ class ProfilesPage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: const Padding(
padding: EdgeInsets.all(8.0),
padding: EdgeInsets.all(10),
child: Column(children: [
Text('Profiles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
Text('Select a Profile', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
SizedBox(height: 10),
Expanded(child: _ProfilesListView()),
])),
@ -39,7 +40,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> {
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
children: profiles.map((p) => Text('Profile: ${p.name}')).toList(),
children: profiles.map(ProfileListItem.new).toList(),
);
}

View File

@ -0,0 +1,59 @@
import 'package:finnow_app/api/auth.dart';
import 'package:finnow_app/auth/model.dart';
import 'package:finnow_app/components/form_label.dart';
import 'package:flutter/material.dart';
import '../main.dart';
class UserAccountPage extends StatelessWidget {
final Authenticated auth;
const UserAccountPage(this.auth, {super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10),
child: ListView(children: [
const Text('My User',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
const SizedBox(height: 10),
const FormLabel('Username'),
Text(auth.username),
const SizedBox(height: 10),
Row(children: [
TextButton(
onPressed: () => attemptDeleteUser(context),
child: const Text('Delete my user'))
])
]));
}
void attemptDeleteUser(BuildContext ctx) async {
bool confirmed = false;
await showDialog(
context: ctx,
builder: (context) {
return AlertDialog(
title: const Text('Confirm User Deletion'),
content: const Text(
'Are you sure you want to delete your user? This is a permanent action that cannot be undone.'),
actions: [
FilledButton(
onPressed: () {
confirmed = true;
Navigator.of(context).pop();
},
child: Text('Ok')),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('Cancel'))
]);
});
if (confirmed) {
await deleteUser(auth.token);
getIt<AuthenticationModel>().state = Unauthenticated();
}
}
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api/profile.dart';
import 'auth/model.dart';
import 'pages/login_page.dart';
import 'main.dart';
import 'pages/profile_page.dart';
import 'pages/profiles_page.dart';
import 'pages/register_page.dart';
import 'pages/user_account_page.dart';
GoRouter getRouterConfig() {
return GoRouter(routes: [
GoRoute(path: '/login', builder: (ctx, state) => const LoginPage()),
GoRoute(path: '/register', builder: (ctx, state) => const RegisterPage()),
// Once a user has logged in, they're directed to a scaffold for the /profiles page.
ShellRoute(
builder: getAppScaffold,
redirect: (context, state) {
// If the user isn't authenticated when they navigate here, send them
// back to /login.
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? null : '/login';
},
routes: [
// The "/" route is just a dummy landing route that redirects to
// /profiles if the user is authenticated, or /login otherwise.
GoRoute(
path: '/',
redirect: (context, state) {
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? '/profiles' : '/login';
}),
GoRoute(
path: '/profiles',
builder: (context, state) => const ProfilesPage(),
routes: [
GoRoute(
path: ':profile',
builder: (context, state) {
final String profileName =
state.pathParameters['profile']!;
return ProfilePage(Profile(profileName));
}),
]),
GoRoute(
path: '/user-account',
builder: (ctx, state) => UserAccountPage(getIt<AuthenticationModel>().state as Authenticated)
)
]),
]);
}
/// Gets the scaffold for the main authenticated view of the app, which is
/// where the user is viewing their set of profiles.
Widget getAppScaffold(BuildContext context, GoRouterState state, Widget child) {
return Scaffold(
body: child,
appBar: AppBar(
title: const Text('Finnow'),
backgroundColor: Colors.grey,
actions: [
IconButton(
onPressed: () {
final router = getIt<GoRouter>();
router.push('/user-account');
},
icon: const Icon(Icons.account_circle)
),
IconButton(
onPressed: () =>
getIt<AuthenticationModel>().state = Unauthenticated(),
icon: const Icon(Icons.logout))
],
),
bottomNavigationBar:
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
BackButton(
onPressed: () {
GoRouter router = getIt<GoRouter>();
if (router.canPop()) router.pop();
},
),
IconButton(
onPressed: () {
GoRouter router = getIt<GoRouter>();
router.replace('/profiles');
},
icon: const Icon(Icons.home))
]),
);
}

View File

@ -8,7 +8,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:finnow_app/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {