diff --git a/.classpath b/.classpath
new file mode 100644
index 0000000..25127bf
--- /dev/null
+++ b/.classpath
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.factorypath b/.factorypath
new file mode 100644
index 0000000..38d6468
--- /dev/null
+++ b/.factorypath
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..87a2d61
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/logs/
+/run-local
+*.log
+/target/
\ No newline at end of file
diff --git a/.project b/.project
new file mode 100644
index 0000000..b019c30
--- /dev/null
+++ b/.project
@@ -0,0 +1,39 @@
+
+
+ budget-api
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.springframework.ide.eclipse.boot.validation.springbootbuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
+
+ 1626184189329
+
+ 30
+
+ org.eclipse.core.resources.regexFilterMatcher
+ node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__
+
+
+
+
diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 0000000..abdea9a
--- /dev/null
+++ b/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,4 @@
+eclipse.preferences.version=1
+encoding//src/main/java=UTF-8
+encoding//src/main/resources=UTF-8
+encoding/=UTF-8
diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs
new file mode 100644
index 0000000..dfa4f3a
--- /dev/null
+++ b/.settings/org.eclipse.jdt.apt.core.prefs
@@ -0,0 +1,4 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.apt.aptEnabled=true
+org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations
+org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000..47dcc0b
--- /dev/null
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,14 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.methodParameters=generate
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
+org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
+org.eclipse.jdt.core.compiler.compliance=17
+org.eclipse.jdt.core.compiler.debug.lineNumber=generate
+org.eclipse.jdt.core.compiler.debug.localVariable=generate
+org.eclipse.jdt.core.compiler.debug.sourceFile=generate
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
+org.eclipse.jdt.core.compiler.processAnnotations=enabled
+org.eclipse.jdt.core.compiler.release=disabled
+org.eclipse.jdt.core.compiler.source=17
diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs
new file mode 100644
index 0000000..f897a7f
--- /dev/null
+++ b/.settings/org.eclipse.m2e.core.prefs
@@ -0,0 +1,4 @@
+activeProfiles=
+eclipse.preferences.version=1
+resolveWorkspaceProjects=true
+version=1
diff --git a/.settings/org.springframework.ide.eclipse.prefs b/.settings/org.springframework.ide.eclipse.prefs
new file mode 100644
index 0000000..a12794d
--- /dev/null
+++ b/.settings/org.springframework.ide.eclipse.prefs
@@ -0,0 +1,2 @@
+boot.validation.initialized=true
+eclipse.preferences.version=1
diff --git a/README.md b/README.md
index be8f126..c198d2d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,6 @@
-# com.stephenschafer.budget.api
+# Budget REST API
+
+Provides a REST API to support my React Budget Application.
+
+Built from this example as a starting point: [Spring Boot Security JWT Authentication](http://www.devglan.com/spring-security/spring-boot-jwt-auth)
-Back-end to budget react app
\ No newline at end of file
diff --git a/build b/build
new file mode 100755
index 0000000..39d9343
--- /dev/null
+++ b/build
@@ -0,0 +1,11 @@
+#!/bin/sh
+cd "$(dirname "${BASH_SOURCE[0]}")"
+ROOT=$(pwd)
+./stop
+# export JAVA_HOME="/usr/lib/jvm/java-11-openjdk"
+mkdir -p logs
+if ! mvn clean package > logs/build.log 2> logs/build.err.log; then
+ echo "build failed"
+ exit 1
+fi
+echo "success"
\ No newline at end of file
diff --git a/deploy b/deploy
new file mode 100755
index 0000000..b3352c9
--- /dev/null
+++ b/deploy
@@ -0,0 +1,5 @@
+#!/bin/sh
+cd "$(dirname "${BASH_SOURCE[0]}")"
+ssh pi@raspi "./budget/backup"
+scp $(find target -name "*.jar") pi@raspi:~/budget
+ssh pi@raspi "./budget/start"
diff --git a/eclipse b/eclipse
new file mode 100755
index 0000000..748a886
--- /dev/null
+++ b/eclipse
@@ -0,0 +1,6 @@
+#!/bin/sh
+cd "$(dirname "${BASH_SOURCE[0]}")"
+LOG=./logs
+mkdir -p $LOG
+/home/eclipse/sts-4.30.0.RELEASE/SpringToolSuite4 -data .. \
+ >$LOG/eclipse-sts.log 2>$LOG/eclipse-sts.err.log &
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..66c1755
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,98 @@
+
+
+ 4.0.0
+
+ com.stephenschafer
+ budget
+ 0.0.1-SNAPSHOT
+ jar
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.7
+
+
+
+
+
+ joda-time
+ joda-time
+ 2.10.9
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.thymeleaf.extras
+ thymeleaf-extras-springsecurity6
+
+ 3.1.1.RELEASE
+
+
+ org.projectlombok
+ lombok
+
+
+ io.jsonwebtoken
+ jjwt
+ 0.9.0
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ joda-time
+ joda-time
+
+
+ org.apache.commons
+ commons-csv
+ 1.5
+
+
+ javax.xml.bind
+ jaxb-api
+ 2.3.0
+
+
+
+
+ 17
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/run b/run
new file mode 100755
index 0000000..5956b43
--- /dev/null
+++ b/run
@@ -0,0 +1,33 @@
+#!/bin/bash
+cd "$(dirname "${BASH_SOURCE[0]}")"
+ROOT=$(pwd)
+./stop
+mkdir -p logs
+rm -f $ROOT/logs/run-*.log
+SUSPEND="n"
+ARGS=""
+while (( "$#" )); do
+ case $1 in
+ suspend)
+ SUSPEND="y"
+ ;;
+ init)
+ ARGS="init"
+ ;;
+ *)
+ echo "Unrecognized argument"
+ exit 1
+ ;;
+ esac
+ shift
+done
+JVM_ARGS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=$SUSPEND,address=8004"
+# JAVA_HOME="/usr/lib/jvm/java-11-openjdk"
+$JAVA_HOME/bin/java $JVM_ARGS -jar $(find target -name "*.jar") $ARGS\
+ --server.port=$PORT\
+ --spring.datasource.url=$DB_URL\
+ --spring.datasource.username=$DB_USERNAME\
+ --spring.datasource.password=$DB_PASSWORD\
+ > $ROOT/logs/run-budget.log 2> $ROOT/logs/run-budget.err.log &
+echo "$!" > $ROOT/logs/run-budget.pid
+echo "running"
\ No newline at end of file
diff --git a/spring-boot-jwt.iml b/spring-boot-jwt.iml
new file mode 100644
index 0000000..5e42dc2
--- /dev/null
+++ b/spring-boot-jwt.iml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/ApiResponse.java b/src/main/java/com/stephenschafer/budget/ApiResponse.java
new file mode 100644
index 0000000..76e47ed
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/ApiResponse.java
@@ -0,0 +1,14 @@
+package com.stephenschafer.budget;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class ApiResponse {
+ private int status;
+ private String message;
+ private T result;
+}
diff --git a/src/main/java/com/stephenschafer/budget/Application.java b/src/main/java/com/stephenschafer/budget/Application.java
new file mode 100644
index 0000000..360bc9b
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Application.java
@@ -0,0 +1,55 @@
+package com.stephenschafer.budget;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+@SpringBootApplication
+public class Application {
+ @Autowired
+ private BCryptPasswordEncoder passwordEncoder;
+
+ public static void main(final String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+
+ @Bean
+ CommandLineRunner init(final UserDao userDao) {
+ return args -> {
+ if (args.length >= 1 && args[0].equals("init")) {
+ final UserEntity user1 = new UserEntity();
+ user1.setFirstName("Devglan");
+ user1.setLastName("Devglan");
+ user1.setUsername("devglan");
+ user1.setPassword(passwordEncoder.encode("devglan"));
+ userDao.save(user1);
+ final UserEntity user2 = new UserEntity();
+ user2.setFirstName("John");
+ user2.setLastName("Doe");
+ user2.setUsername("john");
+ user2.setPassword(passwordEncoder.encode("john"));
+ userDao.save(user2);
+ }
+ else {
+ }
+ };
+ }
+
+ @Bean
+ RegexDao getRegexDao() {
+ return new RegexDaoImpl();
+ }
+
+ @Bean
+ CategoryDao getCategoryDao() {
+ return new CategoryDaoImpl();
+ }
+
+ @Bean
+ TransactionDao getTransactionDao() {
+ return new TransactionDaoImpl();
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/AuthToken.java b/src/main/java/com/stephenschafer/budget/AuthToken.java
new file mode 100644
index 0000000..edcce54
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/AuthToken.java
@@ -0,0 +1,15 @@
+package com.stephenschafer.budget;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class AuthToken {
+ private String token;
+ private String username;
+}
diff --git a/src/main/java/com/stephenschafer/budget/AuthenticationController.java b/src/main/java/com/stephenschafer/budget/AuthenticationController.java
new file mode 100644
index 0000000..a2a1a26
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/AuthenticationController.java
@@ -0,0 +1,49 @@
+package com.stephenschafer.budget;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@CrossOrigin
+@RestController
+@RequestMapping("/token")
+public class AuthenticationController {
+ @Autowired
+ private AuthenticationManager authenticationManager;
+ @Autowired
+ private JwtTokenUtil jwtTokenUtil;
+ @Autowired
+ private UserService userService;
+
+ @CrossOrigin
+ @PostMapping("/generate-token")
+ public ApiResponse register(@RequestBody final LoginUser loginUser)
+ throws AuthenticationException {
+ authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
+ loginUser.getUsername(), loginUser.getPassword()));
+ final UserEntity user = userService.findByUsername(loginUser.getUsername());
+ final String token = jwtTokenUtil.generateToken(user);
+ return new ApiResponse<>(200, "success", new AuthToken(token, user.getUsername()));
+ }
+
+ @CrossOrigin
+ @PostMapping("/register")
+ public ApiResponse saveUser(@RequestBody final UserDto loginUser) {
+ final UserEntity userEntity = userService.findByUsername(loginUser.getUsername());
+ if (userEntity != null) {
+ return new ApiResponse<>(400, "username already exists", null);
+ }
+ userService.save(loginUser);
+ authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
+ loginUser.getUsername(), loginUser.getPassword()));
+ final UserEntity user = userService.findByUsername(loginUser.getUsername());
+ final String token = jwtTokenUtil.generateToken(user);
+ return new ApiResponse<>(200, "success", new AuthToken(token, user.getUsername()));
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/Category.java b/src/main/java/com/stephenschafer/budget/Category.java
new file mode 100644
index 0000000..24c2992
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Category.java
@@ -0,0 +1,23 @@
+package com.stephenschafer.budget;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@ToString
+public class Category {
+ public Category() {
+ }
+
+ public Category(final int intValue, final Category category) {
+ this(intValue, category.getParentCategoryId(), category.getName());
+ }
+
+ Integer id;
+ Integer parentCategoryId;
+ String name;
+}
diff --git a/src/main/java/com/stephenschafer/budget/CategoryController.java b/src/main/java/com/stephenschafer/budget/CategoryController.java
new file mode 100644
index 0000000..46568a2
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/CategoryController.java
@@ -0,0 +1,124 @@
+package com.stephenschafer.budget;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@CrossOrigin(origins = "*", maxAge = 3600)
+@RestController
+public class CategoryController {
+ @Autowired
+ private CategoryDao categoryDao;
+ @Autowired
+ private UserService userService;
+
+ @PostMapping("/categories")
+ @ResponseBody
+ public ApiResponse postCategory(@RequestBody final Category category,
+ final HttpServletRequest req) {
+ if (!userService.isAuthorized(req)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category inserted successfully",
+ categoryDao.add(category));
+ }
+
+ @PutMapping("/categories")
+ @ResponseBody
+ public ApiResponse putCategory(@RequestBody final Category category,
+ final HttpServletRequest req) {
+ if (!userService.isAuthorized(req)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ categoryDao.update(category);
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category updated successfully", category);
+ }
+
+ @GetMapping("/categories")
+ @ResponseBody
+ public ApiResponse> getCategories(final HttpServletRequest request) {
+ log.info("GET /categories");
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final List categories = new ArrayList<>();
+ categoryDao.getAll(category -> {
+ categories.add(category);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully",
+ categories);
+ }
+
+ @GetMapping("/categories/parent/{parentCategoryId}")
+ @ResponseBody
+ public ApiResponse> getCategoriesByCategory(
+ @PathVariable(required = true) final Integer parentCategoryId,
+ final HttpServletRequest request) {
+ log.info("GET /categories/parent/" + parentCategoryId);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final Optional parentCategory = categoryDao.getById(parentCategoryId);
+ if (!parentCategory.isPresent()) {
+ return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Parent category not found",
+ null);
+ }
+ final int categoryId = parentCategory.get().getId();
+ final List categories = new ArrayList<>();
+ categoryDao.getByParent(categoryId, category -> {
+ categories.add(category);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully",
+ categories);
+ }
+
+ @GetMapping("/categories/root")
+ @ResponseBody
+ public ApiResponse> getRootCategories(final HttpServletRequest request) {
+ log.info("GET /categories/root");
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final List categories = new ArrayList<>();
+ categoryDao.getRoot(category -> {
+ categories.add(category);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully",
+ categories);
+ }
+
+ @GetMapping("/categories/ancestry/{categoryId}")
+ @ResponseBody
+ public ApiResponse> getCategoryAncestry(
+ @PathVariable(required = true) final Integer categoryId,
+ final HttpServletRequest request) {
+ log.info("GET /categories/ancestry/" + categoryId);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final List ancestry = categoryDao.getCategoryAncestry(categoryId);
+ return new ApiResponse<>(HttpStatus.OK.value(), "Category ancestry fetched successfully",
+ ancestry);
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/CategoryDao.java b/src/main/java/com/stephenschafer/budget/CategoryDao.java
new file mode 100644
index 0000000..69d5473
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/CategoryDao.java
@@ -0,0 +1,25 @@
+package com.stephenschafer.budget;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+public interface CategoryDao {
+ int deleteById(int id);
+
+ Optional getById(int id);
+
+ Optional getByName(String name);
+
+ List getCategoryAncestry(int categoryId);
+
+ Category add(Category category);
+
+ void update(Category category);
+
+ void getAll(Consumer consumer);
+
+ void getByParent(int parentCategoryId, Consumer consumer);
+
+ void getRoot(Consumer consumer);
+}
diff --git a/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java
new file mode 100644
index 0000000..798e387
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java
@@ -0,0 +1,230 @@
+package com.stephenschafer.budget;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementCreator;
+import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class CategoryDaoImpl implements CategoryDao {
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Override
+ public int deleteById(final int id) {
+ return jdbcTemplate.update("delete from budget.category where id = ?", id);
+ }
+
+ @Override
+ public Optional getById(final int id) {
+ final String sql = "select coalesce(parent_category_id, 0), name"
+ + " from budget.category where id = ?";
+ try {
+ return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
+ final int parentCategoryId = rs.getInt(1);
+ final boolean parentCategoryIdWasNull = rs.wasNull();
+ final String name = rs.getString(2);
+ return Optional.of(new Category(id,
+ parentCategoryIdWasNull ? null : Integer.valueOf(parentCategoryId), name));
+ }, id);
+ }
+ catch (final DataAccessException e) {
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public Optional getByName(final String name) {
+ final String[] parts = name.split("\\.");
+ Category category = null;
+ for (final String part : parts) {
+ Optional optional;
+ if (category == null) {
+ final String sql = "select id"
+ + " from budget.category where coalesce(parent_category_id, 0) = 0 and name = ?";
+ optional = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
+ final int id = rs.getInt(1);
+ return Optional.of(new Category(id, null, part));
+ }, part);
+ }
+ else {
+ final Integer parentId = category.getId();
+ final String sql = "select id"
+ + " from budget.category where coalesce(parent_category_id, 0) = ? and name = ?";
+ optional = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
+ final int id = rs.getInt(1);
+ return Optional.of(new Category(id, parentId, part));
+ }, parentId, part);
+ }
+ if (!optional.isPresent()) {
+ return optional;
+ }
+ category = optional.get();
+ }
+ return Optional.of(category);
+ }
+
+ @Override
+ public List getCategoryAncestry(final int categoryId) {
+ return getCategoryAncestry(null, categoryId);
+ }
+
+ private List getCategoryAncestry(final List ancestry,
+ final int categoryId) {
+ final Optional optionalCategory = getById(categoryId);
+ if (!optionalCategory.isPresent()) {
+ return ancestry;
+ }
+ final Category category = optionalCategory.get();
+ final List result = new ArrayList<>();
+ result.add(category);
+ if (ancestry != null) {
+ result.addAll(ancestry);
+ }
+ if (category.parentCategoryId == null) {
+ return result;
+ }
+ return getCategoryAncestry(result, category.parentCategoryId);
+ }
+
+ @Override
+ public Category add(final Category category) {
+ log.info("CategoryDaoImpl.add " + category);
+ final String sql = "insert into budget.category" + " (parent_category_id, name)"
+ + " values (?, ?)";
+ final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql,
+ Types.INTEGER, Types.VARCHAR);
+ factory.setReturnGeneratedKeys(true);
+ factory.setGeneratedKeysColumnNames("id");
+ final PreparedStatementCreator creator = factory.newPreparedStatementCreator(
+ new Object[] { category.getParentCategoryId(), category.getName() });
+ final KeyHolder keyHolder = new GeneratedKeyHolder();
+ final int rowCount = jdbcTemplate.update(creator, keyHolder);
+ if (rowCount == 0) {
+ return null;
+ }
+ final Number generatedId = keyHolder.getKey();
+ return new Category(generatedId.intValue(), category);
+ }
+
+ @Override
+ public void update(final Category category) {
+ log.info("CategoryDaoImpl.update " + category);
+ final String sql = "update budget.category" + " set parent_category_id = ?, name = ?"
+ + " where id = ?";
+ final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql,
+ Types.INTEGER, Types.VARCHAR, Types.INTEGER);
+ PreparedStatementCreator creator;
+ try {
+ creator = factory.newPreparedStatementCreator(new Object[] {
+ category.getParentCategoryId(), category.getName(), category.getId() });
+ }
+ catch (final Exception e) {
+ log.error("failed to get creator");
+ e.printStackTrace();
+ return;
+ }
+ log.info("update creator = " + creator);
+ jdbcTemplate.update(creator);
+ }
+
+ private static class PreparedStatementHolder {
+ PreparedStatement statement;
+ }
+
+ @Override
+ public void getAll(final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select" + " id, coalesce(parent_category_id, 0), name"
+ + " from budget.category" + " order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final int parentCategoryId = rs.getInt(++i);
+ final String name = rs.getString(++i);
+ consumer.accept(new Category(id, parentCategoryId, name));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getByCategory failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getByParent(final int parentCategoryId, final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select" + " id, name" + " from budget.category"
+ + " where coalesce(parent_category_id, 0) = ?" + " order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ holder.statement.setInt(1, parentCategoryId);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final String name = rs.getString(++i);
+ consumer.accept(new Category(id, parentCategoryId, name));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getByCategory failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getRoot(final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select id, name from budget.category"
+ + " where coalesce(parent_category_id, 0) = 0 order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final String name = rs.getString(++i);
+ consumer.accept(new Category(id, null, name));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getRoot failed", e1);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/Constants.java b/src/main/java/com/stephenschafer/budget/Constants.java
new file mode 100644
index 0000000..8138ff5
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Constants.java
@@ -0,0 +1,8 @@
+package com.stephenschafer.budget;
+
+public class Constants {
+ public static final long ACCESS_TOKEN_VALIDITY_SECONDS = 24L * 60L * 60L;
+ public static final String SIGNING_KEY = "sschafer123r";
+ public static final String TOKEN_PREFIX = "Bearer ";
+ public static final String HEADER_STRING = "Authorization";
+}
diff --git a/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java
new file mode 100644
index 0000000..14117b9
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java
@@ -0,0 +1,21 @@
+package com.stephenschafer.budget;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+@Component
+public class CustomCorsConfiguration implements CorsConfigurationSource {
+ @Override
+ public CorsConfiguration getCorsConfiguration(final HttpServletRequest request) {
+ final CorsConfiguration config = new CorsConfiguration();
+ config.setAllowedOrigins(List.of("http://localhost:3000"));
+ config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ config.setAllowedHeaders(List.of("*"));
+ return config;
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java b/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java
new file mode 100644
index 0000000..890064c
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java
@@ -0,0 +1,13 @@
+package com.stephenschafer.budget;
+
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ExceptionAdvice {
+ @ExceptionHandler(RuntimeException.class)
+ public ApiResponse handleNotFoundException(final RuntimeException ex) {
+ final ApiResponse apiResponse = new ApiResponse<>(400, "Bad request", null);
+ return apiResponse;
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/FileDao.java b/src/main/java/com/stephenschafer/budget/FileDao.java
new file mode 100644
index 0000000..62a717d
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/FileDao.java
@@ -0,0 +1,9 @@
+package com.stephenschafer.budget;
+
+import java.util.Optional;
+
+import org.springframework.data.repository.CrudRepository;
+
+public interface FileDao extends CrudRepository {
+ Optional findByName(String filename);
+}
diff --git a/src/main/java/com/stephenschafer/budget/FileEntity.java b/src/main/java/com/stephenschafer/budget/FileEntity.java
new file mode 100644
index 0000000..aa86ba1
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/FileEntity.java
@@ -0,0 +1,29 @@
+package com.stephenschafer.budget;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
+@Entity
+@Table(name = "file")
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+@ToString
+public class FileEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Integer id;
+ @Column
+ private String name;
+}
diff --git a/src/main/java/com/stephenschafer/budget/FindProjectResult.java b/src/main/java/com/stephenschafer/budget/FindProjectResult.java
new file mode 100644
index 0000000..bf9241c
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/FindProjectResult.java
@@ -0,0 +1,11 @@
+package com.stephenschafer.budget;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class FindProjectResult {
+ private int id;
+ private String code;
+}
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java b/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..650fcf1
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,26 @@
+package com.stephenschafer.budget;
+
+import java.io.IOException;
+import java.io.Serial;
+import java.io.Serializable;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+@Component
+public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void commence(final HttpServletRequest request, final HttpServletResponse response,
+ final AuthenticationException authException) throws IOException {
+ // This is invoked when user tries to access a secured REST resource without supplying any credentials
+ // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java b/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..ede2568
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java
@@ -0,0 +1,69 @@
+package com.stephenschafer.budget;
+
+import static com.stephenschafer.budget.Constants.HEADER_STRING;
+import static com.stephenschafer.budget.Constants.TOKEN_PREFIX;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.SignatureException;
+
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+ @Autowired
+ private UserDetailsService userDetailsService;
+ @Autowired
+ private JwtTokenUtil jwtTokenUtil;
+
+ @Override
+ protected void doFilterInternal(final HttpServletRequest req, final HttpServletResponse res,
+ final FilterChain chain) throws IOException, ServletException {
+ final String header = req.getHeader(HEADER_STRING);
+ String username = null;
+ String authToken = null;
+ if (header != null && header.startsWith(TOKEN_PREFIX)) {
+ authToken = header.replace(TOKEN_PREFIX, "");
+ try {
+ username = jwtTokenUtil.getUsernameFromToken(authToken);
+ }
+ catch (final IllegalArgumentException e) {
+ logger.error("an error occured during getting username from token", e);
+ }
+ catch (final ExpiredJwtException e) {
+ logger.warn("the token is expired and not valid anymore", e);
+ }
+ catch (final SignatureException e) {
+ logger.error("Authentication Failed. Username or Password not valid.");
+ }
+ }
+ else {
+ logger.warn("couldn't find bearer string, will ignore the header");
+ }
+ req.setAttribute("username", username);
+ if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+ final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
+ if (jwtTokenUtil.validateToken(authToken, userDetails)) {
+ final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
+ userDetails, null, Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN")));
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
+ logger.info("authenticated user " + username + ", setting security context");
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ }
+ chain.doFilter(req, res);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java
new file mode 100644
index 0000000..d69a4de
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java
@@ -0,0 +1,65 @@
+package com.stephenschafer.budget;
+
+import static com.stephenschafer.budget.Constants.ACCESS_TOKEN_VALIDITY_SECONDS;
+import static com.stephenschafer.budget.Constants.SIGNING_KEY;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.function.Function;
+
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Component;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+
+@Component
+public class JwtTokenUtil implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ public String getUsernameFromToken(final String token) {
+ return getClaimFromToken(token, Claims::getSubject);
+ }
+
+ public Date getExpirationDateFromToken(final String token) {
+ return getClaimFromToken(token, Claims::getExpiration);
+ }
+
+ public T getClaimFromToken(final String token, final Function claimsResolver) {
+ final Claims claims = getAllClaimsFromToken(token);
+ return claimsResolver.apply(claims);
+ }
+
+ private Claims getAllClaimsFromToken(final String token) {
+ return Jwts.parser().setSigningKey(SIGNING_KEY).parseClaimsJws(token).getBody();
+ }
+
+ private Boolean isTokenExpired(final String token) {
+ final Date expiration = getExpirationDateFromToken(token);
+ return expiration.before(new Date());
+ }
+
+ public String generateToken(final UserEntity user) {
+ return doGenerateToken(user.getUsername());
+ }
+
+ private String doGenerateToken(final String subject) {
+ final Claims claims = Jwts.claims().setSubject(subject);
+ claims.put("scopes", Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN")));
+ return Jwts.builder().setClaims(claims).setIssuer("http://stephenschafer.com").setIssuedAt(
+ new Date(System.currentTimeMillis())).setExpiration(
+ new Date(System.currentTimeMillis()
+ + ACCESS_TOKEN_VALIDITY_SECONDS * 1000L)).signWith(SignatureAlgorithm.HS256,
+ SIGNING_KEY).compact();
+ }
+
+ public Boolean validateToken(final String token, final UserDetails userDetails) {
+ final String username = getUsernameFromToken(token);
+ return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/LoginUser.java b/src/main/java/com/stephenschafer/budget/LoginUser.java
new file mode 100644
index 0000000..dbd59eb
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/LoginUser.java
@@ -0,0 +1,11 @@
+package com.stephenschafer.budget;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class LoginUser {
+ private String username;
+ private String password;
+}
diff --git a/src/main/java/com/stephenschafer/budget/MyUserDetails.java b/src/main/java/com/stephenschafer/budget/MyUserDetails.java
new file mode 100644
index 0000000..d5227fd
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/MyUserDetails.java
@@ -0,0 +1,33 @@
+package com.stephenschafer.budget;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+public class MyUserDetails implements UserDetails {
+ private static final long serialVersionUID = 1L;
+ private final UserEntity user;
+
+ public MyUserDetails(final UserEntity user) {
+ this.user = user;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ final SimpleGrantedAuthority authority = new SimpleGrantedAuthority(user.getRole());
+ return Arrays.asList(authority);
+ }
+
+ @Override
+ public String getPassword() {
+ return user.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getUsername();
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java b/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java
new file mode 100644
index 0000000..8fc7172
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java
@@ -0,0 +1,7 @@
+package com.stephenschafer.budget;
+
+import java.sql.PreparedStatement;
+
+public class PreparedStatementHolder {
+ PreparedStatement statement;
+}
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/Regex.java b/src/main/java/com/stephenschafer/budget/Regex.java
new file mode 100644
index 0000000..3262831
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Regex.java
@@ -0,0 +1,29 @@
+package com.stephenschafer.budget;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@ToString
+public class Regex {
+ public Regex() {
+ }
+
+ public Regex(final int id, final Regex regex) {
+ this(id, regex.categoryId, regex.regex, regex.flags, regex.source, regex.priority,
+ regex.description, regex.year);
+ }
+
+ Integer id;
+ Integer categoryId;
+ String regex;
+ int flags;
+ String source;
+ int priority;
+ String description;
+ Integer year;
+}
diff --git a/src/main/java/com/stephenschafer/budget/RegexController.java b/src/main/java/com/stephenschafer/budget/RegexController.java
new file mode 100644
index 0000000..cde146c
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/RegexController.java
@@ -0,0 +1,130 @@
+package com.stephenschafer.budget;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@CrossOrigin(origins = "*", maxAge = 3600)
+@RestController
+public class RegexController {
+ @Autowired
+ private RegexDao regexDao;
+ @Autowired
+ private CategoryDao categoryDao;
+ @Autowired
+ private UserService userService;
+
+ @PostMapping("/regexes")
+ @ResponseBody
+ public ApiResponse postRegex(@RequestBody final Regex regex,
+ final HttpServletRequest req) {
+ if (!userService.isAuthorized(req)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex inserted successfully",
+ regexDao.add(regex));
+ }
+
+ @PutMapping("/regexes")
+ @ResponseBody
+ public ApiResponse putRegex(@RequestBody final Regex regex,
+ final HttpServletRequest req) {
+ if (!userService.isAuthorized(req)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ regexDao.update(regex);
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex updated successfully", regex);
+ }
+
+ @GetMapping("/regexes")
+ @ResponseBody
+ public ApiResponse> getRegexes(final HttpServletRequest request) {
+ log.info("GET /regexes");
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final List regexDisplays = new ArrayList<>();
+ regexDao.getAllDisplay(regexDisplay -> {
+ regexDisplays.add(regexDisplay);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully",
+ regexDisplays);
+ }
+
+ @GetMapping("/regex/{regexId}")
+ @ResponseBody
+ public ApiResponse getRegex(@PathVariable(required = true) final Integer regexId,
+ final HttpServletRequest request) {
+ log.info("GET /regex/" + regexId);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ if (regexId == null) {
+ return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Regex ID not specified",
+ null);
+ }
+ final Optional optionalRegex = regexDao.getById(regexId.intValue());
+ if (!optionalRegex.isPresent()) {
+ return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Regex ID not found", null);
+ }
+ final Regex regex = optionalRegex.get();
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex retrieved successfully", regex);
+ }
+
+ @GetMapping("/regexes/category/{categoryName}")
+ @ResponseBody
+ public ApiResponse> getRegexesByCategory(
+ @PathVariable(required = true) final String categoryName,
+ final HttpServletRequest request) {
+ log.info("GET /regexes/" + categoryName);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final Optional category = categoryDao.getByName(categoryName);
+ if (!category.isPresent()) {
+ return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Category not found", null);
+ }
+ final int categoryId = category.get().getId();
+ final List regexes = new ArrayList<>();
+ regexDao.getByCategory(categoryId, regex -> {
+ regexes.add(regex);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully", regexes);
+ }
+
+ @GetMapping("/regexes/source/{source}")
+ @ResponseBody
+ public ApiResponse> getRegexesBySource(
+ @PathVariable(required = true) final String source, final HttpServletRequest request) {
+ log.info("GET /regexes/source/" + source);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ final List regexes = new ArrayList<>();
+ regexDao.getBySource(source, regex -> {
+ regexes.add(regex);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully", regexes);
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/RegexDao.java b/src/main/java/com/stephenschafer/budget/RegexDao.java
new file mode 100644
index 0000000..3c8f62b
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/RegexDao.java
@@ -0,0 +1,22 @@
+package com.stephenschafer.budget;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+public interface RegexDao {
+ int deleteById(int id);
+
+ Optional getById(int id);
+
+ Regex add(Regex regex);
+
+ void update(Regex regex);
+
+ void getAll(Consumer consumer);
+
+ void getAllDisplay(Consumer consumer);
+
+ void getByCategory(int categoryId, Consumer consumer);
+
+ void getBySource(String source, Consumer consumer);
+}
diff --git a/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java b/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java
new file mode 100644
index 0000000..fd839b5
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java
@@ -0,0 +1,228 @@
+package com.stephenschafer.budget;
+
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementCreator;
+import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class RegexDaoImpl implements RegexDao {
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+ @Autowired
+ private CategoryDao categoryDao;
+
+ @Override
+ public int deleteById(final int id) {
+ return jdbcTemplate.update("delete from budget.regex where id = ?", id);
+ }
+
+ @Override
+ public Optional getById(final int id) {
+ final String sql = "select"
+ + " category_id, regex, flags, source, priority, description, year"
+ + " from budget.regex where id = ?";
+ return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
+ int i = 0;
+ final int categoryId = rs.getInt(++i);
+ final String regex = rs.getString(++i);
+ final int flags = rs.getInt(++i);
+ final String source = rs.getString(++i);
+ final int priority = rs.getInt(++i);
+ final String description = rs.getString(++i);
+ final int yearInt = rs.getInt(++i);
+ final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt);
+ return Optional.of(
+ new Regex(id, categoryId, regex, flags, source, priority, description, year));
+ }, id);
+ }
+
+ @Override
+ public Regex add(final Regex regex) {
+ log.info("RegexDaoImpl.add " + regex);
+ final String sql = "insert into budget.regex"
+ + " (category_id, regex, flags, source, priority, description, year)"
+ + " values (?, ?, ?, ?, ?, ?, ?)";
+ final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql,
+ Types.INTEGER, Types.VARCHAR, Types.INTEGER, Types.VARCHAR, Types.INTEGER,
+ Types.VARCHAR, Types.INTEGER);
+ factory.setReturnGeneratedKeys(true);
+ factory.setGeneratedKeysColumnNames("id");
+ final PreparedStatementCreator creator = factory.newPreparedStatementCreator(
+ new Object[] { regex.getCategoryId(), regex.getRegex(), regex.getFlags(),
+ regex.getSource(), regex.getPriority(), regex.getDescription(), regex.getYear() });
+ final KeyHolder keyHolder = new GeneratedKeyHolder();
+ final int rowCount = jdbcTemplate.update(creator, keyHolder);
+ if (rowCount == 0) {
+ return null;
+ }
+ final Number generatedId = keyHolder.getKey();
+ return new Regex(generatedId.intValue(), regex);
+ }
+
+ @Override
+ public void update(final Regex regex) {
+ log.info("RegexDaoImpl.update " + regex);
+ final String sql = "update budget.regex"
+ + " set category_id = ?, regex = ?, flags = ?, source = ?, priority = ?, description = ?, year = ?"
+ + " where id = ?";
+ final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql,
+ Types.INTEGER, Types.VARCHAR, Types.INTEGER, Types.VARCHAR, Types.INTEGER,
+ Types.VARCHAR, Types.INTEGER, Types.INTEGER);
+ PreparedStatementCreator creator;
+ try {
+ creator = factory.newPreparedStatementCreator(new Object[] { regex.getCategoryId(),
+ regex.getRegex(), regex.getFlags(), regex.getSource(), regex.getPriority(),
+ regex.getDescription(), regex.getYear(), regex.getId() });
+ }
+ catch (final Exception e) {
+ log.error("failed to get creator");
+ e.printStackTrace();
+ return;
+ }
+ log.info("update creator = " + creator);
+ jdbcTemplate.update(creator);
+ }
+
+ @Override
+ public void getAll(final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select"
+ + " id, category_id, regex, flags, source, priority, description, year"
+ + " from budget.regex" + " order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final int categoryId = rs.getInt(++i);
+ final String regex = rs.getString(++i);
+ final int flags = rs.getInt(++i);
+ final String source = rs.getString(++i);
+ final int priority = rs.getInt(++i);
+ final String description = rs.getString(++i);
+ final int yearInt = rs.getInt(++i);
+ final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt);
+ consumer.accept(
+ new Regex(id, categoryId, regex, flags, source, priority, description, year));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getAll failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getAllDisplay(final Consumer consumer) {
+ final List regexes = new ArrayList<>();
+ getAll(regex -> {
+ regexes.add(regex);
+ });
+ for (final Regex regex : regexes) {
+ final List categoryAncestry = categoryDao.getCategoryAncestry(
+ regex.categoryId);
+ final String fqCategoryName = getFQCategoryName(categoryAncestry);
+ consumer.accept(new RegexDisplay(regex, fqCategoryName));
+ }
+ }
+
+ private String getFQCategoryName(final List categoryAncestry) {
+ final StringBuilder sb = new StringBuilder();
+ String sep = "";
+ for (final Category category : categoryAncestry) {
+ sb.append(sep);
+ sep = ".";
+ sb.append(category.name);
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public void getByCategory(final int categoryId, final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select" + " id, regex, flags, source, priority, description, year"
+ + " from budget.regex" + " where category_id = ?" + " order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ holder.statement.setInt(1, categoryId);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final String regex = rs.getString(++i);
+ final int flags = rs.getInt(++i);
+ final String source = rs.getString(++i);
+ final int priority = rs.getInt(++i);
+ final String description = rs.getString(++i);
+ final int yearInt = rs.getInt(++i);
+ final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt);
+ consumer.accept(
+ new Regex(id, categoryId, regex, flags, source, priority, description, year));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getByCategory failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getBySource(final String source, final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select" + " id, category_id, regex, flags, priority, description, year"
+ + " from budget.regex" + " where source = ?" + " order by id";
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ holder.statement.setString(1, source);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final int categoryId = rs.getInt(++i);
+ final String regex = rs.getString(++i);
+ final int flags = rs.getInt(++i);
+ final int priority = rs.getInt(++i);
+ final String description = rs.getString(++i);
+ final int yearInt = rs.getInt(++i);
+ final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt);
+ consumer.accept(
+ new Regex(id, categoryId, regex, flags, source, priority, description, year));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getBySource failed", e1);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/RegexDisplay.java b/src/main/java/com/stephenschafer/budget/RegexDisplay.java
new file mode 100644
index 0000000..9c9e327
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/RegexDisplay.java
@@ -0,0 +1,27 @@
+package com.stephenschafer.budget;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@ToString
+public class RegexDisplay {
+ public RegexDisplay(final Regex regex, final String fqCategoryName) {
+ this(regex.id, regex.categoryId, fqCategoryName, regex.regex, regex.flags, regex.source,
+ regex.priority, regex.description, regex.year);
+ }
+
+ Integer id;
+ Integer categoryId;
+ String fqCategoryName;
+ String regex;
+ int flags;
+ String source;
+ int priority;
+ String description;
+ Integer year;
+}
diff --git a/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java b/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java
new file mode 100644
index 0000000..87b9c9d
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java
@@ -0,0 +1,42 @@
+package com.stephenschafer.budget;
+
+import java.text.ParseException;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MultipartException;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RestControllerAdvice
+public class RestExceptionHandler {
+ @ExceptionHandler(RuntimeException.class)
+ @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Unknown error")
+ public ApiResponse handleNotFoundException(final RuntimeException ex) {
+ log.info("Exception: " + ex);
+ final ApiResponse apiResponse = new ApiResponse<>(400, "Unknown error", null);
+ return apiResponse;
+ }
+
+ @ExceptionHandler(MultipartException.class)
+ @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Multipart exception")
+ public ApiResponse handleError1(final MultipartException e,
+ final RedirectAttributes redirectAttributes) {
+ redirectAttributes.addFlashAttribute("message", e.getCause().getMessage());
+ log.info("Exception: " + e);
+ final ApiResponse apiResponse = new ApiResponse<>(400, "Multipart Exception", null);
+ return apiResponse;
+ }
+
+ @ExceptionHandler(ParseException.class)
+ @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Bad date format")
+ public ApiResponse handleParseException(final ParseException ex) {
+ log.info("Exception: " + ex);
+ final ApiResponse apiResponse = new ApiResponse<>(400, "Bad date format", null);
+ return apiResponse;
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/StopException.java b/src/main/java/com/stephenschafer/budget/StopException.java
new file mode 100644
index 0000000..559bbf6
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/StopException.java
@@ -0,0 +1,8 @@
+package com.stephenschafer.budget;
+
+import java.io.Serial;
+
+public class StopException extends RuntimeException {
+ @Serial
+ private static final long serialVersionUID = 1L;
+}
\ No newline at end of file
diff --git a/src/main/java/com/stephenschafer/budget/Transaction.java b/src/main/java/com/stephenschafer/budget/Transaction.java
new file mode 100644
index 0000000..dd40c62
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Transaction.java
@@ -0,0 +1,26 @@
+package com.stephenschafer.budget;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@ToString
+public class Transaction {
+ Integer id;
+ String source;
+ String uniqueIdentifier;
+ String type;
+ String description;
+ String extraDescription;
+ Date date;
+ BigDecimal amount;
+ Integer optional;
+ Integer regexId;
+}
diff --git a/src/main/java/com/stephenschafer/budget/TransactionController.java b/src/main/java/com/stephenschafer/budget/TransactionController.java
new file mode 100644
index 0000000..23d5fb3
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/TransactionController.java
@@ -0,0 +1,86 @@
+package com.stephenschafer.budget;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@CrossOrigin(origins = "*", maxAge = 3600)
+@RestController
+public class TransactionController {
+ @Autowired
+ private TransactionDao transactionDao;
+ @Autowired
+ private UserService userService;
+
+ @GetMapping("/sources/{year}")
+ @ResponseBody
+ public ApiResponse> geSources(@PathVariable(required = true) final String year,
+ final HttpServletRequest request) {
+ log.info("GET /sources/" + year);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ if (year == null) {
+ return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null);
+ }
+ final List sources = new ArrayList<>();
+ transactionDao.getSources(year, source -> {
+ sources.add(source);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Sources retrieved successfully", sources);
+ }
+
+ @GetMapping("/transactions/{year}")
+ @ResponseBody
+ public ApiResponse> getTransactions(
+ @PathVariable(required = true) final String year, final HttpServletRequest request) {
+ log.info("GET /transactions/" + year);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ if (year == null) {
+ return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null);
+ }
+ final List transactions = new ArrayList<>();
+ transactionDao.getAll(year, transaction -> {
+ transactions.add(transaction);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Transactions retrieved successfully",
+ transactions);
+ }
+
+ @GetMapping("/transactionsByRegexId/{year}/{regexId}")
+ @ResponseBody
+ public ApiResponse> getTransactionsByRegexId(
+ @PathVariable(required = true) final String year,
+ @PathVariable(required = true) final Integer regexId,
+ final HttpServletRequest request) {
+ log.info("GET /transactions/" + year);
+ if (!userService.isAuthorized(request)) {
+ return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(),
+ "You are not authorized to do this", null);
+ }
+ if (year == null) {
+ return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null);
+ }
+ final List transactions = new ArrayList<>();
+ transactionDao.getByRegexId(year, regexId, transaction -> {
+ transactions.add(transaction);
+ });
+ return new ApiResponse<>(HttpStatus.OK.value(), "Transactions retrieved successfully",
+ transactions);
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/TransactionDao.java b/src/main/java/com/stephenschafer/budget/TransactionDao.java
new file mode 100644
index 0000000..e2854f0
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/TransactionDao.java
@@ -0,0 +1,20 @@
+package com.stephenschafer.budget;
+
+import java.util.Optional;
+import java.util.function.Consumer;
+
+public interface TransactionDao {
+ Optional getById(String year, int id);
+
+ void getAll(String year, Consumer consumer);
+
+ void getByCategory(String year, int categoryId, Consumer consumer);
+
+ void getBySource(String year, String source, Consumer consumer);
+
+ void getByRegexId(String year, Integer regexId, Consumer consumer);
+
+ void getSources(String year, Consumer consumer);
+
+ void update(String year, Transaction transaction);
+}
diff --git a/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java b/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java
new file mode 100644
index 0000000..8317e71
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java
@@ -0,0 +1,159 @@
+package com.stephenschafer.budget;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.sql.SQLException;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementCreator;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class TransactionDaoImpl implements TransactionDao {
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Override
+ public Optional getById(final String year, final int id) {
+ final String sql = ("select"
+ + " source, unique_identifier, type, description, extra_description, date, amount, optional, regex_id"
+ + " from budget_${year}.transaction where id = ?").replace("${year}", year);
+ return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
+ int i = 0;
+ final String source = rs.getString(++i);
+ final String uniqueIdentifier = rs.getString(++i);
+ final String type = rs.getString(++i);
+ final String description = rs.getString(++i);
+ final String extraDescription = rs.getString(++i);
+ final Date date = rs.getDate(++i);
+ final BigDecimal amount = rs.getBigDecimal(++i);
+ final int optional = rs.getInt(++i);
+ final int regexId = rs.getInt(++i);
+ return Optional.of(new Transaction(id, source, uniqueIdentifier, type, description,
+ extraDescription, date, amount, optional, regexId));
+ }, id);
+ }
+
+ @Override
+ public void update(final String year, final Transaction transaction) {
+ // TODO Auto-generated method stub
+ }
+
+ @Override
+ public void getAll(final String year, final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = ("select"
+ + " id, source, unique_identifier, type, description, extra_description, date, amount, optional, regex_id"
+ + " from budget_${year}.transaction" + " order by id").replace("${year}", year);
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final String source = rs.getString(++i);
+ final String uniqueIdentifier = rs.getString(++i);
+ final String type = rs.getString(++i);
+ final String description = rs.getString(++i);
+ final String extraDescription = rs.getString(++i);
+ final Date date = rs.getDate(++i);
+ final BigDecimal amount = rs.getBigDecimal(++i);
+ final int optional = rs.getInt(++i);
+ final int regexId = rs.getInt(++i);
+ consumer.accept(new Transaction(id, source, uniqueIdentifier, type, description,
+ extraDescription, date, amount, optional, regexId));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getByCategory failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getByCategory(final String year, final int categoryId,
+ final Consumer consumer) {
+ // TODO Auto-generated method stub
+ }
+
+ @Override
+ public void getBySource(final String year, final String source,
+ final Consumer consumer) {
+ // TODO Auto-generated method stub
+ }
+
+ @Override
+ public void getByRegexId(final String year, final Integer regexId,
+ final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = ("select"
+ + " id, source, unique_identifier, type, description, extra_description, date, amount, optional"
+ + " from budget_${year}.transaction where regex_id = ?" + " order by id").replace(
+ "${year}", year);
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ holder.statement.setInt(1, regexId.intValue());
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final int id = rs.getInt(++i);
+ final String source = rs.getString(++i);
+ final String uniqueIdentifier = rs.getString(++i);
+ final String type = rs.getString(++i);
+ final String description = rs.getString(++i);
+ final String extraDescription = rs.getString(++i);
+ final Date date = rs.getDate(++i);
+ final BigDecimal amount = rs.getBigDecimal(++i);
+ final int optional = rs.getInt(++i);
+ consumer.accept(new Transaction(id, source, uniqueIdentifier, type, description,
+ extraDescription, date, amount, optional, regexId));
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getByCategory failed", e1);
+ }
+ }
+ }
+
+ @Override
+ public void getSources(final String year, final Consumer consumer) {
+ final PreparedStatementHolder holder = new PreparedStatementHolder();
+ final String sql = "select source from budget_${year}.transaction group by source order by source".replace(
+ "${year}", year);
+ final PreparedStatementCreator creator = connection -> {
+ holder.statement = connection.prepareStatement(sql);
+ return holder.statement;
+ };
+ try {
+ jdbcTemplate.query(creator, rs -> {
+ int i = 0;
+ final String source = rs.getString(++i);
+ consumer.accept(source);
+ });
+ }
+ catch (final StopException e) {
+ try {
+ holder.statement.cancel();
+ }
+ catch (final SQLException e1) {
+ log.error("getSources failed", e1);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserController.java b/src/main/java/com/stephenschafer/budget/UserController.java
new file mode 100644
index 0000000..3508197
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserController.java
@@ -0,0 +1,53 @@
+package com.stephenschafer.budget;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@CrossOrigin(origins = "*", maxAge = 3600)
+@RestController
+@RequestMapping("/users")
+public class UserController {
+ @Autowired
+ private UserService userService;
+
+ @PostMapping
+ public ApiResponse saveUser(@RequestBody final UserDto user) {
+ return new ApiResponse<>(HttpStatus.OK.value(), "User saved successfully.",
+ userService.save(user));
+ }
+
+ @GetMapping
+ public ApiResponse> listUser() {
+ return new ApiResponse<>(HttpStatus.OK.value(), "User list fetched successfully.",
+ userService.findAll());
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse getOne(@PathVariable final int id) {
+ return new ApiResponse<>(HttpStatus.OK.value(), "User fetched successfully.",
+ userService.findById(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@RequestBody final UserDto userDto) {
+ return new ApiResponse<>(HttpStatus.OK.value(), "User updated successfully.",
+ userService.update(userDto));
+ }
+
+ @DeleteMapping("/{id}")
+ public ApiResponse delete(@PathVariable final int id) {
+ userService.delete(id);
+ return new ApiResponse<>(HttpStatus.OK.value(), "User deleted successfully.", null);
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserDao.java b/src/main/java/com/stephenschafer/budget/UserDao.java
new file mode 100644
index 0000000..ca6f96c
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserDao.java
@@ -0,0 +1,9 @@
+package com.stephenschafer.budget;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserDao extends CrudRepository {
+ UserEntity findByUsername(String username);
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserDto.java b/src/main/java/com/stephenschafer/budget/UserDto.java
new file mode 100644
index 0000000..530bfd4
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserDto.java
@@ -0,0 +1,15 @@
+package com.stephenschafer.budget;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class UserDto {
+ private int id;
+ private String firstName;
+ private String lastName;
+ private String username;
+ private String password;
+ private String role;
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserEntity.java b/src/main/java/com/stephenschafer/budget/UserEntity.java
new file mode 100644
index 0000000..ac9e85e
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserEntity.java
@@ -0,0 +1,33 @@
+package com.stephenschafer.budget;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+@Entity
+@Table(name = "user_1")
+@Getter
+@Setter
+public class UserEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private int id;
+ @Column
+ private String firstName;
+ @Column
+ private String lastName;
+ @Column
+ private String username;
+ @Column
+ private String role;
+ @Column
+ @JsonIgnore
+ private String password;
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserService.java b/src/main/java/com/stephenschafer/budget/UserService.java
new file mode 100644
index 0000000..6ce71a4
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserService.java
@@ -0,0 +1,23 @@
+package com.stephenschafer.budget;
+
+import java.util.List;
+
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+public interface UserService extends UserDetailsService {
+ UserEntity save(UserDto user);
+
+ List findAll();
+
+ void delete(int id);
+
+ UserEntity findByUsername(String username);
+
+ UserEntity findById(int id);
+
+ UserDto update(UserDto userDto);
+
+ boolean isAuthorized(final HttpServletRequest request);
+}
diff --git a/src/main/java/com/stephenschafer/budget/UserServiceImpl.java b/src/main/java/com/stephenschafer/budget/UserServiceImpl.java
new file mode 100644
index 0000000..30f5987
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/UserServiceImpl.java
@@ -0,0 +1,98 @@
+package com.stephenschafer.budget;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.transaction.Transactional;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Transactional
+@Service(value = "userService")
+public class UserServiceImpl implements UserDetailsService, UserService {
+ @Autowired
+ private UserDao userDao;
+ @Autowired
+ private BCryptPasswordEncoder bcryptEncoder;
+
+ @Override
+ public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
+ final UserEntity user = userDao.findByUsername(username);
+ if (user == null) {
+ throw new UsernameNotFoundException("Invalid username or password.");
+ }
+ return new org.springframework.security.core.userdetails.User(user.getUsername(),
+ user.getPassword(), getAuthority());
+ }
+
+ private List getAuthority() {
+ return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"));
+ }
+
+ @Override
+ public List findAll() {
+ final List list = new ArrayList<>();
+ userDao.findAll().iterator().forEachRemaining(list::add);
+ return list;
+ }
+
+ @Override
+ public void delete(final int id) {
+ userDao.deleteById(id);
+ }
+
+ @Override
+ public UserEntity findByUsername(final String username) {
+ return userDao.findByUsername(username);
+ }
+
+ @Override
+ public UserEntity findById(final int id) {
+ final Optional optionalUser = userDao.findById(id);
+ return optionalUser.isPresent() ? optionalUser.get() : null;
+ }
+
+ @Override
+ public UserDto update(final UserDto userDto) {
+ final UserEntity user = findById(userDto.getId());
+ if (user != null) {
+ BeanUtils.copyProperties(userDto, user, "password", "username");
+ userDao.save(user);
+ }
+ return userDto;
+ }
+
+ @Override
+ public UserEntity save(final UserDto user) {
+ final UserEntity newUser = new UserEntity();
+ newUser.setUsername(user.getUsername());
+ newUser.setFirstName(user.getFirstName());
+ newUser.setLastName(user.getLastName());
+ newUser.setPassword(bcryptEncoder.encode(user.getPassword()));
+ newUser.setRole(user.getRole());
+ return userDao.save(newUser);
+ }
+
+ @Override
+ public boolean isAuthorized(final HttpServletRequest request) {
+ final String username = (String) request.getAttribute("username");
+ log.info("username = " + username);
+ final UserEntity userEntity = findByUsername(username);
+ if (userEntity == null || !"administrator".equals(userEntity.getRole())) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/com/stephenschafer/budget/Util.java b/src/main/java/com/stephenschafer/budget/Util.java
new file mode 100644
index 0000000..283c36d
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/Util.java
@@ -0,0 +1,4 @@
+package com.stephenschafer.budget;
+
+public class Util {
+}
diff --git a/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java b/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java
new file mode 100644
index 0000000..dc7cada
--- /dev/null
+++ b/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java
@@ -0,0 +1,75 @@
+package com.stephenschafer.budget;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableWebSecurity
+public class WebSecurityConfig {
+ @Autowired
+ private JwtAuthenticationEntryPoint unauthorizedHandler;
+ @Autowired
+ CustomCorsConfiguration customCorsConfiguration;
+
+ @Bean
+ AuthenticationManager authenticationManager(final UserDetailsService userDetailsService,
+ final PasswordEncoder passwordEncoder) {
+ final var provider = new DaoAuthenticationProvider();
+ provider.setUserDetailsService(userDetailsService);
+ provider.setPasswordEncoder(passwordEncoder);
+ return new ProviderManager(provider);
+ }
+
+ @Bean
+ BCryptPasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ JwtAuthenticationFilter authenticationTokenFilterBean() throws Exception {
+ return new JwtAuthenticationFilter();
+ }
+
+ @Bean
+ SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {
+ http.cors(c -> c.configurationSource(customCorsConfiguration)) //
+ .csrf(AbstractHttpConfigurer::disable) //
+ .authorizeHttpRequests(requests -> {
+ requests.requestMatchers("/token/*",
+ "/signup").permitAll().anyRequest().authenticated();
+ }) //
+ .exceptionHandling(
+ configurer -> configurer.authenticationEntryPoint(unauthorizedHandler)) //
+ .sessionManagement(configurer -> configurer.sessionCreationPolicy(
+ SessionCreationPolicy.STATELESS));
+ http.addFilterBefore(authenticationTokenFilterBean(),
+ UsernamePasswordAuthenticationFilter.class);
+ /*
+ requests.requestMatchers("/token/*",
+ "/signup").permitAll().anyRequest().authenticated();
+ */
+ /*
+ http.cors().and().csrf().disable().authorizeRequests().antMatchers("/token/*",
+ "/signup").permitAll().anyRequest().authenticated().and().exceptionHandling().authenticationEntryPoint(
+ unauthorizedHandler).and().sessionManagement().sessionCreationPolicy(
+ SessionCreationPolicy.STATELESS);
+ // @formatter:on
+ http.addFilterBefore(authenticationTokenFilterBean(),
+ UsernamePasswordAuthenticationFilter.class);
+ */
+ return http.build();
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..04515c7
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,20 @@
+spring.datasource.driver-class-name=com.mysql.jdbc.Driver
+spring.datasource.url=jdbc:mysql://localhost:3306?serverTimezone=UTC&useSSL=false
+spring.datasource.username=elephant
+# spring.datasource.password=CHANGEME
+
+spring.jpa.show-sql=true
+# this unconditionally re-creates the table even if it's already populated
+#spring.jpa.hibernate.ddl-auto=create-drop
+# this will add columns to the table if they are missing but doesn't remove any
+spring.jpa.hibernate.ddl-auto=update
+# spring.user.datasource.driver-class-name=com.mysql.jdbc.Driver
+spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
+
+#server.port=8443
+#server.ssl.key-store=classpath:keystore.p12
+#server.ssl.key-store-password=foobar
+#server.ssl.key-store-type=PKCS12
+#server.ssl.key-alias=timesheet
+#server.ssl.key-password=foobar
+#server.ssl.enabled=true
diff --git a/stop b/stop
new file mode 100755
index 0000000..7cecbe8
--- /dev/null
+++ b/stop
@@ -0,0 +1,24 @@
+#!/bin/sh
+cd "$(dirname "${BASH_SOURCE[0]}")"
+ROOT=$(pwd)
+echo "$ROOT"
+function k() {
+ if ! test -f "$1"; then
+ echo "nothing to stop"
+ return 1
+ fi
+ PID=$(cat "$1")
+ if kill -9 $PID; then
+ echo "process $PID stopped"
+ else
+ echo "no such process"
+ fi
+ rm "$1"
+}
+if [ -z "$1" ]; then
+ for file in $ROOT/logs/run-*.pid; do
+ k $file
+ done
+else
+ k "$ROOT/logs/run-$1.pid"
+fi