33 Commits

Author SHA1 Message Date
kasun 7b21011f5a updated README.MD 2026-04-11 14:49:22 +02:00
kasun 01fa3e4267 added integration test for missing creds 2026-04-09 21:18:52 +02:00
kasun a1ebb1be21 format 2026-04-09 21:18:40 +02:00
kasun 7de38afec2 added ConnectionService and HomeboxProvider tests 2026-04-09 21:11:17 +02:00
kasun a5c1931a2a removed apikey variable from homebox test 2026-04-09 21:09:27 +02:00
kasun 3e80b81e07 added comment 2026-04-08 22:35:59 +02:00
kasun ef4ee70aac added WrongServiceTypeException 2026-04-08 22:33:04 +02:00
kasun 00bb929f22 format 2026-04-08 22:32:06 +02:00
kasun c553735653 removed main test 2026-04-08 22:31:36 +02:00
kasun 680e5f0abd revised exception handling for empty fields. 2026-04-08 20:57:27 +02:00
kasun 2000278f1a got rid if Map and added username, password and apikey to request body 2026-04-08 20:51:14 +02:00
kasun 702e6bb973 revised HBTest to match new request body 2026-04-08 20:49:28 +02:00
kasun 8a5f7c2bf8 added username to ConnectionEntity 2026-04-08 20:48:21 +02:00
kasun 3e4a1f92b1 added username for uniqueness of connection db entry 2026-04-08 17:38:42 +02:00
kasun c59f2598b0 added final fileds 2026-04-08 02:22:07 +02:00
kasun 30784fa756 format 2026-04-08 02:21:55 +02:00
kasun da0411f5d1 corrected typo 2026-04-06 11:51:03 +02:00
kasun 240a366ce8 added login logic excl refresh call 2026-04-06 05:02:46 +02:00
kasun 9c3e1469c7 added ApplicationTests 2026-04-06 03:51:36 +02:00
kasun ba7887f6b2 updated spring boot version 2026-04-06 02:09:58 +02:00
kasun 8b1a604dc2 renamed enum classes 2026-04-06 01:28:31 +02:00
kasun be0821b0be replaced constructor with requiredArgsConstructor annotation 2026-04-04 23:15:38 +02:00
kasun 913f3c75f1 made improvements 2026-04-03 21:28:00 +02:00
kasun 0169cf04b6 refactored connection classes to be more generic and accept credentials of different apps. 2026-04-03 02:58:34 +02:00
kasun ab1d7e68f5 added rest path 2026-04-02 20:28:34 +02:00
kasun 2387d41ebb changed format 2026-03-30 22:43:07 +02:00
kasun c15c7a0d61 added ErrorMessages enum 2026-03-30 22:41:41 +02:00
kasun bda9391c75 changed format 2026-03-30 21:30:19 +02:00
kasun 6267e18478 changed format 2026-03-30 20:42:48 +02:00
kasun 79379b238a changes test name added comment 2026-03-30 20:42:39 +02:00
kasun 75b6995b94 added post request to achieve login response with tokens 2026-03-30 05:07:51 +02:00
kasun 8128ab829f added additional .env.local file import pathes 2026-03-30 05:05:57 +02:00
kasun 8da3b14e40 added wiremock dependency for remote api testing 2026-03-30 05:05:24 +02:00
25 changed files with 1162 additions and 13 deletions
+86
View File
@@ -0,0 +1,86 @@
## Project Intent
Vaessl is a **technical playground** and proof-of-concept designed to showcase a complete, professional-grade software lifecycle. The primary goal is not just to build a functional utility, but to demonstrate a multi-experience approach to full-stack engineering, DevOps, and AI integration.
This project serves as a transparent portfolio of my take on:
* **Infrastructure:** Moving from local-machine to browser-based development environment hosted on my Proxmox server.
* **Modern AI Orchestration:** Building a system that actually uses AI to do heavy lifting, like identifying objects and understanding natural language using Spring AI and LiteLLM.
* **Architectural Foresight:** Designing with abstraction layers early to ensure the system can scale from a single-target tool to a multi-app bridge without significant refactoring.
---
## What is Vaessl?
Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API.
### Core Functionality
* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table.
* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice.
* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe").
---
## Technical Stack
| Layer | Technology |
| :--- | :--- |
| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI |
| **Frontend** | React (Vite), Tailwind CSS, SCSS |
| **Database** | PostgreSQL + `pgvector` |
| **AI Gateway** | LiteLLM (Unified API proxy) |
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
---
## Infrastructure & Methodology
### 1. Centralized Development (Code-Server)
To maintain a zero-footprint local setup, I develop within a custom **code-server** Docker instance.
* **Custom Image:** The environment is baked with a specific toolchain (OpenJDK 25, Node.js 24) to ensure absolute environment parity.
* **Remote Access:** Secured via Pangolin tunnel, allowing for secure, remote development without exposing ports to the public internet.
### 2. Abstraction & Scalability
A core requirement was to prevent vendor or application lock-in:
* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles.
* **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes.
### 3. Testing Strategy
* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks.
* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend.
---
## Roadmap
### Phase 1: Foundation
* [x] Deployment of core infrastructure (PostgreSQL, LiteLLM, Hoppscotch via Docker).
* [x] Custom Code-Server image build and deployment.
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
### Phase 2: The Processing Pipeline (Current)
* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow.
* [ ] Development of the data refinement UI in React.
* [ ] Initial API connector for Homebox.
### Phase 3: Semantic Search & Expansion
* [ ] Integration of Spring AI vector embeddings.
* [ ] Implementation of additional API targets (WikiJS).
* [ ] Launch of a public-facing demo.
---
## Setup & Deployment
The project is orchestrated via Docker Compose for high portability.
1. Clone the Repository.
2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys.
```
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
DB_USERNAME={username}
DB_PASSWORD={password}
OPENAI_KEY={api-key} # or LLM provider of choice
OPENAI_BASE_URL=https://api.openai.com/v1
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
```
3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database.
4. Execute "npm i" and build gradle.
+2 -3
View File
@@ -1,6 +1,6 @@
plugins {
java
id("org.springframework.boot") version "4.0.4"
id("org.springframework.boot") version "4.0.5"
id("io.spring.dependency-management") version "1.1.7"
}
@@ -40,8 +40,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
testImplementation("org.wiremock:wiremock-standalone:3.12.0")}
dependencyManagement {
imports {
@@ -9,5 +9,4 @@ public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,24 @@
package com.vaessl.app.connection;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class ConnectionController {
private final ConnectionService connectionService;
@PostMapping("/login")
public ResponseEntity<ConnectionResponse> loginResponse(@Valid @RequestBody ConnectionRequest request) {
return ResponseEntity.ok(connectionService.login(request));
}
}
@@ -0,0 +1,30 @@
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String appUrl;
private String username;
}
@@ -0,0 +1,19 @@
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
void checkCredentials(ConnectionRequest request);
String getServiceType();
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
}
@@ -0,0 +1,10 @@
package com.vaessl.app.connection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
}
@@ -0,0 +1,49 @@
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.WrongServiceTypeException;
@Service
public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry;
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
}
return response;
}
}
@@ -0,0 +1,15 @@
package com.vaessl.app.connection;
public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login");
private final String value;
private Endpoint(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
@@ -0,0 +1,103 @@
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public void checkCredentials(ConnectionRequest request) {
if (request.username() == null || request.password() == null) {
List<String> missingFields = new ArrayList<>();
if (request.username() == null) {
missingFields.add("username");
}
if (request.password() == null) {
missingFields.add("password");
}
throw new EmptyCredentialsException(List.copyOf(missingFields));
}
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.username(),
"password", request.password(), "stayLoggedIn",
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
}
@Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
}
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
}
}
@@ -0,0 +1,35 @@
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
@@ -0,0 +1,14 @@
package com.vaessl.app.connection;
import lombok.Getter;
@Getter
public enum ServiceType {
HOMEBOX("HOMEBOX");
private final String value;
private ServiceType(String value){
this.value = value;
}
}
@@ -0,0 +1,25 @@
package com.vaessl.app.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public record ConnectionRequest(
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
String username,
String password,
String apiKey,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
public ConnectionRequest(String appUrl, String serviceType, String username, String password,
Boolean stayLoggedIn) {
this(appUrl, serviceType, username, password, null, stayLoggedIn);
}
}
@@ -0,0 +1,17 @@
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
@@ -0,0 +1,13 @@
package com.vaessl.app.exception;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class EmptyCredentialsException extends RuntimeException {
private final List<String> missingFields;
}
@@ -0,0 +1,35 @@
package com.vaessl.app.exception;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonValue;
public enum ErrorMessage {
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(
HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL(
"The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND,
"No such service type.");
private final HttpStatus status;
private final String message;
private ErrorMessage(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
private ErrorMessage(String message) {
this.status = null;
this.message = message;
}
public HttpStatus getStatus() {
return status;
}
@JsonValue
public String getMessage() {
return message;
}
}
@@ -0,0 +1,61 @@
package com.vaessl.app.exception;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import static com.vaessl.app.exception.ErrorMessage.*;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
}
@ExceptionHandler(HttpClientErrorException.Unauthorized.class)
public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) {
return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(),
UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
@ExceptionHandler(ResourceAccessException.class)
public ProblemDetail handleNoConnection(ResourceAccessException e) {
return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(),
SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
@ExceptionHandler(HttpServerErrorException.class)
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
return ProblemDetail
.forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
}
@ExceptionHandler(WrongServiceTypeException.class)
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
}
@ExceptionHandler(EmptyCredentialsException.class)
public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) {
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
}
}
@@ -0,0 +1,4 @@
package com.vaessl.app.exception;
public class WrongServiceTypeException extends RuntimeException {
}
+7 -7
View File
@@ -2,18 +2,19 @@ spring:
application:
name: vaessl
config:
import: "optional:file:.env.local[.properties]"
import:
- "optional:file:.env.local[.properties]"
- "optional:file:backend/.env.local[.properties]"
- "optional:file:vaessl/backend/.env.local[.properties]"
datasource:
url : ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
ai:
openai:
base-url: ${OPENAI_BASE_URL}
@@ -21,7 +22,6 @@ spring:
chat:
options:
model: gpt-4o-mini
logging:
level:
org.springframework.boot.context.config: TRACE
server:
servlet:
context-path: /api
@@ -16,14 +16,17 @@ import static org.assertj.core.api.Assertions.assertThat;
@ActiveProfiles("test")
class ApplicationTests {
@Autowired
@Autowired
private DataSource dataSource;
@Test
void contextLoads() {
}
@Test
void connectionToTestDbWorks() throws SQLException {
try (Connection connection = dataSource.getConnection()) {
assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
}
}
}
@@ -0,0 +1,50 @@
package com.vaessl.app.connection;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.connection.Mockdata.*;
@ExtendWith(MockitoExtension.class)
class ConnectionServiceTest {
@Mock
private ConnectionProvider mockProvider;
@Mock
private ConnectionRepository mockRepo;
private ConnectionService connectionService;
@BeforeEach
void setup() {
when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE);
connectionService = new ConnectionService(List.of(mockProvider), mockRepo);
}
@Test
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
doThrow(new EmptyCredentialsException(List.of("username")))
.when(mockProvider).checkCredentials(request);
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
verify(mockProvider, never()).authenticate(any());
}
}
@@ -0,0 +1,27 @@
package com.vaessl.app.connection;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.exception.EmptyCredentialsException;
import static org.assertj.core.api.Assertions.assertThat;
import static com.vaessl.app.connection.Mockdata.*;
class HomeBoxConnectionProviderTest {
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
@Test
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
provider.checkCredentials(request);
});
assertThat(exception.getMissingFields()).containsExactly("username", "password");
}
}
@@ -0,0 +1,220 @@
package com.vaessl.app.connection;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.vaessl.app.dto.ConnectionRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.vaessl.app.connection.Endpoint.*;
import static com.vaessl.app.exception.ErrorMessage.*;
import static com.vaessl.app.connection.ServiceType.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@WireMockTest
class HomeboxIntegrationTest {
@Autowired
TestRestTemplate restTemplate;
@Autowired
ConnectionRepository cRepository;
String okJsonHomeboxResponse = """
{
"token": "fake-jwt-token",
"attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z"
}
""";
private static final String MOCK_URL = "http://localhost:1234";
private static final String TEST_USER = "admin";
private static final String TEST_PASS = "pw";
/**
* Returns Token and status code OK when login is successful.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue())
.willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
DocumentContext documentContext = JsonPath.parse(response.getBody());
String token = documentContext.read("$.token");
assertThat(token).isEqualTo("fake-jwt-token");
String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
assertThat(attachmentToken).isEqualTo("fake-attach");
String expiresAt = documentContext.read("$.expiresAt", String.class);
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
/**
* Tests the Unauthorized custom exception.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
/**
* Tests a server error from the external api.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
}
/**
* Checks when the service is unavailable or the app URL is wrong.
*/
@Test
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() {
ConnectionRequest badRequest = new ConnectionRequest(
MOCK_URL,
HOMEBOX.getValue(),
TEST_USER, TEST_PASS,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
/**
* Checks if any login fields are empty since all of them are mandatory.
*/
@Test
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Test the exception when there is an input for serviceType but it's
* unsupported.
*/
@Test
void shouldReturnWrongServiceTypeException() {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
MOCK_URL,
"wrong-service-type",
TEST_USER, TEST_PASS,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
}
/**
* Tests the succesfull persistance of Homebox credential response to the
* database.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
.willReturn(okJson(okJsonHomeboxResponse)));
ConnectionRequest request = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
DocumentContext responseContext = JsonPath.parse(response.getBody());
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
if (dbEntry instanceof HomeboxEntity hbE) {
assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token"));
assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken"));
assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt"));
}
}
@Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() {
ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null,
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials,
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Creates a valid connection request with a mock Api throuh
* WireMockRuntimeInfo.
*
* @param wm
* the WiremockRuntimeInfo object
* @return a mock api connection request.
*/
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS,
null);
}
}
@@ -0,0 +1,9 @@
package com.vaessl.app.connection;
public final class Mockdata {
private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
}
@@ -0,0 +1,302 @@
**vaessl: Login Architecture**
# Backend
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods.
## Single Table Inheritance
The database entities will follow the Single Table Inheritance concept.
The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field.
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
***ConnectionEntitiy.java***
```
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "connections")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
private String appUrl;
}
```
***HomeboxEntitiy.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
```
## The Provider Pattern (Logic Layer)
To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General).
- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity.
***ConnectionProvider.java***
```
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
String getServiceType();
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
}
```
***HomeboxConnectionProvider.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
"password", request.credentials().get("password"), "stayLoggedIn",
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
}
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
}
}
```
- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string.
***ConnectionService.java***
```
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.ProviderNotFoundException;
@Service
public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry;
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new ProviderNotFoundException();
}
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl());
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
}
return response;
}
}
```
## Generic Data Exchange (The DTOs)
Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database.
- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map<String, String> for credentials. This allows one DTO to handle both username/password logins and API-key-only logins.
***ConnectionRequest.java***
```
package com.vaessl.app.dto;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
public record ConnectionRequest(
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
}
```
- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map<String, Object> called extraResponseData.
- The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need.
***ConnectionResponse.java***
```
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
```