Compare commits
33 Commits
55508a27bb
...
750dda897d
| Author | SHA1 | Date | |
|---|---|---|---|
| 750dda897d | |||
| 01fa3e4267 | |||
| a1ebb1be21 | |||
| 7de38afec2 | |||
| a5c1931a2a | |||
| 3e80b81e07 | |||
| ef4ee70aac | |||
| 00bb929f22 | |||
| c553735653 | |||
| 680e5f0abd | |||
| 2000278f1a | |||
| 702e6bb973 | |||
| 8a5f7c2bf8 | |||
| 3e4a1f92b1 | |||
| c59f2598b0 | |||
| 30784fa756 | |||
| da0411f5d1 | |||
| 240a366ce8 | |||
| 9c3e1469c7 | |||
| ba7887f6b2 | |||
| 8b1a604dc2 | |||
| be0821b0be | |||
| 913f3c75f1 | |||
| 0169cf04b6 | |||
| ab1d7e68f5 | |||
| 2387d41ebb | |||
| c15c7a0d61 | |||
| bda9391c75 | |||
| 6267e18478 | |||
| 79379b238a | |||
| 75b6995b94 | |||
| 8128ab829f | |||
| 8da3b14e40 |
@@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
java
|
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"
|
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-validation-test")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.wiremock:wiremock-standalone:3.12.0")}
|
||||||
}
|
|
||||||
|
|
||||||
dependencyManagement {
|
dependencyManagement {
|
||||||
imports {
|
imports {
|
||||||
|
|||||||
@@ -9,5 +9,4 @@ public class Application {
|
|||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(Application.class, 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 {
|
||||||
|
}
|
||||||
@@ -2,18 +2,19 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: vaessl
|
name: vaessl
|
||||||
config:
|
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:
|
datasource:
|
||||||
url : ${DB_URL}
|
url : ${DB_URL}
|
||||||
username: ${DB_USERNAME}
|
username: ${DB_USERNAME}
|
||||||
password: ${DB_PASSWORD}
|
password: ${DB_PASSWORD}
|
||||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: create-drop
|
ddl-auto: create-drop
|
||||||
show-sql: true
|
show-sql: true
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
openai:
|
openai:
|
||||||
base-url: ${OPENAI_BASE_URL}
|
base-url: ${OPENAI_BASE_URL}
|
||||||
@@ -21,7 +22,6 @@ spring:
|
|||||||
chat:
|
chat:
|
||||||
options:
|
options:
|
||||||
model: gpt-4o-mini
|
model: gpt-4o-mini
|
||||||
|
server:
|
||||||
logging:
|
servlet:
|
||||||
level:
|
context-path: /api
|
||||||
org.springframework.boot.context.config: TRACE
|
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
class ApplicationTests {
|
class ApplicationTests {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DataSource dataSource;
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void connectionToTestDbWorks() throws SQLException {
|
void connectionToTestDbWorks() throws SQLException {
|
||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user