feature/implement-external-login-api #30

Merged
kasun merged 32 commits from feature/implement-external-login-api into main 2026-04-09 21:21:58 +02:00
13 changed files with 589 additions and 46 deletions
Showing only changes of commit 240a366ce8 - Show all commits
@@ -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;
}
@@ -7,5 +7,9 @@ public interface ConnectionProvider {
String getServiceType(); String getServiceType();
ConnectionResponse authenticate (ConnectionRequest connectionRequest); ConnectionResponse authenticate(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 findByAppUrl(String appUrl);
}
@@ -8,24 +8,40 @@ import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.ProviderNotFoundException;
@Service @Service
public class ConnectionService { public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry; private final Map<String, ConnectionProvider> providerRegistry;
public ConnectionService(List<ConnectionProvider> providers) { private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream() this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
} }
public ConnectionResponse login(ConnectionRequest request) { public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType().toUpperCase());
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) { 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;
} }
} }
@@ -1,5 +1,7 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.springframework.stereotype.Component; 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.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoints.*; import static com.vaessl.app.connection.Endpoint.*;
@Component @Component
public class HomeBoxConnectionProvider implements ConnectionProvider { public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder; 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.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
} }
@Override @Override
@@ -25,17 +30,50 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
} }
@Override @Override
public ConnectionResponse authenticate(ConnectionRequest connectionRequest) { public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", connectionRequest.credentials().get("username"), Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
"password", connectionRequest.credentials().get("password"), "stayLoggedIn", "password", request.credentials().get("password"), "stayLoggedIn",
connectionRequest.stayLoggedIn()); request.stayLoggedIn());
return restClientBuilder.baseUrl(connectionRequest.appUrl()) HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build() .build()
.post() .post()
.uri(HOMEBOX_LOGIN.getEndpoint()) .uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload) .body(homeboxPayload)
.retrieve() .retrieve()
.body(ConnectionResponse.class); .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) {
}
} }
@@ -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;
}
}
@@ -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;
}
}
@@ -2,6 +2,8 @@ package com.vaessl.app.dto;
import java.util.Map; import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
@@ -9,5 +11,11 @@ public record ConnectionRequest(
@NotBlank(message = "App URL is mandatory") String appUrl, @NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType, @NotBlank(message = "Service type is mandatory") String serviceType,
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials, @NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
boolean stayLoggedIn) { @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
} }
@@ -1,6 +1,17 @@
package com.vaessl.app.dto; package com.vaessl.app.dto;
import java.time.Instant; 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<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;
}
}
} }
@@ -8,7 +8,7 @@ import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.ResourceAccessException;
import static com.vaessl.app.exception.ErrorMessages.*; import static com.vaessl.app.exception.ErrorMessage.*;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@@ -40,4 +40,9 @@ public class GlobalExceptionHandler {
.forStatusAndDetail(e.getStatusCode(), .forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); 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());
}
} }
@@ -0,0 +1,5 @@
package com.vaessl.app.exception;
public class ProviderNotFoundException extends RuntimeException {
}
@@ -20,39 +20,50 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map; import java.util.Map;
import static com.github.tomakehurst.wiremock.client.WireMock.*; 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) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate @AutoConfigureTestRestTemplate
@WireMockTest @WireMockTest
public class HomeboxIntegrationTest { class HomeboxIntegrationTest {
@Autowired @Autowired
TestRestTemplate restTemplate; TestRestTemplate restTemplate;
/** @Autowired
* Returns Token and status code OK when login is successful. ConnectionRepository cRepository;
*/
@Test
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint()) String okJsonHomeboxResponse = """
.willReturn(okJson("""
{ {
"token": "fake-jwt-token", "token": "fake-jwt-token",
"attachmentToken": "fake-attach", "attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z" "expiresAt": "2026-04-26T02:23:13Z"
} }
"""))); """;
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); /**
* 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()); DocumentContext documentContext = JsonPath.parse(response.getBody());
String token = documentContext.read("$.token"); String token = documentContext.read("$.token");
assertThat(token).isEqualTo("fake-jwt-token"); assertThat(token).isEqualTo("fake-jwt-token");
String attachmentToken = documentContext.read("$.attachmentToken"); String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
assertThat(attachmentToken).isEqualTo("fake-attach"); assertThat(attachmentToken).isEqualTo("fake-attach");
String expiresAt = documentContext.read("$.expiresAt", String.class); 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 @Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(unauthorized())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); 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. * Tests a server error from the external api.
*
* @param wm
*/ */
@Test @Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(serviceUnavailable())); stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); 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( ConnectionRequest badRequest = new ConnectionRequest(
"http://localhost:1234", "http://localhost:1234",
"HOMEBOX", HOMEBOX.getValue(),
Map.of("username", "myUser", "password", "myPass"), Map.of("username", "myUser", "password", "myPass"),
false); false);
ResponseEntity<String> response = restTemplate.postForEntity("/login", badRequest, String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
System.out.println("RESPONSE: " + response.getBody());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); 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); ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false);
ResponseEntity<String> response = restTemplate.postForEntity("/login", emtpyRequest, String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); 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<String> 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<String> 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. * WireMockRuntimeInfo.
* *
* @param wm * @param wm
* the WiremockRuntimeInfo object
* @return a mock api connection request. * @return a mock api connection request.
*/ */
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), "HOMEBOX", Map.of("username", "admin", "password", "pw"), return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(),
false); Map.of("username", "admin", "password", "pw"),
null);
} }
} }
@@ -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<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;
}
}
}
```