Added web app, refactored api to use new handyhttp.
This commit is contained in:
parent
33089b3b75
commit
d610e70b18
|
@ -16,3 +16,6 @@ finnow-api-test-*
|
|||
*.lst
|
||||
|
||||
users/
|
||||
# Ignore testing RSA keys.
|
||||
test-key
|
||||
test-key.pub
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
meta {
|
||||
name: My User copy
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/me
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Authorization: Bearer {{access_token}}
|
||||
}
|
|
@ -7,9 +7,5 @@ meta {
|
|||
get {
|
||||
url: {{base_url}}/profiles
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
auth: inherit
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: Status
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/status
|
||||
body: none
|
||||
auth: none
|
||||
}
|
|
@ -16,6 +16,16 @@ script:pre-request {
|
|||
await checkAuth();
|
||||
}
|
||||
|
||||
net = require("net")
|
||||
console.log("Testing GET /me");
|
||||
try {
|
||||
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.log(err.stack);
|
||||
}
|
||||
|
||||
|
||||
async function checkAuth() {
|
||||
const access_token = bru.getEnvVar("access_token");
|
||||
if (!access_token || access_token === "null") {
|
||||
|
@ -29,6 +39,7 @@ script:pre-request {
|
|||
headers: {"Authorization": "Bearer " + access_token}
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
console.info("Current access token is valid.")
|
||||
return;
|
||||
} else if (resp.status === 401) {
|
||||
await refreshAuth();
|
||||
|
@ -49,7 +60,7 @@ script:pre-request {
|
|||
headers: {"Content-Type": "application/json"}
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
bru.setEnvVar("access_token", resp.data.token);
|
||||
bru.setEnvVar("access_token", resp.data);
|
||||
console.info("Refreshed access token.");
|
||||
} else {
|
||||
throw resp;
|
||||
|
|
|
@ -4,6 +4,3 @@ vars {
|
|||
profile: test-profile-0
|
||||
base_url: http://localhost:8080/api
|
||||
}
|
||||
vars:secret [
|
||||
access_token
|
||||
]
|
||||
|
|
|
@ -4,14 +4,10 @@
|
|||
],
|
||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||
"dependencies": {
|
||||
"asdf": "~>0.7.17",
|
||||
"botan": "~>1.13.6",
|
||||
"d2sqlite3": "~>1.0.0",
|
||||
"handy-httpd": {
|
||||
"path": "/home/andrew/Code/github-andrewlalis/handy-httpd"
|
||||
},
|
||||
"jwt": "~>0.4.0",
|
||||
"slf4d": "~>3.0.1"
|
||||
"d2sqlite3": "~>1.0",
|
||||
"handy-http-starter": "~>1.5",
|
||||
"jwt4d": "~>0.0.2",
|
||||
"secured": "~>3.0"
|
||||
},
|
||||
"description": "Backend API for Finnow.",
|
||||
"license": "proprietary",
|
||||
|
|
|
@ -2,18 +2,22 @@
|
|||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"asdf": "0.7.17",
|
||||
"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",
|
||||
"mir-algorithm": "3.22.1",
|
||||
"mir-core": "1.7.1",
|
||||
"dxml": "0.4.4",
|
||||
"handy-http-data": "1.3.0",
|
||||
"handy-http-handlers": "1.1.0",
|
||||
"handy-http-primitives": "1.8.0",
|
||||
"handy-http-starter": "1.5.0",
|
||||
"handy-http-transport": "1.7.0",
|
||||
"handy-http-websockets": "1.2.0",
|
||||
"jwt4d": "0.0.2",
|
||||
"mir-algorithm": "3.22.4",
|
||||
"mir-core": "1.7.3",
|
||||
"openssl": "3.3.4",
|
||||
"path-matcher": "1.2.0",
|
||||
"secured": "3.0.0",
|
||||
"silly": "1.1.1",
|
||||
"slf4d": "3.0.1",
|
||||
"streams": "3.5.0"
|
||||
"slf4d": "4.1.1",
|
||||
"streams": "3.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
Amazon
|
||||
eBay
|
||||
Walmart
|
||||
Target
|
||||
Best Buy
|
||||
Costco
|
||||
Home Depot
|
||||
Lowe's
|
||||
Kroger
|
||||
CVS
|
||||
Walgreens
|
||||
Starbucks
|
||||
McDonald's
|
||||
Burger King
|
||||
Subway
|
||||
Pizza Hut
|
||||
Domino's
|
||||
Chipotle
|
||||
Taco Bell
|
||||
Panera Bread
|
||||
Dunkin'
|
||||
Chick-fil-A
|
||||
Advance Auto Parts
|
||||
AutoZone
|
||||
Delta Air Lines
|
||||
American Airlines
|
||||
United Airlines
|
||||
Squarespace
|
||||
DigitalOcean
|
||||
GitHub
|
||||
Heroku
|
||||
Stripe
|
||||
PayPal
|
||||
Verizon
|
||||
ALDI
|
||||
IKEA
|
||||
Primark
|
||||
H&M
|
||||
Petco
|
||||
China King
|
||||
GEICO
|
||||
Bank of America
|
||||
Citi
|
|
|
@ -4,12 +4,13 @@
|
|||
*/
|
||||
module account.api;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_http_primitives;
|
||||
import handy_http_data.json;
|
||||
import handy_http_handlers.path_handler;
|
||||
|
||||
import profile.service;
|
||||
import account.model;
|
||||
import util.money;
|
||||
import util.json;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
|
@ -36,21 +37,21 @@ struct AccountResponse {
|
|||
}
|
||||
}
|
||||
|
||||
void handleGetAccounts(ref HttpRequestContext ctx) {
|
||||
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
import std.algorithm;
|
||||
import std.array;
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
auto ds = getProfileDataSource(request);
|
||||
auto accounts = ds.getAccountRepository().findAll()
|
||||
.map!(a => AccountResponse.of(a)).array;
|
||||
writeJsonBody(ctx, accounts);
|
||||
writeJsonBody(response, accounts);
|
||||
}
|
||||
|
||||
void handleGetAccount(ref HttpRequestContext ctx) {
|
||||
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||
auto ds = getProfileDataSource(request);
|
||||
auto account = ds.getAccountRepository().findById(accountId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
writeJsonBody(ctx, AccountResponse.of(account));
|
||||
writeJsonBody(response, AccountResponse.of(account));
|
||||
}
|
||||
|
||||
// The data provided by a user to create a new account.
|
||||
|
@ -62,9 +63,9 @@ struct AccountCreationPayload {
|
|||
string description;
|
||||
}
|
||||
|
||||
void handleCreateAccount(ref HttpRequestContext ctx) {
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
|
||||
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
auto ds = getProfileDataSource(request);
|
||||
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
|
||||
// TODO: Validate the account creation payload.
|
||||
AccountType type = AccountType.fromId(payload.type);
|
||||
Currency currency = Currency.ofCode(payload.currency);
|
||||
|
@ -75,11 +76,11 @@ void handleCreateAccount(ref HttpRequestContext ctx) {
|
|||
currency,
|
||||
payload.description
|
||||
);
|
||||
writeJsonBody(ctx, AccountResponse.of(account));
|
||||
writeJsonBody(response, AccountResponse.of(account));
|
||||
}
|
||||
|
||||
void handleDeleteAccount(ref HttpRequestContext ctx) {
|
||||
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||
auto ds = getProfileDataSource(request);
|
||||
ds.getAccountRepository().deleteById(accountId);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module account.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import account.model;
|
||||
import util.money;
|
||||
|
|
|
@ -3,7 +3,7 @@ module account.data_impl_sqlite;
|
|||
import std.datetime;
|
||||
|
||||
import d2sqlite3;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import account.data;
|
||||
import account.model;
|
||||
|
|
|
@ -1,81 +1,125 @@
|
|||
module api_mapping;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_httpd.handlers.path_handler;
|
||||
import handy_httpd.handlers.filtered_handler;
|
||||
import handy_http_primitives;
|
||||
import handy_http_handlers.path_handler;
|
||||
import handy_http_handlers.filtered_handler;
|
||||
|
||||
/// The base path to all API endpoints.
|
||||
private const API_PATH = "/api";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
HttpRequestHandler mapApiHandlers() {
|
||||
PathHandler h = new PathHandler();
|
||||
|
||||
// Generic, public endpoints:
|
||||
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
|
||||
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
|
||||
h.map(HttpMethod.GET, "/status", &getStatus);
|
||||
h.map(HttpMethod.OPTIONS, "/**", &getOptions);
|
||||
|
||||
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
||||
h.addMapping(Method.POST, API_PATH ~ "/sample-data", &sampleDataEndpoint);
|
||||
h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint);
|
||||
|
||||
// Auth endpoints:
|
||||
import auth.api;
|
||||
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
|
||||
h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister);
|
||||
h.addMapping(Method.GET, API_PATH ~ "/register/username-availability", &getUsernameAvailability);
|
||||
h.map(HttpMethod.POST, "/login", &postLogin);
|
||||
h.map(HttpMethod.POST, "/register", &postRegister);
|
||||
h.map(HttpMethod.GET, "/register/username-availability", &getUsernameAvailability);
|
||||
|
||||
|
||||
|
||||
// Authenticated endpoints:
|
||||
PathHandler a = new PathHandler();
|
||||
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
||||
a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
|
||||
a.map(HttpMethod.GET, "/me", &getMyUser);
|
||||
a.map(HttpMethod.DELETE, "/me", &deleteMyUser);
|
||||
a.map(HttpMethod.GET, "/me/token", &getNewToken);
|
||||
|
||||
import profile.api;
|
||||
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
|
||||
a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile);
|
||||
a.map(HttpMethod.GET, "/profiles", &handleGetProfiles);
|
||||
a.map(HttpMethod.POST, "/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);
|
||||
const PROFILE_PATH = "/profiles/:profile";
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
||||
|
||||
// Account endpoints:
|
||||
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);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
|
||||
|
||||
import transaction.api;
|
||||
// Transaction vendor endpoints:
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors", &getVendors);
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
|
||||
a.addMapping(Method.POST, PROFILE_PATH ~ "/vendors", &createVendor);
|
||||
a.addMapping(Method.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
|
||||
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &getVendors);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &createVendor);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
|
||||
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/transactions", &getTransactions);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &getTransactions);
|
||||
|
||||
// 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]
|
||||
// Protect all authenticated paths with a filter.
|
||||
import auth.service : AuthenticationFilter;
|
||||
HttpRequestFilter authenticationFilter = new AuthenticationFilter();
|
||||
h.addMapping(API_PATH ~ "/**", new FilteredHandler(
|
||||
[authenticationFilter],
|
||||
a
|
||||
));
|
||||
|
||||
return h;
|
||||
return new CorsHandler(h);
|
||||
}
|
||||
|
||||
private void getStatus(ref HttpRequestContext ctx) {
|
||||
ctx.response.writeBodyString("online");
|
||||
private void getStatus(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
response.writeBodyString("online", ContentTypes.TEXT_PLAIN);
|
||||
}
|
||||
|
||||
private void getOptions(ref HttpRequestContext ctx) {}
|
||||
private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
// Do nothing, just return 200 OK.
|
||||
}
|
||||
|
||||
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
|
||||
private void addCorsHeaders(ref ServerHttpResponse response) {
|
||||
response.headers.add("Access-Control-Allow-Origin", "*");
|
||||
response.headers.add("Access-Control-Allow-Methods", "*");
|
||||
response.headers.add("Access-Control-Allow-Headers", "*");
|
||||
}
|
||||
|
||||
private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
import slf4d;
|
||||
import util.sample_data;
|
||||
import core.thread;
|
||||
Thread t = new Thread(() => generateSampleData());
|
||||
Thread t = new Thread(() {
|
||||
try {
|
||||
generateSampleData();
|
||||
} catch (Exception e) {
|
||||
error("Error while generating sample data.", e);
|
||||
}
|
||||
});
|
||||
t.start();
|
||||
info("Started new thread to generate sample data.");
|
||||
}
|
||||
|
||||
private void map(
|
||||
PathHandler handler,
|
||||
HttpMethod method,
|
||||
string subPath,
|
||||
void function(ref ServerHttpRequest, ref ServerHttpResponse) fn
|
||||
) {
|
||||
handler.addMapping(method, API_PATH ~ subPath, HttpRequestHandler.of(fn));
|
||||
}
|
||||
|
||||
private class CorsHandler : HttpRequestHandler {
|
||||
private HttpRequestHandler handler;
|
||||
|
||||
this(HttpRequestHandler handler) {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
response.headers.add("Access-Control-Allow-Origin", "*");
|
||||
response.headers.add("Access-Control-Allow-Methods", "*");
|
||||
response.headers.add("Access-Control-Allow-Headers", "*");
|
||||
this.handler.handle(request, response);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import handy_httpd;
|
||||
import handy_http_transport;
|
||||
import slf4d;
|
||||
import slf4d.default_provider;
|
||||
|
||||
import api_mapping;
|
||||
|
||||
void main() {
|
||||
auto provider = new DefaultProvider(true, Levels.INFO);
|
||||
auto provider = new DefaultProvider(Levels.INFO);
|
||||
configureLoggingProvider(provider);
|
||||
|
||||
ServerConfig cfg;
|
||||
cfg.workerPoolSize = 5;
|
||||
cfg.port = 8080;
|
||||
HttpServer server = new HttpServer(mapApiHandlers(), cfg);
|
||||
server.start();
|
||||
HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers());
|
||||
transport.start();
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module attachment.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import attachment.model;
|
||||
import std.datetime;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module attachment.data_impl_sqlite;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import d2sqlite3;
|
||||
|
||||
import attachment.model;
|
||||
|
|
|
@ -1,64 +1,46 @@
|
|||
/// API endpoints for authentication-related functions, like registration and login.
|
||||
module auth.api;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives;
|
||||
import handy_http_data.json;
|
||||
import slf4d;
|
||||
|
||||
import auth.model;
|
||||
import auth.data;
|
||||
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;
|
||||
void postLogin(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
struct LoginData {
|
||||
string username;
|
||||
string password;
|
||||
}
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
Optional!User optionalUser = userRepo.findByUsername(loginCredentials.username);
|
||||
if (optionalUser.isNull) {
|
||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||
return;
|
||||
}
|
||||
import botan.passhash.bcrypt : checkBcrypt;
|
||||
|
||||
if (!checkBcrypt(loginCredentials.password, optionalUser.value.passwordHash)) {
|
||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||
return;
|
||||
}
|
||||
string token = generateAccessToken(optionalUser.value);
|
||||
writeJsonBody(ctx, TokenResponse(token));
|
||||
LoginData data = readJsonBodyAs!LoginData(request);
|
||||
string token = generateTokenForLogin(data.username, data.password);
|
||||
response.writeBodyString(token);
|
||||
infoF!"Generated token for user: %s"(data.username);
|
||||
}
|
||||
|
||||
struct UsernameAvailabilityResponse {
|
||||
const bool available;
|
||||
}
|
||||
|
||||
void getUsernameAvailability(ref HttpRequestContext ctx) {
|
||||
Optional!string username = ctx.request.queryParams.getFirst("username");
|
||||
if (username.isNull) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Missing username parameter.");
|
||||
void getUsernameAvailability(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
string username = null;
|
||||
foreach (param; request.queryParams) {
|
||||
if (param.key == "username" && param.values.length > 0) {
|
||||
username = param.values[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (username is null || username.length == 0) {
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Missing username parameter.");
|
||||
return;
|
||||
}
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
bool available = userRepo.findByUsername(username.value).isNull;
|
||||
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
|
||||
bool available = userRepo.findByUsername(username).isNull;
|
||||
writeJsonBody(response, UsernameAvailabilityResponse(available));
|
||||
}
|
||||
|
||||
struct RegistrationData {
|
||||
|
@ -66,39 +48,45 @@ struct RegistrationData {
|
|||
string password;
|
||||
}
|
||||
|
||||
void postRegister(ref HttpRequestContext ctx) {
|
||||
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
|
||||
void postRegister(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
RegistrationData registrationData = readJsonBodyAs!RegistrationData(request);
|
||||
if (!validateUsername(registrationData.username)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid username.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Invalid username.");
|
||||
return;
|
||||
}
|
||||
if (!validatePassword(registrationData.password)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid password.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Invalid password.");
|
||||
return;
|
||||
}
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
if (!userRepo.findByUsername(registrationData.username).isNull) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Username is taken.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Username is taken.");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = createNewUser(userRepo, registrationData.username, registrationData.password);
|
||||
infoF!"Created user: %s"(registrationData.username);
|
||||
string token = generateAccessToken(user);
|
||||
writeJsonBody(ctx, TokenResponse(token));
|
||||
response.writeBodyString(user.username);
|
||||
}
|
||||
|
||||
void getMyUser(ref HttpRequestContext ctx) {
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
ctx.response.writeBodyString(auth.user.username);
|
||||
void getMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
AuthContext auth = getAuthContext(request);
|
||||
response.writeBodyString(auth.user.username);
|
||||
}
|
||||
|
||||
void deleteMyUser(ref HttpRequestContext ctx) {
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
AuthContext auth = getAuthContext(request);
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
deleteUser(auth.user, userRepo);
|
||||
infoF!"Deleted user: %s"(auth.user.username);
|
||||
}
|
||||
|
||||
void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
AuthContext auth = getAuthContext(request);
|
||||
string token = generateTokenForUser(auth.user);
|
||||
response.writeBodyString(token);
|
||||
infoF!"Generated token for user: %s"(auth.user.username);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module auth.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import auth.model;
|
||||
|
||||
interface UserRepository {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module auth.data_impl_fs;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import auth.data;
|
||||
import auth.model;
|
||||
|
@ -31,6 +31,7 @@ class FileSystemUserRepository : UserRepository {
|
|||
}
|
||||
|
||||
User[] findAll() {
|
||||
if (!exists(this.usersDir)) return [];
|
||||
User[] users;
|
||||
foreach (DirEntry entry; dirEntries(this.usersDir, SpanMode.shallow, false)) {
|
||||
string username = baseName(entry.name);
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
module auth.service;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_httpd.handlers.filtered_handler;
|
||||
import slf4d;
|
||||
import handy_http_primitives;
|
||||
import handy_http_handlers.filtered_handler;
|
||||
|
||||
import auth.model;
|
||||
import auth.data;
|
||||
import auth.data_impl_fs;
|
||||
import auth.tokens;
|
||||
|
||||
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
|
||||
const ubyte[] PASSWORD_HASH_PEPPER = []; // Example pepper for password hashing
|
||||
|
||||
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);
|
||||
import secured.kdf;
|
||||
HashedPassword hash = securePassword(password, PASSWORD_HASH_PEPPER);
|
||||
return repo.createUser(username, hash.toString());
|
||||
}
|
||||
|
||||
void deleteUser(User user, UserRepository repo) {
|
||||
|
@ -24,50 +22,67 @@ void deleteUser(User user, UserRepository repo) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generates a new JWT access token for a user.
|
||||
* Generates a new token for a user who's logging in with the given credentials.
|
||||
* Params:
|
||||
* username = The user's username.
|
||||
* password = The user's password.
|
||||
* Returns: A JWT the user may use to authenticate requests to the API.
|
||||
*/
|
||||
string generateTokenForLogin(string username, string password) {
|
||||
import secured.kdf;
|
||||
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
Optional!User optionalUser = userRepo.findByUsername(username);
|
||||
if (optionalUser.isNull) {
|
||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
||||
}
|
||||
User user = optionalUser.value;
|
||||
auto verificationResult = verifyPassword(password, HashedPassword(user.passwordHash), PASSWORD_HASH_PEPPER);
|
||||
if (verificationResult == VerifyPasswordResult.Failure) {
|
||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
||||
}
|
||||
return generateTokenForUser(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new token for a user.
|
||||
* Params:
|
||||
* user = The user to generate the token for.
|
||||
* Returns: The token.
|
||||
* Returns: A JWT the user may use to authenticate requests to the API.
|
||||
*/
|
||||
string generateAccessToken(User user) {
|
||||
import jwt.jwt : Token;
|
||||
import jwt.algorithms : JWTAlgorithm;
|
||||
string generateTokenForUser(in User user) {
|
||||
import jwt4d;
|
||||
import std.datetime;
|
||||
const TIMEOUT_MINUTES = 30;
|
||||
Token token = new Token(JWTAlgorithm.HS512);
|
||||
token.claims.aud("finnow-api");
|
||||
token.claims.sub(user.username);
|
||||
token.claims.exp(Clock.currTime().toUnixTime() + TIMEOUT_MINUTES * 60);
|
||||
token.claims.iss("finnow-api");
|
||||
return token.encode(SECRET);
|
||||
JwtClaims claims = JwtClaims()
|
||||
.issuer("finnow")
|
||||
.subject(user.username)
|
||||
.expiresIn(minutes(30))
|
||||
.issuedAtNow();
|
||||
return writeJwt(claims, "test");
|
||||
}
|
||||
|
||||
/**
|
||||
* A request filter that only permits authenticated requests to be processed.
|
||||
*/
|
||||
class TokenAuthenticationFilter : HttpRequestFilter {
|
||||
class AuthenticationFilter : HttpRequestFilter {
|
||||
private static const AUTH_METADATA_KEY = "AuthContext";
|
||||
private immutable string secret;
|
||||
|
||||
this(string secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
void apply(ref HttpRequestContext ctx, FilterChain fc) {
|
||||
Optional!AuthContext optionalAuth = validateAuthContext(ctx);
|
||||
void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain fc) {
|
||||
Optional!AuthContext optionalAuth = extractAuthContextFromBearerToken(request, response);
|
||||
debugF!"Extracted auth context from bearer token: %s"(optionalAuth.value);
|
||||
if (!optionalAuth.isNull) {
|
||||
ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.value;
|
||||
fc.doFilter(ctx); // Only continue the filter chain if a valid auth context was obtained.
|
||||
debugF!"Request was authenticated for user: %s"(optionalAuth.value.user.username);
|
||||
request.contextData[AUTH_METADATA_KEY] = optionalAuth.value;
|
||||
fc.doFilter(request, response); // Only continue the filter chain if a valid auth context was obtained.
|
||||
debug_("Filter chain was called.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about the current request's authentication status.
|
||||
class AuthContext {
|
||||
string token;
|
||||
User user;
|
||||
this(string token, User user) {
|
||||
this.token = token;
|
||||
this(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
|
@ -76,46 +91,47 @@ class AuthContext {
|
|||
* Helper method to get the authentication context from a request context
|
||||
* that was previously passed through this filter.
|
||||
* Params:
|
||||
* ctx = The request context to get.
|
||||
* request = The request to get.
|
||||
* Returns: The auth context that has been set.
|
||||
*/
|
||||
AuthContext getAuthContext(in HttpRequestContext ctx) {
|
||||
return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY];
|
||||
AuthContext getAuthContext(in ServerHttpRequest request) {
|
||||
return cast(AuthContext) request.contextData[AuthenticationFilter.AUTH_METADATA_KEY];
|
||||
}
|
||||
|
||||
private Optional!AuthContext validateAuthContext(ref HttpRequestContext ctx) {
|
||||
import jwt.jwt : verify, Token;
|
||||
import jwt.algorithms : JWTAlgorithm;
|
||||
import std.typecons;
|
||||
private Optional!AuthContext extractAuthContextFromBearerToken(
|
||||
ref ServerHttpRequest request,
|
||||
ref ServerHttpResponse response
|
||||
) {
|
||||
import jwt4d;
|
||||
|
||||
const HEADER_NAME = "Authorization";
|
||||
if (!ctx.request.headers.contains(HEADER_NAME)) {
|
||||
return setUnauthorized(ctx, "Missing Authorization header.");
|
||||
if (!(HEADER_NAME in request.headers)) {
|
||||
return setUnauthorized(response, "Missing Authorization header.");
|
||||
}
|
||||
|
||||
string authorizationHeader = ctx.request.headers.getFirst(HEADER_NAME).orElse("");
|
||||
string authorizationHeader = request.headers[HEADER_NAME][0];
|
||||
if (authorizationHeader.length < 7 || authorizationHeader[0..7] != "Bearer ") {
|
||||
return setUnauthorized(ctx, "Invalid Authorization header format. Expected bearer token.");
|
||||
return setUnauthorized(response, "Invalid Authorization header format. Expected bearer token.");
|
||||
}
|
||||
|
||||
string rawToken = authorizationHeader[7..$];
|
||||
JwtClaims claims;
|
||||
try {
|
||||
Token token = verify(rawToken, SECRET, [JWTAlgorithm.HS512]);
|
||||
string username = token.claims.sub;
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
Optional!User optionalUser = userRepo.findByUsername(username);
|
||||
if (optionalUser.isNull) {
|
||||
return setUnauthorized(ctx, "User does not exist.");
|
||||
}
|
||||
return Optional!AuthContext.of(new AuthContext(rawToken, optionalUser.value));
|
||||
} catch (Exception e) {
|
||||
warn("Failed to verify user token.", e);
|
||||
return setUnauthorized(ctx, "Invalid or malformed token.");
|
||||
claims = readJwt(rawToken, "test");
|
||||
} catch (JwtException e) {
|
||||
return setUnauthorized(response, e.message.idup);
|
||||
}
|
||||
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
Optional!User optionalUser = userRepo.findByUsername(claims.subject);
|
||||
if (optionalUser.isNull) {
|
||||
return setUnauthorized(response, "Invalid user.");
|
||||
}
|
||||
return Optional!AuthContext.of(new AuthContext(optionalUser.value));
|
||||
}
|
||||
|
||||
private Optional!AuthContext setUnauthorized(ref HttpRequestContext ctx, string msg) {
|
||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||
ctx.response.writeBodyString(msg);
|
||||
private Optional!AuthContext setUnauthorized(ref ServerHttpResponse response, string msg) {
|
||||
response.status = HttpStatus.UNAUTHORIZED;
|
||||
response.writeBodyString(msg, ContentTypes.TEXT_PLAIN);
|
||||
return Optional!AuthContext.empty;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
module auth.tokens;
|
||||
|
||||
import slf4d;
|
||||
import secured.rsa;
|
||||
import secured.util;
|
||||
import std.datetime;
|
||||
import std.base64;
|
||||
import std.file;
|
||||
import asdf : serializeToJson, deserialize;
|
||||
import handy_http_primitives : Optional, HttpStatusException, HttpStatus;
|
||||
import streams : Either;
|
||||
|
||||
const TOKEN_EXPIRATION = minutes(60);
|
||||
|
||||
/**
|
||||
* Definition of the token's payload.
|
||||
*/
|
||||
private struct TokenData {
|
||||
string username;
|
||||
string issuedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition for the entire token JSON object, including the payload, and a
|
||||
* signature of the payload generated with the server's private key.
|
||||
*/
|
||||
private struct TokenObject {
|
||||
/// The token's data.
|
||||
TokenData data;
|
||||
/// The base64-encoded cryptographic signature of `data`.
|
||||
string sig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new token for the given user.
|
||||
* Params:
|
||||
* username = The username to generate the token for.
|
||||
* Returns: A new token that the user can provide to authenticate requests.
|
||||
*/
|
||||
string generateToken(in string username) {
|
||||
auto data = TokenData(username, Clock.currTime(UTC()).toISOExtString());
|
||||
RSA rsa = getPrivateKey();
|
||||
try {
|
||||
string dataJson = serializeToJson(data);
|
||||
ubyte[] signature = rsa.sign(cast(ubyte[]) dataJson);
|
||||
TokenObject obj = TokenObject(data, Base64.encode(signature));
|
||||
string jsonStr = serializeToJson(obj);
|
||||
return Base64.encode(cast(ubyte[]) jsonStr);
|
||||
} catch (CryptographicException e) {
|
||||
error("Failed to sign token data.", e);
|
||||
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Possible errors that can occur when verifying a token.
|
||||
enum TokenVerificationFailure {
|
||||
InvalidSignature,
|
||||
Expired
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of token verification, which is the user's username if the token is
|
||||
* valid, or a failure reason if not.
|
||||
*/
|
||||
alias TokenVerificationResult = Either!(string, "username", TokenVerificationFailure, "failure");
|
||||
|
||||
/**
|
||||
* Verifies a token and returns the username, if it's valid.
|
||||
* Params:
|
||||
* token = The token to verify.
|
||||
* Returns: A token verification result.
|
||||
*/
|
||||
TokenVerificationResult verifyToken(in string token) {
|
||||
string jsonStr = cast(string) Base64.decode(cast(ubyte[]) token);
|
||||
TokenObject decodedToken = deserialize!TokenObject(jsonStr);
|
||||
string dataJson = serializeToJson(decodedToken.data);
|
||||
ubyte[] signature = Base64.decode(decodedToken.sig);
|
||||
RSA rsa = getPrivateKey();
|
||||
if (!rsa.verify(cast(ubyte[]) dataJson, signature)) {
|
||||
warnF!"Failed to verify token signature for user: %s"(decodedToken.data.username);
|
||||
return TokenVerificationResult(TokenVerificationFailure.InvalidSignature);
|
||||
}
|
||||
|
||||
// We have verified the signature, so now we can check various properties of the token.
|
||||
|
||||
// Check that the token is not expired.
|
||||
SysTime issuedAt = SysTime.fromISOExtString(decodedToken.data.issuedAt, UTC());
|
||||
SysTime now = Clock.currTime(UTC());
|
||||
Duration diff = now - issuedAt;
|
||||
if (diff > TOKEN_EXPIRATION) {
|
||||
warnF!"Token for user %s has expired."(decodedToken.data.username);
|
||||
return TokenVerificationResult(TokenVerificationFailure.Expired);
|
||||
}
|
||||
|
||||
return TokenVerificationResult(decodedToken.data.username);
|
||||
}
|
||||
|
||||
private RSA getPrivateKey() {
|
||||
ubyte[] pkData = cast(ubyte[]) readText("test-key");
|
||||
return new RSA(pkData, null);
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
module history.data;
|
||||
|
||||
import std.datetime;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import history.model;
|
||||
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
module history.data_impl_sqlite;
|
||||
|
||||
import std.datetime;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import d2sqlite3;
|
||||
|
||||
import history.data;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
module profile.api;
|
||||
|
||||
import std.json;
|
||||
|
||||
import handy_httpd;
|
||||
import asdf;
|
||||
import handy_http_primitives;
|
||||
import handy_http_data.json;
|
||||
import handy_http_handlers.path_handler : getPathParamAs;
|
||||
|
||||
import profile.model;
|
||||
import profile.service;
|
||||
|
@ -11,45 +12,48 @@ import profile.data;
|
|||
import profile.data_impl_sqlite;
|
||||
import auth.model;
|
||||
import auth.service;
|
||||
import util.json;
|
||||
|
||||
void handleCreateNewProfile(ref HttpRequestContext ctx) {
|
||||
JSONValue obj = ctx.request.readBodyAsJson();
|
||||
string name = obj.object["name"].str;
|
||||
struct NewProfilePayload {
|
||||
string name;
|
||||
}
|
||||
|
||||
void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
auto payload = readJsonBodyAs!NewProfilePayload(request);
|
||||
string name = payload.name;
|
||||
if (!validateProfileName(name)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid profile name.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Invalid profile name.");
|
||||
return;
|
||||
}
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
AuthContext auth = getAuthContext(request);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||||
profileRepo.createProfile(name);
|
||||
}
|
||||
|
||||
void handleGetProfiles(ref HttpRequestContext ctx) {
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
AuthContext auth = getAuthContext(request);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||||
Profile[] profiles = profileRepo.findAll();
|
||||
writeJsonBody(ctx, profiles);
|
||||
writeJsonBody(response, profiles);
|
||||
}
|
||||
|
||||
void handleDeleteProfile(ref HttpRequestContext ctx) {
|
||||
string name = ctx.request.getPathParamAs!string("name");
|
||||
void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
string name = request.getPathParamAs!string("profile");
|
||||
if (!validateProfileName(name)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid profile name.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Invalid profile name.");
|
||||
return;
|
||||
}
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
AuthContext auth = getAuthContext(request);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||||
profileRepo.deleteByName(name);
|
||||
}
|
||||
|
||||
void handleGetProperties(ref HttpRequestContext ctx) {
|
||||
ProfileContext profileCtx = getProfileContextOrThrow(ctx);
|
||||
void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileContext profileCtx = getProfileContextOrThrow(request);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
|
||||
ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile);
|
||||
auto propsRepo = ds.getPropertiesRepository();
|
||||
ProfileProperty[] props = propsRepo.findAll();
|
||||
writeJsonBody(ctx, props);
|
||||
writeJsonBody(response, props);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
module profile.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import profile.model;
|
||||
|
||||
/// Repository for interacting with the set of profiles belonging to a user.
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
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 handy_http_primitives;
|
||||
|
||||
import profile.data;
|
||||
import profile.model;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
module profile.service;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives;
|
||||
import handy_http_handlers.path_handler;
|
||||
|
||||
import profile.model;
|
||||
import profile.data;
|
||||
|
@ -29,35 +29,39 @@ struct ProfileContext {
|
|||
}
|
||||
|
||||
/**
|
||||
* Tries to get a profile context from a request context. This will attempt to
|
||||
* Tries to get a profile context from a request. 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.
|
||||
* request = The request to read.
|
||||
* Returns: An optional profile context.
|
||||
*/
|
||||
Optional!ProfileContext getProfileContext(in HttpRequestContext ctx) {
|
||||
Optional!ProfileContext getProfileContext(in ServerHttpRequest request) {
|
||||
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));
|
||||
foreach (param; getPathParams(request)) {
|
||||
if (param.name == "profile") {
|
||||
string profileName = param.value;
|
||||
if (!validateProfileName(profileName)) return Optional!ProfileContext.empty;
|
||||
AuthContext authCtx = getAuthContext(request);
|
||||
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));
|
||||
}
|
||||
}
|
||||
return Optional!ProfileContext.empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* request = The request to read.
|
||||
* Returns: The profile context that was obtained.
|
||||
*/
|
||||
ProfileContext getProfileContextOrThrow(in HttpRequestContext ctx) {
|
||||
return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
ProfileContext getProfileContextOrThrow(in ServerHttpRequest request) {
|
||||
return getProfileContext(request).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -76,10 +80,10 @@ ProfileDataSource getProfileDataSource(in ProfileContext pc) {
|
|||
* Obtains a ProfileDataSource from an HTTP request context. Use this to
|
||||
* access all profile data when handling the request.
|
||||
* Params:
|
||||
* ctx = The request context.
|
||||
* request = The request.
|
||||
* Returns: The profile data source.
|
||||
*/
|
||||
ProfileDataSource getProfileDataSource(in HttpRequestContext ctx) {
|
||||
ProfileContext pc = getProfileContextOrThrow(ctx);
|
||||
ProfileDataSource getProfileDataSource(in ServerHttpRequest request) {
|
||||
ProfileContext pc = getProfileContextOrThrow(request);
|
||||
return getProfileDataSource(pc);
|
||||
}
|
||||
|
|
|
@ -1,43 +1,45 @@
|
|||
module transaction.api;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_http_primitives;
|
||||
import handy_http_data.json;
|
||||
import handy_http_handlers.path_handler;
|
||||
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import transaction.service;
|
||||
import profile.data;
|
||||
import profile.service;
|
||||
import util.json;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]);
|
||||
|
||||
void getTransactions(ref HttpRequestContext ctx) {
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
PageRequest pr = PageRequest.parse(ctx, DEFAULT_TRANSACTION_PAGE);
|
||||
Page!Transaction page = searchTransactions(ds, pr);
|
||||
void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||
Page!Transaction page = ds.getTransactionRepository().findAll(pr);
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
void getVendors(ref HttpRequestContext ctx) {
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionVendor[] vendors = vendorRepo.findAll();
|
||||
writeJsonBody(ctx, vendors);
|
||||
writeJsonBody(response, vendors);
|
||||
}
|
||||
|
||||
void getVendor(ref HttpRequestContext ctx) {
|
||||
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
|
||||
void getVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
long vendorId = request.getPathParamAs!long("vendorId", -1);
|
||||
if (vendorId == -1) {
|
||||
ctx.response.status = HttpStatus.NOT_FOUND;
|
||||
ctx.response.writeBodyString("Missing vendorId path parameter.");
|
||||
response.status = HttpStatus.NOT_FOUND;
|
||||
response.writeBodyString("Missing vendorId path parameter.");
|
||||
return;
|
||||
}
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionVendor vendor = vendorRepo.findById(vendorId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
writeJsonBody(ctx, vendor);
|
||||
writeJsonBody(response, vendor);
|
||||
}
|
||||
|
||||
struct VendorPayload {
|
||||
|
@ -45,51 +47,52 @@ struct VendorPayload {
|
|||
string description;
|
||||
}
|
||||
|
||||
void createVendor(ref HttpRequestContext ctx) {
|
||||
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
void createVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
VendorPayload payload = readJsonBodyAs!VendorPayload(request);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
if (vendorRepo.existsByName(payload.name)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Vendor name is already in use.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Vendor name is already in use.");
|
||||
return;
|
||||
}
|
||||
TransactionVendor vendor = vendorRepo.insert(payload.name, payload.description);
|
||||
writeJsonBody(ctx, vendor);
|
||||
writeJsonBody(response, vendor);
|
||||
}
|
||||
|
||||
void updateVendor(ref HttpRequestContext ctx) {
|
||||
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
|
||||
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
|
||||
void updateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
VendorPayload payload = readJsonBodyAs!VendorPayload(request);
|
||||
long vendorId = request.getPathParamAs!long("vendorId", -1);
|
||||
if (vendorId == -1) {
|
||||
ctx.response.status = HttpStatus.NOT_FOUND;
|
||||
ctx.response.writeBodyString("Missing vendorId path parameter.");
|
||||
response.status = HttpStatus.NOT_FOUND;
|
||||
response.writeBodyString("Missing vendorId path parameter.");
|
||||
return;
|
||||
}
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionVendor existingVendor = vendorRepo.findById(vendorId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
if (payload.name != existingVendor.name && vendorRepo.existsByName(payload.name)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Vendor name is already in use.");
|
||||
response.status = HttpStatus.BAD_REQUEST;
|
||||
response.writeBodyString("Vendor name is already in use.");
|
||||
return;
|
||||
}
|
||||
TransactionVendor updated = vendorRepo.updateById(
|
||||
vendorId,
|
||||
payload.name,
|
||||
payload.description
|
||||
);
|
||||
writeJsonBody(ctx, updated);
|
||||
writeJsonBody(response, updated);
|
||||
}
|
||||
|
||||
void deleteVendor(ref HttpRequestContext ctx) {
|
||||
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
|
||||
void deleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
long vendorId = request.getPathParamAs!long("vendorId", -1);
|
||||
if (vendorId == -1) {
|
||||
ctx.response.status = HttpStatus.NOT_FOUND;
|
||||
ctx.response.writeBodyString("Missing vendorId path parameter.");
|
||||
response.status = HttpStatus.NOT_FOUND;
|
||||
response.writeBodyString("Missing vendorId path parameter.");
|
||||
return;
|
||||
}
|
||||
ProfileDataSource ds = getProfileDataSource(ctx);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||
vendorRepo.deleteById(vendorId);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
module transaction.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
interface TransactionVendorRepository {
|
||||
Optional!TransactionVendor findById(ulong id);
|
||||
|
@ -32,6 +33,7 @@ interface TransactionTagRepository {
|
|||
}
|
||||
|
||||
interface TransactionRepository {
|
||||
Page!Transaction findAll(PageRequest pr);
|
||||
Optional!Transaction findById(ulong id);
|
||||
Transaction insert(
|
||||
SysTime timestamp,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.data_impl_sqlite;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import std.datetime;
|
||||
import d2sqlite3;
|
||||
|
||||
|
@ -8,6 +8,8 @@ import transaction.model;
|
|||
import transaction.data;
|
||||
import util.sqlite;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
|
||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||
private Database db;
|
||||
|
@ -95,7 +97,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
"INSERT INTO transaction_category
|
||||
(parent_id, name, description, color)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
parentId.asNullable(), name, description, color
|
||||
toNullable(parentId), name, description, color
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
|
@ -120,7 +122,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
import std.typecons;
|
||||
return TransactionCategory(
|
||||
row.peek!ulong(0),
|
||||
Optional!ulong.of(row.peek!(Nullable!ulong)(1)),
|
||||
toOptional(row.peek!(Nullable!ulong)(1)),
|
||||
row.peek!string(2),
|
||||
row.peek!string(3),
|
||||
row.peek!string(4)
|
||||
|
@ -187,6 +189,18 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
this.db = db;
|
||||
}
|
||||
|
||||
Page!Transaction findAll(PageRequest pr) {
|
||||
// TODO: Implement filtering or something!
|
||||
import std.array;
|
||||
auto sqlBuilder = appender!string;
|
||||
sqlBuilder ~= "SELECT * FROM " ~ TABLE_NAME;
|
||||
sqlBuilder ~= " ";
|
||||
sqlBuilder ~= pr.toSql();
|
||||
string query = sqlBuilder[];
|
||||
Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction);
|
||||
return Page!Transaction(results, pr);
|
||||
}
|
||||
|
||||
Optional!Transaction findById(ulong id) {
|
||||
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
|
||||
}
|
||||
|
@ -210,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
amount,
|
||||
currency.code,
|
||||
description,
|
||||
vendorId.asNullable,
|
||||
categoryId.asNullable
|
||||
toNullable(vendorId),
|
||||
toNullable(categoryId)
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
|
@ -230,8 +244,8 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
row.peek!ulong(3),
|
||||
Currency.ofCode(row.peek!(string, PeekMode.slice)(4)),
|
||||
row.peek!string(5),
|
||||
Optional!(ulong).of(row.peek!(Nullable!ulong)(6)),
|
||||
Optional!(ulong).of(row.peek!(Nullable!ulong)(7))
|
||||
toOptional(row.peek!(Nullable!ulong)(6)),
|
||||
toOptional(row.peek!(Nullable!ulong)(7))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.model;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import std.datetime;
|
||||
|
||||
import util.money;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.service;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
|
@ -61,7 +61,3 @@ void addTransaction(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
Page!Transaction searchTransactions(ProfileDataSource ds, PageRequest pr) {
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
module util.data;
|
||||
|
||||
import handy_http_primitives;
|
||||
import std.typecons;
|
||||
|
||||
Optional!T toOptional(T)(Nullable!T value) {
|
||||
if (value.isNull) {
|
||||
return Optional!T.empty;
|
||||
} else {
|
||||
return Optional!T.of(value.get);
|
||||
}
|
||||
}
|
||||
|
||||
Nullable!T toNullable(T)(Optional!T value) {
|
||||
if (value.isNull) {
|
||||
return Nullable!T.init;
|
||||
} else {
|
||||
return Nullable!T(value.value);
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
/// Utilities for reading and writing JSON in HTTP request contexts.
|
||||
module util.json;
|
||||
|
||||
import handy_httpd;
|
||||
import slf4d;
|
||||
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. The type T should not have `const` members.
|
||||
* Params:
|
||||
* ctx = The request context to read from.
|
||||
* Returns: The data that was read.
|
||||
*/
|
||||
T readJsonPayload(T)(ref HttpRequestContext ctx) {
|
||||
try {
|
||||
string requestBody = ctx.request.readBodyAsString();
|
||||
return deserialize!T(requestBody);
|
||||
} catch (SerdeException e) {
|
||||
debug_("Got an exception while deserializing a request body.", e);
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data of type T to a JSON response body. Throws an `HttpStatusException`
|
||||
* with status 501 INTERNAL SERVER ERROR if serialization fails.
|
||||
* Params:
|
||||
* ctx = The request context to write to.
|
||||
* data = The data to write.
|
||||
*/
|
||||
void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) {
|
||||
try {
|
||||
string jsonStr = serializeToJson(data);
|
||||
ctx.response.writeBodyString(jsonStr, "application/json");
|
||||
} catch (SerdeException e) {
|
||||
debug_("Exception while serializing a response body.", e);
|
||||
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Defines various components useful for paginated operations.
|
||||
*/
|
||||
module util.pagination;
|
||||
|
||||
import handy_httpd;
|
||||
import handy_httpd.components.multivalue_map;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives;
|
||||
|
||||
import std.conv;
|
||||
|
||||
|
@ -47,14 +48,14 @@ struct PageRequest {
|
|||
return PageRequest(0, 0, []);
|
||||
}
|
||||
|
||||
static PageRequest parse(ref HttpRequestContext ctx, PageRequest defaults) {
|
||||
static PageRequest parse(in ServerHttpRequest request, PageRequest defaults) {
|
||||
import std.algorithm;
|
||||
import std.array;
|
||||
const(StringMultiValueMap) params = ctx.request.queryParams;
|
||||
uint pg = ctx.request.getParamAs!uint("page", defaults.page);
|
||||
ushort sz = ctx.request.getParamAs!ushort("size", defaults.size);
|
||||
Sort[] s = params.getAll("sort")
|
||||
.map!(Sort.parse)
|
||||
uint pg = request.getParamAs!uint("page", defaults.page);
|
||||
ushort sz = request.getParamAs!ushort("size", defaults.size);
|
||||
Sort[] s = request.queryParams
|
||||
.filter!(p => p.key == "sort" && p.values.length > 0)
|
||||
.map!(p => Sort.parse(p.values[0]))
|
||||
.filter!(o => !o.isNull)
|
||||
.map!(o => o.value)
|
||||
.array;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module util.repository;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
import d2sqlite3;
|
||||
import util.sqlite;
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
module util.sample_data;
|
||||
|
||||
import slf4d;
|
||||
import handy_httpd.components.optional;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
import auth;
|
||||
import profile;
|
||||
|
|
|
@ -3,8 +3,8 @@ module util.sqlite;
|
|||
import std.datetime;
|
||||
|
||||
import slf4d;
|
||||
import handy_httpd.components.optional;
|
||||
import d2sqlite3;
|
||||
import handy_http_primitives : Optional;
|
||||
|
||||
/**
|
||||
* Tries to find a single row from a database.
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class Account {
|
||||
final int id;
|
||||
final DateTime createdAt;
|
||||
final bool archived;
|
||||
final String type;
|
||||
final String numberSuffix;
|
||||
final String name;
|
||||
final String currency;
|
||||
final String description;
|
||||
const Account(this.id, this.createdAt, this.archived, this.type,
|
||||
this.numberSuffix, this.name, this.currency, this.description);
|
||||
|
||||
factory Account.fromJson(Map<String, dynamic> data) {
|
||||
final id = data['id'] as int;
|
||||
final createdAtStr = data['createdAt'] as String;
|
||||
final createdAt = DateTime.parse(createdAtStr);
|
||||
final archived = data['archived'] as bool;
|
||||
final type = data['type'] as String;
|
||||
final numberSuffix = data['numberSuffix'] as String;
|
||||
final name = data['name'] as String;
|
||||
final currency = data['currency'] as String;
|
||||
final description = data['description'] as String;
|
||||
return Account(id, createdAt, archived, type, numberSuffix, name, currency,
|
||||
description);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Account>> getAccounts(String token, String profileName) async {
|
||||
final http.Response response = await http.get(
|
||||
Uri.parse('http://localhost:8080/api/profiles/$profileName/accounts'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token'
|
||||
}
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
if (jsonDecode(response.body) == null) return [];
|
||||
final data = jsonDecode(response.body) as List<dynamic>;
|
||||
return data.map((obj) => Account.fromJson(obj)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to get accounts.');
|
||||
}
|
||||
}
|
|
@ -29,3 +29,15 @@ Future<List<Profile>> getProfiles(String token) async {
|
|||
throw Exception('Failed to get profiles.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(String token, Profile profile) async {
|
||||
final http.Response response = await http.delete(
|
||||
Uri.parse('http://localhost:8080/api/profiles/${profile.name}'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token'
|
||||
}
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to delete profile ${profile.name}');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:finnow_app/api/profile.dart';
|
||||
import 'package:finnow_app/auth/model.dart';
|
||||
import 'package:finnow_app/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
@ -6,27 +7,61 @@ 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});
|
||||
final Function onDeletedCallback;
|
||||
const ProfileListItem(this.profile, this.onDeletedCallback, {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'))
|
||||
])));
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: const Color.fromARGB(255, 236, 236, 236)),
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(profile.name)),
|
||||
ElevatedButton(
|
||||
child: const Text('View'),
|
||||
onPressed: () => getIt<GoRouter>().go('/profiles/${profile.name}'),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => attemptDeleteProfile(context),
|
||||
)
|
||||
]));
|
||||
}
|
||||
|
||||
void attemptDeleteProfile(BuildContext ctx) async {
|
||||
bool confirmed = false;
|
||||
await showDialog(
|
||||
context: ctx,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirm Profile Deletion'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this profile? This will permamently delete all accounts, transactions, and other data in this profile. This data is not recoverable.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
confirmed = true;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Ok')),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
]);
|
||||
});
|
||||
if (confirmed) {
|
||||
final auth = getIt<AuthenticationModel>();
|
||||
if (auth.state is Authenticated) {
|
||||
await deleteProfile((auth.state as Authenticated).token, profile);
|
||||
onDeletedCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,14 +14,13 @@ class ProfilesPage extends StatelessWidget {
|
|||
body: const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(children: [
|
||||
Text('Select a Profile', 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()),
|
||||
])),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => print('pressed'),
|
||||
child: const Icon(Icons.add)
|
||||
),
|
||||
onPressed: () => print('pressed'), child: const Icon(Icons.add)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +39,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> {
|
|||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
scrollDirection: Axis.vertical,
|
||||
children: profiles.map(ProfileListItem.new).toList(),
|
||||
children: profiles.map((p) => ProfileListItem(p, () => refreshProfiles())).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class UserAccountPage extends StatelessWidget {
|
|||
Text(auth.username),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: () => attemptDeleteUser(context),
|
||||
child: const Text('Delete my user'))
|
||||
])
|
||||
|
@ -36,19 +36,19 @@ class UserAccountPage extends StatelessWidget {
|
|||
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.'),
|
||||
'Are you sure you want to delete your user? This is a permanent action that cannot be undone. All your profiles will be deleted.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
confirmed = true;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Ok')),
|
||||
child: const Text('Ok')),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Cancel'))
|
||||
child: const Text('Cancel'))
|
||||
]);
|
||||
});
|
||||
if (confirmed) {
|
||||
|
|
|
@ -47,9 +47,9 @@ GoRouter getRouterConfig() {
|
|||
}),
|
||||
]),
|
||||
GoRoute(
|
||||
path: '/user-account',
|
||||
builder: (ctx, state) => UserAccountPage(getIt<AuthenticationModel>().state as Authenticated)
|
||||
)
|
||||
path: '/user-account',
|
||||
builder: (ctx, state) => UserAccountPage(
|
||||
getIt<AuthenticationModel>().state as Authenticated))
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
@ -64,12 +64,13 @@ Widget getAppScaffold(BuildContext context, GoRouterState state, Widget child) {
|
|||
backgroundColor: Colors.grey,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final router = getIt<GoRouter>();
|
||||
router.push('/user-account');
|
||||
},
|
||||
icon: const Icon(Icons.account_circle)
|
||||
),
|
||||
onPressed: () {
|
||||
final router = GoRouter.of(context);
|
||||
if (state.fullPath != '/user-account') {
|
||||
router.push('/user-account');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.account_circle)),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
getIt<AuthenticationModel>().state = Unauthenticated(),
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
|
@ -0,0 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
# web-app
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -0,0 +1,22 @@
|
|||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
skipFormatting,
|
||||
)
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Finnow</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "web-app",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.16.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-vue": "~10.3.0",
|
||||
"jiti": "^2.4.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vue-tsc": "^3.0.4"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { AuthApiClient } from './api/auth';
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('mounted!')
|
||||
const client = new AuthApiClient()
|
||||
console.log(await client.getApiStatus())
|
||||
const token = await client.login('testuser0', 'testpass')
|
||||
console.log('logged in with token', token)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>You did it!</h1>
|
||||
<p>
|
||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
||||
documentation
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
|
@ -0,0 +1,18 @@
|
|||
import { ApiClient, ApiError, type ApiResponse } from './base'
|
||||
|
||||
export class AuthApiClient extends ApiClient {
|
||||
async login(username: string, password: string): ApiResponse<string> {
|
||||
return await super.postText('/login', { username, password })
|
||||
}
|
||||
|
||||
async register(username: string, password: string): ApiResponse<void> {
|
||||
const r = await super.post('/register', { username, password })
|
||||
if (r instanceof ApiError) return r
|
||||
}
|
||||
|
||||
async getUsernameAvailability(username: string): ApiResponse<boolean> {
|
||||
const r = await super.post('/register/username-availability?username=' + username)
|
||||
if (r instanceof ApiError) return r
|
||||
return (await r.json()).available
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
export abstract class ApiError {
|
||||
readonly message: string
|
||||
|
||||
constructor(message: string) {
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends ApiError {}
|
||||
|
||||
export class StatusError extends ApiError {
|
||||
readonly status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = Promise<T | ApiError>
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
async getJson<R>(path: string): ApiResponse<R> {
|
||||
const r = await this.get(path)
|
||||
if (r instanceof ApiError) return r
|
||||
return await r.json()
|
||||
}
|
||||
|
||||
async postJson<R>(path: string, body: object | undefined = undefined): ApiResponse<R> {
|
||||
const r = await this.post(path, body)
|
||||
if (r instanceof ApiError) return r
|
||||
return await r.json()
|
||||
}
|
||||
|
||||
async postText(path: string, body: object | undefined = undefined): ApiResponse<string> {
|
||||
const r = await this.post(path, body)
|
||||
if (r instanceof ApiError) return r
|
||||
return await r.text()
|
||||
}
|
||||
|
||||
async get(path: string): Promise<Response | ApiError> {
|
||||
try {
|
||||
const response = await fetch(this.baseUrl + path)
|
||||
if (!response.ok) {
|
||||
throw new StatusError(response.status, 'Status error')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new NetworkError('Request to ' + path + ' failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async post(path: string, body: object | undefined = undefined): Promise<Response | ApiError> {
|
||||
try {
|
||||
const response = await fetch(this.baseUrl + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new StatusError(response.status, 'Status error')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new NetworkError('Request to ' + path + ' failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStatus(): ApiResponse<boolean> {
|
||||
const resp = await this.get('/status')
|
||||
return !(resp instanceof ApiError)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
|
@ -0,0 +1,8 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
})
|
||||
|
||||
export default router
|
|
@ -0,0 +1,12 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue