From 240a366ce84f7d586c01f9ee1eae8c761c8d4081 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 6 Apr 2026 05:02:46 +0200 Subject: [PATCH] added login logic excl refresh call --- .../app/connection/ConnectionEntity.java | 28 ++ .../app/connection/ConnectionProvider.java | 8 +- .../app/connection/ConnectionRepository.java | 10 + .../app/connection/ConnectionService.java | 24 +- .../connection/HomeBoxConnectionProvider.java | 56 +++- .../vaessl/app/connection/HomeboxEntity.java | 34 ++ .../vaessl/app/connection/ServiceType.java | 14 + .../com/vaessl/app/dto/ConnectionRequest.java | 10 +- .../vaessl/app/dto/ConnectionResponse.java | 13 +- .../app/exception/GlobalExceptionHandler.java | 7 +- .../exception/ProviderNotFoundException.java | 5 + .../connection/HomeboxIntegrationTest.java | 124 +++++-- docs/03-Architecture/01-Login-Architecture.md | 302 ++++++++++++++++++ 13 files changed, 589 insertions(+), 46 deletions(-) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ServiceType.java create mode 100644 backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java create mode 100644 docs/03-Architecture/01-Login-Architecture.md diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java new file mode 100644 index 0000000..df06a47 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -0,0 +1,28 @@ +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) + private Long id; + + private String appUrl; +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index 80b4e8e..ac05ca7 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -6,6 +6,10 @@ import com.vaessl.app.dto.ConnectionResponse; public interface ConnectionProvider { String getServiceType(); - - ConnectionResponse authenticate (ConnectionRequest connectionRequest); + + ConnectionResponse authenticate(ConnectionRequest request); + + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); + + void updateToRepository(ConnectionEntity existing, ConnectionResponse response); } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java new file mode 100644 index 0000000..d02823b --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java @@ -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 findByAppUrl(String appUrl); +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java index 7a98f9f..ea5a526 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -8,24 +8,40 @@ 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 providerRegistry; - public ConnectionService(List providers) { + private final ConnectionRepository cRepository; + + public ConnectionService(List 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().toUpperCase()); + + ConnectionProvider provider = providerRegistry.get(request.serviceType()); if (provider == null) { - throw new IllegalArgumentException("Unknown provider: " + request.serviceType()); + throw new ProviderNotFoundException(); } - return provider.authenticate(request); + 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; } } \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java index c7f9181..fd3fcb1 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -1,5 +1,7 @@ package com.vaessl.app.connection; +import java.time.Instant; +import java.util.HashMap; import java.util.Map; import org.springframework.stereotype.Component; @@ -8,15 +10,18 @@ import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; -import static com.vaessl.app.connection.Endpoints.*; +import static com.vaessl.app.connection.Endpoint.*; @Component public class HomeBoxConnectionProvider implements ConnectionProvider { private final RestClient.Builder restClientBuilder; - public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder) { + private final ConnectionRepository cRepository; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; } @Override @@ -25,17 +30,50 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { } @Override - public ConnectionResponse authenticate(ConnectionRequest connectionRequest) { - Map homeboxPayload = Map.of("username", connectionRequest.credentials().get("username"), - "password", connectionRequest.credentials().get("password"), "stayLoggedIn", - connectionRequest.stayLoggedIn()); + public ConnectionResponse authenticate(ConnectionRequest request) { + Map homeboxPayload = Map.of("username", request.credentials().get("username"), + "password", request.credentials().get("password"), "stayLoggedIn", + request.stayLoggedIn()); - return restClientBuilder.baseUrl(connectionRequest.appUrl()) + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) .build() .post() - .uri(HOMEBOX_LOGIN.getEndpoint()) + .uri(HOMEBOX_LOGIN.getValue()) .body(homeboxPayload) .retrieve() - .body(ConnectionResponse.class); + .body(HomeboxLoginResponse.class); + + if (hbResponse == null) { + throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + } + + Map 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) { + } + } diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java new file mode 100644 index 0000000..f23521d --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -0,0 +1,34 @@ +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; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java new file mode 100644 index 0000000..d579690 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java @@ -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; + } +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java index 72a7f26..587a94b 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -2,6 +2,8 @@ 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; @@ -9,5 +11,11 @@ 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 credentials, - boolean stayLoggedIn) { + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } } diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java index bc9f370..9422e7d 100644 --- a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -1,6 +1,17 @@ package com.vaessl.app.dto; import java.time.Instant; +import java.util.Map; -public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) { +public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } } diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java index 60e2e9e..8bf462b 100644 --- a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -8,7 +8,7 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; -import static com.vaessl.app.exception.ErrorMessages.*; +import static com.vaessl.app.exception.ErrorMessage.*; @RestControllerAdvice public class GlobalExceptionHandler { @@ -40,4 +40,9 @@ public class GlobalExceptionHandler { .forStatusAndDetail(e.getStatusCode(), SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); } + + @ExceptionHandler(ProviderNotFoundException.class) + public ProblemDetail handleWrongServiceType(ProviderNotFoundException e) { + return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); + } } diff --git a/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java b/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java new file mode 100644 index 0000000..a5d8200 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ProviderNotFoundException.java @@ -0,0 +1,5 @@ +package com.vaessl.app.exception; + +public class ProviderNotFoundException extends RuntimeException { + +} diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java index f205159..be6fa09 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -20,39 +20,50 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static com.vaessl.app.connection.Endpoints.*; +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 -public class HomeboxIntegrationTest { +class HomeboxIntegrationTest { @Autowired TestRestTemplate restTemplate; + @Autowired + ConnectionRepository cRepository; + + String okJsonHomeboxResponse = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """; + /** * Returns Token and status code OK when login is successful. + * + * @param wm + * the WiremockRuntimeInfo object */ @Test void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()) - .willReturn(okJson(""" - { - "token": "fake-jwt-token", - "attachmentToken": "fake-attach", - "expiresAt": "2026-04-26T02:23:13Z" - } - """))); + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(okJson(okJsonHomeboxResponse))); - ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + ResponseEntity 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("$.attachmentToken"); + String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken"); assertThat(attachmentToken).isEqualTo("fake-attach"); String expiresAt = documentContext.read("$.expiresAt", String.class); @@ -61,31 +72,38 @@ public class HomeboxIntegrationTest { } /** - * Test if login request fails with 401 unauthorized. + * Tests the Unauthorized custom exception. + * + * @param wm + * the WiremockRuntimeInfo object */ @Test void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(unauthorized())); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); - ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(response.getBody()).contains("Invalid username or password."); + assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); } /** * Tests a server error from the external api. + * + * @param wm */ @Test void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { - stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(serviceUnavailable())); + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); - ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains("The external app returned a server error"); + assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); } /** @@ -96,16 +114,14 @@ public class HomeboxIntegrationTest { ConnectionRequest badRequest = new ConnectionRequest( "http://localhost:1234", - "HOMEBOX", + HOMEBOX.getValue(), Map.of("username", "myUser", "password", "myPass"), false); - ResponseEntity response = restTemplate.postForEntity("/login", badRequest, String.class); - - System.out.println("RESPONSE: " + response.getBody()); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody()).contains("The target URL is unreachable."); + assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); } /** @@ -116,10 +132,60 @@ public class HomeboxIntegrationTest { ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false); - ResponseEntity response = restTemplate.postForEntity("/login", emtpyRequest, String.class); + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("Fields must not be empty."); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Test the custom ProviderNotFound exception. + */ + @Test + void shouldReturnProviderNotFound() { + ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( + "http://localhost:1234", + "wrong-service-type", + Map.of("username", "myUser", "password", "myPass"), + false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + 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 response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); + + DocumentContext responseContext = JsonPath.parse(response.getBody()); + + ConnectionEntity dbEntry = cRepository.findByAppUrl(request.appUrl()); + + assertThat(dbEntry).isNotNull(); + + assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + + 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")); + } } /** @@ -127,10 +193,12 @@ public class HomeboxIntegrationTest { * WireMockRuntimeInfo. * * @param wm + * the WiremockRuntimeInfo object * @return a mock api connection request. */ private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { - return new ConnectionRequest(wm.getHttpBaseUrl(), "HOMEBOX", Map.of("username", "admin", "password", "pw"), - false); + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), + Map.of("username", "admin", "password", "pw"), + null); } } diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md new file mode 100644 index 0000000..8d40aa6 --- /dev/null +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -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 databse 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 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 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 providerRegistry; + + private final ConnectionRepository cRepository; + + public ConnectionService(List 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 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 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 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 extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} + +```